diff --git a/.github/secrets/ProfileDev.mobileprovision.gpg b/.github/secrets/ProfileDev.mobileprovision.gpg new file mode 100644 index 00000000..e6e6a041 Binary files /dev/null and b/.github/secrets/ProfileDev.mobileprovision.gpg differ diff --git a/.github/secrets/certification.p12.gpg b/.github/secrets/certification.p12.gpg new file mode 100644 index 00000000..608c28f4 Binary files /dev/null and b/.github/secrets/certification.p12.gpg differ diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml new file mode 100644 index 00000000..8a60ca82 --- /dev/null +++ b/.github/workflows/CD.yml @@ -0,0 +1,92 @@ + +name: Dev CD + +on: + # develop 브랜치로 직접 푸시 했을 때 + # 버전과 빌드 번호가 변경되었을 때 + push: + branches: [develop] + paths: [SOOUM/**.xcodeproj/**] + +jobs: + upload-testflight: + runs-on: mocos-15 + + env: + XC_WORKSPACE: ${{ 'SOOUM/SOOUM.xcworkspace' }} + XC_SCHEME: ${{ 'SOOUM-Dev' }} + XC_ARCHIVE: ${{ 'SOOUM-Dev.xcarchive' }} + + # certificate + ENCRYPTED_CERT_FILE_PATH: ${{ '.github/secrets/certification.p12.gpg' }} + DECRYPTED_CERT_FILE_PATH: ${{ '.github/secrets/ertification.p12' }} + CERT_ENCRYPTION_KEY: ${{ secrets.CERTS_ENCRYPTION_PWD }} # gpg로 파일 암호화할 때 사용한 암호 + + # provisioning + ENCRYPTED_PROVISION_FILE_PATH: ${{ '.github/secrets/ProfileDev.mobileprovision.gpg' }} + DECRYPTED_PROVISION_FILE_PATH: ${{ '.github/secrets/ProfileDev.mobileprovision' }} + PROVISIONING_ENCRYPTION_KEY: ${{ secrets.PROVISION_ENCRYPTION_PWD }} # gpg로 파일 암호화할 때 사용한 암호 + + # certification export key + CERT_EXPORT_KEY: ${{ secrets.CERT_EXPORT_PWD }} + + KEYCHAIN: ${{ 'test.keychain' }} + + # Step은 job의 일부로 실행될 일련의 task들을 나타냄 + steps: + # 단계별 task 를 나타낼 이름 + - name: Select latest Xcode + run: "sudo xcode-select -s /Applications/Xcode.app" + + - name: Checkout project + uses: actions/checkout@v4 + + - name: Configure Keychain + # 키체인 초기화 - 임시 키체인 생성 + run: | + security create-keychain -p "" "$KEYCHAIN" + security list-keychains -s "$KEYCHAIN" + security default-keychain -s "$KEYCHAIN" + security unlock-keychain -p "" "$KEYCHAIN" + security set-keychain-settings + + - name : Configure Code Signing + run: | + # certificate 복호화 + gpg -d -o "$DECRYPTED_CERT_FILE_PATH" --pinentry-mode=loopback --passphrase "$CERT_ENCRYPTION_KEY" "$ENCRYPTED_CERT_FILE_PATH" + # provisioning 복호화 + gpg -d -o "$DECRYPTED_PROVISION_FILE_PATH" --pinentry-mode=loopback --passphrase "$PROVISIONING_ENCRYPTION_KEY" "$ENCRYPTED_PROVISION_FILE_PATH" + + # security를 사용하여 인증서와 개인 키를 새로 만든 키 체인으로 가져옴 + security import "$DECRYPTED_CERT_FILE_PATH" -k "$KEYCHAIN" -P "$CERT_EXPORT_KEY" -A + security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN" + + # Xcode에서 찾을 수 있는 프로비저닝 프로필 설치하기 위해 우선 프로비저닝 디렉토리를 생성 + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + # 디버깅 용 echo 명령어 + echo `ls .github/secrets/*.mobileprovision` + # 모든 프로비저닝 프로파일을 rename 하고 위에서 만든 디렉토리로 복사하는 과정 + for PROVISION in `ls .github/secrets/*.mobileprovision` + do + UUID=`/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i ./$PROVISION)` + cp "./$PROVISION" "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision" + done + + # 빌드 및 아카이브 + - name: Archive app + run: | + pod install --repo-update --clean-install --project-directory=SOOUM/ + xcodebuild clean archive -workspace $XC_WORKSPACE -scheme $XC_SCHEME -configuration release -archivePath $XC_ARCHIVE + + # export 를 통해 ipa 파일 만듦 + - name: Export app + run: | + xcodebuild -exportArchive -archivePath $XC_ARCHIVE -exportOptionsPlist ExportOptions.plist -exportPath . -allowProvisioningUpdates + + - name: Upload app to TestFlight + uses: apple-actions/upload-testflight-build@v3 + with: + app-path: 'SOOUM-Dev.ipa' + issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} + api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} + api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 00000000..9a83e9b1 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,53 @@ +name: CI + +# main, develop 브랜치에 직접 푸시와 PR을 열었을 때 +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build-and-test: + runs-on: macos-15 + + env: + XC_WORKSPACE: SOOUM/SOOUM.xcworkspace + XC_SCHEME: SOOUM-Dev + XC_DESTINATION: platform=iOS Simulator,name=iPhone 16,OS=18.0 + + steps: + - uses: actions/checkout@v4 + + - name: Pods cache + uses: actions/cache@v4 + id: pods-cache + with: + path: | + SOOUM/Pods + SOOUM/Podfile.lock + key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods- + + - name: Pod install + if: steps.pods-cache.outputs.cache-hit != 'true' + run: | + cd SOOUM && pod install --repo-update --clean-install + + - name: Run SwiftLint + run: .github/workflows/swiftlint.sh --strict + shell: bash + + # 테스트 결과 출력 + - name: Xcpretty install + run: gem install xcpretty + + - name: Build and run tests + run: | + xcodebuild clean test \ + -workspace "$XC_WORKSPACE" \ + -scheme "$XC_SCHEME" \ + -destination "$XC_DESTINATION" \ + -enableCodeCoverage YES \ + | xcpretty --test --color diff --git a/.github/workflows/swiftlint.sh b/.github/workflows/swiftlint.sh new file mode 100755 index 00000000..6ed3cecd --- /dev/null +++ b/.github/workflows/swiftlint.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Copyright (c) 2018 Norio Nomura +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# convert swiftlint's output into GitHub Actions Logging commands +# https://help.github.com/en/github/automating-your-workflow-with-github-actions/development-tools-for-github-actions#logging-commands + +function stripPWD() { + sed -E "s/$(pwd|sed 's/\//\\\//g')\///" +} + +function convertToGitHubActionsLoggingCommands() { + sed -E 's/^(.*):([0-9]+):([0-9]+): (warning|error|[^:]+): (.*)/::\4 file=\1,line=\2,col=\3::\5/' +} + +if ! ${DIFF_BASE+false}; +then + changedFiles=$(git --no-pager diff --name-only --relative FETCH_HEAD $(git merge-base FETCH_HEAD $DIFF_BASE) -- '*.swift') + + if [ -z "$changedFiles" ] + then + echo "No Swift file changed" + exit + fi +fi + +swiftlintPath="SOOUM/Pods/SwiftLint/swiftlint" +config="--config SOOUM/.swiftlint.yml" + +set -o pipefail && $swiftlintPath "$@" $config -- $changedFiles | stripPWD | convertToGitHubActionsLoggingCommands diff --git a/.gitignore b/.gitignore index 158a6eef..43c079dc 100644 --- a/.gitignore +++ b/.gitignore @@ -74,7 +74,7 @@ playground.xcworkspace Carthage/Build/ # Accio dependency management -Dependencies/ +# Dependencies/ .accio/ # fastlane diff --git a/ExportOptions.plist b/ExportOptions.plist new file mode 100644 index 00000000..dee3064f --- /dev/null +++ b/ExportOptions.plist @@ -0,0 +1,29 @@ + + + + + destination + export + manageAppVersionAndBuildNumber + + method + app-store-connect + provisioningProfiles + + com.sooum.dev + Profile-Dev + + signingCertificate + Apple Distribution + signingStyle + manual + stripSwiftSymbols + + teamID + 99FRG743RX + testFlightInternalTestingOnly + + uploadSymbols + + + diff --git a/README.md b/README.md index a60ccb4b..534f6815 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ - **숨**은 완전한 익명성을 보장하는 카드형 SNS 앱 서비스입니다. 사용자들이 자유롭게 생각을 공유할 수 있는 안전한 공간을 제공합니다. - 사용자의 개인정보 사용을 최소화하기 위해 하나의 기기당 하나의 계정을 발급하고, 계정을 위한 ID는 비대칭 키 암호화를 사용해 안전하게 사용합니다. - 사용자의 감정을 글과 함께 직접 찍은 사진 또는 숨에서 제공하는 기본 이미지로 표현할 수 있습니다. - - 해시태그 검색을 통해 특정 키워드의 피드를 구경할 수 있습니다. - - 다양한 사용자들을 팔로우하며 피드에 공감 혹은 답카드 작성으로 표현할 수 있습니다. + - 해시태그 검색을 통해 특정 키워드가 포함된 알림이나 피드를 구경할 수 있습니다. + - 다양한 사용자들을 팔로우하며 피드에 공감 혹은 댓글카드 작성으로 표현할 수 있습니다.
@@ -17,13 +17,14 @@ # Preview
- Preview + sooum_v2_banner_image
+ # Features ### 1. 네트워크 레이어 -HTTP 네트워킹을 위한 Alamofire와 반응형 프로그래밍을 위한 RxSwift를 활용한 네트워킹 레이어를 포함하고 있습니다. 이 프로젝트에선 적절한 오류 처리, 요청 구성 및 환경별 엔드포인트를 갖춘 RESTful API 호출을 위한 깔끔한 프로토콜 지향 아키텍처를 제공합니다. +HTTP 네트워킹을 위한 Alamofire와 반응형 프로그래밍을 위한 RxSwift를 활용한 네트워크 클래스를 사용하고 있습니다. 이 프로젝트에선 적절한 오류 처리, 요청 구성 및 환경별 엔드포인트를 갖춘 RESTful API 호출을 위한 프로토콜 지향 아키텍처를 제공합니다. 개발 환경과 운영 환경 간에 endpoint를 전처리문으로 전환합니다: ``` @@ -53,7 +54,7 @@ protocol BaseRequest: URLRequestConvertible { func request(_ object: T.Type, request: BaseRequest) -> Observable ``` -서버측과 함께 정의한 HTTP 상태 코드 매핑을 포함한 포괄적인 오류 처리: +서버측과 함께 정의한 HTTP 상태 코드 매핑을 포함한 오류 처리: ``` enum DefinedError: Error, LocalizedError { case badRequest @@ -68,7 +69,7 @@ enum DefinedError: Error, LocalizedError { ``` ### 2. 테스트하기 쉬운 매니저 -의존성 주입을 활용하여 매니저 객체들을 효율적으로 구성하고 테스트하기 쉬운 아키텍처를 구현했습니다. 이 접근 방식은 코드의 결합도를 낮추고, 단위 테스트를 용이하게 하며, 애플리케이션의 유지보수성을 크게 향상시킵니다. +의존성 주입을 활용하여 매니저 객체들을 구성하고 테스트하기 쉬운 아키텍처를 구현했습니다. 이 구조는 코드의 결합도를 낮추고, 단위 테스트하기 쉬운 구조를 만들어줍니다. 여러 매니저들을 관리하는 **CompositeManager**를 통해 접근에 용이합니다: ``` @@ -98,20 +99,9 @@ final class ManagerTypeContainer: ManagerTypeDelegate { } } ``` -매니저 단위 테스트를 위해 목 객체를 사용해 매니저를 독립적으로 테스트할 수 있습니다: -``` -final class MockManagerProviderContainer: ManagerProviderType { - lazy var managerType: ManagerTypeDelegate = MockManagerProvider() - - var authManager: AuthManagerDelegate { self.managerType.authManager } - var pushManager: PushManagerDelegate { self.managerType.pushManager } - var networkManager: NetworkManagerDelegate { self.managerType.networkManager } - var locationManager: LocationManagerDelegate { self.managerType.locationManager } -} -``` ### 3. 단방향 데이터 흐름 아키텍처 -을 활용하여 단방향 데이터 흐름 아키텍처를 구현했습니다. 이 아키텍처는 앞서 설명한 의존성 주입 기반 관리자 패턴과 결합하여 예측 가능하고 테스트하기 쉬운 코드베이스를 구축합니다. +단방향 데이터 흐름 아키텍처는 앞서 설명한 의존성 주입 기반 관리자 패턴과 결합하여 예측 가능하고 테스트하기 쉬운 구조를 만듭니다. ReactorKit의 가장 큰 특징은 데이터가 한 방향으로만 흐른다는 것입니다: ``` @@ -119,22 +109,7 @@ ReactorKit의 가장 큰 특징은 데이터가 한 방향으로만 흐른다는 ``` 이 흐름은 상태 변화를 예측 가능하게 만들고 디버깅을 용이하게 합니다. -ReactorKit은 View와 Reactor를 쉽게 바인딩할 수 있는 방법을 제공합니다: -``` -class SomeViewController: UIViewController, View { - var disposeBag = DisposeBag() - - let button = UIButton() - - func bind(reactor: SomeViewReactor) { - button.rx.tap - .map { _ in Reactor.Action.someAction } - .bind(to: reactore.action) - .disposed(by: disposeBag) - } -} -``` -앞서 설명한 의존성 주입과 ReactorKit을 결합하여 강력한 아키텍처를 구현합니다: +앞서 설명한 의존성 주입과 ReactorKit을 결합하여 아키텍처를 구현합니다: ``` class SomeViewReactor: Reactor { private let provider: ManagerProviderType @@ -154,6 +129,100 @@ class SomeViewReactor: Reactor { } ``` +# 25.12.11 변경사항 + +### 4. 클린 아키텍처 도입 +클린 아키텍처를 도입하기에 앞서 레파지토리 패턴을 도입해 데이터 접근 계층을 분리하고, 비즈니스 로직의 독립성을 보장합니다. 이 변경으로 인해 테스트하기 쉬운 구조를 설계했습니다. + +레파지토리 패턴의 핵심 구조는 구체적인 구현 방식에 의존하지 않도록 추상화하는 것입니다. 이 구조는 데이터 접근 변경하더라도 상위 계층의 코드 수정을 최소화하도록 보장합니다. + +레파지토리의 프로토콜은 도메인 계층에 정의되어, 비즈니스 로직이 요구하는 데이터 접근 인터페이스를 정의합니다. +``` +protocol AuthRepository { + + func signUp(nickname: String, profileImageName: String?) -> Observable + func login() -> Observable + func withdraw(reaseon: String) -> Observable + + ... +} +``` + +레파지토리의 구현체는 데이터 계증에서 구현하며, 하나 이상의 데이터 소스를 조합하여 데이터를 제공합니다. +``` +class AuthRepositoryImpl: AuthRepository { + + private let remoteDataSource: AuthRemoteDataSource + private let localDataSource: AuthLocalDataSource + + init(remoteDataSource: AuthRemoteDataSource, localDataSource: AuthLocalDataSource) { + self.remoteDataSource = remoteDataSource + self.localDataSource = localDataSource + } + + func signUp(nickname: String, profileImageName: String?) -> Observable { + + self.remoteDataSource.signUp(nickname: nickname, profileImageName: profileImageName) + } + + func login() -> Observable { + + self.remoteDataSource.login() + } + + func withdraw(reaseon: String) -> Observable { + + self.remoteDataSource.withdraw(reaseon: reaseon) + } + + ... +} +``` + +UseCase는 앱의 특정 기능을 캡슐화 합니다. ReactorKit에서 Reactor는 UseCase만 호출하며, 복잡한 비즈니스 로직을 UseCase에 위임하여 책임을 가볍게 만듭니다. +``` +protocol FetchTagUseCase: AnyObject { + + ... + + func isFavorites(with tagInfo: FavoriteTagInfo) -> Observable + func ranked() -> Observable<[TagInfo]> +} + +final class FetchTagUseCaseImpl: FetchTagUseCase { + + private let repository: TagRepository + + init(repository: TagRepository) { + self.repository = repository + } + + ... + + func isFavorites(with tagInfo: FavoriteTagInfo) -> Observable { + + return self.favorites().map { $0.contains(tagInfo) } + } + + // 인기 태그는 최소 1개 이상일 때 표시 + // 인기 태그는 최대 10개까지 표시 + func ranked() -> Observable<[TagInfo]> { + + return self.repository.ranked() + .map(\.tagInfos) + .map { $0.filter { $0.usageCnt > 0 } } + // 중복 제거 + // .map { Array(Set($0)) } + // 태그 갯수로 정렬 + // .map { $0.sorted(by: { $0.usageCnt > $1.usageCnt }) } + .map { Array($0.prefix(10)) } + } +} +``` + +비즈니스 로직에서 사용하는 모델과 데이터 통신을 위한 모델을 분리합니다. + - 도메인의 모델: 순수한 비즈니스 로직만 포함하며 데이터 소스 구현에 독립적입니다. + - 데이터의 모델: API 응답 구조 또는 로컬 DB 스키마에 맞게 정의합니다. # 향후 개선할 사항 1. ReactorKit 기반 비즈니스 로직의 단위 테스트 추가 @@ -164,14 +233,14 @@ class SomeViewReactor: Reactor { - 액션-뮤테이션-상태 흐름 검증: 단방향 데이터 흐름의 각 단계가 올바르게 작동하는지 검증 - 테스트 커버리지 확대: 모든 비즈니스 로직 컴포넌트에 대한 테스트 커버리지 80% 이상 달성 -2. 클린 아키텍처의 레포지토리 패턴 적용 +2. ~~클린 아키텍처의 레포지토리 패턴 적용~~ - 현재 프로젝트는 매니저들과 의존성 주입을 통해 관리하고 있지만, 데이터 접근 계층을 더욱 체계화하기 위해 클린 아키텍처의 레포지토리 패턴을 도입할 계획입니다: + ~~현재 프로젝트는 매니저들과 의존성 주입을 통해 관리하고 있지만, 데이터 접근 계층을 더욱 체계화하기 위해 클린 아키텍처의 레포지토리 패턴을 도입할 계획입니다:~~ - - 데이터 레이어 추상화: 데이터 소스(로컬 저장소, 원격 API 등)에 상관없이 일관된 인터페이스를 제공하는 레포지토리 계층 도입 - - 도메인 모델과 데이터 모델 분리: 비즈니스 로직에서 사용하는 도메인 모델과 데이터 저장/통신에 사용하는 데이터 모델을 명확히 분리 - - UseCase 패턴 도입: 비즈니스 로직을 캡슐화하는 UseCase 클래스 구현으로 리액터의 책임을 더욱 가볍게 만듦 - - 데이터 소스 전략 패턴: 네트워크, 로컬 간의 전환을 자동화하는 전략 패턴 구현 + - ~~데이터 레이어 추상화: 데이터 소스(로컬 저장소, 원격 API 등)에 상관없이 일관된 인터페이스를 제공하는 레포지토리 계층 도입~~ + - ~~도메인 모델과 데이터 모델 분리: 비즈니스 로직에서 사용하는 도메인 모델과 데이터 저장/통신에 사용하는 데이터 모델을 명확히 분리~~ + - ~~UseCase 패턴 도입: 비즈니스 로직을 캡슐화하는 UseCase 클래스 구현으로 리액터의 책임을 더욱 가볍게 만듦~~ + - ~~데이터 소스 전략 패턴: 네트워크, 로컬 간의 전환을 자동화하는 전략 패턴 구현~~ # Tech Stacks
@@ -187,7 +256,7 @@ class SomeViewReactor: Reactor { # Environment - Xcode 16.0(16A242d) - iOS Deployment Target 15.0 - - Ccocoapods Version 1.16.2 + - Cocoapods Version 1.16.2 # Members |오현식|서정덕| diff --git a/SOOUM/.swiftlint.yml b/SOOUM/.swiftlint.yml index a81af621..31a18bd0 100644 --- a/SOOUM/.swiftlint.yml +++ b/SOOUM/.swiftlint.yml @@ -35,7 +35,7 @@ disabled_rules: - inclusive_language # - inert_defer # - is_disjoint - # - large_tuple + - large_tuple # - leading_whitespace ⭕️ # - legacy_cggeometry_functions ⭕️ # - legacy_constant ⭕️ @@ -158,7 +158,7 @@ excluded: - SOOUM/Resources - SOOUM/Models - SOOUM-DevTests - - Pods + - SOOUM/DesignSystem/Foundations # 👉🏻 Options # line_length: 100 @@ -168,4 +168,3 @@ identifier_name: warning: 0 error: 0 allowed_symbols: "_" - diff --git a/SOOUM/Podfile b/SOOUM/Podfile index 15dc37e4..a9f7ef14 100644 --- a/SOOUM/Podfile +++ b/SOOUM/Podfile @@ -13,9 +13,9 @@ def pods pod 'Alamofire', '~> 5.9.1' - pod 'Firebase/Analytics', '~> 10.22.0' - pod 'Firebase/Crashlytics', '~> 10.22.0' - pod 'Firebase/Messaging', '~> 10.22.0' + pod 'Firebase/Analytics', '~> 10.5.0' + pod 'Firebase/Crashlytics', '~> 10.5.0' + pod 'Firebase/Messaging', '~> 10.5.0' pod 'Clarity' @@ -23,8 +23,11 @@ def pods pod 'Then', '~> 3.0.0' pod 'Kingfisher', '~> 7.10.0' pod 'YPImagePicker', '~> 5.2.2' + pod 'SwiftEntryKit', '~> 2.0.0' pod 'CocoaLumberjack/Swift', '~> 3.7.2' + + pod 'lottie-ios' end target 'SOOUM-Dev' do diff --git a/SOOUM/Podfile.lock b/SOOUM/Podfile.lock index 4f8b5a2c..5a94b4e5 100644 --- a/SOOUM/Podfile.lock +++ b/SOOUM/Podfile.lock @@ -1,99 +1,87 @@ PODS: - Alamofire (5.9.1) - - Clarity (3.0.5) + - Clarity (3.3.5) - CocoaLumberjack/Core (3.7.4) - CocoaLumberjack/Swift (3.7.4): - CocoaLumberjack/Core - - Firebase/Analytics (10.22.0): + - Firebase/Analytics (10.5.0): - Firebase/Core - - Firebase/Core (10.22.0): + - Firebase/Core (10.5.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 10.22.0) - - Firebase/CoreOnly (10.22.0): - - FirebaseCore (= 10.22.0) - - Firebase/Crashlytics (10.22.0): + - FirebaseAnalytics (~> 10.5.0) + - Firebase/CoreOnly (10.5.0): + - FirebaseCore (= 10.5.0) + - Firebase/Crashlytics (10.5.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 10.22.0) - - Firebase/Messaging (10.22.0): + - FirebaseCrashlytics (~> 10.5.0) + - Firebase/Messaging (10.5.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 10.22.0) - - FirebaseAnalytics (10.22.0): - - FirebaseAnalytics/AdIdSupport (= 10.22.0) + - FirebaseMessaging (~> 10.5.0) + - FirebaseAnalytics (10.5.0): + - FirebaseAnalytics/AdIdSupport (= 10.5.0) - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseAnalytics/AdIdSupport (10.22.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.5.0): - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) - - GoogleAppMeasurement (= 10.22.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseCore (10.22.0): + - GoogleAppMeasurement (= 10.5.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseCore (10.5.0): - FirebaseCoreInternal (~> 10.0) - - GoogleUtilities/Environment (~> 7.12) - - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreExtension (10.29.0): - - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) - FirebaseCoreInternal (10.29.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseCrashlytics (10.22.0): - - FirebaseCore (~> 10.5) + - FirebaseCrashlytics (10.5.0): + - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) - - FirebaseSessions (~> 10.5) - GoogleDataTransport (~> 9.2) - GoogleUtilities/Environment (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) + - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (~> 2.1) - FirebaseInstallations (10.29.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - PromisesObjC (~> 2.1) - - FirebaseMessaging (10.22.0): + - FirebaseMessaging (10.5.0): - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.3) + - GoogleDataTransport (~> 9.2) - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Reachability (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseSessions (10.29.0): - - FirebaseCore (~> 10.5) - - FirebaseCoreExtension (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.13) - - GoogleUtilities/UserDefaults (~> 7.13) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesSwift (~> 2.1) - - GoogleAppMeasurement (10.22.0): - - GoogleAppMeasurement/AdIdSupport (= 10.22.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleAppMeasurement/AdIdSupport (10.22.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 10.22.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleAppMeasurement/WithoutAdIdSupport (10.22.0): - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement (10.5.0): + - GoogleAppMeasurement/AdIdSupport (= 10.5.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.5.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.5.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.5.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - nanopb (< 2.30910.0, >= 2.30908.0) - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) @@ -127,14 +115,13 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - Kingfisher (7.10.2) - - nanopb (2.30910.0): - - nanopb/decode (= 2.30910.0) - - nanopb/encode (= 2.30910.0) - - nanopb/decode (2.30910.0) - - nanopb/encode (2.30910.0) + - lottie-ios (4.5.2) + - nanopb (2.30909.1): + - nanopb/decode (= 2.30909.1) + - nanopb/encode (= 2.30909.1) + - nanopb/decode (2.30909.1) + - nanopb/encode (2.30909.1) - PromisesObjC (2.4.0) - - PromisesSwift (2.4.0): - - PromisesObjC (= 2.4.0) - PryntTrimmerView (4.0.2) - ReactorKit (3.2.0): - RxSwift (~> 6.0) @@ -153,6 +140,7 @@ PODS: - RxSwift (6.8.0) - SnapKit (5.7.1) - SteviaLayout (5.1.2) + - SwiftEntryKit (2.0.0) - SwiftLint (0.56.2) - Then (3.0.0) - WeakMapTable (1.2.0) @@ -164,16 +152,18 @@ DEPENDENCIES: - Alamofire (~> 5.9.1) - Clarity - CocoaLumberjack/Swift (~> 3.7.2) - - Firebase/Analytics (~> 10.22.0) - - Firebase/Crashlytics (~> 10.22.0) - - Firebase/Messaging (~> 10.22.0) + - Firebase/Analytics (~> 10.5.0) + - Firebase/Crashlytics (~> 10.5.0) + - Firebase/Messaging (~> 10.5.0) - Kingfisher (~> 7.10.0) + - lottie-ios - ReactorKit (~> 3.2.0) - RxCocoa (~> 6.8.0) - RxGesture (~> 4.0.4) - RxKeyboard (~> 2.0.0) - RxSwift (~> 6.8.0) - SnapKit (~> 5.7.1) + - SwiftEntryKit (~> 2.0.0) - SwiftLint (~> 0.56.2) - Then (~> 3.0.0) - YPImagePicker (~> 5.2.2) @@ -186,19 +176,17 @@ SPEC REPOS: - Firebase - FirebaseAnalytics - FirebaseCore - - FirebaseCoreExtension - FirebaseCoreInternal - FirebaseCrashlytics - FirebaseInstallations - FirebaseMessaging - - FirebaseSessions - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities - Kingfisher + - lottie-ios - nanopb - PromisesObjC - - PromisesSwift - PryntTrimmerView - ReactorKit - RxCocoa @@ -208,6 +196,7 @@ SPEC REPOS: - RxSwift - SnapKit - SteviaLayout + - SwiftEntryKit - SwiftLint - Then - WeakMapTable @@ -215,24 +204,22 @@ SPEC REPOS: SPEC CHECKSUMS: Alamofire: f36a35757af4587d8e4f4bfa223ad10be2422b8c - Clarity: 214d0e426d63a8f6edbcd2a39edb5d2955728e0e + Clarity: fbd41ffa7b3c925ffb092a717c2dd896c39eff72 CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646 - Firebase: 797fd7297b7e1be954432743a0b3f90038e45a71 - FirebaseAnalytics: 8d0ff929c63b7f72260f332b86ccf569776b75d3 - FirebaseCore: 0326ec9b05fbed8f8716cddbf0e36894a13837f7 - FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f + Firebase: 3d6637234ab163e31852834617b4fe1df3b7af6d + FirebaseAnalytics: 6f7430030cb6b22fe1db2e3bed782e999b76dc00 + FirebaseCore: 324b8d182d9ff7e3f6d1fa2f04b082d1c7a3e366 FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 - FirebaseCrashlytics: e568d68ce89117c80cddb04073ab9018725fbb8c + FirebaseCrashlytics: ea94bc81f67d5d4702d0c782da13e1483217bac8 FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd - FirebaseMessaging: 9f71037fd9db3376a4caa54e5a3949d1027b4b6e - FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc - GoogleAppMeasurement: ccefe3eac9b0aa27f96066809fb1a7fe4b462626 + FirebaseMessaging: 35ecbbc68ff547fca80f9326c9622e79288c7149 + GoogleAppMeasurement: 40c70a7d89013f0eca72006c4b9732163ea4cdae GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 Kingfisher: 99edc495d3b7607e6425f0d6f6847b2abd6d716d - nanopb: 438bc412db1928dac798aa6fd75726007be04262 + lottie-ios: 96784afc26ea031d3e2b6cae342a4b8915072489 + nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 PryntTrimmerView: 6a43cc90df5d99addeabd33d4ba09b1365322130 ReactorKit: e8b11d6b9c415405f381669b095c154a05b59eca RxCocoa: 2d33c1e1e5d66492052ad46b11024ae287572880 @@ -242,11 +229,12 @@ SPEC CHECKSUMS: RxSwift: 4e28be97cbcfeee614af26d83415febbf2bf6f45 SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SteviaLayout: 05424e528d643657d39ce72c786e4adf13cfc5f2 + SwiftEntryKit: 61b5fa36f34a97dd8013e48a7345bc4c4720be9a SwiftLint: bd7cfb914762ab5f0cbb632964849571db075706 Then: 844265ae87834bbe1147d91d5d41a404da2ec27d WeakMapTable: 05c694ce8439a7a9ebabb56187287a63c57673d6 YPImagePicker: afc81b3cffab05a6e7261c5daf80dc31b4e917b4 -PODFILE CHECKSUM: 77a47a04877511a9af2378cb76d7e51ca6d46f27 +PODFILE CHECKSUM: 6d1a37655fb23a49ce710070e44e23b44b19f000 COCOAPODS: 1.16.2 diff --git a/SOOUM/SOOUM-DevTests/Managers/Location/MockLocationManager.swift b/SOOUM/SOOUM-DevTests/Managers/Location/MockLocationManager.swift index 39ddf2ac..dfd5b579 100644 --- a/SOOUM/SOOUM-DevTests/Managers/Location/MockLocationManager.swift +++ b/SOOUM/SOOUM-DevTests/Managers/Location/MockLocationManager.swift @@ -18,6 +18,8 @@ class MockLocationManager: CompositeManager, Locat return Coordinate() } + var hasPermission: Bool { return true } + override init(provider: ManagerTypeDelegate, configure: LocationManagerConfigruation) { self.mockCoreLocation = MockCoreLocation() diff --git a/SOOUM/SOOUM-DevTests/Managers/Network/MockAlamofire.swift b/SOOUM/SOOUM-DevTests/Managers/Network/MockAlamofire.swift index b7863ca6..8f391bdb 100644 --- a/SOOUM/SOOUM-DevTests/Managers/Network/MockAlamofire.swift +++ b/SOOUM/SOOUM-DevTests/Managers/Network/MockAlamofire.swift @@ -22,6 +22,7 @@ enum MockRequest: BaseRequest { var parameters: Parameters { [:] } var encoding: ParameterEncoding { URLEncoding.default } var authorizationType: AuthorizationType { return .none } + var version: APIVersion { return .v1 } func asURLRequest() throws -> URLRequest { URLRequest(url: URL(string: "http://test.com")!) } } diff --git a/SOOUM/SOOUM-DevTests/Managers/Push/MockPushManager.swift b/SOOUM/SOOUM-DevTests/Managers/Push/MockPushManager.swift index 7eecdf33..ce34d188 100644 --- a/SOOUM/SOOUM-DevTests/Managers/Push/MockPushManager.swift +++ b/SOOUM/SOOUM-DevTests/Managers/Push/MockPushManager.swift @@ -27,4 +27,6 @@ class MockPushManager: CompositeManager, PushManagerDe self.notificationStatus = isOn && self.canReceiveNotifications completion?(nil) } + + func deleteNotification(notificationId: String) { } } diff --git a/SOOUM/SOOUM.xcodeproj/project.pbxproj b/SOOUM/SOOUM.xcodeproj/project.pbxproj index d158b335..494dbbf2 100644 --- a/SOOUM/SOOUM.xcodeproj/project.pbxproj +++ b/SOOUM/SOOUM.xcodeproj/project.pbxproj @@ -10,45 +10,22 @@ 2192C5356D8F39905D0A8181 /* Pods_SOOUM.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 74194BC62F22BC2F5596D850 /* Pods_SOOUM.framework */; }; 2A032EFD2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A032EFC2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift */; }; 2A032EFE2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A032EFC2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift */; }; - 2A048E7B2C9BDF5F00FFD485 /* SOMLocationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A048E7A2C9BDF5F00FFD485 /* SOMLocationFilter.swift */; }; - 2A048E7C2C9BDF5F00FFD485 /* SOMLocationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A048E7A2C9BDF5F00FFD485 /* SOMLocationFilter.swift */; }; - 2A048E842C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A048E832C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift */; }; - 2A048E852C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A048E832C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift */; }; - 2A34AFB52D144F08007BD7E7 /* EmptyTagDetailTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A34AFB42D144EEF007BD7E7 /* EmptyTagDetailTableViewCell.swift */; }; - 2A34AFB62D144F08007BD7E7 /* EmptyTagDetailTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A34AFB42D144EEF007BD7E7 /* EmptyTagDetailTableViewCell.swift */; }; - 2A44A42A2CAC09AE00DC463E /* RSAKeyResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A4292CAC09AE00DC463E /* RSAKeyResponse.swift */; }; - 2A44A42B2CAC09AE00DC463E /* RSAKeyResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A4292CAC09AE00DC463E /* RSAKeyResponse.swift */; }; - 2A44A42D2CAC14C800DC463E /* SignInResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A42C2CAC14C800DC463E /* SignInResponse.swift */; }; - 2A44A4342CAC21A500DC463E /* SignUpResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A4332CAC21A500DC463E /* SignUpResponse.swift */; }; - 2A44A4352CAC21A500DC463E /* SignUpResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A4332CAC21A500DC463E /* SignUpResponse.swift */; }; - 2A44A4372CAC227300DC463E /* BaseAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A4362CAC227300DC463E /* BaseAuthResponse.swift */; }; - 2A44A4382CAC227300DC463E /* BaseAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A4362CAC227300DC463E /* BaseAuthResponse.swift */; }; - 2A45B36D2CE3A3E30071026A /* ProfileImageSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7F92CE277AF00E1C799 /* ProfileImageSettingViewReactor.swift */; }; - 2A45B36F2CE4C5510071026A /* RegisterUserResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A45B36E2CE4C5510071026A /* RegisterUserResponse.swift */; }; - 2A45B3702CE4C5510071026A /* RegisterUserResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A45B36E2CE4C5510071026A /* RegisterUserResponse.swift */; }; - 2A5ABA342D464E0B00BF6C9B /* ConfigureRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5ABA332D464E0B00BF6C9B /* ConfigureRequest.swift */; }; - 2A5ABA352D464E0B00BF6C9B /* ConfigureRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5ABA332D464E0B00BF6C9B /* ConfigureRequest.swift */; }; + 2A45B36D2CE3A3E30071026A /* OnboardingProfileImageSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7F92CE277AF00E1C799 /* OnboardingProfileImageSettingViewReactor.swift */; }; 2A5BB7B92CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7B82CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift */; }; 2A5BB7BA2CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7B82CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift */; }; 2A5BB7BE2CDB870000E1C799 /* OnboardingGuideMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7BD2CDB870000E1C799 /* OnboardingGuideMessageView.swift */; }; 2A5BB7BF2CDB870000E1C799 /* OnboardingGuideMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7BD2CDB870000E1C799 /* OnboardingGuideMessageView.swift */; }; 2A5BB7C92CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7C82CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift */; }; 2A5BB7CA2CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7C82CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift */; }; - 2A5BB7CD2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7CC2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift */; }; - 2A5BB7CE2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7CC2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift */; }; + 2A5BB7CD2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7CC2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift */; }; + 2A5BB7CE2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7CC2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift */; }; 2A5BB7D12CDC7ADC00E1C799 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D02CDC7ADC00E1C799 /* OnboardingViewController.swift */; }; 2A5BB7D22CDC7ADC00E1C799 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D02CDC7ADC00E1C799 /* OnboardingViewController.swift */; }; 2A5BB7D52CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */; }; 2A5BB7D62CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */; }; - 2A5BB7D92CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */; }; - 2A5BB7DA2CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */; }; 2A5BB7E02CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */; }; 2A5BB7E12CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */; }; - 2A5BB7E32CDCD97300E1C799 /* JoinRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */; }; - 2A5BB7E42CDCD97300E1C799 /* JoinRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */; }; - 2A5BB7E72CDCDC3600E1C799 /* NicknameValidationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7E62CDCDC3600E1C799 /* NicknameValidationResponse.swift */; }; - 2A5BB7E82CDCDC3600E1C799 /* NicknameValidationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7E62CDCDC3600E1C799 /* NicknameValidationResponse.swift */; }; - 2A5BB7FA2CE277AF00E1C799 /* ProfileImageSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7F92CE277AF00E1C799 /* ProfileImageSettingViewReactor.swift */; }; + 2A5BB7FA2CE277AF00E1C799 /* OnboardingProfileImageSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7F92CE277AF00E1C799 /* OnboardingProfileImageSettingViewReactor.swift */; }; 2A62805B2D084FEB00803BE9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2A62805A2D084FEB00803BE9 /* GoogleService-Info.plist */; }; 2A649ECF2CAE8970002D8284 /* SOMDialogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A649ECE2CAE8970002D8284 /* SOMDialogViewController.swift */; }; 2A649ED42CAE990B002D8284 /* SOMDialogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A649ECE2CAE8970002D8284 /* SOMDialogViewController.swift */; }; @@ -56,89 +33,14 @@ 2A980B9E2D803E9D007DFA45 /* FirebaseLoggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980B9C2D803E9D007DFA45 /* FirebaseLoggable.swift */; }; 2A980BA02D803EB1007DFA45 /* AnalyticsEventProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */; }; 2A980BA12D803EB1007DFA45 /* AnalyticsEventProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */; }; - 2A980BA42D803EEA007DFA45 /* SOMEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */; }; - 2A980BA52D803EEA007DFA45 /* SOMEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */; }; - 2A980BA82D803F04007DFA45 /* GAManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAManager.swift */; }; - 2A980BA92D803F04007DFA45 /* GAManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAManager.swift */; }; - 2ACBD4132CC944FB0057C013 /* UploadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD4122CC944FB0057C013 /* UploadRequest.swift */; }; - 2ACBD4142CC944FB0057C013 /* UploadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD4122CC944FB0057C013 /* UploadRequest.swift */; }; - 2ACBD4172CC963390057C013 /* DefaultCardImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD4162CC963390057C013 /* DefaultCardImageResponse.swift */; }; - 2ACBD4182CC963390057C013 /* DefaultCardImageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD4162CC963390057C013 /* DefaultCardImageResponse.swift */; }; - 2ACBD41A2CCA03790057C013 /* ImageURLWithName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD4192CCA03790057C013 /* ImageURLWithName.swift */; }; - 2ACBD41B2CCA03790057C013 /* ImageURLWithName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD4192CCA03790057C013 /* ImageURLWithName.swift */; }; - 2ACBD41D2CCAB3490057C013 /* PresignedStorageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD41C2CCAB3490057C013 /* PresignedStorageResponse.swift */; }; - 2ACBD41E2CCAB3490057C013 /* PresignedStorageResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACBD41C2CCAB3490057C013 /* PresignedStorageResponse.swift */; }; + 2A980BA82D803F04007DFA45 /* GAHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAHelper.swift */; }; + 2A980BA92D803F04007DFA45 /* GAHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAHelper.swift */; }; 2AE6B1492CBC15BF00FA5C3C /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */; }; 2AE6B14A2CBC15BF00FA5C3C /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */; }; 2AE6B14C2CBC160C00FA5C3C /* ReportViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B14B2CBC160C00FA5C3C /* ReportViewReactor.swift */; }; 2AE6B14D2CBC160C00FA5C3C /* ReportViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B14B2CBC160C00FA5C3C /* ReportViewReactor.swift */; }; - 2AE6B1502CBCC2F600FA5C3C /* ReportTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B14F2CBCC2F600FA5C3C /* ReportTableViewCell.swift */; }; - 2AE6B1512CBCC2F600FA5C3C /* ReportTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B14F2CBCC2F600FA5C3C /* ReportTableViewCell.swift */; }; - 2AE6B1542CBCC34B00FA5C3C /* ReportReasonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1532CBCC34B00FA5C3C /* ReportReasonView.swift */; }; - 2AE6B1552CBCC34B00FA5C3C /* ReportReasonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1532CBCC34B00FA5C3C /* ReportReasonView.swift */; }; - 2AE6B15A2CBEAEC000FA5C3C /* ReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1592CBEAEC000FA5C3C /* ReportRequest.swift */; }; - 2AE6B15B2CBEAEC000FA5C3C /* ReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1592CBEAEC000FA5C3C /* ReportRequest.swift */; }; - 2AE6B1632CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1622CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift */; }; - 2AE6B1642CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1622CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift */; }; - 2AE6B1662CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1652CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift */; }; - 2AE6B1672CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1652CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift */; }; - 2AE6B16D2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B16C2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift */; }; - 2AE6B16E2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B16C2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift */; }; - 2AE6B1712CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1702CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift */; }; - 2AE6B1722CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1702CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift */; }; - 2AE6B1752CBFD59B00FA5C3C /* ImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1742CBFD59B00FA5C3C /* ImageCollectionViewCell.swift */; }; - 2AE6B1762CBFD59B00FA5C3C /* ImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1742CBFD59B00FA5C3C /* ImageCollectionViewCell.swift */; }; - 2AE6B1782CBFE49D00FA5C3C /* SelectFontTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1772CBFE49D00FA5C3C /* SelectFontTableViewCell.swift */; }; - 2AE6B1792CBFE49D00FA5C3C /* SelectFontTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1772CBFE49D00FA5C3C /* SelectFontTableViewCell.swift */; }; - 2AE6B17B2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B17A2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift */; }; - 2AE6B17C2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B17A2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift */; }; - 2AE6B17F2CBFEA5200FA5C3C /* ToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B17E2CBFEA5200FA5C3C /* ToggleView.swift */; }; - 2AE6B1802CBFEA5200FA5C3C /* ToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B17E2CBFEA5200FA5C3C /* ToggleView.swift */; }; - 2AE6B18F2CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B18E2CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift */; }; - 2AE6B1902CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B18E2CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift */; }; - 2AE6B1922CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1912CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift */; }; - 2AE6B1932CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1912CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift */; }; - 2AFD05462CFF75DD007C84AD /* FavoriteTagsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05452CFF75DD007C84AD /* FavoriteTagsResponse.swift */; }; - 2AFD05472CFF75DD007C84AD /* FavoriteTagsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05452CFF75DD007C84AD /* FavoriteTagsResponse.swift */; }; - 2AFD05492CFF7687007C84AD /* RecommendTagsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05482CFF7687007C84AD /* RecommendTagsResponse.swift */; }; - 2AFD054A2CFF7687007C84AD /* RecommendTagsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05482CFF7687007C84AD /* RecommendTagsResponse.swift */; }; 2AFD054C2CFF76CB007C84AD /* TagRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */; }; - 2AFD054F2CFF79D8007C84AD /* TagsViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD054E2CFF79D8007C84AD /* TagsViewReactor.swift */; }; - 2AFD05502CFF79D8007C84AD /* TagsViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD054E2CFF79D8007C84AD /* TagsViewReactor.swift */; }; - 2AFD05522D007F2F007C84AD /* TagSearchViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05512D007F2F007C84AD /* TagSearchViewReactor.swift */; }; - 2AFD05532D007F2F007C84AD /* TagSearchViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05512D007F2F007C84AD /* TagSearchViewReactor.swift */; }; - 2AFD05552D0082DE007C84AD /* SearchTagsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05542D0082DE007C84AD /* SearchTagsResponse.swift */; }; - 2AFD05562D0082DE007C84AD /* SearchTagsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05542D0082DE007C84AD /* SearchTagsResponse.swift */; }; - 2AFD055A2D008D23007C84AD /* TagDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05582D008D23007C84AD /* TagDetailViewController.swift */; }; - 2AFD055D2D009513007C84AD /* TagDetailNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD055C2D009513007C84AD /* TagDetailNavigationBarView.swift */; }; - 2AFD055E2D009513007C84AD /* TagDetailNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD055C2D009513007C84AD /* TagDetailNavigationBarView.swift */; }; - 2AFD05602D009FA1007C84AD /* TagDetailViewrReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD055F2D009FA1007C84AD /* TagDetailViewrReactor.swift */; }; - 2AFD05612D009FA1007C84AD /* TagDetailViewrReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD055F2D009FA1007C84AD /* TagDetailViewrReactor.swift */; }; - 2AFD05632D00A1E1007C84AD /* TagDetailCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05622D00A1E1007C84AD /* TagDetailCardResponse.swift */; }; - 2AFD05642D00A1E1007C84AD /* TagDetailCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05622D00A1E1007C84AD /* TagDetailCardResponse.swift */; }; - 2AFD05672D01CB30007C84AD /* TagInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05652D01CB30007C84AD /* TagInfoResponse.swift */; }; - 2AFD05692D03264C007C84AD /* AddFavoriteTagResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05682D03264C007C84AD /* AddFavoriteTagResponse.swift */; }; - 2AFD056A2D03264C007C84AD /* AddFavoriteTagResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05682D03264C007C84AD /* AddFavoriteTagResponse.swift */; }; - 2AFD056D2D048C84007C84AD /* FavoriteTagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF955C2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift */; }; 2AFD056E2D048CAF007C84AD /* TagRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */; }; - 2AFF95552CF3222400CBFB12 /* TagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95542CF3222400CBFB12 /* TagsViewController.swift */; }; - 2AFF95562CF3222400CBFB12 /* TagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95542CF3222400CBFB12 /* TagsViewController.swift */; }; - 2AFF955A2CF3227900CBFB12 /* TagSearchTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95592CF3227900CBFB12 /* TagSearchTextFieldView.swift */; }; - 2AFF955B2CF3227900CBFB12 /* TagSearchTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95592CF3227900CBFB12 /* TagSearchTextFieldView.swift */; }; - 2AFF955D2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF955C2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift */; }; - 2AFF95612CF33A3900CBFB12 /* FavoriteTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95602CF33A3900CBFB12 /* FavoriteTagView.swift */; }; - 2AFF95622CF33A3900CBFB12 /* FavoriteTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95602CF33A3900CBFB12 /* FavoriteTagView.swift */; }; - 2AFF95642CF33D9F00CBFB12 /* TagsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95632CF33D9F00CBFB12 /* TagsHeaderView.swift */; }; - 2AFF95652CF33D9F00CBFB12 /* TagsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95632CF33D9F00CBFB12 /* TagsHeaderView.swift */; }; - 2AFF95682CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95672CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift */; }; - 2AFF95692CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95672CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift */; }; - 2AFF956C2CF5E00600CBFB12 /* RecommendTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF956A2CF5E00600CBFB12 /* RecommendTagView.swift */; }; - 2AFF95702CF5E8DE00CBFB12 /* TagSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF956F2CF5E8DE00CBFB12 /* TagSearchViewController.swift */; }; - 2AFF95712CF5E8DE00CBFB12 /* TagSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF956F2CF5E8DE00CBFB12 /* TagSearchViewController.swift */; }; - 2AFF95742CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95732CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift */; }; - 2AFF95752CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95732CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift */; }; - 2AFF95782CF5F0B000CBFB12 /* TagPreviewCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95772CF5F0B000CBFB12 /* TagPreviewCardView.swift */; }; - 2AFF95792CF5F0B000CBFB12 /* TagPreviewCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF95772CF5F0B000CBFB12 /* TagPreviewCardView.swift */; }; 3800575C2D9C12CB00E58A19 /* DefinedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3800575B2D9C12CB00E58A19 /* DefinedError.swift */; }; 3800575D2D9C12CB00E58A19 /* DefinedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3800575B2D9C12CB00E58A19 /* DefinedError.swift */; }; 38026E3F2CA2B45A0045E1CE /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38026E3E2CA2B45A0045E1CE /* LocationManager.swift */; }; @@ -149,34 +51,48 @@ 3802BDB62D0AF16A001256EA /* PushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3802BDB02D0AE900001256EA /* PushManager.swift */; }; 3802BDB82D0AF2F7001256EA /* PushManager+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3802BDB72D0AF2F7001256EA /* PushManager+Rx.swift */; }; 3802BDB92D0AF2F7001256EA /* PushManager+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3802BDB72D0AF2F7001256EA /* PushManager+Rx.swift */; }; + 3803B91B2ECF3944009D14B9 /* PopularTagHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B91A2ECF3937009D14B9 /* PopularTagHeaderView.swift */; }; + 3803B91C2ECF3944009D14B9 /* PopularTagHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B91A2ECF3937009D14B9 /* PopularTagHeaderView.swift */; }; + 3803B91E2ECF3A75009D14B9 /* FavoriteTagHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B91D2ECF3A6B009D14B9 /* FavoriteTagHeaderView.swift */; }; + 3803B91F2ECF3A75009D14B9 /* FavoriteTagHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B91D2ECF3A6B009D14B9 /* FavoriteTagHeaderView.swift */; }; + 3803B9232ECF52CE009D14B9 /* TagCollectCardViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9222ECF52C0009D14B9 /* TagCollectCardViewCell.swift */; }; + 3803B9242ECF52CE009D14B9 /* TagCollectCardViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9222ECF52C0009D14B9 /* TagCollectCardViewCell.swift */; }; + 3803B9262ECF530C009D14B9 /* TagCollectPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9252ECF5302009D14B9 /* TagCollectPlaceholderViewCell.swift */; }; + 3803B9272ECF530C009D14B9 /* TagCollectPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9252ECF5302009D14B9 /* TagCollectPlaceholderViewCell.swift */; }; + 3803B92A2ECF5580009D14B9 /* TagCollectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9292ECF5579009D14B9 /* TagCollectViewController.swift */; }; + 3803B92B2ECF5580009D14B9 /* TagCollectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9292ECF5579009D14B9 /* TagCollectViewController.swift */; }; + 3803B92D2ECF5589009D14B9 /* TagCollectViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B92C2ECF5584009D14B9 /* TagCollectViewReactor.swift */; }; + 3803B92E2ECF5589009D14B9 /* TagCollectViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B92C2ECF5584009D14B9 /* TagCollectViewReactor.swift */; }; + 3803B9312ECF5F21009D14B9 /* SearchTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9302ECF5F1B009D14B9 /* SearchTextFieldView.swift */; }; + 3803B9322ECF5F21009D14B9 /* SearchTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803B9302ECF5F1B009D14B9 /* SearchTextFieldView.swift */; }; 3803CF692D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */; }; 3803CF6A2D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */; }; - 3803CF6C2D0156FC00FD90DB /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */; }; - 3803CF6D2D0156FC00FD90DB /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */; }; - 3803CF702D0159A500FD90DB /* SettingsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */; }; - 3803CF712D0159A500FD90DB /* SettingsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */; }; - 3803CF742D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */; }; - 3803CF752D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */; }; - 3803CF772D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */; }; - 3803CF782D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */; }; 3803CF7A2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF792D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift */; }; 3803CF7B2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF792D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift */; }; - 3803CF7D2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF7C2D016DA200FD90DB /* TransferCodeResponse.swift */; }; - 3803CF7E2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF7C2D016DA200FD90DB /* TransferCodeResponse.swift */; }; 3803CF822D017DB800FD90DB /* EnterMemberTransferViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF812D017DB800FD90DB /* EnterMemberTransferViewController.swift */; }; 3803CF832D017DB800FD90DB /* EnterMemberTransferViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF812D017DB800FD90DB /* EnterMemberTransferViewController.swift */; }; 3803CF852D017DC700FD90DB /* EnterMemberTransferViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF842D017DC700FD90DB /* EnterMemberTransferViewReactor.swift */; }; 3803CF862D017DC700FD90DB /* EnterMemberTransferViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF842D017DC700FD90DB /* EnterMemberTransferViewReactor.swift */; }; 3803CF882D01914200FD90DB /* ResignViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF872D01914200FD90DB /* ResignViewReactor.swift */; }; 3803CF892D01914200FD90DB /* ResignViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF872D01914200FD90DB /* ResignViewReactor.swift */; }; - 38121E292CA6A52400602499 /* UIRefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38121E282CA6A52400602499 /* UIRefreshControl.swift */; }; - 38121E2A2CA6A52400602499 /* UIRefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38121E282CA6A52400602499 /* UIRefreshControl.swift */; }; + 380F42212E87ECA3009AC59E /* CompositeNotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42202E87ECA2009AC59E /* CompositeNotificationInfo.swift */; }; + 380F42222E87ECA3009AC59E /* CompositeNotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42202E87ECA2009AC59E /* CompositeNotificationInfo.swift */; }; + 380F42242E884AE5009AC59E /* CardRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42232E884ADF009AC59E /* CardRemoteDataSource.swift */; }; + 380F42252E884AE5009AC59E /* CardRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42232E884ADF009AC59E /* CardRemoteDataSource.swift */; }; + 380F42272E884B80009AC59E /* BaseCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42262E884B6F009AC59E /* BaseCardInfo.swift */; }; + 380F42282E884B80009AC59E /* BaseCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42262E884B6F009AC59E /* BaseCardInfo.swift */; }; + 380F422A2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42292E884E85009AC59E /* HomeCardInfoResponse.swift */; }; + 380F422B2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42292E884E85009AC59E /* HomeCardInfoResponse.swift */; }; + 380F422D2E884F3D009AC59E /* CardRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F422C2E884F35009AC59E /* CardRemoteDataSourceImpl.swift */; }; + 380F422E2E884F3D009AC59E /* CardRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F422C2E884F35009AC59E /* CardRemoteDataSourceImpl.swift */; }; + 380F42302E884FBC009AC59E /* CardRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F422F2E884FB5009AC59E /* CardRepository.swift */; }; + 380F42312E884FBC009AC59E /* CardRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F422F2E884FB5009AC59E /* CardRepository.swift */; }; + 380F42332E884FDC009AC59E /* CardRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42322E884FD4009AC59E /* CardRepositoryImpl.swift */; }; + 380F42342E884FDC009AC59E /* CardRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380F42322E884FD4009AC59E /* CardRepositoryImpl.swift */; }; 38121E312CA6C77500602499 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38121E302CA6C77500602499 /* Double.swift */; }; 38121E322CA6C77500602499 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38121E302CA6C77500602499 /* Double.swift */; }; 38121E342CA6DA4000602499 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38121E332CA6DA4000602499 /* Date.swift */; }; 38121E352CA6DA4000602499 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38121E332CA6DA4000602499 /* Date.swift */; }; - 3816C05C2CCDDF3D00C8688C /* ReAuthenticationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3816C05B2CCDDF3D00C8688C /* ReAuthenticationResponse.swift */; }; - 3816C05D2CCDDF3D00C8688C /* ReAuthenticationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3816C05B2CCDDF3D00C8688C /* ReAuthenticationResponse.swift */; }; 3816C0602CCDE35300C8688C /* ErrorInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3816C05F2CCDE35300C8688C /* ErrorInterceptor.swift */; }; 3816C0612CCDE35300C8688C /* ErrorInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3816C05F2CCDE35300C8688C /* ErrorInterceptor.swift */; }; 3816E2372D3BEE7E004CC196 /* TermsOfServiceCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3816E2362D3BEE7E004CC196 /* TermsOfServiceCellView.swift */; }; @@ -189,32 +105,48 @@ 381701722CD88374005FC220 /* CompositeInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381701702CD88374005FC220 /* CompositeInterceptor.swift */; }; 381701782CD88854005FC220 /* LogginMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381701772CD88854005FC220 /* LogginMonitor.swift */; }; 381701792CD88854005FC220 /* LogginMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381701772CD88854005FC220 /* LogginMonitor.swift */; }; - 381A1D652CC38E7D005FDB8E /* WriteTagTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D642CC38E7D005FDB8E /* WriteTagTextField.swift */; }; - 381A1D662CC38E7D005FDB8E /* WriteTagTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D642CC38E7D005FDB8E /* WriteTagTextField.swift */; }; - 381A1D6A2CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D692CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift */; }; - 381A1D6B2CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D692CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift */; }; + 381854982E992E9900424D71 /* WriteCardSelectImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381854972E992E8900424D71 /* WriteCardSelectImageView.swift */; }; + 381854992E992E9900424D71 /* WriteCardSelectImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381854972E992E8900424D71 /* WriteCardSelectImageView.swift */; }; + 3818549C2E992F7D00424D71 /* WriteCardDefaultImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3818549B2E992F7400424D71 /* WriteCardDefaultImageCell.swift */; }; + 3818549D2E992F7D00424D71 /* WriteCardDefaultImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3818549B2E992F7400424D71 /* WriteCardDefaultImageCell.swift */; }; + 3818549F2E99340600424D71 /* WriteCardUserImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3818549E2E9933FF00424D71 /* WriteCardUserImageCell.swift */; }; + 381854A02E99340600424D71 /* WriteCardUserImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3818549E2E9933FF00424D71 /* WriteCardUserImageCell.swift */; }; + 381854A92E99574100424D71 /* SelectTypographyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381854A82E99573700424D71 /* SelectTypographyView.swift */; }; + 381854AA2E99574100424D71 /* SelectTypographyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381854A82E99573700424D71 /* SelectTypographyView.swift */; }; 381A1D742CC3D799005FDB8E /* SOMTagsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D732CC3D799005FDB8E /* SOMTagsDelegate.swift */; }; 381A1D752CC3D799005FDB8E /* SOMTagsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D732CC3D799005FDB8E /* SOMTagsDelegate.swift */; }; 381A1D772CC3DA99005FDB8E /* SOMTagsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D762CC3DA99005FDB8E /* SOMTagsLayout.swift */; }; 381A1D782CC3DA99005FDB8E /* SOMTagsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D762CC3DA99005FDB8E /* SOMTagsLayout.swift */; }; + 381B83DC2EBC707A00C84015 /* ProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DB2EBC707400C84015 /* ProfileInfo.swift */; }; + 381B83DD2EBC707A00C84015 /* ProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DB2EBC707400C84015 /* ProfileInfo.swift */; }; + 381B83DF2EBC72B400C84015 /* FollowInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */; }; + 381B83E02EBC72B400C84015 /* FollowInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */; }; + 381B83E22EBC736800C84015 /* ProfileInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */; }; + 381B83E32EBC736800C84015 /* ProfileInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */; }; + 381B83E52EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */; }; + 381B83E62EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */; }; + 381B83E82EBC75D700C84015 /* ProfileCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */; }; + 381B83E92EBC75D700C84015 /* ProfileCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */; }; + 381B83EB2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */; }; + 381B83EC2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */; }; + 381B83F22EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */; }; + 381B83F32EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */; }; 381DEA8B2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */; }; 381DEA8C2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */; }; - 381DEA8D2CD4BE4A009F1FE9 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38608B2F2CB5195D0066BB40 /* Card.swift */; }; - 381DEA8E2CD4BE55009F1FE9 /* SignInResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A44A42C2CAC14C800DC463E /* SignInResponse.swift */; }; + 381E7C192ECCB1A700E80249 /* TagViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381E7C182ECCB1A200E80249 /* TagViewController.swift */; }; + 381E7C1A2ECCB1A700E80249 /* TagViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381E7C182ECCB1A200E80249 /* TagViewController.swift */; }; + 381E7C1C2ECCB1AD00E80249 /* TagViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381E7C1B2ECCB1AA00E80249 /* TagViewReactor.swift */; }; + 381E7C1D2ECCB1AD00E80249 /* TagViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381E7C1B2ECCB1AA00E80249 /* TagViewReactor.swift */; }; + 381E7C232ECCC63E00E80249 /* SearchViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381E7C222ECCC62900E80249 /* SearchViewButton.swift */; }; + 381E7C242ECCC63E00E80249 /* SearchViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381E7C222ECCC62900E80249 /* SearchViewButton.swift */; }; 382D5CF62CFE9B8600BFA23E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382D5CF52CFE9B8600BFA23E /* ProfileViewController.swift */; }; 382D5CF72CFE9B8600BFA23E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382D5CF52CFE9B8600BFA23E /* ProfileViewController.swift */; }; - 382E15362D15A6460097B09C /* NotificationTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E15352D15A6460097B09C /* NotificationTabBarController.swift */; }; - 382E15372D15A6460097B09C /* NotificationTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E15352D15A6460097B09C /* NotificationTabBarController.swift */; }; 382E153A2D15A67A0097B09C /* NotificationViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E15392D15A67A0097B09C /* NotificationViewCell.swift */; }; 382E153B2D15A67A0097B09C /* NotificationViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E15392D15A67A0097B09C /* NotificationViewCell.swift */; }; - 382E153F2D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E153E2D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift */; }; - 382E15402D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E153E2D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift */; }; - 382E15422D15BA490097B09C /* NotificationWithReportViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E15412D15BA490097B09C /* NotificationWithReportViewCell.swift */; }; - 382E15432D15BA490097B09C /* NotificationWithReportViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382E15412D15BA490097B09C /* NotificationWithReportViewCell.swift */; }; + 383088092EDC7B8C00D99D88 /* SOMMessageBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383088082EDC7B8200D99D88 /* SOMMessageBubbleView.swift */; }; + 3830880A2EDC7B8C00D99D88 /* SOMMessageBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383088082EDC7B8200D99D88 /* SOMMessageBubbleView.swift */; }; 3830FFA62CEC6E3100ABA9FD /* Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3830FFA52CEC6E3100ABA9FD /* Kingfisher.swift */; }; 3830FFA72CEC6E3100ABA9FD /* Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3830FFA52CEC6E3100ABA9FD /* Kingfisher.swift */; }; - 3834FADD2D11C5AC00C9108D /* SimpleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3834FADC2D11C5AC00C9108D /* SimpleCache.swift */; }; - 3834FADE2D11C5AC00C9108D /* SimpleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3834FADC2D11C5AC00C9108D /* SimpleCache.swift */; }; 3836ACB42C8F045300A3C566 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3836ACB32C8F045300A3C566 /* Typography.swift */; }; 3836ACB52C8F045300A3C566 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3836ACB32C8F045300A3C566 /* Typography.swift */; }; 3836ACB72C8F04CD00A3C566 /* UILabel+Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3836ACB62C8F04CD00A3C566 /* UILabel+Observer.swift */; }; @@ -225,8 +157,18 @@ 38389B9D2CCCF98B006728AF /* AuthRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38389B9B2CCCF98B006728AF /* AuthRequest.swift */; }; 38389B9F2CCCFB7D006728AF /* AuthKeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38389B9E2CCCFB7D006728AF /* AuthKeyChain.swift */; }; 38389BA02CCCFB7D006728AF /* AuthKeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38389B9E2CCCFB7D006728AF /* AuthKeyChain.swift */; }; - 38405CCB2CC611FD00612D1E /* BaseEmptyAndHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38405CCA2CC611FD00612D1E /* BaseEmptyAndHeader.swift */; }; - 38405CCC2CC611FD00612D1E /* BaseEmptyAndHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38405CCA2CC611FD00612D1E /* BaseEmptyAndHeader.swift */; }; + 383EC6112E7A4F6B00EC2D1E /* AuthLocalDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6102E7A4F5E00EC2D1E /* AuthLocalDataSource.swift */; }; + 383EC6122E7A4F6B00EC2D1E /* AuthLocalDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6102E7A4F5E00EC2D1E /* AuthLocalDataSource.swift */; }; + 383EC6152E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6142E7A50E000EC2D1E /* AuthLocalDataSourceImpl.swift */; }; + 383EC6162E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6142E7A50E000EC2D1E /* AuthLocalDataSourceImpl.swift */; }; + 383EC6192E7A547900EC2D1E /* BaseAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6182E7A546B00EC2D1E /* BaseAssembler.swift */; }; + 383EC61A2E7A547900EC2D1E /* BaseAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6182E7A546B00EC2D1E /* BaseAssembler.swift */; }; + 383EC61C2E7A548E00EC2D1E /* BaseDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC61B2E7A548600EC2D1E /* BaseDIContainer.swift */; }; + 383EC61D2E7A548E00EC2D1E /* BaseDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC61B2E7A548600EC2D1E /* BaseDIContainer.swift */; }; + 383EC6202E7A564600EC2D1E /* AppAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC61F2E7A564200EC2D1E /* AppAssembler.swift */; }; + 383EC6212E7A564600EC2D1E /* AppAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC61F2E7A564200EC2D1E /* AppAssembler.swift */; }; + 383EC6232E7A56CE00EC2D1E /* AppDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6222E7A56CA00EC2D1E /* AppDIContainer.swift */; }; + 383EC6242E7A56CE00EC2D1E /* AppDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383EC6222E7A56CA00EC2D1E /* AppDIContainer.swift */; }; 3843C1BE2D4FB778009283AC /* MockAlamofire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3843C1BD2D4FB778009283AC /* MockAlamofire.swift */; }; 3843C1C02D4FC226009283AC /* MockNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3843C1BF2D4FC226009283AC /* MockNetworkManager.swift */; }; 3843C1C22D4FC99E009283AC /* NetworkManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3843C1C12D4FC99E009283AC /* NetworkManagerTests.swift */; }; @@ -250,63 +192,83 @@ 385441922C870544004E2BB0 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FBAF12C8702C100A5E139 /* SceneDelegate.swift */; }; 385441952C870544004E2BB0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 387FBAF82C8702C200A5E139 /* Assets.xcassets */; }; 385441962C870544004E2BB0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 387FBAFB2C8702C200A5E139 /* Base */; }; - 385602B62D2FB18400118530 /* NotiPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385602B52D2FB18400118530 /* NotiPlaceholderViewCell.swift */; }; - 385602B72D2FB18400118530 /* NotiPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385602B52D2FB18400118530 /* NotiPlaceholderViewCell.swift */; }; + 385602B62D2FB18400118530 /* NotificationPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385602B52D2FB18400118530 /* NotificationPlaceholderViewCell.swift */; }; + 385602B72D2FB18400118530 /* NotificationPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385602B52D2FB18400118530 /* NotificationPlaceholderViewCell.swift */; }; 385620EF2CA19C9500E0AB5A /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385620EE2CA19C9500E0AB5A /* NetworkManager.swift */; }; 385620F02CA19C9500E0AB5A /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385620EE2CA19C9500E0AB5A /* NetworkManager.swift */; }; 385620F22CA19D2D00E0AB5A /* Alamofire_Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385620F12CA19D2D00E0AB5A /* Alamofire_Request.swift */; }; 385620F32CA19D2D00E0AB5A /* Alamofire_Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385620F12CA19D2D00E0AB5A /* Alamofire_Request.swift */; }; 385620F62CA19EA900E0AB5A /* Alamofire_constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385620F52CA19EA900E0AB5A /* Alamofire_constants.swift */; }; 385620F72CA19EA900E0AB5A /* Alamofire_constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385620F52CA19EA900E0AB5A /* Alamofire_constants.swift */; }; - 38572CD82D2230C900B07C69 /* NotificationAllowResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CD72D2230C900B07C69 /* NotificationAllowResponse.swift */; }; - 38572CD92D2230C900B07C69 /* NotificationAllowResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CD72D2230C900B07C69 /* NotificationAllowResponse.swift */; }; - 38572CDB2D22464F00B07C69 /* PungTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CDA2D22464F00B07C69 /* PungTimeView.swift */; }; - 38572CDC2D22464F00B07C69 /* PungTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CDA2D22464F00B07C69 /* PungTimeView.swift */; }; - 38572CDE2D2254E800B07C69 /* PlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CDD2D2254E800B07C69 /* PlaceholderViewCell.swift */; }; - 38572CDF2D2254E800B07C69 /* PlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CDD2D2254E800B07C69 /* PlaceholderViewCell.swift */; }; + 38572CDE2D2254E800B07C69 /* HomePlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CDD2D2254E800B07C69 /* HomePlaceholderViewCell.swift */; }; + 38572CDF2D2254E800B07C69 /* HomePlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38572CDD2D2254E800B07C69 /* HomePlaceholderViewCell.swift */; }; 3857BC3F2D4D1FFA008D4264 /* MockManagerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3857BC3E2D4D1FFA008D4264 /* MockManagerProvider.swift */; }; 3857BC412D4D22B4008D4264 /* MockManagerProviderContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3857BC402D4D22B4008D4264 /* MockManagerProviderContainerTests.swift */; }; + 385C01AE2E8E8C6F003C7894 /* SOMPageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01AD2E8E8C6A003C7894 /* SOMPageModel.swift */; }; + 385C01AF2E8E8C6F003C7894 /* SOMPageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01AD2E8E8C6A003C7894 /* SOMPageModel.swift */; }; + 385C01B12E8E8DD8003C7894 /* SOMPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01B02E8E8DD4003C7894 /* SOMPageView.swift */; }; + 385C01B22E8E8DD8003C7894 /* SOMPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01B02E8E8DD4003C7894 /* SOMPageView.swift */; }; + 385C01B42E8EA1B7003C7894 /* SOMPageViewsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01B32E8EA1B1003C7894 /* SOMPageViewsDelegate.swift */; }; + 385C01B52E8EA1B7003C7894 /* SOMPageViewsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01B32E8EA1B1003C7894 /* SOMPageViewsDelegate.swift */; }; + 385C01B72E8EA1EF003C7894 /* SOMPageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01B62E8EA1EB003C7894 /* SOMPageViews.swift */; }; + 385C01B82E8EA1EF003C7894 /* SOMPageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C01B62E8EA1EB003C7894 /* SOMPageViews.swift */; }; 385E65A32CBE56D00032E120 /* Coordinate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385E65A22CBE56D00032E120 /* Coordinate.swift */; }; 385E65A42CBE56D00032E120 /* Coordinate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385E65A22CBE56D00032E120 /* Coordinate.swift */; }; 38601E182D31399400A465A9 /* CardRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384972A02CA4DEC00012FCA1 /* CardRequest.swift */; }; - 38601E192D3139A500A465A9 /* TagDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05582D008D23007C84AD /* TagDetailViewController.swift */; }; - 38601E1A2D3139BB00A465A9 /* TagInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFD05652D01CB30007C84AD /* TagInfoResponse.swift */; }; - 38601E1B2D3139D000A465A9 /* RecommendTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFF956A2CF5E00600CBFB12 /* RecommendTagView.swift */; }; 38601E1C2D313A8200A465A9 /* SOMNavigationBar+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878FE0C2D0365C800D8955C /* SOMNavigationBar+Rx.swift */; }; - 38608B302CB5195D0066BB40 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38608B2F2CB5195D0066BB40 /* Card.swift */; }; 3862C0DF2C9EB6670023C046 /* UIViewController+PushAndPop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3862C0DE2C9EB6670023C046 /* UIViewController+PushAndPop.swift */; }; 3862C0E02C9EB6670023C046 /* UIViewController+PushAndPop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3862C0DE2C9EB6670023C046 /* UIViewController+PushAndPop.swift */; }; 3866577E2CEF3554009F7F60 /* UIButton+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3866577D2CEF3554009F7F60 /* UIButton+Rx.swift */; }; 3866577F2CEF3554009F7F60 /* UIButton+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3866577D2CEF3554009F7F60 /* UIButton+Rx.swift */; }; - 38738D4B2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38738D4A2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift */; }; - 38738D4C2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38738D4A2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift */; }; + 386712C42E97734B00541389 /* UITextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386712C32E97734800541389 /* UITextField.swift */; }; + 386712C52E97734B00541389 /* UITextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386712C32E97734800541389 /* UITextField.swift */; }; + 386712CA2E977E9D00541389 /* Kkukkkuk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 386712C92E977E9D00541389 /* Kkukkkuk.ttf */; }; + 386712CB2E977E9D00541389 /* Kkukkkuk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 386712C92E977E9D00541389 /* Kkukkkuk.ttf */; }; + 386712CD2E977EC200541389 /* Yoonwoo.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 386712CC2E977EC200541389 /* Yoonwoo.ttf */; }; + 386712CE2E977EC200541389 /* Yoonwoo.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 386712CC2E977EC200541389 /* Yoonwoo.ttf */; }; + 386712D02E977F1B00541389 /* RIDIBatang.otf in Resources */ = {isa = PBXBuildFile; fileRef = 386712CF2E977F1B00541389 /* RIDIBatang.otf */; }; + 386712D12E977F1B00541389 /* RIDIBatang.otf in Resources */ = {isa = PBXBuildFile; fileRef = 386712CF2E977F1B00541389 /* RIDIBatang.otf */; }; + 386867A42E9E378200171A5E /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386867A32E9E378000171A5E /* Array.swift */; }; + 386867A52E9E378200171A5E /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386867A32E9E378000171A5E /* Array.swift */; }; + 386867A72E9E932B00171A5E /* WriteCardSelectImageView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386867A62E9E932300171A5E /* WriteCardSelectImageView+Rx.swift */; }; + 386867A82E9E932B00171A5E /* WriteCardSelectImageView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386867A62E9E932300171A5E /* WriteCardSelectImageView+Rx.swift */; }; + 386E966B2E9A51D9005E047D /* SelectOptionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386E966A2E9A51D2005E047D /* SelectOptionItem.swift */; }; + 386E966C2E9A51D9005E047D /* SelectOptionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386E966A2E9A51D2005E047D /* SelectOptionItem.swift */; }; + 386E966E2E9A53D6005E047D /* SelectOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386E966D2E9A53CC005E047D /* SelectOptionsView.swift */; }; + 386E966F2E9A53D6005E047D /* SelectOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386E966D2E9A53CC005E047D /* SelectOptionsView.swift */; }; + 3874B5602ECB25D1004CC22A /* SettingsLocalDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3874B55F2ECB25C9004CC22A /* SettingsLocalDataSource.swift */; }; + 3874B5612ECB25D1004CC22A /* SettingsLocalDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3874B55F2ECB25C9004CC22A /* SettingsLocalDataSource.swift */; }; + 3874B5632ECB2613004CC22A /* SettingsLocalDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3874B5622ECB2606004CC22A /* SettingsLocalDataSourceImpl.swift */; }; + 3874B5642ECB2613004CC22A /* SettingsLocalDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3874B5622ECB2606004CC22A /* SettingsLocalDataSourceImpl.swift */; }; 38773E7C2CB3ACB2004815CD /* SOMRefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38773E7B2CB3ACB2004815CD /* SOMRefreshControl.swift */; }; 38773E7D2CB3ACB2004815CD /* SOMRefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38773E7B2CB3ACB2004815CD /* SOMRefreshControl.swift */; }; + 38787B752ED1E5B3004BBAA7 /* SearchTermViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B742ED1E5A8004BBAA7 /* SearchTermViewCell.swift */; }; + 38787B762ED1E5B3004BBAA7 /* SearchTermViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B742ED1E5A8004BBAA7 /* SearchTermViewCell.swift */; }; + 38787B782ED1E719004BBAA7 /* SearchTermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B772ED1E715004BBAA7 /* SearchTermsView.swift */; }; + 38787B792ED1E719004BBAA7 /* SearchTermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B772ED1E715004BBAA7 /* SearchTermsView.swift */; }; + 38787B7B2ED1E8B3004BBAA7 /* TagSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B7A2ED1E8AB004BBAA7 /* TagSearchViewController.swift */; }; + 38787B7C2ED1E8B3004BBAA7 /* TagSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B7A2ED1E8AB004BBAA7 /* TagSearchViewController.swift */; }; + 38787B7E2ED1E8F4004BBAA7 /* TagSearchViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B7D2ED1E8EF004BBAA7 /* TagSearchViewReactor.swift */; }; + 38787B7F2ED1E8F4004BBAA7 /* TagSearchViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B7D2ED1E8EF004BBAA7 /* TagSearchViewReactor.swift */; }; + 38787B812ED1EB21004BBAA7 /* TagCollectCardsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B802ED1EB1C004BBAA7 /* TagCollectCardsView.swift */; }; + 38787B822ED1EB21004BBAA7 /* TagCollectCardsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B802ED1EB1C004BBAA7 /* TagCollectCardsView.swift */; }; + 38787B882ED22324004BBAA7 /* RxSwift+Unretained.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B872ED22323004BBAA7 /* RxSwift+Unretained.swift */; }; + 38787B892ED22324004BBAA7 /* RxSwift+Unretained.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B872ED22323004BBAA7 /* RxSwift+Unretained.swift */; }; + 38787B8B2ED22A29004BBAA7 /* SearchTermPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B8A2ED22A1F004BBAA7 /* SearchTermPlaceholderViewCell.swift */; }; + 38787B8C2ED22A29004BBAA7 /* SearchTermPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38787B8A2ED22A1F004BBAA7 /* SearchTermPlaceholderViewCell.swift */; }; 387894462D31788800F69487 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 387894442D31786B00F69487 /* GoogleService-Info.plist */; }; 3878B8622D0DC8BD00B3B128 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878B8612D0DC8BD00B3B128 /* UIViewController+Toast.swift */; }; 3878B8632D0DC8BD00B3B128 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878B8612D0DC8BD00B3B128 /* UIViewController+Toast.swift */; }; - 3878D04E2CFFC5F300F9522F /* ProfileRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D04D2CFFC5F300F9522F /* ProfileRequest.swift */; }; - 3878D04F2CFFC5F300F9522F /* ProfileRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D04D2CFFC5F300F9522F /* ProfileRequest.swift */; }; - 3878D0532CFFC6C100F9522F /* ProfileResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0522CFFC6C100F9522F /* ProfileResponse.swift */; }; - 3878D0542CFFC6C100F9522F /* ProfileResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0522CFFC6C100F9522F /* ProfileResponse.swift */; }; - 3878D0562CFFCBDA00F9522F /* CommentHistoryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0552CFFCBDA00F9522F /* CommentHistoryResponse.swift */; }; - 3878D0572CFFCBDA00F9522F /* CommentHistoryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0552CFFCBDA00F9522F /* CommentHistoryResponse.swift */; }; - 3878D05C2CFFD10D00F9522F /* FollowingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D05B2CFFD10D00F9522F /* FollowingResponse.swift */; }; - 3878D05D2CFFD10D00F9522F /* FollowingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D05B2CFFD10D00F9522F /* FollowingResponse.swift */; }; - 3878D05F2CFFD45100F9522F /* FollowerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D05E2CFFD45100F9522F /* FollowerResponse.swift */; }; - 3878D0602CFFD45100F9522F /* FollowerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D05E2CFFD45100F9522F /* FollowerResponse.swift */; }; 3878D0632CFFD66700F9522F /* FollowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0622CFFD66700F9522F /* FollowViewController.swift */; }; 3878D0642CFFD66700F9522F /* FollowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0622CFFD66700F9522F /* FollowViewController.swift */; }; - 3878D0672CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */; }; - 3878D0682CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */; }; 3878D06B2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */; }; 3878D06C2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */; }; 3878D06F2CFFDF9600F9522F /* SettingTextCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */; }; 3878D0702CFFDF9600F9522F /* SettingTextCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */; }; 3878D0722CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */; }; 3878D0732CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */; }; - 3878D0752CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */; }; - 3878D0762CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */; }; + 3878D0752CFFE01500F9522F /* SettingVersionCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */; }; + 3878D0762CFFE01500F9522F /* SettingVersionCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */; }; 3878D0792CFFE1E800F9522F /* ResignViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0782CFFE1E800F9522F /* ResignViewController.swift */; }; 3878D07A2CFFE1E800F9522F /* ResignViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0782CFFE1E800F9522F /* ResignViewController.swift */; }; 3878D07D2CFFE6E500F9522F /* IssueMemberTransferViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D07C2CFFE6E500F9522F /* IssueMemberTransferViewController.swift */; }; @@ -321,8 +283,6 @@ 3878D08D2CFFF0BF00F9522F /* AnnouncementViewControler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D08B2CFFF0BF00F9522F /* AnnouncementViewControler.swift */; }; 3878D0902CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D08F2CFFF0E300F9522F /* AnnouncementViewCell.swift */; }; 3878D0912CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D08F2CFFF0E300F9522F /* AnnouncementViewCell.swift */; }; - 3878D0972CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */; }; - 3878D0982CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */; }; 3878F4712CA3F03400AA46A2 /* SOMCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4702CA3F03400AA46A2 /* SOMCard.swift */; }; 3878F4722CA3F03400AA46A2 /* SOMCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4702CA3F03400AA46A2 /* SOMCard.swift */; }; 3878F4742CA3F06C00AA46A2 /* UIStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4732CA3F06C00AA46A2 /* UIStackView.swift */; }; @@ -330,8 +290,14 @@ 3878F4772CA3F08300AA46A2 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4762CA3F08300AA46A2 /* UIView.swift */; }; 3878F4782CA3F08300AA46A2 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4762CA3F08300AA46A2 /* UIView.swift */; }; 3878FE0D2D0365C800D8955C /* SOMNavigationBar+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878FE0C2D0365C800D8955C /* SOMNavigationBar+Rx.swift */; }; - 387D852C2D08320A005D9D22 /* SOMCardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387D852B2D08320A005D9D22 /* SOMCardModel.swift */; }; - 387D852D2D08320A005D9D22 /* SOMCardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387D852B2D08320A005D9D22 /* SOMCardModel.swift */; }; + 3879B4B52EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */; }; + 3879B4B62EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */; }; + 3879B4B82EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */; }; + 3879B4B92EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */; }; + 387B73892EED71510055E384 /* GAEvent+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */; }; + 387B738A2EED71510055E384 /* GAEvent+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */; }; + 387FA11D2E88DDC1004DF7CE /* HomeViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */; }; + 387FA11E2E88DDC1004DF7CE /* HomeViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */; }; 387FBAF02C8702C100A5E139 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FBAEF2C8702C100A5E139 /* AppDelegate.swift */; }; 387FBAF22C8702C100A5E139 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FBAF12C8702C100A5E139 /* SceneDelegate.swift */; }; 387FBAF92C8702C200A5E139 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 387FBAF82C8702C200A5E139 /* Assets.xcassets */; }; @@ -346,10 +312,22 @@ 388009952CABFAAA002A9209 /* SOMTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388009932CABFAAA002A9209 /* SOMTags.swift */; }; 388009972CAC20EC002A9209 /* SOMTags+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388009962CAC20EC002A9209 /* SOMTags+Rx.swift */; }; 388009982CAC20EC002A9209 /* SOMTags+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388009962CAC20EC002A9209 /* SOMTags+Rx.swift */; }; + 3880EF6E2EA0CD7100D88608 /* RelatedTagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF6D2EA0CD6A00D88608 /* RelatedTagViewModel.swift */; }; + 3880EF6F2EA0CD7100D88608 /* RelatedTagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF6D2EA0CD6A00D88608 /* RelatedTagViewModel.swift */; }; + 3880EF712EA0CDA100D88608 /* RelatedTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF702EA0CD9D00D88608 /* RelatedTagView.swift */; }; + 3880EF722EA0CDA100D88608 /* RelatedTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF702EA0CD9D00D88608 /* RelatedTagView.swift */; }; + 3880EF742EA0CEEE00D88608 /* RelatedTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF732EA0CEE700D88608 /* RelatedTagsView.swift */; }; + 3880EF752EA0CEEE00D88608 /* RelatedTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF732EA0CEE700D88608 /* RelatedTagsView.swift */; }; + 3880EF772EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF762EA0CF2800D88608 /* RelatedTagsViewLayout.swift */; }; + 3880EF782EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF762EA0CF2800D88608 /* RelatedTagsViewLayout.swift */; }; + 3880EF7A2EA0D17E00D88608 /* RelatedTagsView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF792EA0D17900D88608 /* RelatedTagsView+Rx.swift */; }; + 3880EF7B2EA0D17E00D88608 /* RelatedTagsView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF792EA0D17900D88608 /* RelatedTagsView+Rx.swift */; }; + 3880EF7D2EA0DA7400D88608 /* WritrCardTextViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF7C2EA0DA6F00D88608 /* WritrCardTextViewDelegate.swift */; }; + 3880EF7E2EA0DA7400D88608 /* WritrCardTextViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF7C2EA0DA6F00D88608 /* WritrCardTextViewDelegate.swift */; }; + 3880EF802EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF7F2EA0DB0300D88608 /* WriteCardTagsDelegate.swift */; }; + 3880EF812EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF7F2EA0DB0300D88608 /* WriteCardTagsDelegate.swift */; }; 38816D9E2D004A5E00EB87D6 /* UpdateProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */; }; 38816D9F2D004A5E00EB87D6 /* UpdateProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */; }; - 38816DA22D004DED00EB87D6 /* UpdateProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */; }; - 38816DA32D004DED00EB87D6 /* UpdateProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */; }; 388371F92C8C8EB1004212EB /* SooumStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388371F82C8C8EB1004212EB /* SooumStyle.swift */; }; 388371FA2C8C8EB1004212EB /* SooumStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388371F82C8C8EB1004212EB /* SooumStyle.swift */; }; 388371FC2C8C8F11004212EB /* UIColor+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388371FB2C8C8F11004212EB /* UIColor+SOOUM.swift */; }; @@ -358,32 +336,114 @@ 388372022C8C8FCF004212EB /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388372002C8C8FCF004212EB /* UIColor.swift */; }; 3886939F2CF77FA7005F9EF3 /* UIApplication+Top.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3886939E2CF77FA7005F9EF3 /* UIApplication+Top.swift */; }; 388693A02CF77FA7005F9EF3 /* UIApplication+Top.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3886939E2CF77FA7005F9EF3 /* UIApplication+Top.swift */; }; - 388698512D191F2100008600 /* MainHomeDistanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698502D191F2100008600 /* MainHomeDistanceViewController.swift */; }; - 388698522D191F2100008600 /* MainHomeDistanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698502D191F2100008600 /* MainHomeDistanceViewController.swift */; }; - 388698542D191F4B00008600 /* MainHomeDistanceViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698532D191F4B00008600 /* MainHomeDistanceViewReactor.swift */; }; - 388698552D191F4B00008600 /* MainHomeDistanceViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698532D191F4B00008600 /* MainHomeDistanceViewReactor.swift */; }; 388698582D1982DE00008600 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698572D1982DE00008600 /* NotificationViewController.swift */; }; 388698592D1982DE00008600 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698572D1982DE00008600 /* NotificationViewController.swift */; }; 3886985F2D1984D600008600 /* NotificationViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3886985E2D1984D600008600 /* NotificationViewReactor.swift */; }; 388698602D1984D600008600 /* NotificationViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3886985E2D1984D600008600 /* NotificationViewReactor.swift */; }; 388698622D1986B100008600 /* NotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698612D1986B100008600 /* NotificationRequest.swift */; }; 388698632D1986B100008600 /* NotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698612D1986B100008600 /* NotificationRequest.swift */; }; - 388698652D1998DB00008600 /* NotificationTabBarReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698642D1998DB00008600 /* NotificationTabBarReactor.swift */; }; - 388698662D1998DB00008600 /* NotificationTabBarReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388698642D1998DB00008600 /* NotificationTabBarReactor.swift */; }; + 3887176A2E7BD7AC00C6143B /* loading_indicator_lottie.json in Resources */ = {isa = PBXBuildFile; fileRef = 388717692E7BD7AC00C6143B /* loading_indicator_lottie.json */; }; + 3887176B2E7BD7AC00C6143B /* loading_indicator_lottie.json in Resources */ = {isa = PBXBuildFile; fileRef = 388717692E7BD7AC00C6143B /* loading_indicator_lottie.json */; }; + 3887176D2E7BDBAE00C6143B /* NicknameResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887176C2E7BDBA800C6143B /* NicknameResponse.swift */; }; + 3887176E2E7BDBAE00C6143B /* NicknameResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887176C2E7BDBA800C6143B /* NicknameResponse.swift */; }; 3887D0332CC5335200FB52E1 /* WriteCardViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887D0322CC5335200FB52E1 /* WriteCardViewReactor.swift */; }; 3887D0342CC5335200FB52E1 /* WriteCardViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887D0322CC5335200FB52E1 /* WriteCardViewReactor.swift */; }; 3887D0362CC5335D00FB52E1 /* WriteCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887D0352CC5335D00FB52E1 /* WriteCardView.swift */; }; 3887D0372CC5335D00FB52E1 /* WriteCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887D0352CC5335D00FB52E1 /* WriteCardView.swift */; }; 3887D0392CC5504500FB52E1 /* UITextField+Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887D0382CC5504500FB52E1 /* UITextField+Typography.swift */; }; 3887D03A2CC5504500FB52E1 /* UITextField+Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3887D0382CC5504500FB52E1 /* UITextField+Typography.swift */; }; - 388A2D2D2D00A45800E2F2F0 /* writtenCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388A2D2C2D00A45800E2F2F0 /* writtenCardResponse.swift */; }; - 388A2D2E2D00A45800E2F2F0 /* writtenCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388A2D2C2D00A45800E2F2F0 /* writtenCardResponse.swift */; }; + 38899E582E7936DD0030F7CA /* SooumStyle_V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E572E7936D50030F7CA /* SooumStyle_V2.swift */; }; + 38899E592E7936DD0030F7CA /* SooumStyle_V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E572E7936D50030F7CA /* SooumStyle_V2.swift */; }; + 38899E5E2E7937E50030F7CA /* NicknameValidateResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E5D2E7937DB0030F7CA /* NicknameValidateResponse.swift */; }; + 38899E5F2E7937E50030F7CA /* NicknameValidateResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E5D2E7937DB0030F7CA /* NicknameValidateResponse.swift */; }; + 38899E662E7939600030F7CA /* CheckAvailableResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E652E79395E0030F7CA /* CheckAvailableResponse.swift */; }; + 38899E672E7939600030F7CA /* CheckAvailableResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E652E79395E0030F7CA /* CheckAvailableResponse.swift */; }; + 38899E6B2E793AFD0030F7CA /* CheckAvailable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E6A2E793AF70030F7CA /* CheckAvailable.swift */; }; + 38899E6C2E793AFD0030F7CA /* CheckAvailable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E6A2E793AF70030F7CA /* CheckAvailable.swift */; }; + 38899E6E2E79400C0030F7CA /* ImageUrlInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E6D2E79400B0030F7CA /* ImageUrlInfoResponse.swift */; }; + 38899E6F2E79400C0030F7CA /* ImageUrlInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E6D2E79400B0030F7CA /* ImageUrlInfoResponse.swift */; }; + 38899E712E79402C0030F7CA /* ImageUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E702E7940280030F7CA /* ImageUrlInfo.swift */; }; + 38899E722E79402C0030F7CA /* ImageUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E702E7940280030F7CA /* ImageUrlInfo.swift */; }; + 38899E7D2E794B420030F7CA /* SignUpResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E7C2E794B3D0030F7CA /* SignUpResponse.swift */; }; + 38899E7E2E794B420030F7CA /* SignUpResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E7C2E794B3D0030F7CA /* SignUpResponse.swift */; }; + 38899E832E794C360030F7CA /* LoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E822E794C330030F7CA /* LoginResponse.swift */; }; + 38899E842E794C360030F7CA /* LoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E822E794C330030F7CA /* LoginResponse.swift */; }; + 38899E862E794CEE0030F7CA /* NetworkManager_FCM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E852E794CE90030F7CA /* NetworkManager_FCM.swift */; }; + 38899E872E794CEE0030F7CA /* NetworkManager_FCM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E852E794CE90030F7CA /* NetworkManager_FCM.swift */; }; + 38899E892E794D620030F7CA /* NetworkManager_Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E882E794D5D0030F7CA /* NetworkManager_Version.swift */; }; + 38899E8A2E794D620030F7CA /* NetworkManager_Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E882E794D5D0030F7CA /* NetworkManager_Version.swift */; }; + 38899E8C2E794E690030F7CA /* AppVersionStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E8B2E794E680030F7CA /* AppVersionStatusResponse.swift */; }; + 38899E8D2E794E690030F7CA /* AppVersionStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E8B2E794E680030F7CA /* AppVersionStatusResponse.swift */; }; + 38899E8F2E7951200030F7CA /* KeyInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E8E2E79511F0030F7CA /* KeyInfoResponse.swift */; }; + 38899E902E7951200030F7CA /* KeyInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E8E2E79511F0030F7CA /* KeyInfoResponse.swift */; }; + 38899E932E79518F0030F7CA /* CommonNotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E922E79518E0030F7CA /* CommonNotificationInfo.swift */; }; + 38899E942E79518F0030F7CA /* CommonNotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E922E79518E0030F7CA /* CommonNotificationInfo.swift */; }; + 38899E962E7953310030F7CA /* NotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E952E7953300030F7CA /* NotificationInfoResponse.swift */; }; + 38899E972E7953310030F7CA /* NotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E952E7953300030F7CA /* NotificationInfoResponse.swift */; }; + 38899E992E7954680030F7CA /* BlockedNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E982E7954670030F7CA /* BlockedNotificationInfoResponse.swift */; }; + 38899E9A2E7954680030F7CA /* BlockedNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E982E7954670030F7CA /* BlockedNotificationInfoResponse.swift */; }; + 38899E9C2E7954D90030F7CA /* DeletedNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E9B2E7954D70030F7CA /* DeletedNotificationInfoResponse.swift */; }; + 38899E9D2E7954D90030F7CA /* DeletedNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899E9B2E7954D70030F7CA /* DeletedNotificationInfoResponse.swift */; }; + 38899EA32E799B260030F7CA /* AppVersionRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EA22E799B190030F7CA /* AppVersionRemoteDataSource.swift */; }; + 38899EA42E799B260030F7CA /* AppVersionRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EA22E799B190030F7CA /* AppVersionRemoteDataSource.swift */; }; + 38899EA62E799BD60030F7CA /* AppVersionRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EA52E799BD10030F7CA /* AppVersionRemoteDataSourceImpl.swift */; }; + 38899EA72E799BD60030F7CA /* AppVersionRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EA52E799BD10030F7CA /* AppVersionRemoteDataSourceImpl.swift */; }; + 38899EA92E799C630030F7CA /* VersionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EA82E799C5D0030F7CA /* VersionRequest.swift */; }; + 38899EAA2E799C630030F7CA /* VersionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EA82E799C5D0030F7CA /* VersionRequest.swift */; }; + 38899EAD2E79A09B0030F7CA /* UserRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EAC2E79A0990030F7CA /* UserRequest.swift */; }; + 38899EAE2E79A09B0030F7CA /* UserRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38899EAC2E79A0990030F7CA /* UserRequest.swift */; }; + 3889A2432E79AD7D0030F7CA /* AppVersionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2422E79AD600030F7CA /* AppVersionRepository.swift */; }; + 3889A2442E79AD7D0030F7CA /* AppVersionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2422E79AD600030F7CA /* AppVersionRepository.swift */; }; + 3889A2462E79ADCE0030F7CA /* AppVersionRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2452E79ADBD0030F7CA /* AppVersionRepositoryImpl.swift */; }; + 3889A2472E79ADCE0030F7CA /* AppVersionRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2452E79ADBD0030F7CA /* AppVersionRepositoryImpl.swift */; }; + 3889A24A2E79AE960030F7CA /* AppVersionUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2492E79AE900030F7CA /* AppVersionUseCase.swift */; }; + 3889A24B2E79AE960030F7CA /* AppVersionUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2492E79AE900030F7CA /* AppVersionUseCase.swift */; }; + 3889A24D2E79AEB30030F7CA /* AppVersionUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A24C2E79AEAD0030F7CA /* AppVersionUseCaseImpl.swift */; }; + 3889A24E2E79AEB30030F7CA /* AppVersionUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A24C2E79AEAD0030F7CA /* AppVersionUseCaseImpl.swift */; }; + 3889A2502E79B3260030F7CA /* UserRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A24F2E79B3210030F7CA /* UserRemoteDataSource.swift */; }; + 3889A2512E79B3260030F7CA /* UserRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A24F2E79B3210030F7CA /* UserRemoteDataSource.swift */; }; + 3889A2562E79BA160030F7CA /* UserRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2552E79BA0F0030F7CA /* UserRemoteDataSourceImpl.swift */; }; + 3889A2572E79BA160030F7CA /* UserRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2552E79BA0F0030F7CA /* UserRemoteDataSourceImpl.swift */; }; + 3889A25C2E79BB340030F7CA /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A25B2E79BB2F0030F7CA /* UserRepository.swift */; }; + 3889A25D2E79BB340030F7CA /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A25B2E79BB2F0030F7CA /* UserRepository.swift */; }; + 3889A2622E79BB5B0030F7CA /* UserRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2612E79BB540030F7CA /* UserRepositoryImpl.swift */; }; + 3889A2632E79BB5B0030F7CA /* UserRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2612E79BB540030F7CA /* UserRepositoryImpl.swift */; }; + 3889A26B2E79BD450030F7CA /* AuthRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A26A2E79BD410030F7CA /* AuthRemoteDataSource.swift */; }; + 3889A26C2E79BD450030F7CA /* AuthRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A26A2E79BD410030F7CA /* AuthRemoteDataSource.swift */; }; + 3889A26E2E79BE9F0030F7CA /* AuthRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A26D2E79BE970030F7CA /* AuthRemoteDataSourceImpl.swift */; }; + 3889A26F2E79BE9F0030F7CA /* AuthRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A26D2E79BE970030F7CA /* AuthRemoteDataSourceImpl.swift */; }; + 3889A2712E79C03B0030F7CA /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2702E79C0370030F7CA /* AuthRepository.swift */; }; + 3889A2722E79C03B0030F7CA /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2702E79C0370030F7CA /* AuthRepository.swift */; }; + 3889A2742E79C1D80030F7CA /* NotificationRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2732E79C1D30030F7CA /* NotificationRemoteDataSource.swift */; }; + 3889A2752E79C1D80030F7CA /* NotificationRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2732E79C1D30030F7CA /* NotificationRemoteDataSource.swift */; }; + 3889A2772E79C29F0030F7CA /* NotificationRemoteDataSoruceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2762E79C2980030F7CA /* NotificationRemoteDataSoruceImpl.swift */; }; + 3889A2782E79C29F0030F7CA /* NotificationRemoteDataSoruceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2762E79C2980030F7CA /* NotificationRemoteDataSoruceImpl.swift */; }; + 3889A27D2E79C56E0030F7CA /* ToeknResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A27C2E79C5670030F7CA /* ToeknResponse.swift */; }; + 3889A27E2E79C56E0030F7CA /* ToeknResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A27C2E79C5670030F7CA /* ToeknResponse.swift */; }; + 3889A2802E79D0250030F7CA /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A27F2E79D0230030F7CA /* Token.swift */; }; + 3889A2812E79D0250030F7CA /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A27F2E79D0230030F7CA /* Token.swift */; }; + 3889A2832E79D7D40030F7CA /* AuthRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2822E79D7CE0030F7CA /* AuthRepositoryImpl.swift */; }; + 3889A2842E79D7D40030F7CA /* AuthRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2822E79D7CE0030F7CA /* AuthRepositoryImpl.swift */; }; + 3889A2862E79D8090030F7CA /* AuthUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2852E79D8060030F7CA /* AuthUseCase.swift */; }; + 3889A2872E79D8090030F7CA /* AuthUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2852E79D8060030F7CA /* AuthUseCase.swift */; }; + 3889A2892E79D8220030F7CA /* AuthUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2882E79D81E0030F7CA /* AuthUseCaseImpl.swift */; }; + 3889A28A2E79D8220030F7CA /* AuthUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2882E79D81E0030F7CA /* AuthUseCaseImpl.swift */; }; + 3889A28C2E79D86B0030F7CA /* NotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A28B2E79D8650030F7CA /* NotificationRepository.swift */; }; + 3889A28D2E79D86B0030F7CA /* NotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A28B2E79D8650030F7CA /* NotificationRepository.swift */; }; + 3889A28F2E79D8860030F7CA /* NotificationRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A28E2E79D8800030F7CA /* NotificationRepositoryImpl.swift */; }; + 3889A2902E79D8860030F7CA /* NotificationRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A28E2E79D8800030F7CA /* NotificationRepositoryImpl.swift */; }; + 3889A2922E79D8F80030F7CA /* NotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2912E79D8F40030F7CA /* NotificationUseCase.swift */; }; + 3889A2932E79D8F80030F7CA /* NotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2912E79D8F40030F7CA /* NotificationUseCase.swift */; }; + 3889A2952E79D9250030F7CA /* NotificationUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2942E79D9200030F7CA /* NotificationUseCaseImpl.swift */; }; + 3889A2962E79D9250030F7CA /* NotificationUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3889A2942E79D9200030F7CA /* NotificationUseCaseImpl.swift */; }; 388A2D302D00D6A100E2F2F0 /* FollowViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388A2D2F2D00D6A100E2F2F0 /* FollowViewReactor.swift */; }; 388A2D312D00D6A100E2F2F0 /* FollowViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388A2D2F2D00D6A100E2F2F0 /* FollowViewReactor.swift */; }; 388A2D332D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388A2D322D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift */; }; 388A2D342D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388A2D322D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift */; }; 388C96362CCE41700061C598 /* AuthInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388C96352CCE41700061C598 /* AuthInfo.swift */; }; 388C96372CCE41700061C598 /* AuthInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388C96352CCE41700061C598 /* AuthInfo.swift */; }; + 388D8ADF2E73E6190044BA79 /* SwiftEntryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388D8ADE2E73E6130044BA79 /* SwiftEntryKit.swift */; }; + 388D8AE02E73E6190044BA79 /* SwiftEntryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388D8ADE2E73E6130044BA79 /* SwiftEntryKit.swift */; }; 388DA0FB2C8F521000A9DD56 /* FontContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388DA0FA2C8F521000A9DD56 /* FontContainer.swift */; }; 388DA0FC2C8F521300A9DD56 /* FontContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388DA0FA2C8F521000A9DD56 /* FontContainer.swift */; }; 388DA0FE2C8F526C00A9DD56 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388DA0FD2C8F526C00A9DD56 /* UIFont.swift */; }; @@ -398,8 +458,54 @@ 3893B6CF2D36728000F2004C /* ManagerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3893B6CD2D36728000F2004C /* ManagerProvider.swift */; }; 3893B6D12D36739500F2004C /* CompositeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3893B6D02D36739500F2004C /* CompositeManager.swift */; }; 3893B6D22D36739500F2004C /* CompositeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3893B6D02D36739500F2004C /* CompositeManager.swift */; }; + 3894EDE32ED4B2BB0024213E /* FavoriteTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3894EDE22ED4B2BA0024213E /* FavoriteTagsViewModel.swift */; }; + 3894EDE42ED4B2BB0024213E /* FavoriteTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3894EDE22ED4B2BA0024213E /* FavoriteTagsViewModel.swift */; }; 389681102CAFBD6A00FFD89F /* DetailViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896810F2CAFBD6A00FFD89F /* DetailViewReactor.swift */; }; 389681112CAFBD6A00FFD89F /* DetailViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896810F2CAFBD6A00FFD89F /* DetailViewReactor.swift */; }; + 389E59A52EDEE39500D0946D /* ValidateUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59A42EDEE38E00D0946D /* ValidateUserUseCase.swift */; }; + 389E59A62EDEE39500D0946D /* ValidateUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59A42EDEE38E00D0946D /* ValidateUserUseCase.swift */; }; + 389E59A82EDEE6F600D0946D /* ValidateNicknameUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59A72EDEE6EF00D0946D /* ValidateNicknameUseCase.swift */; }; + 389E59A92EDEE6F600D0946D /* ValidateNicknameUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59A72EDEE6EF00D0946D /* ValidateNicknameUseCase.swift */; }; + 389E59AB2EDEE74B00D0946D /* UploadUserImageUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59AA2EDEE73500D0946D /* UploadUserImageUseCase.swift */; }; + 389E59AC2EDEE74B00D0946D /* UploadUserImageUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59AA2EDEE73500D0946D /* UploadUserImageUseCase.swift */; }; + 389E59AE2EDEE8BD00D0946D /* FetchCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59AD2EDEE8B500D0946D /* FetchCardUseCase.swift */; }; + 389E59AF2EDEE8BD00D0946D /* FetchCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59AD2EDEE8B500D0946D /* FetchCardUseCase.swift */; }; + 389E59B12EDEEA3A00D0946D /* FetchNoticeUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B02EDEEA3300D0946D /* FetchNoticeUseCase.swift */; }; + 389E59B22EDEEA3A00D0946D /* FetchNoticeUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B02EDEEA3300D0946D /* FetchNoticeUseCase.swift */; }; + 389E59B42EDEEA8200D0946D /* FetchCardDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B32EDEEA7600D0946D /* FetchCardDetailUseCase.swift */; }; + 389E59B52EDEEA8200D0946D /* FetchCardDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B32EDEEA7600D0946D /* FetchCardDetailUseCase.swift */; }; + 389E59B72EDEEAFC00D0946D /* UpdateCardLikeUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B62EDEEAEB00D0946D /* UpdateCardLikeUseCase.swift */; }; + 389E59B82EDEEAFC00D0946D /* UpdateCardLikeUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B62EDEEAEB00D0946D /* UpdateCardLikeUseCase.swift */; }; + 389E59BA2EDEEB8300D0946D /* BlockUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B92EDEEB8000D0946D /* BlockUserUseCase.swift */; }; + 389E59BB2EDEEB8300D0946D /* BlockUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59B92EDEEB8000D0946D /* BlockUserUseCase.swift */; }; + 389E59BD2EDEEBF300D0946D /* ReportCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59BC2EDEEBEA00D0946D /* ReportCardUseCase.swift */; }; + 389E59BE2EDEEBF300D0946D /* ReportCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59BC2EDEEBEA00D0946D /* ReportCardUseCase.swift */; }; + 389E59C02EDEEC4900D0946D /* DeleteCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59BF2EDEEC4500D0946D /* DeleteCardUseCase.swift */; }; + 389E59C12EDEEC4900D0946D /* DeleteCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59BF2EDEEC4500D0946D /* DeleteCardUseCase.swift */; }; + 389E59C32EDEEC7B00D0946D /* CardImageUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59C22EDEEC7600D0946D /* CardImageUseCase.swift */; }; + 389E59C42EDEEC7B00D0946D /* CardImageUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59C22EDEEC7600D0946D /* CardImageUseCase.swift */; }; + 389E59C62EDEECBF00D0946D /* WriteCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59C52EDEECBC00D0946D /* WriteCardUseCase.swift */; }; + 389E59C72EDEECBF00D0946D /* WriteCardUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59C52EDEECBC00D0946D /* WriteCardUseCase.swift */; }; + 389E59C92EDEED2B00D0946D /* FetchTagUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59C82EDEED2700D0946D /* FetchTagUseCase.swift */; }; + 389E59CA2EDEED2B00D0946D /* FetchTagUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59C82EDEED2700D0946D /* FetchTagUseCase.swift */; }; + 389E59CC2EDEED6500D0946D /* FetchUserInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59CB2EDEED6100D0946D /* FetchUserInfoUseCase.swift */; }; + 389E59CD2EDEED6500D0946D /* FetchUserInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59CB2EDEED6100D0946D /* FetchUserInfoUseCase.swift */; }; + 389E59CF2EDEEDD500D0946D /* FetchFollowUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59CE2EDEEDD000D0946D /* FetchFollowUseCase.swift */; }; + 389E59D02EDEEDD500D0946D /* FetchFollowUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59CE2EDEEDD000D0946D /* FetchFollowUseCase.swift */; }; + 389E59D22EDEEE4100D0946D /* UpdateFollowUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59D12EDEEE3800D0946D /* UpdateFollowUseCase.swift */; }; + 389E59D32EDEEE4100D0946D /* UpdateFollowUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59D12EDEEE3800D0946D /* UpdateFollowUseCase.swift */; }; + 389E59D52EDEEE6B00D0946D /* UpdateUserInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59D42EDEEE6500D0946D /* UpdateUserInfoUseCase.swift */; }; + 389E59D62EDEEE6B00D0946D /* UpdateUserInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59D42EDEEE6500D0946D /* UpdateUserInfoUseCase.swift */; }; + 389E59D82EDEEEC500D0946D /* UpdateNotifyUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59D72EDEEEB900D0946D /* UpdateNotifyUseCase.swift */; }; + 389E59D92EDEEEC500D0946D /* UpdateNotifyUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59D72EDEEEB900D0946D /* UpdateNotifyUseCase.swift */; }; + 389E59DB2EDEEF3600D0946D /* FetchBlockUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59DA2EDEEF3000D0946D /* FetchBlockUserUseCase.swift */; }; + 389E59DC2EDEEF3600D0946D /* FetchBlockUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59DA2EDEEF3000D0946D /* FetchBlockUserUseCase.swift */; }; + 389E59DE2EDEEF7C00D0946D /* TransferAccountUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59DD2EDEEF7600D0946D /* TransferAccountUseCase.swift */; }; + 389E59DF2EDEEF7C00D0946D /* TransferAccountUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59DD2EDEEF7600D0946D /* TransferAccountUseCase.swift */; }; + 389E59E12EDEEFA500D0946D /* UpdateTagFavoriteUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59E02EDEEF9E00D0946D /* UpdateTagFavoriteUseCase.swift */; }; + 389E59E22EDEEFA500D0946D /* UpdateTagFavoriteUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59E02EDEEF9E00D0946D /* UpdateTagFavoriteUseCase.swift */; }; + 389E59E42EDEF03000D0946D /* LocationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59E32EDEF02900D0946D /* LocationUseCase.swift */; }; + 389E59E52EDEF03000D0946D /* LocationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389E59E32EDEF02900D0946D /* LocationUseCase.swift */; }; 389EF8172D2F450000E053AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389EF8162D2F450000E053AE /* Log.swift */; }; 389EF8182D2F450000E053AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389EF8162D2F450000E053AE /* Log.swift */; }; 389EF81A2D2F454600E053AE /* Log+Extract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389EF8192D2F454600E053AE /* Log+Extract.swift */; }; @@ -410,14 +516,76 @@ 38A5D1552C8CB12300B68363 /* UIImage+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A5D1532C8CB11E00B68363 /* UIImage+SOOUM.swift */; }; 38A627172CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A627162CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift */; }; 38A627182CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A627162CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift */; }; + 38A721952E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A721942E73E7010071E1D8 /* View+SwiftEntryKit.swift */; }; + 38A721962E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A721942E73E7010071E1D8 /* View+SwiftEntryKit.swift */; }; + 38A721992E73EA6F0071E1D8 /* SOMBottomFloatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A721982E73EA610071E1D8 /* SOMBottomFloatView.swift */; }; + 38A7219A2E73EA6F0071E1D8 /* SOMBottomFloatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A721982E73EA610071E1D8 /* SOMBottomFloatView.swift */; }; 38AA00022CAD1BCC002C5F1E /* LikeAndCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA00012CAD1BCC002C5F1E /* LikeAndCommentView.swift */; }; 38AA00032CAD1BCC002C5F1E /* LikeAndCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA00012CAD1BCC002C5F1E /* LikeAndCommentView.swift */; }; - 38AA00062CAD96E3002C5F1E /* MoreBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA00052CAD96E3002C5F1E /* MoreBottomSheetViewController.swift */; }; - 38AA00072CAD96E3002C5F1E /* MoreBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA00052CAD96E3002C5F1E /* MoreBottomSheetViewController.swift */; }; - 38AA66262D3AC3F500B3F6B2 /* DialogMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA66252D3AC3F500B3F6B2 /* DialogMessageView.swift */; }; - 38AA66272D3AC3F500B3F6B2 /* DialogMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA66252D3AC3F500B3F6B2 /* DialogMessageView.swift */; }; 38AE565C2D048B4800CAA431 /* SOMDialogViewController+Show.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE565B2D048B4800CAA431 /* SOMDialogViewController+Show.swift */; }; 38AE565D2D048B4800CAA431 /* SOMDialogViewController+Show.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE565B2D048B4800CAA431 /* SOMDialogViewController+Show.swift */; }; + 38AE77D42E74580000B6FD13 /* OnboardingCompletedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77D32E7457F400B6FD13 /* OnboardingCompletedViewController.swift */; }; + 38AE77D52E74580000B6FD13 /* OnboardingCompletedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77D32E7457F400B6FD13 /* OnboardingCompletedViewController.swift */; }; + 38AE77D72E7459F400B6FD13 /* OnboardingCompletedViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77D62E7459EA00B6FD13 /* OnboardingCompletedViewReactor.swift */; }; + 38AE77D82E7459F400B6FD13 /* OnboardingCompletedViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77D62E7459EA00B6FD13 /* OnboardingCompletedViewReactor.swift */; }; + 38AE77DB2E745FFF00B6FD13 /* EnterMemberTransferTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DA2E745FF700B6FD13 /* EnterMemberTransferTextFieldView.swift */; }; + 38AE77DC2E745FFF00B6FD13 /* EnterMemberTransferTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DA2E745FF700B6FD13 /* EnterMemberTransferTextFieldView.swift */; }; + 38AE77DE2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */; }; + 38AE77DF2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */; }; + 38AE85092EDF414400029E4C /* BlockUserUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85082EDF413C00029E4C /* BlockUserUseCaseImpl.swift */; }; + 38AE850A2EDF414400029E4C /* BlockUserUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85082EDF413C00029E4C /* BlockUserUseCaseImpl.swift */; }; + 38AE850C2EDF41B700029E4C /* CardImageUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE850B2EDF41AF00029E4C /* CardImageUseCaseImpl.swift */; }; + 38AE850D2EDF41B700029E4C /* CardImageUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE850B2EDF41AF00029E4C /* CardImageUseCaseImpl.swift */; }; + 38AE850F2EDF420700029E4C /* DeleteCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE850E2EDF420300029E4C /* DeleteCardUseCaseImpl.swift */; }; + 38AE85102EDF420700029E4C /* DeleteCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE850E2EDF420300029E4C /* DeleteCardUseCaseImpl.swift */; }; + 38AE85122EDF424800029E4C /* FetchBlockUserUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85112EDF424600029E4C /* FetchBlockUserUseCaseImpl.swift */; }; + 38AE85132EDF424800029E4C /* FetchBlockUserUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85112EDF424600029E4C /* FetchBlockUserUseCaseImpl.swift */; }; + 38AE85152EDF42B700029E4C /* FetchCardDetailUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85142EDF42B400029E4C /* FetchCardDetailUseCaseImpl.swift */; }; + 38AE85162EDF42B700029E4C /* FetchCardDetailUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85142EDF42B400029E4C /* FetchCardDetailUseCaseImpl.swift */; }; + 38AE85182EDF437400029E4C /* FetchCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85172EDF436F00029E4C /* FetchCardUseCaseImpl.swift */; }; + 38AE85192EDF437400029E4C /* FetchCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85172EDF436F00029E4C /* FetchCardUseCaseImpl.swift */; }; + 38AE851B2EDFF7E000029E4C /* FetchFollowUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE851A2EDFF7DE00029E4C /* FetchFollowUseCaseImpl.swift */; }; + 38AE851C2EDFF7E000029E4C /* FetchFollowUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE851A2EDFF7DE00029E4C /* FetchFollowUseCaseImpl.swift */; }; + 38AE851E2EDFF84700029E4C /* FetchNoticeUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE851D2EDFF84500029E4C /* FetchNoticeUseCaseImpl.swift */; }; + 38AE851F2EDFF84700029E4C /* FetchNoticeUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE851D2EDFF84500029E4C /* FetchNoticeUseCaseImpl.swift */; }; + 38AE85212EDFF88C00029E4C /* FetchTagUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85202EDFF88A00029E4C /* FetchTagUseCaseImpl.swift */; }; + 38AE85222EDFF88C00029E4C /* FetchTagUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85202EDFF88A00029E4C /* FetchTagUseCaseImpl.swift */; }; + 38AE85242EDFF90400029E4C /* FetchUserInfoUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85232EDFF90000029E4C /* FetchUserInfoUseCaseImpl.swift */; }; + 38AE85252EDFF90400029E4C /* FetchUserInfoUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85232EDFF90000029E4C /* FetchUserInfoUseCaseImpl.swift */; }; + 38AE85272EDFF95500029E4C /* LocationUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85262EDFF95200029E4C /* LocationUseCaseImpl.swift */; }; + 38AE85282EDFF95500029E4C /* LocationUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85262EDFF95200029E4C /* LocationUseCaseImpl.swift */; }; + 38AE852A2EDFF9CC00029E4C /* ReportCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85292EDFF99B00029E4C /* ReportCardUseCaseImpl.swift */; }; + 38AE852B2EDFF9CC00029E4C /* ReportCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85292EDFF99B00029E4C /* ReportCardUseCaseImpl.swift */; }; + 38AE852D2EDFFA3C00029E4C /* TransferAccountUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE852C2EDFFA3900029E4C /* TransferAccountUseCaseImpl.swift */; }; + 38AE852E2EDFFA3C00029E4C /* TransferAccountUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE852C2EDFFA3900029E4C /* TransferAccountUseCaseImpl.swift */; }; + 38AE85302EDFFA9500029E4C /* UpdateCardLikeUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE852F2EDFFA9300029E4C /* UpdateCardLikeUseCaseImpl.swift */; }; + 38AE85312EDFFA9500029E4C /* UpdateCardLikeUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE852F2EDFFA9300029E4C /* UpdateCardLikeUseCaseImpl.swift */; }; + 38AE85332EDFFAC400029E4C /* UpdateFollowUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85322EDFFAC300029E4C /* UpdateFollowUseCaseImpl.swift */; }; + 38AE85342EDFFAC400029E4C /* UpdateFollowUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85322EDFFAC300029E4C /* UpdateFollowUseCaseImpl.swift */; }; + 38AE85362EDFFAF900029E4C /* UpdateNotifyUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85352EDFFAF700029E4C /* UpdateNotifyUseCaseImpl.swift */; }; + 38AE85372EDFFAF900029E4C /* UpdateNotifyUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85352EDFFAF700029E4C /* UpdateNotifyUseCaseImpl.swift */; }; + 38AE85392EDFFBBF00029E4C /* UpdateTagFavoriteUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85382EDFFBBE00029E4C /* UpdateTagFavoriteUseCaseImpl.swift */; }; + 38AE853A2EDFFBBF00029E4C /* UpdateTagFavoriteUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85382EDFFBBE00029E4C /* UpdateTagFavoriteUseCaseImpl.swift */; }; + 38AE853C2EDFFBF700029E4C /* UpdateUserInfoUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE853B2EDFFBF500029E4C /* UpdateUserInfoUseCaseImpl.swift */; }; + 38AE853D2EDFFBF700029E4C /* UpdateUserInfoUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE853B2EDFFBF500029E4C /* UpdateUserInfoUseCaseImpl.swift */; }; + 38AE853F2EDFFC3600029E4C /* UploadUserImageUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE853E2EDFFC3500029E4C /* UploadUserImageUseCaseImpl.swift */; }; + 38AE85402EDFFC3600029E4C /* UploadUserImageUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE853E2EDFFC3500029E4C /* UploadUserImageUseCaseImpl.swift */; }; + 38AE85422EDFFCA600029E4C /* ValidateNicknameUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85412EDFFCA400029E4C /* ValidateNicknameUseCaseImpl.swift */; }; + 38AE85432EDFFCA600029E4C /* ValidateNicknameUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85412EDFFCA400029E4C /* ValidateNicknameUseCaseImpl.swift */; }; + 38AE85452EDFFCF900029E4C /* ValidateUserUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85442EDFFCF800029E4C /* ValidateUserUseCaseImpl.swift */; }; + 38AE85462EDFFCF900029E4C /* ValidateUserUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85442EDFFCF800029E4C /* ValidateUserUseCaseImpl.swift */; }; + 38AE85482EDFFD7E00029E4C /* WriteCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85472EDFFD7B00029E4C /* WriteCardUseCaseImpl.swift */; }; + 38AE85492EDFFD7E00029E4C /* WriteCardUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE85472EDFFD7B00029E4C /* WriteCardUseCaseImpl.swift */; }; + 38B21C022ECEF46200990F49 /* FavoriteTagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C012ECEF45D00990F49 /* FavoriteTagViewModel.swift */; }; + 38B21C032ECEF46200990F49 /* FavoriteTagViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C012ECEF45D00990F49 /* FavoriteTagViewModel.swift */; }; + 38B21C082ECEF7D400990F49 /* PopularTagViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C072ECEF7CF00990F49 /* PopularTagViewCell.swift */; }; + 38B21C092ECEF7D400990F49 /* PopularTagViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C072ECEF7CF00990F49 /* PopularTagViewCell.swift */; }; + 38B21C0B2ECEFFAA00990F49 /* FavoriteTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C0A2ECEFF9F00990F49 /* FavoriteTagsView.swift */; }; + 38B21C0C2ECEFFAA00990F49 /* FavoriteTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C0A2ECEFF9F00990F49 /* FavoriteTagsView.swift */; }; + 38B21C0E2ECF0F1D00990F49 /* PopularTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C0D2ECF0F1800990F49 /* PopularTagsView.swift */; }; + 38B21C0F2ECF0F1D00990F49 /* PopularTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B21C0D2ECF0F1800990F49 /* PopularTagsView.swift */; }; + 38B35D082EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */; }; + 38B35D092EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */; }; 38B543DF2D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543DE2D46171300DDF2C5 /* ManagerConfiguration.swift */; }; 38B543E02D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543DE2D46171300DDF2C5 /* ManagerConfiguration.swift */; }; 38B543E22D46179500DDF2C5 /* AuthManagerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543E12D46179500DDF2C5 /* AuthManagerConfiguration.swift */; }; @@ -430,38 +598,96 @@ 38B543EC2D461B1A00DDF2C5 /* LocationManagerConfigruation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543EA2D461B1A00DDF2C5 /* LocationManagerConfigruation.swift */; }; 38B543EE2D46506300DDF2C5 /* ManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543ED2D46506300DDF2C5 /* ManagerType.swift */; }; 38B543EF2D46506300DDF2C5 /* ManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543ED2D46506300DDF2C5 /* ManagerType.swift */; }; + 38B65E792E72A29F00DF6919 /* OnboardingNumberingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B65E782E72A29100DF6919 /* OnboardingNumberingView.swift */; }; + 38B65E7A2E72A29F00DF6919 /* OnboardingNumberingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B65E782E72A29100DF6919 /* OnboardingNumberingView.swift */; }; + 38B65E7C2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B65E7B2E72ADB500DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift */; }; + 38B65E7D2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B65E7B2E72ADB500DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift */; }; 38B6AACD2CA410D800CE6DB6 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AACC2CA410D800CE6DB6 /* MainTabBarController.swift */; }; 38B6AACE2CA410D800CE6DB6 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AACC2CA410D800CE6DB6 /* MainTabBarController.swift */; }; - 38B6AAD82CA424AE00CE6DB6 /* MoveTopButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AAD72CA424AE00CE6DB6 /* MoveTopButtonView.swift */; }; - 38B6AAD92CA424AE00CE6DB6 /* MoveTopButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AAD72CA424AE00CE6DB6 /* MoveTopButtonView.swift */; }; 38B6AADB2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AADA2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift */; }; 38B6AADC2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AADA2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift */; }; 38B6AADF2CA4777200CE6DB6 /* UIViewController+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AADE2CA4777200CE6DB6 /* UIViewController+Rx.swift */; }; 38B6AAE02CA4777200CE6DB6 /* UIViewController+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AADE2CA4777200CE6DB6 /* UIViewController+Rx.swift */; }; 38B6AAE22CA4787200CE6DB6 /* MainTabBarReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AAE12CA4787200CE6DB6 /* MainTabBarReactor.swift */; }; 38B6AAE32CA4787200CE6DB6 /* MainTabBarReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B6AAE12CA4787200CE6DB6 /* MainTabBarReactor.swift */; }; - 38B8A5842CAE9CC4000AFE83 /* MainHomeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A5832CAE9CC4000AFE83 /* MainHomeViewCell.swift */; }; - 38B8A5852CAE9CC4000AFE83 /* MainHomeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A5832CAE9CC4000AFE83 /* MainHomeViewCell.swift */; }; + 38B8A5842CAE9CC4000AFE83 /* HomeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A5832CAE9CC4000AFE83 /* HomeViewCell.swift */; }; + 38B8A5852CAE9CC4000AFE83 /* HomeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A5832CAE9CC4000AFE83 /* HomeViewCell.swift */; }; 38B8A5882CAEA5F9000AFE83 /* DetailViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A5872CAEA5F9000AFE83 /* DetailViewCell.swift */; }; 38B8A5892CAEA5F9000AFE83 /* DetailViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A5872CAEA5F9000AFE83 /* DetailViewCell.swift */; }; 38B8A58B2CAEA79A000AFE83 /* DetailViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A58A2CAEA79A000AFE83 /* DetailViewFooter.swift */; }; 38B8A58C2CAEA79A000AFE83 /* DetailViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A58A2CAEA79A000AFE83 /* DetailViewFooter.swift */; }; 38B8A58E2CAEB61A000AFE83 /* DetailViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A58D2CAEB61A000AFE83 /* DetailViewFooterCell.swift */; }; 38B8A58F2CAEB61A000AFE83 /* DetailViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A58D2CAEB61A000AFE83 /* DetailViewFooterCell.swift */; }; - 38B8BE472D1ECBDA0084569C /* NotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */; }; - 38B8BE482D1ECBDA0084569C /* NotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */; }; - 38BE72172CC696E9002662DD /* WriteTagTextField+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BE72162CC696E9002662DD /* WriteTagTextField+Rx.swift */; }; - 38BE72182CC696E9002662DD /* WriteTagTextField+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BE72162CC696E9002662DD /* WriteTagTextField+Rx.swift */; }; - 38C2D4112CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */; }; - 38C2D4122CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */; }; - 38C2D4142CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */; }; - 38C2D4152CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */; }; - 38C2D4172CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */; }; - 38C2D4182CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */; }; - 38C2D41A2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */; }; - 38C2D41B2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */; }; + 38B8BE472D1ECBDA0084569C /* PushNotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8BE462D1ECBDA0084569C /* PushNotificationInfo.swift */; }; + 38B8BE482D1ECBDA0084569C /* PushNotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8BE462D1ECBDA0084569C /* PushNotificationInfo.swift */; }; + 38C2A7D82EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */; }; + 38C2A7D92EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */; }; + 38C2A7DB2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */; }; + 38C2A7DC2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */; }; + 38C2A7DE2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */; }; + 38C2A7DF2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */; }; + 38C2A7E12EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */; }; + 38C2A7E22EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */; }; + 38C2A7E42EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */; }; + 38C2A7E52EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */; }; + 38C2A7E72EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */; }; + 38C2A7E82EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */; }; + 38C2A7EA2EC074A200B941A2 /* SettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */; }; + 38C2A7EB2EC074A200B941A2 /* SettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */; }; + 38C2A7ED2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */; }; + 38C2A7EE2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */; }; + 38C2A7F62EC08FF600B941A2 /* BlockUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */; }; + 38C2A7F72EC08FF600B941A2 /* BlockUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */; }; + 38C2A7F92EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */; }; + 38C2A7FA2EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */; }; + 38C2A7FC2EC0925C00B941A2 /* WithdrawType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */; }; + 38C2A7FD2EC0925C00B941A2 /* WithdrawType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */; }; + 38C2A8012EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */; }; + 38C2A8022EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */; }; + 38C2A8042EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */; }; + 38C2A8052EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */; }; + 38C2A8082EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */; }; + 38C2A8092EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */; }; + 38C2A80B2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */; }; + 38C2A80C2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */; }; + 38C2A80E2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */; }; + 38C2A80F2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */; }; + 38C2A8112EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */; }; + 38C2A8122EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */; }; + 38C2D4172CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */; }; + 38C2D4182CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */; }; 38C2D4202CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D41F2CFEB82400CEA092 /* ProfileViewReactor.swift */; }; 38C2D4212CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D41F2CFEB82400CEA092 /* ProfileViewReactor.swift */; }; + 38C9AF0B2E965EFB00B401C0 /* DefaultImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF0A2E965EEE00B401C0 /* DefaultImages.swift */; }; + 38C9AF0C2E965EFB00B401C0 /* DefaultImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF0A2E965EEE00B401C0 /* DefaultImages.swift */; }; + 38C9AF0E2E96602300B401C0 /* DefaultImagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF0D2E96601E00B401C0 /* DefaultImagesResponse.swift */; }; + 38C9AF0F2E96602300B401C0 /* DefaultImagesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF0D2E96601E00B401C0 /* DefaultImagesResponse.swift */; }; + 38C9AF112E96656600B401C0 /* TagInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF102E96656400B401C0 /* TagInfo.swift */; }; + 38C9AF122E96656600B401C0 /* TagInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF102E96656400B401C0 /* TagInfo.swift */; }; + 38C9AF142E9665C900B401C0 /* TagInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF132E9665C500B401C0 /* TagInfoResponse.swift */; }; + 38C9AF152E9665C900B401C0 /* TagInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF132E9665C500B401C0 /* TagInfoResponse.swift */; }; + 38C9AF172E96693600B401C0 /* TagRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF162E96692900B401C0 /* TagRemoteDataSource.swift */; }; + 38C9AF182E96693600B401C0 /* TagRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF162E96692900B401C0 /* TagRemoteDataSource.swift */; }; + 38C9AF1A2E96696C00B401C0 /* TagRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF192E96696500B401C0 /* TagRemoteDataSourceImpl.swift */; }; + 38C9AF1B2E96696C00B401C0 /* TagRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF192E96696500B401C0 /* TagRemoteDataSourceImpl.swift */; }; + 38C9AF202E9669F600B401C0 /* TagRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF1F2E9669F100B401C0 /* TagRepository.swift */; }; + 38C9AF212E9669F600B401C0 /* TagRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF1F2E9669F100B401C0 /* TagRepository.swift */; }; + 38C9AF232E966A1B00B401C0 /* TagRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF222E966A1300B401C0 /* TagRepositoryImpl.swift */; }; + 38C9AF242E966A1B00B401C0 /* TagRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF222E966A1300B401C0 /* TagRepositoryImpl.swift */; }; + 38C9AF2D2E96A3E500B401C0 /* WriteCardTagModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF2C2E96A3D600B401C0 /* WriteCardTagModel.swift */; }; + 38C9AF2E2E96A3E500B401C0 /* WriteCardTagModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF2C2E96A3D600B401C0 /* WriteCardTagModel.swift */; }; + 38C9AF302E96A49F00B401C0 /* WriteCardTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF2F2E96A49B00B401C0 /* WriteCardTag.swift */; }; + 38C9AF312E96A49F00B401C0 /* WriteCardTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF2F2E96A49B00B401C0 /* WriteCardTag.swift */; }; + 38C9AF332E96A82900B401C0 /* WriteCardTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF322E96A82600B401C0 /* WriteCardTags.swift */; }; + 38C9AF342E96A82900B401C0 /* WriteCardTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF322E96A82600B401C0 /* WriteCardTags.swift */; }; + 38C9AF392E96AB9100B401C0 /* WriteCardTagFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF382E96AB8800B401C0 /* WriteCardTagFooter.swift */; }; + 38C9AF3A2E96AB9100B401C0 /* WriteCardTagFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF382E96AB8800B401C0 /* WriteCardTagFooter.swift */; }; + 38C9AF3C2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */; }; + 38C9AF3D2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */; }; + 38CA91F32EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */; }; + 38CA91F42EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */; }; + 38CA91F62EBDD342002C261A /* ProfileViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */; }; + 38CA91F72EBDD342002C261A /* ProfileViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */; }; 38CC49822CDE3854007A0145 /* SOMPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CC49812CDE3854007A0145 /* SOMPresentationController.swift */; }; 38CC49832CDE3854007A0145 /* SOMPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CC49812CDE3854007A0145 /* SOMPresentationController.swift */; }; 38CC49852CDE3885007A0145 /* SOMTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CC49842CDE3885007A0145 /* SOMTransitioningDelegate.swift */; }; @@ -472,18 +698,30 @@ 38CE94C02C904D460004B238 /* SOMNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CE94BE2C904D460004B238 /* SOMNavigationBar.swift */; }; 38D055C32CD862FE00E75590 /* SOMActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D055C22CD862FE00E75590 /* SOMActivityIndicatorView.swift */; }; 38D055C42CD862FE00E75590 /* SOMActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D055C22CD862FE00E75590 /* SOMActivityIndicatorView.swift */; }; - 38D3CB162CC2362B001EC280 /* Hakgyoansim-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 38D3CB142CC2362B001EC280 /* Hakgyoansim-Bold.ttf */; }; - 38D3CB172CC2362B001EC280 /* Hakgyoansim-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 38D3CB142CC2362B001EC280 /* Hakgyoansim-Bold.ttf */; }; - 38D3CB182CC2362B001EC280 /* Hakgyoansim-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 38D3CB152CC2362B001EC280 /* Hakgyoansim-Light.ttf */; }; - 38D3CB192CC2362B001EC280 /* Hakgyoansim-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 38D3CB152CC2362B001EC280 /* Hakgyoansim-Light.ttf */; }; + 38D2FBC12E812354006DD739 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBC02E81234C006DD739 /* HomeViewController.swift */; }; + 38D2FBC22E812354006DD739 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBC02E81234C006DD739 /* HomeViewController.swift */; }; + 38D2FBC42E81AD26006DD739 /* refrech_control_lottie.json in Resources */ = {isa = PBXBuildFile; fileRef = 38D2FBC32E81AD26006DD739 /* refrech_control_lottie.json */; }; + 38D2FBC52E81AD26006DD739 /* refrech_control_lottie.json in Resources */ = {isa = PBXBuildFile; fileRef = 38D2FBC32E81AD26006DD739 /* refrech_control_lottie.json */; }; + 38D2FBCB2E81B0E5006DD739 /* SOMSwipableTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBCA2E81B0DE006DD739 /* SOMSwipableTabBarItem.swift */; }; + 38D2FBCC2E81B0E5006DD739 /* SOMSwipableTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBCA2E81B0DE006DD739 /* SOMSwipableTabBarItem.swift */; }; + 38D2FBCE2E81B52F006DD739 /* SOMSwipableTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBCD2E81B529006DD739 /* SOMSwipableTabBar.swift */; }; + 38D2FBCF2E81B52F006DD739 /* SOMSwipableTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBCD2E81B529006DD739 /* SOMSwipableTabBar.swift */; }; + 38D2FBD12E81B9B7006DD739 /* SOMSwipableTabBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBD02E81B9B0006DD739 /* SOMSwipableTabBarDelegate.swift */; }; + 38D2FBD22E81B9B7006DD739 /* SOMSwipableTabBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D2FBD02E81B9B0006DD739 /* SOMSwipableTabBarDelegate.swift */; }; + 38D478072EBBAA0B0041FF6C /* WriteCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D478062EBBAA080041FF6C /* WriteCardResponse.swift */; }; + 38D478082EBBAA0B0041FF6C /* WriteCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D478062EBBAA080041FF6C /* WriteCardResponse.swift */; }; + 38D4780A2EBBABF60041FF6C /* EntranceCardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D478092EBBABE40041FF6C /* EntranceCardType.swift */; }; + 38D4780B2EBBABF60041FF6C /* EntranceCardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D478092EBBABE40041FF6C /* EntranceCardType.swift */; }; 38D488CA2D0C557300F2D38D /* SOMButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D488C92D0C557300F2D38D /* SOMButton.swift */; }; 38D488CB2D0C557300F2D38D /* SOMButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D488C92D0C557300F2D38D /* SOMButton.swift */; }; - 38D5637B2D16D72D006265AA /* SOMSwipeTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637A2D16D72D006265AA /* SOMSwipeTabBar.swift */; }; - 38D5637C2D16D72D006265AA /* SOMSwipeTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637A2D16D72D006265AA /* SOMSwipeTabBar.swift */; }; - 38D5637E2D17152F006265AA /* SOMSwipeTabBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637D2D17152F006265AA /* SOMSwipeTabBarDelegate.swift */; }; - 38D5637F2D17152F006265AA /* SOMSwipeTabBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637D2D17152F006265AA /* SOMSwipeTabBarDelegate.swift */; }; - 38D563842D1719B1006265AA /* SOMSwipeTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D563832D1719B1006265AA /* SOMSwipeTabBarItem.swift */; }; - 38D563852D1719B1006265AA /* SOMSwipeTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D563832D1719B1006265AA /* SOMSwipeTabBarItem.swift */; }; + 38D522682E742F610044911B /* SOMLoadingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D522672E742F550044911B /* SOMLoadingIndicatorView.swift */; }; + 38D522692E742F610044911B /* SOMLoadingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D522672E742F550044911B /* SOMLoadingIndicatorView.swift */; }; + 38D5637B2D16D72D006265AA /* SOMStickyTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637A2D16D72D006265AA /* SOMStickyTabBar.swift */; }; + 38D5637C2D16D72D006265AA /* SOMStickyTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637A2D16D72D006265AA /* SOMStickyTabBar.swift */; }; + 38D5637E2D17152F006265AA /* SOMStickyTabBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637D2D17152F006265AA /* SOMStickyTabBarDelegate.swift */; }; + 38D5637F2D17152F006265AA /* SOMStickyTabBarDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5637D2D17152F006265AA /* SOMStickyTabBarDelegate.swift */; }; + 38D563842D1719B1006265AA /* SOMStickyTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D563832D1719B1006265AA /* SOMStickyTabBarItem.swift */; }; + 38D563852D1719B1006265AA /* SOMStickyTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D563832D1719B1006265AA /* SOMStickyTabBarItem.swift */; }; 38D5CE0B2CBCE8CA0054AB9A /* SimpleDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5CE0A2CBCE8CA0054AB9A /* SimpleDefaults.swift */; }; 38D5CE0C2CBCE8CA0054AB9A /* SimpleDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D5CE0A2CBCE8CA0054AB9A /* SimpleDefaults.swift */; }; 38D6F17C2CC2406700E11530 /* WriteCardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D6F17B2CC2406700E11530 /* WriteCardViewController.swift */; }; @@ -492,72 +730,108 @@ 38D6F1812CC2413400E11530 /* WriteCardTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D6F17F2CC2413400E11530 /* WriteCardTextView.swift */; }; 38D6F1832CC243DB00E11530 /* UITextView+Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D6F1822CC243DB00E11530 /* UITextView+Typography.swift */; }; 38D6F1842CC243DB00E11530 /* UITextView+Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D6F1822CC243DB00E11530 /* UITextView+Typography.swift */; }; - 38D6F1862CC24C4F00E11530 /* WriteCardTextViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D6F1852CC24C4F00E11530 /* WriteCardTextViewDelegate.swift */; }; - 38D6F1872CC24C4F00E11530 /* WriteCardTextViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D6F1852CC24C4F00E11530 /* WriteCardTextViewDelegate.swift */; }; 38D869632CF821F900BF87DA /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D869622CF821F900BF87DA /* UserDefaults.swift */; }; 38D869642CF821F900BF87DA /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D869622CF821F900BF87DA /* UserDefaults.swift */; }; 38D8E2912CCD232B00CE2E0A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8E2902CCD232B00CE2E0A /* AuthManager.swift */; }; 38D8E2922CCD232B00CE2E0A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8E2902CCD232B00CE2E0A /* AuthManager.swift */; }; + 38D8F5582EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */; }; + 38D8F5592EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */; }; + 38D8F55E2EC4F38700DED428 /* SimpleReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */; }; + 38D8F55F2EC4F38700DED428 /* SimpleReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */; }; + 38D8FE8D2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */; }; + 38D8FE8E2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */; }; + 38D8FE902EBE664C00F32D02 /* SOMNicknameTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */; }; + 38D8FE912EBE664D00F32D02 /* SOMNicknameTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */; }; 38DA12B42D4E54EB00AB9468 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12B32D4E54EB00AB9468 /* MockAuthManager.swift */; }; 38DA12B62D4E642C00AB9468 /* AuthManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12B52D4E642C00AB9468 /* AuthManagerTests.swift */; }; 38DA12B92D4E847100AB9468 /* MockPushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12B82D4E847100AB9468 /* MockPushManager.swift */; }; 38DA12BB2D4E872000AB9468 /* PushManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12BA2D4E872000AB9468 /* PushManagerTests.swift */; }; 38E7FBEF2D3CF6BB00A359CD /* SOMDialogAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA66212D3AA86F00B3F6B2 /* SOMDialogAction.swift */; }; 38E7FBF02D3CF6BC00A359CD /* SOMDialogAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AA66212D3AA86F00B3F6B2 /* SOMDialogAction.swift */; }; + 38E928B62EB711E200B3F00B /* DetailCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928B52EB711DE00B3F00B /* DetailCardInfo.swift */; }; + 38E928B72EB711E200B3F00B /* DetailCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928B52EB711DE00B3F00B /* DetailCardInfo.swift */; }; + 38E928B92EB715C900B3F00B /* ReortType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928B82EB715C300B3F00B /* ReortType.swift */; }; + 38E928BA2EB715C900B3F00B /* ReortType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928B82EB715C300B3F00B /* ReortType.swift */; }; + 38E928BF2EB72D3D00B3F00B /* DetailCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928BE2EB72D3600B3F00B /* DetailCardInfoResponse.swift */; }; + 38E928C02EB72D3D00B3F00B /* DetailCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928BE2EB72D3600B3F00B /* DetailCardInfoResponse.swift */; }; + 38E928C22EB73D6B00B3F00B /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928C12EB73D4B00B3F00B /* MemberInfoView.swift */; }; + 38E928C32EB73D6B00B3F00B /* MemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928C12EB73D4B00B3F00B /* MemberInfoView.swift */; }; + 38E928C72EB73FF800B3F00B /* WrittenTagModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928C62EB73FF300B3F00B /* WrittenTagModel.swift */; }; + 38E928C82EB73FF800B3F00B /* WrittenTagModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928C62EB73FF300B3F00B /* WrittenTagModel.swift */; }; + 38E928CA2EB7402200B3F00B /* WrittenTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928C92EB7401F00B3F00B /* WrittenTag.swift */; }; + 38E928CB2EB7402200B3F00B /* WrittenTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928C92EB7401F00B3F00B /* WrittenTag.swift */; }; + 38E928CD2EB7409100B3F00B /* WrittenTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928CC2EB7408E00B3F00B /* WrittenTags.swift */; }; + 38E928CE2EB7409100B3F00B /* WrittenTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928CC2EB7408E00B3F00B /* WrittenTags.swift */; }; + 38E928D02EB75FA300B3F00B /* PungView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928CF2EB75F9900B3F00B /* PungView.swift */; }; + 38E928D12EB75FA300B3F00B /* PungView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928CF2EB75F9900B3F00B /* PungView.swift */; }; + 38E928D32EB7624300B3F00B /* FloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928D22EB7623A00B3F00B /* FloatingButton.swift */; }; + 38E928D42EB7624300B3F00B /* FloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928D22EB7623A00B3F00B /* FloatingButton.swift */; }; + 38E928D92EB7727400B3F00B /* SOMBottomToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928D82EB7726E00B3F00B /* SOMBottomToastView.swift */; }; + 38E928DA2EB7727400B3F00B /* SOMBottomToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928D82EB7726E00B3F00B /* SOMBottomToastView.swift */; }; + 38E928DC2EB7921200B3F00B /* UIRefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928DB2EB7920C00B3F00B /* UIRefreshControl.swift */; }; + 38E928DD2EB7921200B3F00B /* UIRefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E928DB2EB7920C00B3F00B /* UIRefreshControl.swift */; }; 38E9CE102D376E0E00E85A2D /* PushTokenSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E9CE0F2D376E0E00E85A2D /* PushTokenSet.swift */; }; 38E9CE112D376E0E00E85A2D /* PushTokenSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E9CE0F2D376E0E00E85A2D /* PushTokenSet.swift */; }; 38E9CE132D37711600E85A2D /* OnboardingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E9CE122D37711600E85A2D /* OnboardingViewReactor.swift */; }; 38E9CE142D37711600E85A2D /* OnboardingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E9CE122D37711600E85A2D /* OnboardingViewReactor.swift */; }; 38E9CE192D37FED000E85A2D /* AddingTokenInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E9CE182D37FED000E85A2D /* AddingTokenInterceptor.swift */; }; 38E9CE1A2D37FED000E85A2D /* AddingTokenInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E9CE182D37FED000E85A2D /* AddingTokenInterceptor.swift */; }; - 38F006AA2D395A7F001AC5F7 /* SuspensionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F006A92D395A7F001AC5F7 /* SuspensionResponse.swift */; }; - 38F006AB2D395A7F001AC5F7 /* SuspensionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F006A92D395A7F001AC5F7 /* SuspensionResponse.swift */; }; - 38F131882CC7B7E0000D0475 /* RelatedTagResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F131872CC7B7E0000D0475 /* RelatedTagResponse.swift */; }; - 38F131892CC7B7E0000D0475 /* RelatedTagResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F131872CC7B7E0000D0475 /* RelatedTagResponse.swift */; }; + 38EBA90E2EB39920008B28F4 /* PostingPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EBA90D2EB39917008B28F4 /* PostingPermission.swift */; }; + 38EBA90F2EB39920008B28F4 /* PostingPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EBA90D2EB39917008B28F4 /* PostingPermission.swift */; }; + 38EBA9112EB399A1008B28F4 /* PostingPermissionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EBA9102EB3999C008B28F4 /* PostingPermissionResponse.swift */; }; + 38EBA9122EB399A1008B28F4 /* PostingPermissionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EBA9102EB3999C008B28F4 /* PostingPermissionResponse.swift */; }; + 38EC8D002ED44661009C2857 /* TagSearchCollectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EC8CFF2ED44658009C2857 /* TagSearchCollectViewController.swift */; }; + 38EC8D012ED44661009C2857 /* TagSearchCollectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EC8CFF2ED44658009C2857 /* TagSearchCollectViewController.swift */; }; + 38EC8D032ED44669009C2857 /* TagSearchCollectViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EC8D022ED44664009C2857 /* TagSearchCollectViewReactor.swift */; }; + 38EC8D042ED44669009C2857 /* TagSearchCollectViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EC8D022ED44664009C2857 /* TagSearchCollectViewReactor.swift */; }; + 38F161432ECDA858003BADB6 /* SearchViewButton+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F161422ECDA853003BADB6 /* SearchViewButton+Rx.swift */; }; + 38F161442ECDA858003BADB6 /* SearchViewButton+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F161422ECDA853003BADB6 /* SearchViewButton+Rx.swift */; }; + 38F161472ECDA8F0003BADB6 /* FavoriteTagPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F161462ECDA8D1003BADB6 /* FavoriteTagPlaceholderViewCell.swift */; }; + 38F161482ECDA8F0003BADB6 /* FavoriteTagPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F161462ECDA8D1003BADB6 /* FavoriteTagPlaceholderViewCell.swift */; }; + 38F1614A2ECDAD34003BADB6 /* FavoriteTagViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F161492ECDAD29003BADB6 /* FavoriteTagViewCell.swift */; }; + 38F1614B2ECDAD34003BADB6 /* FavoriteTagViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F161492ECDAD29003BADB6 /* FavoriteTagViewCell.swift */; }; + 38F3398F2EE31C870066A5F7 /* IsCardDeletedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3398E2EE31C7E0066A5F7 /* IsCardDeletedResponse.swift */; }; + 38F339902EE31C870066A5F7 /* IsCardDeletedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3398E2EE31C7E0066A5F7 /* IsCardDeletedResponse.swift */; }; + 38F339922EE328750066A5F7 /* WriteCardGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F339912EE328710066A5F7 /* WriteCardGuideView.swift */; }; + 38F339932EE328750066A5F7 /* WriteCardGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F339912EE328710066A5F7 /* WriteCardGuideView.swift */; }; + 38F3760A2ECB772A00E4A41D /* FavoriteTagInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F376092ECB772600E4A41D /* FavoriteTagInfo.swift */; }; + 38F3760B2ECB772A00E4A41D /* FavoriteTagInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F376092ECB772600E4A41D /* FavoriteTagInfo.swift */; }; + 38F3760D2ECB779E00E4A41D /* FavoriteTagInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3760C2ECB778C00E4A41D /* FavoriteTagInfoResponse.swift */; }; + 38F3760E2ECB779E00E4A41D /* FavoriteTagInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3760C2ECB778C00E4A41D /* FavoriteTagInfoResponse.swift */; }; + 38F376112ECB78A600E4A41D /* TagCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F376102ECB789200E4A41D /* TagCardInfoResponse.swift */; }; + 38F376122ECB78A600E4A41D /* TagCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F376102ECB789200E4A41D /* TagCardInfoResponse.swift */; }; 38F3D9302D06C2370049F575 /* SOMAnimationTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3D92F2D06C2370049F575 /* SOMAnimationTransitioning.swift */; }; 38F3D9312D06C2370049F575 /* SOMAnimationTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3D92F2D06C2370049F575 /* SOMAnimationTransitioning.swift */; }; - 38F70E5B2D1905D000B33C9D /* MainHomeTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E5A2D1905D000B33C9D /* MainHomeTabBarController.swift */; }; - 38F70E5C2D1905D000B33C9D /* MainHomeTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E5A2D1905D000B33C9D /* MainHomeTabBarController.swift */; }; - 38F70E5E2D190FBD00B33C9D /* MainHomeTabBarReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E5D2D190FBD00B33C9D /* MainHomeTabBarReactor.swift */; }; - 38F70E5F2D190FBD00B33C9D /* MainHomeTabBarReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E5D2D190FBD00B33C9D /* MainHomeTabBarReactor.swift */; }; - 38F70E622D19113E00B33C9D /* MainHomeLatestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E612D19113E00B33C9D /* MainHomeLatestViewController.swift */; }; - 38F70E632D19113E00B33C9D /* MainHomeLatestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E612D19113E00B33C9D /* MainHomeLatestViewController.swift */; }; - 38F70E652D19161800B33C9D /* MainHomeLatestViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E642D19161800B33C9D /* MainHomeLatestViewReactor.swift */; }; - 38F70E662D19161800B33C9D /* MainHomeLatestViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E642D19161800B33C9D /* MainHomeLatestViewReactor.swift */; }; - 38F70E6C2D191D9A00B33C9D /* MainHomePopularViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E6B2D191D9A00B33C9D /* MainHomePopularViewController.swift */; }; - 38F70E6D2D191D9A00B33C9D /* MainHomePopularViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E6B2D191D9A00B33C9D /* MainHomePopularViewController.swift */; }; - 38F70E6F2D191DFB00B33C9D /* MainHomePopularViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E6E2D191DFB00B33C9D /* MainHomePopularViewReactor.swift */; }; - 38F70E702D191DFB00B33C9D /* MainHomePopularViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F70E6E2D191DFB00B33C9D /* MainHomePopularViewReactor.swift */; }; - 38F720A52CD4F15900DF32B5 /* CardSummaryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7209A2CD4F15900DF32B5 /* CardSummaryResponse.swift */; }; - 38F720A62CD4F15900DF32B5 /* CardSummaryResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7209A2CD4F15900DF32B5 /* CardSummaryResponse.swift */; }; - 38F720A72CD4F15900DF32B5 /* CommentCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7209B2CD4F15900DF32B5 /* CommentCardResponse.swift */; }; - 38F720A82CD4F15900DF32B5 /* CommentCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7209B2CD4F15900DF32B5 /* CommentCardResponse.swift */; }; - 38F720AD2CD4F15900DF32B5 /* DetailCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7209E2CD4F15900DF32B5 /* DetailCardResponse.swift */; }; - 38F720AE2CD4F15900DF32B5 /* DetailCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7209E2CD4F15900DF32B5 /* DetailCardResponse.swift */; }; - 38F720B12CD4F15900DF32B5 /* distanceCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720A12CD4F15900DF32B5 /* distanceCardResponse.swift */; }; - 38F720B22CD4F15900DF32B5 /* distanceCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720A12CD4F15900DF32B5 /* distanceCardResponse.swift */; }; - 38F720B32CD4F15900DF32B5 /* LatestCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720A22CD4F15900DF32B5 /* LatestCardResponse.swift */; }; - 38F720B42CD4F15900DF32B5 /* LatestCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720A22CD4F15900DF32B5 /* LatestCardResponse.swift */; }; - 38F720B52CD4F15900DF32B5 /* PopularCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720A32CD4F15900DF32B5 /* PopularCardResponse.swift */; }; - 38F720B62CD4F15900DF32B5 /* PopularCardResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720A32CD4F15900DF32B5 /* PopularCardResponse.swift */; }; - 38F720B82CD4F16500DF32B5 /* CardProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720B72CD4F16500DF32B5 /* CardProtocol.swift */; }; - 38F720B92CD4F16500DF32B5 /* CardProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F720B72CD4F16500DF32B5 /* CardProtocol.swift */; }; 38F88EBA2D2C1CB8002AD7A8 /* Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F88EB92D2C1CB8002AD7A8 /* Info.swift */; }; 38F88EBB2D2C1CB8002AD7A8 /* Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F88EB92D2C1CB8002AD7A8 /* Info.swift */; }; 38F88EBE2D2C1E22002AD7A8 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F88EBD2D2C1E22002AD7A8 /* Version.swift */; }; 38F88EBF2D2C1E22002AD7A8 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F88EBD2D2C1E22002AD7A8 /* Version.swift */; }; - 38FD4DAB2D032CF000BF5FF1 /* AnnouncementResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DAA2D032CF000BF5FF1 /* AnnouncementResponse.swift */; }; - 38FD4DAC2D032CF000BF5FF1 /* AnnouncementResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DAA2D032CF000BF5FF1 /* AnnouncementResponse.swift */; }; + 38FCF4192E9F88EA003AC3D8 /* WriteCardTags+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF4182E9F88E3003AC3D8 /* WriteCardTags+Rx.swift */; }; + 38FCF41A2E9F88EA003AC3D8 /* WriteCardTags+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF4182E9F88E3003AC3D8 /* WriteCardTags+Rx.swift */; }; + 38FCF41C2EA00625003AC3D8 /* UITtextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF41B2EA00623003AC3D8 /* UITtextView.swift */; }; + 38FCF41D2EA00625003AC3D8 /* UITtextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF41B2EA00623003AC3D8 /* UITtextView.swift */; }; 38FD4DAE2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DAD2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift */; }; 38FD4DAF2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DAD2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift */; }; 38FD4DB12D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */; }; 38FD4DB22D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */; }; - 38FD4DB42D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */; }; - 38FD4DB52D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */; }; + 38FD4DB42D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */; }; + 38FD4DB52D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */; }; + 38FD56242EC9FAA400EC6106 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD56232EC9FAA000EC6106 /* String.swift */; }; + 38FD56252EC9FAA400EC6106 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD56232EC9FAA000EC6106 /* String.swift */; }; 38FDC2B62C9E746B00C094C2 /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */; }; 38FDC2B72C9E746B00C094C2 /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */; }; 38FDC2C72C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2C62C9E764300C094C2 /* BaseNavigationViewController.swift */; }; 38FDC2C82C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2C62C9E764300C094C2 /* BaseNavigationViewController.swift */; }; + 38FEBE542E865121002916A8 /* FollowNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE532E865119002916A8 /* FollowNotificationInfoResponse.swift */; }; + 38FEBE552E865121002916A8 /* FollowNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE532E865119002916A8 /* FollowNotificationInfoResponse.swift */; }; + 38FEBE5B2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE5A2E8652D2002916A8 /* CompositeNotificationInfoResponse.swift */; }; + 38FEBE5C2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE5A2E8652D2002916A8 /* CompositeNotificationInfoResponse.swift */; }; + 38FEBE5E2E86612C002916A8 /* NoticeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE5D2E866125002916A8 /* NoticeViewCell.swift */; }; + 38FEBE5F2E86612C002916A8 /* NoticeViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE5D2E866125002916A8 /* NoticeViewCell.swift */; }; + 38FEBE612E8661F4002916A8 /* NoticeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE602E8661F2002916A8 /* NoticeInfo.swift */; }; + 38FEBE622E8661F4002916A8 /* NoticeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE602E8661F2002916A8 /* NoticeInfo.swift */; }; + 38FEBE642E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE632E86629F002916A8 /* NoticeInfoResponse.swift */; }; + 38FEBE652E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEBE632E86629F002916A8 /* NoticeInfoResponse.swift */; }; 9F83B2E5C38D60FE4D409059 /* Pods_SOOUM_DevTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D0E55D6E2C99714CF229CA1 /* Pods_SOOUM_DevTests.framework */; }; A40D6E37CF713608CA27DF02 /* Pods_SOOUM_Dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 365609D6E03DDA36B6BDBE3A /* Pods_SOOUM_Dev.framework */; }; /* End PBXBuildFile section */ @@ -575,119 +849,89 @@ /* Begin PBXFileReference section */ 294DA89A179AE3234F3E293F /* Pods-SOOUM-Dev.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SOOUM-Dev.release.xcconfig"; path = "Target Support Files/Pods-SOOUM-Dev/Pods-SOOUM-Dev.release.xcconfig"; sourceTree = ""; }; 2A032EFC2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTermsOfServiceViewReactor.swift; sourceTree = ""; }; - 2A048E7A2C9BDF5F00FFD485 /* SOMLocationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMLocationFilter.swift; sourceTree = ""; }; - 2A048E832C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMLocationFilterCollectionViewCell.swift; sourceTree = ""; }; - 2A34AFB42D144EEF007BD7E7 /* EmptyTagDetailTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTagDetailTableViewCell.swift; sourceTree = ""; }; - 2A44A4292CAC09AE00DC463E /* RSAKeyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSAKeyResponse.swift; sourceTree = ""; }; - 2A44A42C2CAC14C800DC463E /* SignInResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInResponse.swift; sourceTree = ""; }; - 2A44A4332CAC21A500DC463E /* SignUpResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpResponse.swift; sourceTree = ""; }; - 2A44A4362CAC227300DC463E /* BaseAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAuthResponse.swift; sourceTree = ""; }; - 2A45B36E2CE4C5510071026A /* RegisterUserResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterUserResponse.swift; sourceTree = ""; }; - 2A5ABA332D464E0B00BF6C9B /* ConfigureRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigureRequest.swift; sourceTree = ""; }; 2A5BB7B82CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTermsOfServiceViewController.swift; sourceTree = ""; }; 2A5BB7BD2CDB870000E1C799 /* OnboardingGuideMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingGuideMessageView.swift; sourceTree = ""; }; 2A5BB7C82CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNicknameSettingViewController.swift; sourceTree = ""; }; - 2A5BB7CC2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImageSettingViewController.swift; sourceTree = ""; }; + 2A5BB7CC2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProfileImageSettingViewController.swift; sourceTree = ""; }; 2A5BB7D02CDC7ADC00E1C799 /* OnboardingViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; tabWidth = 4; }; 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServiceAgreeButtonView.swift; sourceTree = ""; }; - 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNicknameTextFieldView.swift; sourceTree = ""; }; 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNicknameSettingViewReactor.swift; sourceTree = ""; }; - 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRequest.swift; sourceTree = ""; }; - 2A5BB7E62CDCDC3600E1C799 /* NicknameValidationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameValidationResponse.swift; sourceTree = ""; }; - 2A5BB7F92CE277AF00E1C799 /* ProfileImageSettingViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImageSettingViewReactor.swift; sourceTree = ""; }; + 2A5BB7F92CE277AF00E1C799 /* OnboardingProfileImageSettingViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProfileImageSettingViewReactor.swift; sourceTree = ""; }; 2A62805A2D084FEB00803BE9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2A6280602D085C6200803BE9 /* SOOUM.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SOOUM.entitlements; sourceTree = ""; }; 2A6280612D085C7600803BE9 /* SOOUM-Dev.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SOOUM-Dev.entitlements"; sourceTree = ""; }; 2A649ECE2CAE8970002D8284 /* SOMDialogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMDialogViewController.swift; sourceTree = ""; }; 2A980B9C2D803E9D007DFA45 /* FirebaseLoggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseLoggable.swift; sourceTree = ""; }; 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventProtocol.swift; sourceTree = ""; }; - 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMEvent.swift; sourceTree = ""; }; - 2A980BA72D803F04007DFA45 /* GAManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GAManager.swift; sourceTree = ""; }; - 2ACBD4122CC944FB0057C013 /* UploadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadRequest.swift; sourceTree = ""; }; - 2ACBD4162CC963390057C013 /* DefaultCardImageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultCardImageResponse.swift; sourceTree = ""; }; - 2ACBD4192CCA03790057C013 /* ImageURLWithName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageURLWithName.swift; sourceTree = ""; }; - 2ACBD41C2CCAB3490057C013 /* PresignedStorageResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresignedStorageResponse.swift; sourceTree = ""; }; + 2A980BA72D803F04007DFA45 /* GAHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GAHelper.swift; sourceTree = ""; }; 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; 2AE6B14B2CBC160C00FA5C3C /* ReportViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewReactor.swift; sourceTree = ""; }; - 2AE6B14F2CBCC2F600FA5C3C /* ReportTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportTableViewCell.swift; sourceTree = ""; }; - 2AE6B1532CBCC34B00FA5C3C /* ReportReasonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportReasonView.swift; sourceTree = ""; }; - 2AE6B1592CBEAEC000FA5C3C /* ReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRequest.swift; sourceTree = ""; }; - 2AE6B1622CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCardBottomSheetViewController.swift; sourceTree = ""; }; - 2AE6B1652CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCardBottomSheetViewReactor.swift; sourceTree = ""; }; - 2AE6B16C2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCardBottomSheetSegmentView.swift; sourceTree = ""; }; - 2AE6B1702CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectDefaultImageTableViewCell.swift; sourceTree = ""; }; - 2AE6B1742CBFD59B00FA5C3C /* ImageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCollectionViewCell.swift; sourceTree = ""; }; - 2AE6B1772CBFE49D00FA5C3C /* SelectFontTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectFontTableViewCell.swift; sourceTree = ""; }; - 2AE6B17A2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCardSettingTableViewCell.swift; sourceTree = ""; }; - 2AE6B17E2CBFEA5200FA5C3C /* ToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleView.swift; sourceTree = ""; }; - 2AE6B18E2CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetSegmentTableViewCell.swift; sourceTree = ""; }; - 2AE6B1912CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectMyImageTableViewCell.swift; sourceTree = ""; }; - 2AFD05452CFF75DD007C84AD /* FavoriteTagsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagsResponse.swift; sourceTree = ""; }; - 2AFD05482CFF7687007C84AD /* RecommendTagsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendTagsResponse.swift; sourceTree = ""; }; 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRequest.swift; sourceTree = ""; }; - 2AFD054E2CFF79D8007C84AD /* TagsViewReactor.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = TagsViewReactor.swift; sourceTree = ""; tabWidth = 4; }; - 2AFD05512D007F2F007C84AD /* TagSearchViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchViewReactor.swift; sourceTree = ""; }; - 2AFD05542D0082DE007C84AD /* SearchTagsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTagsResponse.swift; sourceTree = ""; }; - 2AFD05582D008D23007C84AD /* TagDetailViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = TagDetailViewController.swift; sourceTree = ""; tabWidth = 4; }; - 2AFD055C2D009513007C84AD /* TagDetailNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailNavigationBarView.swift; sourceTree = ""; }; - 2AFD055F2D009FA1007C84AD /* TagDetailViewrReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailViewrReactor.swift; sourceTree = ""; }; - 2AFD05622D00A1E1007C84AD /* TagDetailCardResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagDetailCardResponse.swift; sourceTree = ""; }; - 2AFD05652D01CB30007C84AD /* TagInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagInfoResponse.swift; sourceTree = ""; }; - 2AFD05682D03264C007C84AD /* AddFavoriteTagResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFavoriteTagResponse.swift; sourceTree = ""; }; - 2AFF95542CF3222400CBFB12 /* TagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsViewController.swift; sourceTree = ""; }; - 2AFF95592CF3227900CBFB12 /* TagSearchTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchTextFieldView.swift; sourceTree = ""; }; - 2AFF955C2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagTableViewCell.swift; sourceTree = ""; }; - 2AFF95602CF33A3900CBFB12 /* FavoriteTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagView.swift; sourceTree = ""; }; - 2AFF95632CF33D9F00CBFB12 /* TagsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsHeaderView.swift; sourceTree = ""; }; - 2AFF95672CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendTagTableViewCell.swift; sourceTree = ""; }; - 2AFF956A2CF5E00600CBFB12 /* RecommendTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendTagView.swift; sourceTree = ""; }; - 2AFF956F2CF5E8DE00CBFB12 /* TagSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchViewController.swift; sourceTree = ""; }; - 2AFF95732CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPreviewCardCollectionViewCell.swift; sourceTree = ""; }; - 2AFF95772CF5F0B000CBFB12 /* TagPreviewCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagPreviewCardView.swift; sourceTree = ""; }; 365609D6E03DDA36B6BDBE3A /* Pods_SOOUM_Dev.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SOOUM_Dev.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3800575B2D9C12CB00E58A19 /* DefinedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinedError.swift; sourceTree = ""; }; 38026E3E2CA2B45A0045E1CE /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; 3802BDAB2D0AC1FB001256EA /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 3802BDB02D0AE900001256EA /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; 3802BDB72D0AF2F7001256EA /* PushManager+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PushManager+Rx.swift"; sourceTree = ""; }; + 3803B91A2ECF3937009D14B9 /* PopularTagHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularTagHeaderView.swift; sourceTree = ""; }; + 3803B91D2ECF3A6B009D14B9 /* FavoriteTagHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagHeaderView.swift; sourceTree = ""; }; + 3803B9222ECF52C0009D14B9 /* TagCollectCardViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectCardViewCell.swift; sourceTree = ""; }; + 3803B9252ECF5302009D14B9 /* TagCollectPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectPlaceholderViewCell.swift; sourceTree = ""; }; + 3803B9292ECF5579009D14B9 /* TagCollectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectViewController.swift; sourceTree = ""; }; + 3803B92C2ECF5584009D14B9 /* TagCollectViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectViewReactor.swift; sourceTree = ""; }; + 3803B9302ECF5F1B009D14B9 /* SearchTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTextFieldView.swift; sourceTree = ""; }; 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewReactor.swift; sourceTree = ""; tabWidth = 4; }; - 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequest.swift; sourceTree = ""; }; - 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsResponse.swift; sourceTree = ""; }; - 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistoryViewCell.swift; sourceTree = ""; }; - 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistroyViewReactor.swift; sourceTree = ""; }; 3803CF792D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueMemberTransferViewReactor.swift; sourceTree = ""; }; - 3803CF7C2D016DA200FD90DB /* TransferCodeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferCodeResponse.swift; sourceTree = ""; }; 3803CF812D017DB800FD90DB /* EnterMemberTransferViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = EnterMemberTransferViewController.swift; sourceTree = ""; tabWidth = 4; }; 3803CF842D017DC700FD90DB /* EnterMemberTransferViewReactor.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = EnterMemberTransferViewReactor.swift; sourceTree = ""; tabWidth = 4; }; 3803CF872D01914200FD90DB /* ResignViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignViewReactor.swift; sourceTree = ""; }; - 38121E282CA6A52400602499 /* UIRefreshControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIRefreshControl.swift; sourceTree = ""; }; + 380F42202E87ECA2009AC59E /* CompositeNotificationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeNotificationInfo.swift; sourceTree = ""; }; + 380F42232E884ADF009AC59E /* CardRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardRemoteDataSource.swift; sourceTree = ""; }; + 380F42262E884B6F009AC59E /* BaseCardInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCardInfo.swift; sourceTree = ""; }; + 380F42292E884E85009AC59E /* HomeCardInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCardInfoResponse.swift; sourceTree = ""; }; + 380F422C2E884F35009AC59E /* CardRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardRemoteDataSourceImpl.swift; sourceTree = ""; }; + 380F422F2E884FB5009AC59E /* CardRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardRepository.swift; sourceTree = ""; }; + 380F42322E884FD4009AC59E /* CardRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardRepositoryImpl.swift; sourceTree = ""; }; 38121E302CA6C77500602499 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; 38121E332CA6DA4000602499 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; - 3816C05B2CCDDF3D00C8688C /* ReAuthenticationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReAuthenticationResponse.swift; sourceTree = ""; }; 3816C05F2CCDE35300C8688C /* ErrorInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorInterceptor.swift; sourceTree = ""; }; 3816E2362D3BEE7E004CC196 /* TermsOfServiceCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServiceCellView.swift; sourceTree = ""; }; 3816E2392D3BF402004CC196 /* TermsOfServiceCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TermsOfServiceCellView+Rx.swift"; sourceTree = ""; }; 3817016D2CD882C2005FC220 /* TimeoutInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeoutInterceptor.swift; sourceTree = ""; }; 381701702CD88374005FC220 /* CompositeInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeInterceptor.swift; sourceTree = ""; }; 381701772CD88854005FC220 /* LogginMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogginMonitor.swift; sourceTree = ""; }; - 381A1D642CC38E7D005FDB8E /* WriteTagTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteTagTextField.swift; sourceTree = ""; }; - 381A1D692CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteTagTextFieldDelegate.swift; sourceTree = ""; }; + 381854972E992E8900424D71 /* WriteCardSelectImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardSelectImageView.swift; sourceTree = ""; }; + 3818549B2E992F7400424D71 /* WriteCardDefaultImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardDefaultImageCell.swift; sourceTree = ""; }; + 3818549E2E9933FF00424D71 /* WriteCardUserImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardUserImageCell.swift; sourceTree = ""; }; + 381854A82E99573700424D71 /* SelectTypographyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectTypographyView.swift; sourceTree = ""; }; 381A1D732CC3D799005FDB8E /* SOMTagsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTagsDelegate.swift; sourceTree = ""; }; 381A1D762CC3DA99005FDB8E /* SOMTagsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTagsLayout.swift; sourceTree = ""; }; + 381B83DB2EBC707400C84015 /* ProfileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInfo.swift; sourceTree = ""; }; + 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowInfo.swift; sourceTree = ""; }; + 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInfoResponse.swift; sourceTree = ""; }; + 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowInfoResponse.swift; sourceTree = ""; }; + 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardInfo.swift; sourceTree = ""; }; + 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardInfoResponse.swift; sourceTree = ""; }; + 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUserViewCell.swift; sourceTree = ""; }; 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteCardTextView+Rx.swift"; sourceTree = ""; }; + 381E7C182ECCB1A200E80249 /* TagViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewController.swift; sourceTree = ""; }; + 381E7C1B2ECCB1AA00E80249 /* TagViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagViewReactor.swift; sourceTree = ""; }; + 381E7C222ECCC62900E80249 /* SearchViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewButton.swift; sourceTree = ""; }; 382D5CF52CFE9B8600BFA23E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; - 382E15352D15A6460097B09C /* NotificationTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTabBarController.swift; sourceTree = ""; }; 382E15392D15A67A0097B09C /* NotificationViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewCell.swift; sourceTree = ""; }; - 382E153E2D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistoryInNotiResponse.swift; sourceTree = ""; }; - 382E15412D15BA490097B09C /* NotificationWithReportViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationWithReportViewCell.swift; sourceTree = ""; }; + 383088082EDC7B8200D99D88 /* SOMMessageBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMMessageBubbleView.swift; sourceTree = ""; }; 3830FFA52CEC6E3100ABA9FD /* Kingfisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Kingfisher.swift; sourceTree = ""; }; - 3834FADC2D11C5AC00C9108D /* SimpleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleCache.swift; sourceTree = ""; }; 3836ACB32C8F045300A3C566 /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; 3836ACB62C8F04CD00A3C566 /* UILabel+Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Observer.swift"; sourceTree = ""; }; 3836ACB92C8F050D00A3C566 /* UILabel+Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Typography.swift"; sourceTree = ""; }; 38389B9B2CCCF98B006728AF /* AuthRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRequest.swift; sourceTree = ""; }; 38389B9E2CCCFB7D006728AF /* AuthKeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthKeyChain.swift; sourceTree = ""; }; - 38405CCA2CC611FD00612D1E /* BaseEmptyAndHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmptyAndHeader.swift; sourceTree = ""; }; + 383EC6102E7A4F5E00EC2D1E /* AuthLocalDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthLocalDataSource.swift; sourceTree = ""; }; + 383EC6142E7A50E000EC2D1E /* AuthLocalDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthLocalDataSourceImpl.swift; sourceTree = ""; }; + 383EC6182E7A546B00EC2D1E /* BaseAssembler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAssembler.swift; sourceTree = ""; }; + 383EC61B2E7A548600EC2D1E /* BaseDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseDIContainer.swift; sourceTree = ""; }; + 383EC61F2E7A564200EC2D1E /* AppAssembler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssembler.swift; sourceTree = ""; }; + 383EC6222E7A56CA00EC2D1E /* AppDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDIContainer.swift; sourceTree = ""; }; 3843C1BD2D4FB778009283AC /* MockAlamofire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAlamofire.swift; sourceTree = ""; }; 3843C1BF2D4FC226009283AC /* MockNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkManager.swift; sourceTree = ""; }; 3843C1C12D4FC99E009283AC /* NetworkManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManagerTests.swift; sourceTree = ""; }; @@ -702,35 +946,46 @@ 385053572C92DD2300C80B02 /* SOMTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTabBarController.swift; sourceTree = ""; }; 385071992CA295A800A7905A /* LaunchScreenViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = LaunchScreenViewController.swift; sourceTree = ""; tabWidth = 4; }; 3854419A2C870544004E2BB0 /* SOOUM-Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SOOUM-Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 385602B52D2FB18400118530 /* NotiPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotiPlaceholderViewCell.swift; sourceTree = ""; }; + 385602B52D2FB18400118530 /* NotificationPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPlaceholderViewCell.swift; sourceTree = ""; }; 385620EE2CA19C9500E0AB5A /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; 385620F12CA19D2D00E0AB5A /* Alamofire_Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alamofire_Request.swift; sourceTree = ""; }; 385620F52CA19EA900E0AB5A /* Alamofire_constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alamofire_constants.swift; sourceTree = ""; }; - 38572CD72D2230C900B07C69 /* NotificationAllowResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAllowResponse.swift; sourceTree = ""; }; - 38572CDA2D22464F00B07C69 /* PungTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PungTimeView.swift; sourceTree = ""; }; - 38572CDD2D2254E800B07C69 /* PlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderViewCell.swift; sourceTree = ""; }; + 38572CDD2D2254E800B07C69 /* HomePlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePlaceholderViewCell.swift; sourceTree = ""; }; 3857BC312D4D1A7B008D4264 /* SOOUM-DevTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SOOUM-DevTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 3857BC3E2D4D1FFA008D4264 /* MockManagerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockManagerProvider.swift; sourceTree = ""; }; 3857BC402D4D22B4008D4264 /* MockManagerProviderContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockManagerProviderContainerTests.swift; sourceTree = ""; }; + 385C01AD2E8E8C6A003C7894 /* SOMPageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMPageModel.swift; sourceTree = ""; }; + 385C01B02E8E8DD4003C7894 /* SOMPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMPageView.swift; sourceTree = ""; }; + 385C01B32E8EA1B1003C7894 /* SOMPageViewsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMPageViewsDelegate.swift; sourceTree = ""; }; + 385C01B62E8EA1EB003C7894 /* SOMPageViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMPageViews.swift; sourceTree = ""; }; 385E65A22CBE56D00032E120 /* Coordinate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinate.swift; sourceTree = ""; }; - 38608B2F2CB5195D0066BB40 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = ""; }; 3862C0DE2C9EB6670023C046 /* UIViewController+PushAndPop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+PushAndPop.swift"; sourceTree = ""; }; 3866577D2CEF3554009F7F60 /* UIButton+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Rx.swift"; sourceTree = ""; }; - 38738D4A2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithoutReadNotisCountResponse.swift; sourceTree = ""; }; + 386712C32E97734800541389 /* UITextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITextField.swift; sourceTree = ""; }; + 386712C92E977E9D00541389 /* Kkukkkuk.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Kkukkkuk.ttf; sourceTree = ""; }; + 386712CC2E977EC200541389 /* Yoonwoo.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Yoonwoo.ttf; sourceTree = ""; }; + 386712CF2E977F1B00541389 /* RIDIBatang.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = RIDIBatang.otf; sourceTree = ""; }; + 386867A32E9E378000171A5E /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + 386867A62E9E932300171A5E /* WriteCardSelectImageView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteCardSelectImageView+Rx.swift"; sourceTree = ""; }; + 386E966A2E9A51D2005E047D /* SelectOptionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectOptionItem.swift; sourceTree = ""; }; + 386E966D2E9A53CC005E047D /* SelectOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectOptionsView.swift; sourceTree = ""; }; + 3874B55F2ECB25C9004CC22A /* SettingsLocalDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLocalDataSource.swift; sourceTree = ""; }; + 3874B5622ECB2606004CC22A /* SettingsLocalDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLocalDataSourceImpl.swift; sourceTree = ""; }; 38773E7B2CB3ACB2004815CD /* SOMRefreshControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMRefreshControl.swift; sourceTree = ""; }; + 38787B742ED1E5A8004BBAA7 /* SearchTermViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTermViewCell.swift; sourceTree = ""; }; + 38787B772ED1E715004BBAA7 /* SearchTermsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTermsView.swift; sourceTree = ""; }; + 38787B7A2ED1E8AB004BBAA7 /* TagSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchViewController.swift; sourceTree = ""; }; + 38787B7D2ED1E8EF004BBAA7 /* TagSearchViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchViewReactor.swift; sourceTree = ""; }; + 38787B802ED1EB1C004BBAA7 /* TagCollectCardsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCollectCardsView.swift; sourceTree = ""; }; + 38787B872ED22323004BBAA7 /* RxSwift+Unretained.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RxSwift+Unretained.swift"; sourceTree = ""; }; + 38787B8A2ED22A1F004BBAA7 /* SearchTermPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTermPlaceholderViewCell.swift; sourceTree = ""; }; 387894442D31786B00F69487 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 3878B8612D0DC8BD00B3B128 /* UIViewController+Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Toast.swift"; sourceTree = ""; }; - 3878D04D2CFFC5F300F9522F /* ProfileRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRequest.swift; sourceTree = ""; }; - 3878D0522CFFC6C100F9522F /* ProfileResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileResponse.swift; sourceTree = ""; }; - 3878D0552CFFCBDA00F9522F /* CommentHistoryResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistoryResponse.swift; sourceTree = ""; }; - 3878D05B2CFFD10D00F9522F /* FollowingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingResponse.swift; sourceTree = ""; }; - 3878D05E2CFFD45100F9522F /* FollowerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerResponse.swift; sourceTree = ""; }; 3878D0622CFFD66700F9522F /* FollowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowViewController.swift; sourceTree = ""; }; - 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherFollowViewCell.swift; sourceTree = ""; }; 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; tabWidth = 4; }; 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingTextCellView.swift; sourceTree = ""; }; 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingTextCellView+Rx.swift"; sourceTree = ""; }; - 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingScrollViewHeader.swift; sourceTree = ""; }; + 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingVersionCellView.swift; sourceTree = ""; }; 3878D0782CFFE1E800F9522F /* ResignViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignViewController.swift; sourceTree = ""; }; 3878D07C2CFFE6E500F9522F /* IssueMemberTransferViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueMemberTransferViewController.swift; sourceTree = ""; }; 3878D0802CFFEC6900F9522F /* TermsOfServiceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServiceViewController.swift; sourceTree = ""; }; @@ -738,12 +993,14 @@ 3878D0872CFFEF0F00F9522F /* TermsOfServiceTextCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TermsOfServiceTextCellView+Rx.swift"; sourceTree = ""; }; 3878D08B2CFFF0BF00F9522F /* AnnouncementViewControler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementViewControler.swift; sourceTree = ""; }; 3878D08F2CFFF0E300F9522F /* AnnouncementViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementViewCell.swift; sourceTree = ""; }; - 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistroyViewController.swift; sourceTree = ""; }; 3878F4702CA3F03400AA46A2 /* SOMCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMCard.swift; sourceTree = ""; }; 3878F4732CA3F06C00AA46A2 /* UIStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIStackView.swift; sourceTree = ""; }; 3878F4762CA3F08300AA46A2 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 3878FE0C2D0365C800D8955C /* SOMNavigationBar+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SOMNavigationBar+Rx.swift"; sourceTree = ""; }; - 387D852B2D08320A005D9D22 /* SOMCardModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SOMCardModel.swift; sourceTree = ""; }; + 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejoinableDateInfo.swift; sourceTree = ""; }; + 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejoinableDateInfoResponse.swift; sourceTree = ""; }; + 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GAEvent+SOOUM.swift"; sourceTree = ""; }; + 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewReactor.swift; sourceTree = ""; }; 387FBAEC2C8702C100A5E139 /* SOOUM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SOOUM.app; sourceTree = BUILT_PRODUCTS_DIR; }; 387FBAEF2C8702C100A5E139 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 387FBAF12C8702C100A5E139 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -755,25 +1012,72 @@ 388009902CABF855002A9209 /* SOMTagModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTagModel.swift; sourceTree = ""; }; 388009932CABFAAA002A9209 /* SOMTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTags.swift; sourceTree = ""; }; 388009962CAC20EC002A9209 /* SOMTags+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SOMTags+Rx.swift"; sourceTree = ""; }; + 3880EF6D2EA0CD6A00D88608 /* RelatedTagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedTagViewModel.swift; sourceTree = ""; }; + 3880EF702EA0CD9D00D88608 /* RelatedTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedTagView.swift; sourceTree = ""; }; + 3880EF732EA0CEE700D88608 /* RelatedTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedTagsView.swift; sourceTree = ""; }; + 3880EF762EA0CF2800D88608 /* RelatedTagsViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedTagsViewLayout.swift; sourceTree = ""; }; + 3880EF792EA0D17900D88608 /* RelatedTagsView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelatedTagsView+Rx.swift"; sourceTree = ""; }; + 3880EF7C2EA0DA6F00D88608 /* WritrCardTextViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritrCardTextViewDelegate.swift; sourceTree = ""; }; + 3880EF7F2EA0DB0300D88608 /* WriteCardTagsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagsDelegate.swift; sourceTree = ""; }; 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfileViewController.swift; sourceTree = ""; }; - 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfileView.swift; sourceTree = ""; }; 388371F82C8C8EB1004212EB /* SooumStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SooumStyle.swift; sourceTree = ""; }; 388371FB2C8C8F11004212EB /* UIColor+SOOUM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+SOOUM.swift"; sourceTree = ""; }; 388372002C8C8FCF004212EB /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 3886939E2CF77FA7005F9EF3 /* UIApplication+Top.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Top.swift"; sourceTree = ""; }; - 388698502D191F2100008600 /* MainHomeDistanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeDistanceViewController.swift; sourceTree = ""; }; - 388698532D191F4B00008600 /* MainHomeDistanceViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeDistanceViewReactor.swift; sourceTree = ""; }; 388698572D1982DE00008600 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 3886985E2D1984D600008600 /* NotificationViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewReactor.swift; sourceTree = ""; }; 388698612D1986B100008600 /* NotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRequest.swift; sourceTree = ""; }; - 388698642D1998DB00008600 /* NotificationTabBarReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTabBarReactor.swift; sourceTree = ""; }; + 388717692E7BD7AC00C6143B /* loading_indicator_lottie.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = loading_indicator_lottie.json; sourceTree = ""; }; + 3887176C2E7BDBA800C6143B /* NicknameResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameResponse.swift; sourceTree = ""; }; 3887D0322CC5335200FB52E1 /* WriteCardViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardViewReactor.swift; sourceTree = ""; }; 3887D0352CC5335D00FB52E1 /* WriteCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardView.swift; sourceTree = ""; }; 3887D0382CC5504500FB52E1 /* UITextField+Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+Typography.swift"; sourceTree = ""; }; - 388A2D2C2D00A45800E2F2F0 /* writtenCardResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = writtenCardResponse.swift; sourceTree = ""; }; + 38899E572E7936D50030F7CA /* SooumStyle_V2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SooumStyle_V2.swift; sourceTree = ""; }; + 38899E5D2E7937DB0030F7CA /* NicknameValidateResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameValidateResponse.swift; sourceTree = ""; }; + 38899E652E79395E0030F7CA /* CheckAvailableResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckAvailableResponse.swift; sourceTree = ""; }; + 38899E6A2E793AF70030F7CA /* CheckAvailable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckAvailable.swift; sourceTree = ""; }; + 38899E6D2E79400B0030F7CA /* ImageUrlInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUrlInfoResponse.swift; sourceTree = ""; }; + 38899E702E7940280030F7CA /* ImageUrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUrlInfo.swift; sourceTree = ""; }; + 38899E7C2E794B3D0030F7CA /* SignUpResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpResponse.swift; sourceTree = ""; }; + 38899E822E794C330030F7CA /* LoginResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginResponse.swift; sourceTree = ""; }; + 38899E852E794CE90030F7CA /* NetworkManager_FCM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager_FCM.swift; sourceTree = ""; }; + 38899E882E794D5D0030F7CA /* NetworkManager_Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager_Version.swift; sourceTree = ""; }; + 38899E8B2E794E680030F7CA /* AppVersionStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionStatusResponse.swift; sourceTree = ""; }; + 38899E8E2E79511F0030F7CA /* KeyInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyInfoResponse.swift; sourceTree = ""; }; + 38899E922E79518E0030F7CA /* CommonNotificationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonNotificationInfo.swift; sourceTree = ""; }; + 38899E952E7953300030F7CA /* NotificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationInfoResponse.swift; sourceTree = ""; }; + 38899E982E7954670030F7CA /* BlockedNotificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedNotificationInfoResponse.swift; sourceTree = ""; }; + 38899E9B2E7954D70030F7CA /* DeletedNotificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedNotificationInfoResponse.swift; sourceTree = ""; }; + 38899EA22E799B190030F7CA /* AppVersionRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionRemoteDataSource.swift; sourceTree = ""; }; + 38899EA52E799BD10030F7CA /* AppVersionRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionRemoteDataSourceImpl.swift; sourceTree = ""; }; + 38899EA82E799C5D0030F7CA /* VersionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionRequest.swift; sourceTree = ""; }; + 38899EAC2E79A0990030F7CA /* UserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRequest.swift; sourceTree = ""; }; + 3889A2422E79AD600030F7CA /* AppVersionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionRepository.swift; sourceTree = ""; }; + 3889A2452E79ADBD0030F7CA /* AppVersionRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionRepositoryImpl.swift; sourceTree = ""; }; + 3889A2492E79AE900030F7CA /* AppVersionUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionUseCase.swift; sourceTree = ""; }; + 3889A24C2E79AEAD0030F7CA /* AppVersionUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionUseCaseImpl.swift; sourceTree = ""; }; + 3889A24F2E79B3210030F7CA /* UserRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRemoteDataSource.swift; sourceTree = ""; }; + 3889A2552E79BA0F0030F7CA /* UserRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRemoteDataSourceImpl.swift; sourceTree = ""; }; + 3889A25B2E79BB2F0030F7CA /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; + 3889A2612E79BB540030F7CA /* UserRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryImpl.swift; sourceTree = ""; }; + 3889A26A2E79BD410030F7CA /* AuthRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRemoteDataSource.swift; sourceTree = ""; }; + 3889A26D2E79BE970030F7CA /* AuthRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRemoteDataSourceImpl.swift; sourceTree = ""; }; + 3889A2702E79C0370030F7CA /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; + 3889A2732E79C1D30030F7CA /* NotificationRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRemoteDataSource.swift; sourceTree = ""; }; + 3889A2762E79C2980030F7CA /* NotificationRemoteDataSoruceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRemoteDataSoruceImpl.swift; sourceTree = ""; }; + 3889A27C2E79C5670030F7CA /* ToeknResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToeknResponse.swift; sourceTree = ""; }; + 3889A27F2E79D0230030F7CA /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; + 3889A2822E79D7CE0030F7CA /* AuthRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepositoryImpl.swift; sourceTree = ""; }; + 3889A2852E79D8060030F7CA /* AuthUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthUseCase.swift; sourceTree = ""; }; + 3889A2882E79D81E0030F7CA /* AuthUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthUseCaseImpl.swift; sourceTree = ""; }; + 3889A28B2E79D8650030F7CA /* NotificationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRepository.swift; sourceTree = ""; }; + 3889A28E2E79D8800030F7CA /* NotificationRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRepositoryImpl.swift; sourceTree = ""; }; + 3889A2912E79D8F40030F7CA /* NotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUseCase.swift; sourceTree = ""; }; + 3889A2942E79D9200030F7CA /* NotificationUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUseCaseImpl.swift; sourceTree = ""; }; 388A2D2F2D00D6A100E2F2F0 /* FollowViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowViewReactor.swift; sourceTree = ""; }; 388A2D322D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfileViewReactor.swift; sourceTree = ""; }; 388C96352CCE41700061C598 /* AuthInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthInfo.swift; sourceTree = ""; }; + 388D8ADE2E73E6130044BA79 /* SwiftEntryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftEntryKit.swift; sourceTree = ""; }; 388DA0FA2C8F521000A9DD56 /* FontContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontContainer.swift; sourceTree = ""; }; 388DA0FD2C8F526C00A9DD56 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 388DA1002C8F538400A9DD56 /* PretendardVariable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = PretendardVariable.ttf; sourceTree = ""; }; @@ -782,90 +1086,197 @@ 388FCACF2CFAC2BF0012C4D6 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; 3893B6CD2D36728000F2004C /* ManagerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagerProvider.swift; sourceTree = ""; }; 3893B6D02D36739500F2004C /* CompositeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeManager.swift; sourceTree = ""; }; + 3894EDE22ED4B2BA0024213E /* FavoriteTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagsViewModel.swift; sourceTree = ""; }; 3896810F2CAFBD6A00FFD89F /* DetailViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewReactor.swift; sourceTree = ""; }; + 389E59A42EDEE38E00D0946D /* ValidateUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateUserUseCase.swift; sourceTree = ""; }; + 389E59A72EDEE6EF00D0946D /* ValidateNicknameUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateNicknameUseCase.swift; sourceTree = ""; }; + 389E59AA2EDEE73500D0946D /* UploadUserImageUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadUserImageUseCase.swift; sourceTree = ""; }; + 389E59AD2EDEE8B500D0946D /* FetchCardUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCardUseCase.swift; sourceTree = ""; }; + 389E59B02EDEEA3300D0946D /* FetchNoticeUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchNoticeUseCase.swift; sourceTree = ""; }; + 389E59B32EDEEA7600D0946D /* FetchCardDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCardDetailUseCase.swift; sourceTree = ""; }; + 389E59B62EDEEAEB00D0946D /* UpdateCardLikeUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCardLikeUseCase.swift; sourceTree = ""; }; + 389E59B92EDEEB8000D0946D /* BlockUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserUseCase.swift; sourceTree = ""; }; + 389E59BC2EDEEBEA00D0946D /* ReportCardUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCardUseCase.swift; sourceTree = ""; }; + 389E59BF2EDEEC4500D0946D /* DeleteCardUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteCardUseCase.swift; sourceTree = ""; }; + 389E59C22EDEEC7600D0946D /* CardImageUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageUseCase.swift; sourceTree = ""; }; + 389E59C52EDEECBC00D0946D /* WriteCardUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardUseCase.swift; sourceTree = ""; }; + 389E59C82EDEED2700D0946D /* FetchTagUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTagUseCase.swift; sourceTree = ""; }; + 389E59CB2EDEED6100D0946D /* FetchUserInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchUserInfoUseCase.swift; sourceTree = ""; }; + 389E59CE2EDEEDD000D0946D /* FetchFollowUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchFollowUseCase.swift; sourceTree = ""; }; + 389E59D12EDEEE3800D0946D /* UpdateFollowUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFollowUseCase.swift; sourceTree = ""; }; + 389E59D42EDEEE6500D0946D /* UpdateUserInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateUserInfoUseCase.swift; sourceTree = ""; }; + 389E59D72EDEEEB900D0946D /* UpdateNotifyUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotifyUseCase.swift; sourceTree = ""; }; + 389E59DA2EDEEF3000D0946D /* FetchBlockUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchBlockUserUseCase.swift; sourceTree = ""; }; + 389E59DD2EDEEF7600D0946D /* TransferAccountUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferAccountUseCase.swift; sourceTree = ""; }; + 389E59E02EDEEF9E00D0946D /* UpdateTagFavoriteUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTagFavoriteUseCase.swift; sourceTree = ""; }; + 389E59E32EDEF02900D0946D /* LocationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationUseCase.swift; sourceTree = ""; }; 389EF8162D2F450000E053AE /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 389EF8192D2F454600E053AE /* Log+Extract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Log+Extract.swift"; sourceTree = ""; }; 389EF81D2D2F469B00E053AE /* CocoaLumberjack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CocoaLumberjack.swift; sourceTree = ""; }; 38A5D1532C8CB11E00B68363 /* UIImage+SOOUM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SOOUM.swift"; sourceTree = ""; }; 38A627162CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTagsLayoutConfigure.swift; sourceTree = ""; }; + 38A721942E73E7010071E1D8 /* View+SwiftEntryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SwiftEntryKit.swift"; sourceTree = ""; }; + 38A721982E73EA610071E1D8 /* SOMBottomFloatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMBottomFloatView.swift; sourceTree = ""; }; 38AA00012CAD1BCC002C5F1E /* LikeAndCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeAndCommentView.swift; sourceTree = ""; }; - 38AA00052CAD96E3002C5F1E /* MoreBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreBottomSheetViewController.swift; sourceTree = ""; }; 38AA66212D3AA86F00B3F6B2 /* SOMDialogAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMDialogAction.swift; sourceTree = ""; }; - 38AA66252D3AC3F500B3F6B2 /* DialogMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogMessageView.swift; sourceTree = ""; }; 38AE565B2D048B4800CAA431 /* SOMDialogViewController+Show.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SOMDialogViewController+Show.swift"; sourceTree = ""; }; + 38AE77D32E7457F400B6FD13 /* OnboardingCompletedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCompletedViewController.swift; sourceTree = ""; }; + 38AE77D62E7459EA00B6FD13 /* OnboardingCompletedViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCompletedViewReactor.swift; sourceTree = ""; }; + 38AE77DA2E745FF700B6FD13 /* EnterMemberTransferTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterMemberTransferTextFieldView.swift; sourceTree = ""; }; + 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnterMemberTransferTextFieldView+Rx.swift"; sourceTree = ""; }; + 38AE85082EDF413C00029E4C /* BlockUserUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserUseCaseImpl.swift; sourceTree = ""; }; + 38AE850B2EDF41AF00029E4C /* CardImageUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageUseCaseImpl.swift; sourceTree = ""; }; + 38AE850E2EDF420300029E4C /* DeleteCardUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteCardUseCaseImpl.swift; sourceTree = ""; }; + 38AE85112EDF424600029E4C /* FetchBlockUserUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchBlockUserUseCaseImpl.swift; sourceTree = ""; }; + 38AE85142EDF42B400029E4C /* FetchCardDetailUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCardDetailUseCaseImpl.swift; sourceTree = ""; }; + 38AE85172EDF436F00029E4C /* FetchCardUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCardUseCaseImpl.swift; sourceTree = ""; }; + 38AE851A2EDFF7DE00029E4C /* FetchFollowUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchFollowUseCaseImpl.swift; sourceTree = ""; }; + 38AE851D2EDFF84500029E4C /* FetchNoticeUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchNoticeUseCaseImpl.swift; sourceTree = ""; }; + 38AE85202EDFF88A00029E4C /* FetchTagUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTagUseCaseImpl.swift; sourceTree = ""; }; + 38AE85232EDFF90000029E4C /* FetchUserInfoUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchUserInfoUseCaseImpl.swift; sourceTree = ""; }; + 38AE85262EDFF95200029E4C /* LocationUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationUseCaseImpl.swift; sourceTree = ""; }; + 38AE85292EDFF99B00029E4C /* ReportCardUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCardUseCaseImpl.swift; sourceTree = ""; }; + 38AE852C2EDFFA3900029E4C /* TransferAccountUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferAccountUseCaseImpl.swift; sourceTree = ""; }; + 38AE852F2EDFFA9300029E4C /* UpdateCardLikeUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCardLikeUseCaseImpl.swift; sourceTree = ""; }; + 38AE85322EDFFAC300029E4C /* UpdateFollowUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFollowUseCaseImpl.swift; sourceTree = ""; }; + 38AE85352EDFFAF700029E4C /* UpdateNotifyUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotifyUseCaseImpl.swift; sourceTree = ""; }; + 38AE85382EDFFBBE00029E4C /* UpdateTagFavoriteUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTagFavoriteUseCaseImpl.swift; sourceTree = ""; }; + 38AE853B2EDFFBF500029E4C /* UpdateUserInfoUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateUserInfoUseCaseImpl.swift; sourceTree = ""; }; + 38AE853E2EDFFC3500029E4C /* UploadUserImageUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadUserImageUseCaseImpl.swift; sourceTree = ""; }; + 38AE85412EDFFCA400029E4C /* ValidateNicknameUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateNicknameUseCaseImpl.swift; sourceTree = ""; }; + 38AE85442EDFFCF800029E4C /* ValidateUserUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateUserUseCaseImpl.swift; sourceTree = ""; }; + 38AE85472EDFFD7B00029E4C /* WriteCardUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardUseCaseImpl.swift; sourceTree = ""; }; + 38B21C012ECEF45D00990F49 /* FavoriteTagViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagViewModel.swift; sourceTree = ""; }; + 38B21C072ECEF7CF00990F49 /* PopularTagViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularTagViewCell.swift; sourceTree = ""; }; + 38B21C0A2ECEFF9F00990F49 /* FavoriteTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagsView.swift; sourceTree = ""; }; + 38B21C0D2ECF0F1800990F49 /* PopularTagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularTagsView.swift; sourceTree = ""; }; + 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPlaceholderViewCell.swift; sourceTree = ""; }; 38B543DE2D46171300DDF2C5 /* ManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagerConfiguration.swift; sourceTree = ""; }; 38B543E12D46179500DDF2C5 /* AuthManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManagerConfiguration.swift; sourceTree = ""; }; 38B543E42D4617CB00DDF2C5 /* PushManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManagerConfiguration.swift; sourceTree = ""; }; 38B543E72D4617EA00DDF2C5 /* NetworkManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManagerConfiguration.swift; sourceTree = ""; }; 38B543EA2D461B1A00DDF2C5 /* LocationManagerConfigruation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerConfigruation.swift; sourceTree = ""; }; 38B543ED2D46506300DDF2C5 /* ManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagerType.swift; sourceTree = ""; }; + 38B65E782E72A29100DF6919 /* OnboardingNumberingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNumberingView.swift; sourceTree = ""; }; + 38B65E7B2E72ADB500DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TermsOfServiceAgreeButtonView+Rx.swift"; sourceTree = ""; }; 38B6AACC2CA410D800CE6DB6 /* MainTabBarController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; tabWidth = 4; }; - 38B6AAD72CA424AE00CE6DB6 /* MoveTopButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveTopButtonView.swift; sourceTree = ""; }; 38B6AADA2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = LaunchScreenViewReactor.swift; sourceTree = ""; tabWidth = 4; }; 38B6AADE2CA4777200CE6DB6 /* UIViewController+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Rx.swift"; sourceTree = ""; }; 38B6AAE12CA4787200CE6DB6 /* MainTabBarReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarReactor.swift; sourceTree = ""; }; - 38B8A5832CAE9CC4000AFE83 /* MainHomeViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeViewCell.swift; sourceTree = ""; }; + 38B8A5832CAE9CC4000AFE83 /* HomeViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewCell.swift; sourceTree = ""; }; 38B8A5872CAEA5F9000AFE83 /* DetailViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewCell.swift; sourceTree = ""; }; 38B8A58A2CAEA79A000AFE83 /* DetailViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewFooter.swift; sourceTree = ""; }; 38B8A58D2CAEB61A000AFE83 /* DetailViewFooterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewFooterCell.swift; sourceTree = ""; }; - 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationInfo.swift; sourceTree = ""; }; + 38B8BE462D1ECBDA0084569C /* PushNotificationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationInfo.swift; sourceTree = ""; }; 38B9E4692CE72CC1008A24C8 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; 38BCF2302D32BB22004F653A /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/LaunchScreen.strings; sourceTree = ""; }; - 38BE72162CC696E9002662DD /* WriteTagTextField+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteTagTextField+Rx.swift"; sourceTree = ""; }; - 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherProfileViewCell.swift; sourceTree = ""; }; - 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewCell.swift; sourceTree = ""; }; - 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewFooterCell.swift; sourceTree = ""; }; - 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewFooter.swift; sourceTree = ""; }; + 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingVersionCellView+Rx.swift"; sourceTree = ""; }; + 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequest.swift; sourceTree = ""; }; + 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRemoteDataSource.swift; sourceTree = ""; }; + 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferCodeInfo.swift; sourceTree = ""; }; + 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferCodeInfoResponse.swift; sourceTree = ""; }; + 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRemoteDataSourceImpl.swift; sourceTree = ""; }; + 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRepository.swift; sourceTree = ""; }; + 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRepositoryImpl.swift; sourceTree = ""; }; + 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserInfo.swift; sourceTree = ""; }; + 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUsersInfoResponse.swift; sourceTree = ""; }; + 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawType.swift; sourceTree = ""; }; + 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignTextFieldView.swift; sourceTree = ""; }; + 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResignTextFieldView+Rx.swift"; sourceTree = ""; }; + 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserViewCell.swift; sourceTree = ""; }; + 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUsersViewController.swift; sourceTree = ""; }; + 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserPlaceholderViewCell.swift; sourceTree = ""; }; + 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUsersViewReactor.swift; sourceTree = ""; }; + 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardViewCell.swift; sourceTree = ""; }; 38C2D41F2CFEB82400CEA092 /* ProfileViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewReactor.swift; sourceTree = ""; }; + 38C9AF0A2E965EEE00B401C0 /* DefaultImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultImages.swift; sourceTree = ""; }; + 38C9AF0D2E96601E00B401C0 /* DefaultImagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultImagesResponse.swift; sourceTree = ""; }; + 38C9AF102E96656400B401C0 /* TagInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagInfo.swift; sourceTree = ""; }; + 38C9AF132E9665C500B401C0 /* TagInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagInfoResponse.swift; sourceTree = ""; }; + 38C9AF162E96692900B401C0 /* TagRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRemoteDataSource.swift; sourceTree = ""; }; + 38C9AF192E96696500B401C0 /* TagRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRemoteDataSourceImpl.swift; sourceTree = ""; }; + 38C9AF1F2E9669F100B401C0 /* TagRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRepository.swift; sourceTree = ""; }; + 38C9AF222E966A1300B401C0 /* TagRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRepositoryImpl.swift; sourceTree = ""; }; + 38C9AF2C2E96A3D600B401C0 /* WriteCardTagModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagModel.swift; sourceTree = ""; }; + 38C9AF2F2E96A49B00B401C0 /* WriteCardTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTag.swift; sourceTree = ""; }; + 38C9AF322E96A82600B401C0 /* WriteCardTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTags.swift; sourceTree = ""; }; + 38C9AF382E96AB8800B401C0 /* WriteCardTagFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagFooter.swift; sourceTree = ""; }; + 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagFooterDelegate.swift; sourceTree = ""; }; + 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardsPlaceholderViewCell.swift; sourceTree = ""; }; + 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewHeader.swift; sourceTree = ""; }; 38CC49812CDE3854007A0145 /* SOMPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMPresentationController.swift; sourceTree = ""; }; 38CC49842CDE3885007A0145 /* SOMTransitioningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTransitioningDelegate.swift; sourceTree = ""; }; 38CC49872CDE3972007A0145 /* SOMPresentationController+Show.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SOMPresentationController+Show.swift"; sourceTree = ""; }; 38CE94BE2C904D460004B238 /* SOMNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMNavigationBar.swift; sourceTree = ""; }; 38D055C22CD862FE00E75590 /* SOMActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMActivityIndicatorView.swift; sourceTree = ""; }; - 38D3CB142CC2362B001EC280 /* Hakgyoansim-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Hakgyoansim-Bold.ttf"; sourceTree = ""; }; - 38D3CB152CC2362B001EC280 /* Hakgyoansim-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Hakgyoansim-Light.ttf"; sourceTree = ""; }; + 38D2FBC02E81234C006DD739 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; + 38D2FBC32E81AD26006DD739 /* refrech_control_lottie.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = refrech_control_lottie.json; sourceTree = ""; }; + 38D2FBCA2E81B0DE006DD739 /* SOMSwipableTabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMSwipableTabBarItem.swift; sourceTree = ""; }; + 38D2FBCD2E81B529006DD739 /* SOMSwipableTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMSwipableTabBar.swift; sourceTree = ""; }; + 38D2FBD02E81B9B0006DD739 /* SOMSwipableTabBarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMSwipableTabBarDelegate.swift; sourceTree = ""; }; + 38D478062EBBAA080041FF6C /* WriteCardResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardResponse.swift; sourceTree = ""; }; + 38D478092EBBABE40041FF6C /* EntranceCardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntranceCardType.swift; sourceTree = ""; }; 38D488C92D0C557300F2D38D /* SOMButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMButton.swift; sourceTree = ""; }; - 38D5637A2D16D72D006265AA /* SOMSwipeTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMSwipeTabBar.swift; sourceTree = ""; }; - 38D5637D2D17152F006265AA /* SOMSwipeTabBarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMSwipeTabBarDelegate.swift; sourceTree = ""; }; - 38D563832D1719B1006265AA /* SOMSwipeTabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMSwipeTabBarItem.swift; sourceTree = ""; }; + 38D522672E742F550044911B /* SOMLoadingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMLoadingIndicatorView.swift; sourceTree = ""; }; + 38D5637A2D16D72D006265AA /* SOMStickyTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMStickyTabBar.swift; sourceTree = ""; }; + 38D5637D2D17152F006265AA /* SOMStickyTabBarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMStickyTabBarDelegate.swift; sourceTree = ""; }; + 38D563832D1719B1006265AA /* SOMStickyTabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMStickyTabBarItem.swift; sourceTree = ""; }; 38D5CE0A2CBCE8CA0054AB9A /* SimpleDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleDefaults.swift; sourceTree = ""; }; 38D6F17B2CC2406700E11530 /* WriteCardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardViewController.swift; sourceTree = ""; }; 38D6F17F2CC2413400E11530 /* WriteCardTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTextView.swift; sourceTree = ""; }; 38D6F1822CC243DB00E11530 /* UITextView+Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+Typography.swift"; sourceTree = ""; }; - 38D6F1852CC24C4F00E11530 /* WriteCardTextViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTextViewDelegate.swift; sourceTree = ""; }; 38D869622CF821F900BF87DA /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; 38D8E2902CCD232B00CE2E0A /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagNofificationInfoResponse.swift; sourceTree = ""; }; + 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleReachability.swift; sourceTree = ""; }; + 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardsViewCell.swift; sourceTree = ""; }; + 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMNicknameTextField.swift; sourceTree = ""; }; 38DA12B32D4E54EB00AB9468 /* MockAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthManager.swift; sourceTree = ""; }; 38DA12B52D4E642C00AB9468 /* AuthManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManagerTests.swift; sourceTree = ""; }; 38DA12B82D4E847100AB9468 /* MockPushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPushManager.swift; sourceTree = ""; }; 38DA12BA2D4E872000AB9468 /* PushManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManagerTests.swift; sourceTree = ""; }; + 38E928B52EB711DE00B3F00B /* DetailCardInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailCardInfo.swift; sourceTree = ""; }; + 38E928B82EB715C300B3F00B /* ReortType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReortType.swift; sourceTree = ""; }; + 38E928BE2EB72D3600B3F00B /* DetailCardInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailCardInfoResponse.swift; sourceTree = ""; }; + 38E928C12EB73D4B00B3F00B /* MemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberInfoView.swift; sourceTree = ""; }; + 38E928C62EB73FF300B3F00B /* WrittenTagModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrittenTagModel.swift; sourceTree = ""; }; + 38E928C92EB7401F00B3F00B /* WrittenTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrittenTag.swift; sourceTree = ""; }; + 38E928CC2EB7408E00B3F00B /* WrittenTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrittenTags.swift; sourceTree = ""; }; + 38E928CF2EB75F9900B3F00B /* PungView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PungView.swift; sourceTree = ""; }; + 38E928D22EB7623A00B3F00B /* FloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingButton.swift; sourceTree = ""; }; + 38E928D82EB7726E00B3F00B /* SOMBottomToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMBottomToastView.swift; sourceTree = ""; }; + 38E928DB2EB7920C00B3F00B /* UIRefreshControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIRefreshControl.swift; sourceTree = ""; }; 38E9CE0F2D376E0E00E85A2D /* PushTokenSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTokenSet.swift; sourceTree = ""; }; 38E9CE122D37711600E85A2D /* OnboardingViewReactor.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = OnboardingViewReactor.swift; sourceTree = ""; tabWidth = 4; }; 38E9CE182D37FED000E85A2D /* AddingTokenInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddingTokenInterceptor.swift; sourceTree = ""; }; - 38F006A92D395A7F001AC5F7 /* SuspensionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspensionResponse.swift; sourceTree = ""; }; - 38F131872CC7B7E0000D0475 /* RelatedTagResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedTagResponse.swift; sourceTree = ""; }; + 38EBA90D2EB39917008B28F4 /* PostingPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingPermission.swift; sourceTree = ""; }; + 38EBA9102EB3999C008B28F4 /* PostingPermissionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingPermissionResponse.swift; sourceTree = ""; }; + 38EC8CFF2ED44658009C2857 /* TagSearchCollectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchCollectViewController.swift; sourceTree = ""; }; + 38EC8D022ED44664009C2857 /* TagSearchCollectViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSearchCollectViewReactor.swift; sourceTree = ""; }; + 38F161422ECDA853003BADB6 /* SearchViewButton+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewButton+Rx.swift"; sourceTree = ""; }; + 38F161462ECDA8D1003BADB6 /* FavoriteTagPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagPlaceholderViewCell.swift; sourceTree = ""; }; + 38F161492ECDAD29003BADB6 /* FavoriteTagViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagViewCell.swift; sourceTree = ""; }; + 38F3398E2EE31C7E0066A5F7 /* IsCardDeletedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsCardDeletedResponse.swift; sourceTree = ""; }; + 38F339912EE328710066A5F7 /* WriteCardGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardGuideView.swift; sourceTree = ""; }; + 38F376092ECB772600E4A41D /* FavoriteTagInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagInfo.swift; sourceTree = ""; }; + 38F3760C2ECB778C00E4A41D /* FavoriteTagInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteTagInfoResponse.swift; sourceTree = ""; }; + 38F376102ECB789200E4A41D /* TagCardInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCardInfoResponse.swift; sourceTree = ""; }; 38F3D92F2D06C2370049F575 /* SOMAnimationTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMAnimationTransitioning.swift; sourceTree = ""; }; - 38F70E5A2D1905D000B33C9D /* MainHomeTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeTabBarController.swift; sourceTree = ""; }; - 38F70E5D2D190FBD00B33C9D /* MainHomeTabBarReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeTabBarReactor.swift; sourceTree = ""; }; - 38F70E612D19113E00B33C9D /* MainHomeLatestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLatestViewController.swift; sourceTree = ""; }; - 38F70E642D19161800B33C9D /* MainHomeLatestViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLatestViewReactor.swift; sourceTree = ""; }; - 38F70E6B2D191D9A00B33C9D /* MainHomePopularViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomePopularViewController.swift; sourceTree = ""; }; - 38F70E6E2D191DFB00B33C9D /* MainHomePopularViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomePopularViewReactor.swift; sourceTree = ""; }; - 38F7209A2CD4F15900DF32B5 /* CardSummaryResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardSummaryResponse.swift; sourceTree = ""; }; - 38F7209B2CD4F15900DF32B5 /* CommentCardResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentCardResponse.swift; sourceTree = ""; }; - 38F7209E2CD4F15900DF32B5 /* DetailCardResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailCardResponse.swift; sourceTree = ""; }; - 38F720A12CD4F15900DF32B5 /* distanceCardResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = distanceCardResponse.swift; sourceTree = ""; }; - 38F720A22CD4F15900DF32B5 /* LatestCardResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestCardResponse.swift; sourceTree = ""; }; - 38F720A32CD4F15900DF32B5 /* PopularCardResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PopularCardResponse.swift; sourceTree = ""; }; - 38F720B72CD4F16500DF32B5 /* CardProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardProtocol.swift; sourceTree = ""; }; 38F88EB92D2C1CB8002AD7A8 /* Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Info.swift; sourceTree = ""; }; 38F88EBD2D2C1E22002AD7A8 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; - 38FD4DAA2D032CF000BF5FF1 /* AnnouncementResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementResponse.swift; sourceTree = ""; }; + 38FCF4182E9F88E3003AC3D8 /* WriteCardTags+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteCardTags+Rx.swift"; sourceTree = ""; }; + 38FCF41B2EA00623003AC3D8 /* UITtextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITtextView.swift; sourceTree = ""; }; 38FD4DAD2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementViewReactor.swift; sourceTree = ""; }; 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyFollowingViewCell.swift; sourceTree = ""; }; - 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyFollowerViewCell.swift; sourceTree = ""; }; + 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerViewCell.swift; sourceTree = ""; }; + 38FD56232EC9FAA000EC6106 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = ""; }; 38FDC2C62C9E764300C094C2 /* BaseNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseNavigationViewController.swift; sourceTree = ""; }; + 38FEBE532E865119002916A8 /* FollowNotificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationInfoResponse.swift; sourceTree = ""; }; + 38FEBE5A2E8652D2002916A8 /* CompositeNotificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeNotificationInfoResponse.swift; sourceTree = ""; }; + 38FEBE5D2E866125002916A8 /* NoticeViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeViewCell.swift; sourceTree = ""; }; + 38FEBE602E8661F2002916A8 /* NoticeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeInfo.swift; sourceTree = ""; }; + 38FEBE632E86629F002916A8 /* NoticeInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeInfoResponse.swift; sourceTree = ""; }; 4C597C004C07775E636659FE /* Pods-SOOUM.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SOOUM.release.xcconfig"; path = "Target Support Files/Pods-SOOUM/Pods-SOOUM.release.xcconfig"; sourceTree = ""; }; 74194BC62F22BC2F5596D850 /* Pods_SOOUM.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SOOUM.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 79FB23C15AD70915D59D7DC3 /* Pods-SOOUM.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SOOUM.debug.xcconfig"; path = "Target Support Files/Pods-SOOUM/Pods-SOOUM.debug.xcconfig"; sourceTree = ""; }; @@ -903,23 +1314,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 2A048E792C9BDF1000FFD485 /* SOMLocationFilter */ = { - isa = PBXGroup; - children = ( - 2A048E7A2C9BDF5F00FFD485 /* SOMLocationFilter.swift */, - 2A048E832C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift */, - ); - path = SOMLocationFilter; - sourceTree = ""; - }; - 2A34AFB32D144EE7007BD7E7 /* Cells */ = { - isa = PBXGroup; - children = ( - 2A34AFB42D144EEF007BD7E7 /* EmptyTagDetailTableViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; 2A5BB7B72CDB856D00E1C799 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -927,6 +1321,7 @@ 2A5BB7BB2CDB86DD00E1C799 /* TermsOfService */, 2A5BB7C72CDBA4F400E1C799 /* NicknameSetting */, 2A5BB7CB2CDBB7B200E1C799 /* ProfileImageSetting */, + 38AE77D22E7457E700B6FD13 /* Completed */, 2A5BB7BC2CDB86E500E1C799 /* Views */, ); path = Onboarding; @@ -945,6 +1340,7 @@ 2A5BB7BC2CDB86E500E1C799 /* Views */ = { isa = PBXGroup; children = ( + 38B65E782E72A29100DF6919 /* OnboardingNumberingView.swift */, 2A5BB7BD2CDB870000E1C799 /* OnboardingGuideMessageView.swift */, ); path = Views; @@ -955,7 +1351,6 @@ children = ( 2A5BB7C82CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift */, 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */, - 2A5BB7D72CDCBA7C00E1C799 /* Views */, ); path = NicknameSetting; sourceTree = ""; @@ -963,8 +1358,8 @@ 2A5BB7CB2CDBB7B200E1C799 /* ProfileImageSetting */ = { isa = PBXGroup; children = ( - 2A5BB7CC2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift */, - 2A5BB7F92CE277AF00E1C799 /* ProfileImageSettingViewReactor.swift */, + 2A5BB7CC2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift */, + 2A5BB7F92CE277AF00E1C799 /* OnboardingProfileImageSettingViewReactor.swift */, ); path = ProfileImageSetting; sourceTree = ""; @@ -974,7 +1369,6 @@ children = ( 2A5BB7D02CDC7ADC00E1C799 /* OnboardingViewController.swift */, 38E9CE122D37711600E85A2D /* OnboardingViewReactor.swift */, - 38AA66242D3AC3E400B3F6B2 /* Views */, ); path = Onboarding; sourceTree = ""; @@ -982,6 +1376,7 @@ 2A5BB7D32CDCA5BE00E1C799 /* Views */ = { isa = PBXGroup; children = ( + 38B65E7B2E72ADB500DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift */, 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */, 3816E2362D3BEE7E004CC196 /* TermsOfServiceCellView.swift */, 3816E2392D3BF402004CC196 /* TermsOfServiceCellView+Rx.swift */, @@ -989,43 +1384,14 @@ path = Views; sourceTree = ""; }; - 2A5BB7D72CDCBA7C00E1C799 /* Views */ = { + 2A980B9B2D803E8B007DFA45 /* GAHelper */ = { isa = PBXGroup; children = ( - 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 2A5BB7E52CDCDC2B00E1C799 /* Join */ = { - isa = PBXGroup; - children = ( - 38F006A92D395A7F001AC5F7 /* SuspensionResponse.swift */, - 2A5BB7E62CDCDC3600E1C799 /* NicknameValidationResponse.swift */, - 2A45B36E2CE4C5510071026A /* RegisterUserResponse.swift */, - ); - path = Join; - sourceTree = ""; - }; - 2A980B9B2D803E8B007DFA45 /* GAManager */ = { - isa = PBXGroup; - children = ( - 2A980BA72D803F04007DFA45 /* GAManager.swift */, - 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */, + 2A980BA72D803F04007DFA45 /* GAHelper.swift */, 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */, 2A980B9C2D803E9D007DFA45 /* FirebaseLoggable.swift */, ); - path = GAManager; - sourceTree = ""; - }; - 2ACBD4152CC9631B0057C013 /* UploadCard */ = { - isa = PBXGroup; - children = ( - 2ACBD4162CC963390057C013 /* DefaultCardImageResponse.swift */, - 2ACBD4192CCA03790057C013 /* ImageURLWithName.swift */, - 2ACBD41C2CCAB3490057C013 /* PresignedStorageResponse.swift */, - ); - path = UploadCard; + path = GAHelper; sourceTree = ""; }; 2AE6B1472CBC157200FA5C3C /* Report */ = { @@ -1033,193 +1399,23 @@ children = ( 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */, 2AE6B14B2CBC160C00FA5C3C /* ReportViewReactor.swift */, - 2AE6B1522CBCC32100FA5C3C /* Views */, - 2AE6B14E2CBCC2C700FA5C3C /* Cells */, ); path = Report; sourceTree = ""; }; - 2AE6B14E2CBCC2C700FA5C3C /* Cells */ = { - isa = PBXGroup; - children = ( - 2AE6B14F2CBCC2F600FA5C3C /* ReportTableViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; - 2AE6B1522CBCC32100FA5C3C /* Views */ = { - isa = PBXGroup; - children = ( - 2AE6B1532CBCC34B00FA5C3C /* ReportReasonView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 2AE6B1602CBFB7C000FA5C3C /* UploadCard */ = { - isa = PBXGroup; - children = ( - 2AE6B1622CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift */, - 2AE6B1652CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift */, - 2AE6B16F2CBFD00E00FA5C3C /* Cells */, - 2AE6B16B2CBFBC3F00FA5C3C /* Views */, - ); - path = UploadCard; - sourceTree = ""; - }; - 2AE6B16B2CBFBC3F00FA5C3C /* Views */ = { - isa = PBXGroup; - children = ( - 2AE6B16C2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 2AE6B16F2CBFD00E00FA5C3C /* Cells */ = { - isa = PBXGroup; - children = ( - 2AE6B18E2CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift */, - 2AE6B1702CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift */, - 2AE6B1912CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift */, - 2AE6B1772CBFE49D00FA5C3C /* SelectFontTableViewCell.swift */, - 2AE6B17A2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift */, - 2AE6B17D2CBFEA4200FA5C3C /* Views */, - 2AE6B1732CBFD58B00FA5C3C /* Cells */, - ); - path = Cells; - sourceTree = ""; - }; - 2AE6B1732CBFD58B00FA5C3C /* Cells */ = { - isa = PBXGroup; - children = ( - 2AE6B1742CBFD59B00FA5C3C /* ImageCollectionViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; - 2AE6B17D2CBFEA4200FA5C3C /* Views */ = { - isa = PBXGroup; - children = ( - 2AE6B17E2CBFEA5200FA5C3C /* ToggleView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 2AFD05442CFF75CA007C84AD /* Tags */ = { - isa = PBXGroup; - children = ( - 2AFD05452CFF75DD007C84AD /* FavoriteTagsResponse.swift */, - 2AFD05482CFF7687007C84AD /* RecommendTagsResponse.swift */, - 2AFD05542D0082DE007C84AD /* SearchTagsResponse.swift */, - 2AFD05622D00A1E1007C84AD /* TagDetailCardResponse.swift */, - 2AFD05652D01CB30007C84AD /* TagInfoResponse.swift */, - 2AFD05682D03264C007C84AD /* AddFavoriteTagResponse.swift */, - ); - path = Tags; - sourceTree = ""; - }; - 2AFD05572D008D01007C84AD /* TagDetail */ = { - isa = PBXGroup; - children = ( - 2A34AFB32D144EE7007BD7E7 /* Cells */, - 2AFD05582D008D23007C84AD /* TagDetailViewController.swift */, - 2AFD055F2D009FA1007C84AD /* TagDetailViewrReactor.swift */, - 2AFD055B2D0094F0007C84AD /* Views */, - ); - path = TagDetail; - sourceTree = ""; - }; - 2AFD055B2D0094F0007C84AD /* Views */ = { - isa = PBXGroup; - children = ( - 2AFD055C2D009513007C84AD /* TagDetailNavigationBarView.swift */, - ); - path = Views; - sourceTree = ""; - }; 2AFF95522CF3207F00CBFB12 /* Tags */ = { isa = PBXGroup; children = ( - 2AFF95532CF3220A00CBFB12 /* Tags */, - 2AFF956E2CF5E8A500CBFB12 /* TagSearch */, - 2AFD05572D008D01007C84AD /* TagDetail */, + 381E7C182ECCB1A200E80249 /* TagViewController.swift */, + 381E7C1B2ECCB1AA00E80249 /* TagViewReactor.swift */, + 38F161452ECDA8B3003BADB6 /* Cells */, + 381E7C212ECCC62200E80249 /* Views */, + 3803B9202ECF51FA009D14B9 /* Search */, + 3803B9212ECF5262009D14B9 /* Collect */, ); path = Tags; sourceTree = ""; }; - 2AFF95532CF3220A00CBFB12 /* Tags */ = { - isa = PBXGroup; - children = ( - 2AFF95542CF3222400CBFB12 /* TagsViewController.swift */, - 2AFD054E2CFF79D8007C84AD /* TagsViewReactor.swift */, - 2AFF95572CF3225100CBFB12 /* Views */, - 2AFF95582CF3225900CBFB12 /* Cells */, - ); - path = Tags; - sourceTree = ""; - }; - 2AFF95572CF3225100CBFB12 /* Views */ = { - isa = PBXGroup; - children = ( - 2AFF95592CF3227900CBFB12 /* TagSearchTextFieldView.swift */, - 2AFF95632CF33D9F00CBFB12 /* TagsHeaderView.swift */, - ); - path = Views; - sourceTree = ""; - }; - 2AFF95582CF3225900CBFB12 /* Cells */ = { - isa = PBXGroup; - children = ( - 2AFF95662CF5DFA500CBFB12 /* RecommendTag */, - 2AFF955F2CF33A2B00CBFB12 /* FavoriteTag */, - ); - path = Cells; - sourceTree = ""; - }; - 2AFF955F2CF33A2B00CBFB12 /* FavoriteTag */ = { - isa = PBXGroup; - children = ( - 2AFF95722CF5F05000CBFB12 /* Cells */, - 2AFF955C2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift */, - 2AFF95602CF33A3900CBFB12 /* FavoriteTagView.swift */, - ); - path = FavoriteTag; - sourceTree = ""; - }; - 2AFF95662CF5DFA500CBFB12 /* RecommendTag */ = { - isa = PBXGroup; - children = ( - 2AFF95672CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift */, - 2AFF956A2CF5E00600CBFB12 /* RecommendTagView.swift */, - ); - path = RecommendTag; - sourceTree = ""; - }; - 2AFF956E2CF5E8A500CBFB12 /* TagSearch */ = { - isa = PBXGroup; - children = ( - 2AFF956F2CF5E8DE00CBFB12 /* TagSearchViewController.swift */, - 2AFD05512D007F2F007C84AD /* TagSearchViewReactor.swift */, - ); - path = TagSearch; - sourceTree = ""; - }; - 2AFF95722CF5F05000CBFB12 /* Cells */ = { - isa = PBXGroup; - children = ( - 2AFF95762CF5F09F00CBFB12 /* TagPreviewCard */, - ); - path = Cells; - sourceTree = ""; - }; - 2AFF95762CF5F09F00CBFB12 /* TagPreviewCard */ = { - isa = PBXGroup; - children = ( - 2AFF95732CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift */, - 2AFF95772CF5F0B000CBFB12 /* TagPreviewCardView.swift */, - ); - path = TagPreviewCard; - sourceTree = ""; - }; 2DAC60EF30AB4D54BFEBDF3B /* Pods */ = { isa = PBXGroup; children = ( @@ -1254,26 +1450,46 @@ path = PushManager; sourceTree = ""; }; - 3803CF6E2D01598100FD90DB /* Settings */ = { + 3803B9202ECF51FA009D14B9 /* Search */ = { isa = PBXGroup; children = ( - 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */, - 3878D0552CFFCBDA00F9522F /* CommentHistoryResponse.swift */, - 3803CF7C2D016DA200FD90DB /* TransferCodeResponse.swift */, - 38FD4DAA2D032CF000BF5FF1 /* AnnouncementResponse.swift */, - 38572CD72D2230C900B07C69 /* NotificationAllowResponse.swift */, + 38787B7A2ED1E8AB004BBAA7 /* TagSearchViewController.swift */, + 38787B7D2ED1E8EF004BBAA7 /* TagSearchViewReactor.swift */, + 38787B732ED1E10E004BBAA7 /* Cells */, + 3803B92F2ECF5F15009D14B9 /* Views */, + 38EC8CFE2ED44643009C2857 /* Search+Collect */, ); - path = Settings; + path = Search; sourceTree = ""; }; - 3803CF722D0166C900FD90DB /* Cells */ = { + 3803B9212ECF5262009D14B9 /* Collect */ = { isa = PBXGroup; children = ( - 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */, + 3803B9292ECF5579009D14B9 /* TagCollectViewController.swift */, + 3803B92C2ECF5584009D14B9 /* TagCollectViewReactor.swift */, + 3803B9282ECF5572009D14B9 /* Cells */, + ); + path = Collect; + sourceTree = ""; + }; + 3803B9282ECF5572009D14B9 /* Cells */ = { + isa = PBXGroup; + children = ( + 3803B9222ECF52C0009D14B9 /* TagCollectCardViewCell.swift */, + 38787B802ED1EB1C004BBAA7 /* TagCollectCardsView.swift */, + 3803B9252ECF5302009D14B9 /* TagCollectPlaceholderViewCell.swift */, ); path = Cells; sourceTree = ""; }; + 3803B92F2ECF5F15009D14B9 /* Views */ = { + isa = PBXGroup; + children = ( + 3803B9302ECF5F1B009D14B9 /* SearchTextFieldView.swift */, + ); + path = Views; + sourceTree = ""; + }; 3803CF7F2D017D7000FD90DB /* Issue */ = { isa = PBXGroup; children = ( @@ -1288,6 +1504,7 @@ children = ( 3803CF812D017DB800FD90DB /* EnterMemberTransferViewController.swift */, 3803CF842D017DC700FD90DB /* EnterMemberTransferViewReactor.swift */, + 38AE77D92E745FF100B6FD13 /* Views */, ); path = Enter; sourceTree = ""; @@ -1296,8 +1513,10 @@ isa = PBXGroup; children = ( 389EF81C2D2F468A00E053AE /* Log */, + 386867A32E9E378000171A5E /* Array.swift */, 38121E302CA6C77500602499 /* Double.swift */, 38121E332CA6DA4000602499 /* Date.swift */, + 38FD56232EC9FAA000EC6106 /* String.swift */, 38D869622CF821F900BF87DA /* UserDefaults.swift */, ); path = Foundation; @@ -1322,24 +1541,42 @@ path = Monitor; sourceTree = ""; }; + 3818549A2E992EC400424D71 /* SelectImage */ = { + isa = PBXGroup; + children = ( + 3818549E2E9933FF00424D71 /* WriteCardUserImageCell.swift */, + 3818549B2E992F7400424D71 /* WriteCardDefaultImageCell.swift */, + 381854972E992E8900424D71 /* WriteCardSelectImageView.swift */, + 386867A62E9E932300171A5E /* WriteCardSelectImageView+Rx.swift */, + ); + path = SelectImage; + sourceTree = ""; + }; + 381854A42E9954DF00424D71 /* SelectTypography */ = { + isa = PBXGroup; + children = ( + 381854A82E99573700424D71 /* SelectTypographyView.swift */, + ); + path = SelectTypography; + sourceTree = ""; + }; 381A1D672CC38E8E005FDB8E /* TextView */ = { isa = PBXGroup; children = ( + 3880EF7C2EA0DA6F00D88608 /* WritrCardTextViewDelegate.swift */, 38D6F17F2CC2413400E11530 /* WriteCardTextView.swift */, - 38D6F1852CC24C4F00E11530 /* WriteCardTextViewDelegate.swift */, 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */, ); path = TextView; sourceTree = ""; }; - 381A1D682CC38E9B005FDB8E /* TextField */ = { + 381E7C212ECCC62200E80249 /* Views */ = { isa = PBXGroup; children = ( - 381A1D642CC38E7D005FDB8E /* WriteTagTextField.swift */, - 381A1D692CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift */, - 38BE72162CC696E9002662DD /* WriteTagTextField+Rx.swift */, + 381E7C222ECCC62900E80249 /* SearchViewButton.swift */, + 38F161422ECDA853003BADB6 /* SearchViewButton+Rx.swift */, ); - path = TextField; + path = Views; sourceTree = ""; }; 382D5CF42CFE9B6400BFA23E /* Profile */ = { @@ -1358,9 +1595,9 @@ 382E15382D15A6680097B09C /* cells */ = { isa = PBXGroup; children = ( + 38FEBE5D2E866125002916A8 /* NoticeViewCell.swift */, 382E15392D15A67A0097B09C /* NotificationViewCell.swift */, - 382E15412D15BA490097B09C /* NotificationWithReportViewCell.swift */, - 385602B52D2FB18400118530 /* NotiPlaceholderViewCell.swift */, + 385602B52D2FB18400118530 /* NotificationPlaceholderViewCell.swift */, ); path = cells; sourceTree = ""; @@ -1373,23 +1610,16 @@ path = Models; sourceTree = ""; }; - 382E153D2D15AF310097B09C /* Notification */ = { - isa = PBXGroup; - children = ( - 382E153E2D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift */, - 38738D4A2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift */, - ); - path = Notification; - sourceTree = ""; - }; 3836ACB12C8F042C00A3C566 /* Utilities */ = { isa = PBXGroup; children = ( 385620F42CA19E8600E0AB5A /* Alamofire */, + 2A980B9B2D803E8B007DFA45 /* GAHelper */, + 388D8ADD2E73E60B0044BA79 /* SwiftEntryKit */, 3836ACB22C8F043500A3C566 /* Typography */, 38D5CE0A2CBCE8CA0054AB9A /* SimpleDefaults.swift */, 38389B9E2CCCFB7D006728AF /* AuthKeyChain.swift */, - 3834FADC2D11C5AC00C9108D /* SimpleCache.swift */, + 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */, 38F88EB92D2C1CB8002AD7A8 /* Info.swift */, 389EF8162D2F450000E053AE /* Log.swift */, ); @@ -1409,16 +1639,31 @@ path = Typography; sourceTree = ""; }; - 38389B9A2CCCF4D2006728AF /* Auth */ = { + 383EC6132E7A50D900EC2D1E /* Interfaces */ = { isa = PBXGroup; children = ( - 2A44A4362CAC227300DC463E /* BaseAuthResponse.swift */, - 2A44A4292CAC09AE00DC463E /* RSAKeyResponse.swift */, - 2A44A42C2CAC14C800DC463E /* SignInResponse.swift */, - 2A44A4332CAC21A500DC463E /* SignUpResponse.swift */, - 3816C05B2CCDDF3D00C8688C /* ReAuthenticationResponse.swift */, + 383EC6102E7A4F5E00EC2D1E /* AuthLocalDataSource.swift */, + 3874B55F2ECB25C9004CC22A /* SettingsLocalDataSource.swift */, ); - path = Auth; + path = Interfaces; + sourceTree = ""; + }; + 383EC6172E7A544B00EC2D1E /* DIContainer */ = { + isa = PBXGroup; + children = ( + 383EC61B2E7A548600EC2D1E /* BaseDIContainer.swift */, + 383EC6182E7A546B00EC2D1E /* BaseAssembler.swift */, + ); + path = DIContainer; + sourceTree = ""; + }; + 383EC61E2E7A563600EC2D1E /* Dependencies */ = { + isa = PBXGroup; + children = ( + 383EC6222E7A56CA00EC2D1E /* AppDIContainer.swift */, + 383EC61F2E7A564200EC2D1E /* AppAssembler.swift */, + ); + path = Dependencies; sourceTree = ""; }; 3843C1BC2D4FB399009283AC /* Network */ = { @@ -1472,7 +1717,6 @@ 385620EC2CA19C0D00E0AB5A /* Managers */ = { isa = PBXGroup; children = ( - 2A980B9B2D803E8B007DFA45 /* GAManager */, 3893B6D02D36739500F2004C /* CompositeManager.swift */, 38B543ED2D46506300DDF2C5 /* ManagerType.swift */, 3893B6CD2D36728000F2004C /* ManagerProvider.swift */, @@ -1489,6 +1733,8 @@ isa = PBXGroup; children = ( 3800575B2D9C12CB00E58A19 /* DefinedError.swift */, + 38899E882E794D5D0030F7CA /* NetworkManager_Version.swift */, + 38899E852E794CE90030F7CA /* NetworkManager_FCM.swift */, 385620EE2CA19C9500E0AB5A /* NetworkManager.swift */, 38B543E72D4617EA00DDF2C5 /* NetworkManagerConfiguration.swift */, 38F88EBC2D2C1E19002AD7A8 /* Models */, @@ -1528,15 +1774,43 @@ path = Managers; sourceTree = ""; }; - 3878D0502CFFC69D00F9522F /* Profile */ = { + 386E96692E9A51C5005E047D /* SelectOptions */ = { isa = PBXGroup; children = ( - 3878D0522CFFC6C100F9522F /* ProfileResponse.swift */, - 388A2D2C2D00A45800E2F2F0 /* writtenCardResponse.swift */, - 3878D05B2CFFD10D00F9522F /* FollowingResponse.swift */, - 3878D05E2CFFD45100F9522F /* FollowerResponse.swift */, + 386E966A2E9A51D2005E047D /* SelectOptionItem.swift */, + 386E966D2E9A53CC005E047D /* SelectOptionsView.swift */, ); - path = Profile; + path = SelectOptions; + sourceTree = ""; + }; + 386E96702E9BC008005E047D /* RelatedTags */ = { + isa = PBXGroup; + children = ( + 3880EF6D2EA0CD6A00D88608 /* RelatedTagViewModel.swift */, + 3880EF702EA0CD9D00D88608 /* RelatedTagView.swift */, + 3880EF732EA0CEE700D88608 /* RelatedTagsView.swift */, + 3880EF762EA0CF2800D88608 /* RelatedTagsViewLayout.swift */, + 3880EF792EA0D17900D88608 /* RelatedTagsView+Rx.swift */, + ); + path = RelatedTags; + sourceTree = ""; + }; + 38787B732ED1E10E004BBAA7 /* Cells */ = { + isa = PBXGroup; + children = ( + 38787B742ED1E5A8004BBAA7 /* SearchTermViewCell.swift */, + 38787B772ED1E715004BBAA7 /* SearchTermsView.swift */, + 38787B8A2ED22A1F004BBAA7 /* SearchTermPlaceholderViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; + 38787B862ED2230A004BBAA7 /* RxSwift */ = { + isa = PBXGroup; + children = ( + 38787B872ED22323004BBAA7 /* RxSwift+Unretained.swift */, + ); + path = RxSwift; sourceTree = ""; }; 3878D0612CFFD64600F9522F /* Follow */ = { @@ -1552,9 +1826,9 @@ 3878D0652CFFDAE500F9522F /* Cells */ = { isa = PBXGroup; children = ( + 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */, 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */, - 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */, - 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */, + 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */, ); path = Cells; sourceTree = ""; @@ -1565,7 +1839,7 @@ 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */, 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */, 3878D06D2CFFDF8200F9522F /* Views */, - 3878D0952CFFF25000F9522F /* CommentHistory */, + 38C2A7FE2EC098FB00B941A2 /* BlockUsers */, 3878D07B2CFFE6C500F9522F /* MemberTransfer */, 3878D07F2CFFEC4300F9522F /* termsOfService */, 3878D0772CFFE19600F9522F /* Resign */, @@ -1579,7 +1853,8 @@ children = ( 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */, 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */, - 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */, + 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */, + 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */, ); path = Views; sourceTree = ""; @@ -1589,6 +1864,7 @@ children = ( 3878D0782CFFE1E800F9522F /* ResignViewController.swift */, 3803CF872D01914200FD90DB /* ResignViewReactor.swift */, + 38C2A7FF2EC09A4D00B941A2 /* Views */, ); path = Resign; sourceTree = ""; @@ -1638,25 +1914,6 @@ path = Cells; sourceTree = ""; }; - 3878D0952CFFF25000F9522F /* CommentHistory */ = { - isa = PBXGroup; - children = ( - 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */, - 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */, - 3803CF722D0166C900FD90DB /* Cells */, - ); - path = CommentHistory; - sourceTree = ""; - }; - 3878F46F2CA3F01100AA46A2 /* SOMCard */ = { - isa = PBXGroup; - children = ( - 387D852B2D08320A005D9D22 /* SOMCardModel.swift */, - 3878F4702CA3F03400AA46A2 /* SOMCard.swift */, - ); - path = SOMCard; - sourceTree = ""; - }; 3878FE0B2D0365B000D8955C /* SOMNavigationBar */ = { isa = PBXGroup; children = ( @@ -1693,8 +1950,8 @@ 388371F62C8C8E7F004212EB /* DesignSystem */, 387FBB032C87036D00A5E139 /* App */, 38FDC2B42C9E745B00C094C2 /* Base */, - 385620EC2CA19C0D00E0AB5A /* Managers */, - 38B6AAE42CA47E8D00CE6DB6 /* Models */, + 38899E5C2E79378A0030F7CA /* Data */, + 38899E682E793AD10030F7CA /* Domain */, 385071972CA2956D00A7905A /* Presentations */, 388371FE2C8C8FAC004212EB /* Extensions */, 3836ACB12C8F042C00A3C566 /* Utilities */, @@ -1706,6 +1963,7 @@ 387FBB032C87036D00A5E139 /* App */ = { isa = PBXGroup; children = ( + 383EC61E2E7A563600EC2D1E /* Dependencies */, 387FBAEF2C8702C100A5E139 /* AppDelegate.swift */, 387FBAF12C8702C100A5E139 /* SceneDelegate.swift */, ); @@ -1715,13 +1973,13 @@ 387FBB052C87038F00A5E139 /* Resources */ = { isa = PBXGroup; children = ( + 38D2FBC32E81AD26006DD739 /* refrech_control_lottie.json */, + 388717692E7BD7AC00C6143B /* loading_indicator_lottie.json */, 387FBAF82C8702C200A5E139 /* Assets.xcassets */, 2A6280602D085C6200803BE9 /* SOOUM.entitlements */, 387FBAFA2C8702C200A5E139 /* LaunchScreen.storyboard */, 2A6280612D085C7600803BE9 /* SOOUM-Dev.entitlements */, - 38D3CB142CC2362B001EC280 /* Hakgyoansim-Bold.ttf */, - 38D3CB152CC2362B001EC280 /* Hakgyoansim-Light.ttf */, - 388DA1002C8F538400A9DD56 /* PretendardVariable.ttf */, + 3880DE182E785C90000B12E2 /* Font */, 38B6AAE82CA47F5C00CE6DB6 /* Alamofire */, 387FBB152C87044700A5E139 /* Develop */, 387FBB062C8703F600A5E139 /* Production */, @@ -1773,22 +2031,24 @@ path = SOMTags; sourceTree = ""; }; - 38816D9C2D004A3000EB87D6 /* UpdateProfile */ = { + 3880DE182E785C90000B12E2 /* Font */ = { isa = PBXGroup; children = ( - 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */, - 388A2D322D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift */, - 38816DA02D004DDE00EB87D6 /* Views */, + 386712CF2E977F1B00541389 /* RIDIBatang.otf */, + 386712CC2E977EC200541389 /* Yoonwoo.ttf */, + 386712C92E977E9D00541389 /* Kkukkkuk.ttf */, + 388DA1002C8F538400A9DD56 /* PretendardVariable.ttf */, ); - path = UpdateProfile; + path = Font; sourceTree = ""; }; - 38816DA02D004DDE00EB87D6 /* Views */ = { + 38816D9C2D004A3000EB87D6 /* UpdateProfile */ = { isa = PBXGroup; children = ( - 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */, + 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */, + 388A2D322D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift */, ); - path = Views; + path = UpdateProfile; sourceTree = ""; }; 388371F62C8C8E7F004212EB /* DesignSystem */ = { @@ -1803,10 +2063,11 @@ 388371F72C8C8E8C004212EB /* Foundations */ = { isa = PBXGroup; children = ( - 388371F82C8C8EB1004212EB /* SooumStyle.swift */, + 38899E562E7936CD0030F7CA /* Style */, 388DA1032C8F545E00A9DD56 /* Typography+SOOUM.swift */, 388371FB2C8C8F11004212EB /* UIColor+SOOUM.swift */, 38A5D1532C8CB11E00B68363 /* UIImage+SOOUM.swift */, + 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */, ); path = Foundations; sourceTree = ""; @@ -1824,6 +2085,9 @@ 388371FF2C8C8FC2004212EB /* Cocoa */ = { isa = PBXGroup; children = ( + 38E928DB2EB7920C00B3F00B /* UIRefreshControl.swift */, + 38FCF41B2EA00623003AC3D8 /* UITtextView.swift */, + 386712C32E97734800541389 /* UITextField.swift */, 388372002C8C8FCF004212EB /* UIColor.swift */, 388DA0FD2C8F526C00A9DD56 /* UIFont.swift */, 3862C0DE2C9EB6670023C046 /* UIViewController+PushAndPop.swift */, @@ -1831,7 +2095,6 @@ 3878F4762CA3F08300AA46A2 /* UIView.swift */, 384972A22CA54DC10012FCA1 /* UIImgeView.swift */, 3802BDAB2D0AC1FB001256EA /* UIImage.swift */, - 38121E282CA6A52400602499 /* UIRefreshControl.swift */, 3886939E2CF77FA7005F9EF3 /* UIApplication+Top.swift */, 388FCACF2CFAC2BF0012C4D6 /* Notification.swift */, 3830FFA52CEC6E3100ABA9FD /* Kingfisher.swift */, @@ -1849,22 +2112,273 @@ path = Auth; sourceTree = ""; }; - 388698562D19829400008600 /* Views */ = { + 38899E562E7936CD0030F7CA /* Style */ = { isa = PBXGroup; children = ( - 388698572D1982DE00008600 /* NotificationViewController.swift */, - 3886985E2D1984D600008600 /* NotificationViewReactor.swift */, + 38899E572E7936D50030F7CA /* SooumStyle_V2.swift */, + 388371F82C8C8EB1004212EB /* SooumStyle.swift */, ); - path = Views; + path = Style; + sourceTree = ""; + }; + 38899E5A2E79374E0030F7CA /* Repositories */ = { + isa = PBXGroup; + children = ( + 3889A2452E79ADBD0030F7CA /* AppVersionRepositoryImpl.swift */, + 3889A2822E79D7CE0030F7CA /* AuthRepositoryImpl.swift */, + 380F42322E884FD4009AC59E /* CardRepositoryImpl.swift */, + 3889A28E2E79D8800030F7CA /* NotificationRepositoryImpl.swift */, + 38C9AF222E966A1300B401C0 /* TagRepositoryImpl.swift */, + 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */, + 3889A2612E79BB540030F7CA /* UserRepositoryImpl.swift */, + 38899E9F2E799A7E0030F7CA /* Remotes */, + 38899E9E2E799A740030F7CA /* Locals */, + ); + path = Repositories; + sourceTree = ""; + }; + 38899E5B2E7937660030F7CA /* Models */ = { + isa = PBXGroup; + children = ( + 38899E632E7938CD0030F7CA /* Responses */, + ); + path = Models; + sourceTree = ""; + }; + 38899E5C2E79378A0030F7CA /* Data */ = { + isa = PBXGroup; + children = ( + 38899E5B2E7937660030F7CA /* Models */, + 385620EC2CA19C0D00E0AB5A /* Managers */, + 38899E5A2E79374E0030F7CA /* Repositories */, + ); + path = Data; + sourceTree = ""; + }; + 38899E632E7938CD0030F7CA /* Responses */ = { + isa = PBXGroup; + children = ( + 38F3398E2EE31C7E0066A5F7 /* IsCardDeletedResponse.swift */, + 38F376102ECB789200E4A41D /* TagCardInfoResponse.swift */, + 38F3760C2ECB778C00E4A41D /* FavoriteTagInfoResponse.swift */, + 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */, + 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */, + 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */, + 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */, + 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */, + 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */, + 38D478062EBBAA080041FF6C /* WriteCardResponse.swift */, + 38E928BE2EB72D3600B3F00B /* DetailCardInfoResponse.swift */, + 38EBA9102EB3999C008B28F4 /* PostingPermissionResponse.swift */, + 38C9AF0D2E96601E00B401C0 /* DefaultImagesResponse.swift */, + 380F42292E884E85009AC59E /* HomeCardInfoResponse.swift */, + 38FEBE632E86629F002916A8 /* NoticeInfoResponse.swift */, + 3887176C2E7BDBA800C6143B /* NicknameResponse.swift */, + 38C9AF132E9665C500B401C0 /* TagInfoResponse.swift */, + 3889A27C2E79C5670030F7CA /* ToeknResponse.swift */, + 38FEBE5A2E8652D2002916A8 /* CompositeNotificationInfoResponse.swift */, + 38899E952E7953300030F7CA /* NotificationInfoResponse.swift */, + 38FEBE532E865119002916A8 /* FollowNotificationInfoResponse.swift */, + 38899E9B2E7954D70030F7CA /* DeletedNotificationInfoResponse.swift */, + 38899E982E7954670030F7CA /* BlockedNotificationInfoResponse.swift */, + 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */, + 38899E8E2E79511F0030F7CA /* KeyInfoResponse.swift */, + 38899E8B2E794E680030F7CA /* AppVersionStatusResponse.swift */, + 38899E822E794C330030F7CA /* LoginResponse.swift */, + 38899E7C2E794B3D0030F7CA /* SignUpResponse.swift */, + 38899E6D2E79400B0030F7CA /* ImageUrlInfoResponse.swift */, + 38899E652E79395E0030F7CA /* CheckAvailableResponse.swift */, + 38899E5D2E7937DB0030F7CA /* NicknameValidateResponse.swift */, + ); + path = Responses; + sourceTree = ""; + }; + 38899E682E793AD10030F7CA /* Domain */ = { + isa = PBXGroup; + children = ( + 3889A2412E79AD3A0030F7CA /* Repositories */, + 3889A2402E79AD320030F7CA /* UseCases */, + 38899E692E793AEA0030F7CA /* Models */, + ); + path = Domain; + sourceTree = ""; + }; + 38899E692E793AEA0030F7CA /* Models */ = { + isa = PBXGroup; + children = ( + 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */, + 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */, + 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */, + 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */, + 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */, + 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */, + 381B83DB2EBC707400C84015 /* ProfileInfo.swift */, + 38D478092EBBABE40041FF6C /* EntranceCardType.swift */, + 38E928B82EB715C300B3F00B /* ReortType.swift */, + 38E928B52EB711DE00B3F00B /* DetailCardInfo.swift */, + 38EBA90D2EB39917008B28F4 /* PostingPermission.swift */, + 380F42262E884B6F009AC59E /* BaseCardInfo.swift */, + 38899E922E79518E0030F7CA /* CommonNotificationInfo.swift */, + 380F42202E87ECA2009AC59E /* CompositeNotificationInfo.swift */, + 38899E6A2E793AF70030F7CA /* CheckAvailable.swift */, + 38C9AF0A2E965EEE00B401C0 /* DefaultImages.swift */, + 38899E702E7940280030F7CA /* ImageUrlInfo.swift */, + 38FEBE602E8661F2002916A8 /* NoticeInfo.swift */, + 38C9AF102E96656400B401C0 /* TagInfo.swift */, + 38F376092ECB772600E4A41D /* FavoriteTagInfo.swift */, + 3889A27F2E79D0230030F7CA /* Token.swift */, + 38F88EBD2D2C1E22002AD7A8 /* Version.swift */, + ); + path = Models; + sourceTree = ""; + }; + 38899E9E2E799A740030F7CA /* Locals */ = { + isa = PBXGroup; + children = ( + 383EC6142E7A50E000EC2D1E /* AuthLocalDataSourceImpl.swift */, + 3874B5622ECB2606004CC22A /* SettingsLocalDataSourceImpl.swift */, + 383EC6132E7A50D900EC2D1E /* Interfaces */, + ); + path = Locals; + sourceTree = ""; + }; + 38899E9F2E799A7E0030F7CA /* Remotes */ = { + isa = PBXGroup; + children = ( + 38899EA52E799BD10030F7CA /* AppVersionRemoteDataSourceImpl.swift */, + 3889A26D2E79BE970030F7CA /* AuthRemoteDataSourceImpl.swift */, + 380F422C2E884F35009AC59E /* CardRemoteDataSourceImpl.swift */, + 3889A2762E79C2980030F7CA /* NotificationRemoteDataSoruceImpl.swift */, + 38C9AF192E96696500B401C0 /* TagRemoteDataSourceImpl.swift */, + 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */, + 3889A2552E79BA0F0030F7CA /* UserRemoteDataSourceImpl.swift */, + 38899EA02E799AA30030F7CA /* Interfaces */, + ); + path = Remotes; + sourceTree = ""; + }; + 38899EA02E799AA30030F7CA /* Interfaces */ = { + isa = PBXGroup; + children = ( + 38899EA22E799B190030F7CA /* AppVersionRemoteDataSource.swift */, + 3889A26A2E79BD410030F7CA /* AuthRemoteDataSource.swift */, + 380F42232E884ADF009AC59E /* CardRemoteDataSource.swift */, + 3889A2732E79C1D30030F7CA /* NotificationRemoteDataSource.swift */, + 38C9AF162E96692900B401C0 /* TagRemoteDataSource.swift */, + 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */, + 3889A24F2E79B3210030F7CA /* UserRemoteDataSource.swift */, + ); + path = Interfaces; + sourceTree = ""; + }; + 38899EAB2E799E360030F7CA /* V2 */ = { + isa = PBXGroup; + children = ( + 38389B9B2CCCF98B006728AF /* AuthRequest.swift */, + 384972A02CA4DEC00012FCA1 /* CardRequest.swift */, + 388698612D1986B100008600 /* NotificationRequest.swift */, + 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */, + 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */, + 38899EAC2E79A0990030F7CA /* UserRequest.swift */, + 38899EA82E799C5D0030F7CA /* VersionRequest.swift */, + ); + path = V2; + sourceTree = ""; + }; + 3889A2402E79AD320030F7CA /* UseCases */ = { + isa = PBXGroup; + children = ( + 3889A24C2E79AEAD0030F7CA /* AppVersionUseCaseImpl.swift */, + 3889A2882E79D81E0030F7CA /* AuthUseCaseImpl.swift */, + 38AE85082EDF413C00029E4C /* BlockUserUseCaseImpl.swift */, + 38AE850B2EDF41AF00029E4C /* CardImageUseCaseImpl.swift */, + 38AE850E2EDF420300029E4C /* DeleteCardUseCaseImpl.swift */, + 38AE85112EDF424600029E4C /* FetchBlockUserUseCaseImpl.swift */, + 38AE85142EDF42B400029E4C /* FetchCardDetailUseCaseImpl.swift */, + 38AE85172EDF436F00029E4C /* FetchCardUseCaseImpl.swift */, + 38AE851A2EDFF7DE00029E4C /* FetchFollowUseCaseImpl.swift */, + 38AE851D2EDFF84500029E4C /* FetchNoticeUseCaseImpl.swift */, + 38AE85202EDFF88A00029E4C /* FetchTagUseCaseImpl.swift */, + 38AE85232EDFF90000029E4C /* FetchUserInfoUseCaseImpl.swift */, + 38AE85262EDFF95200029E4C /* LocationUseCaseImpl.swift */, + 3889A2942E79D9200030F7CA /* NotificationUseCaseImpl.swift */, + 38AE85292EDFF99B00029E4C /* ReportCardUseCaseImpl.swift */, + 38AE852C2EDFFA3900029E4C /* TransferAccountUseCaseImpl.swift */, + 38AE852F2EDFFA9300029E4C /* UpdateCardLikeUseCaseImpl.swift */, + 38AE85322EDFFAC300029E4C /* UpdateFollowUseCaseImpl.swift */, + 38AE85352EDFFAF700029E4C /* UpdateNotifyUseCaseImpl.swift */, + 38AE85382EDFFBBE00029E4C /* UpdateTagFavoriteUseCaseImpl.swift */, + 38AE853B2EDFFBF500029E4C /* UpdateUserInfoUseCaseImpl.swift */, + 38AE853E2EDFFC3500029E4C /* UploadUserImageUseCaseImpl.swift */, + 38AE85412EDFFCA400029E4C /* ValidateNicknameUseCaseImpl.swift */, + 38AE85442EDFFCF800029E4C /* ValidateUserUseCaseImpl.swift */, + 38AE85472EDFFD7B00029E4C /* WriteCardUseCaseImpl.swift */, + 3889A2482E79AE870030F7CA /* Interfaces */, + ); + path = UseCases; + sourceTree = ""; + }; + 3889A2412E79AD3A0030F7CA /* Repositories */ = { + isa = PBXGroup; + children = ( + 3889A2422E79AD600030F7CA /* AppVersionRepository.swift */, + 3889A2702E79C0370030F7CA /* AuthRepository.swift */, + 380F422F2E884FB5009AC59E /* CardRepository.swift */, + 3889A28B2E79D8650030F7CA /* NotificationRepository.swift */, + 38C9AF1F2E9669F100B401C0 /* TagRepository.swift */, + 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */, + 3889A25B2E79BB2F0030F7CA /* UserRepository.swift */, + ); + path = Repositories; + sourceTree = ""; + }; + 3889A2482E79AE870030F7CA /* Interfaces */ = { + isa = PBXGroup; + children = ( + 3889A2492E79AE900030F7CA /* AppVersionUseCase.swift */, + 3889A2852E79D8060030F7CA /* AuthUseCase.swift */, + 389E59B92EDEEB8000D0946D /* BlockUserUseCase.swift */, + 389E59C22EDEEC7600D0946D /* CardImageUseCase.swift */, + 389E59BF2EDEEC4500D0946D /* DeleteCardUseCase.swift */, + 389E59DA2EDEEF3000D0946D /* FetchBlockUserUseCase.swift */, + 389E59B32EDEEA7600D0946D /* FetchCardDetailUseCase.swift */, + 389E59AD2EDEE8B500D0946D /* FetchCardUseCase.swift */, + 389E59CE2EDEEDD000D0946D /* FetchFollowUseCase.swift */, + 389E59B02EDEEA3300D0946D /* FetchNoticeUseCase.swift */, + 389E59C82EDEED2700D0946D /* FetchTagUseCase.swift */, + 389E59CB2EDEED6100D0946D /* FetchUserInfoUseCase.swift */, + 389E59E32EDEF02900D0946D /* LocationUseCase.swift */, + 3889A2912E79D8F40030F7CA /* NotificationUseCase.swift */, + 389E59BC2EDEEBEA00D0946D /* ReportCardUseCase.swift */, + 389E59DD2EDEEF7600D0946D /* TransferAccountUseCase.swift */, + 389E59B62EDEEAEB00D0946D /* UpdateCardLikeUseCase.swift */, + 389E59D12EDEEE3800D0946D /* UpdateFollowUseCase.swift */, + 389E59D72EDEEEB900D0946D /* UpdateNotifyUseCase.swift */, + 389E59E02EDEEF9E00D0946D /* UpdateTagFavoriteUseCase.swift */, + 389E59D42EDEEE6500D0946D /* UpdateUserInfoUseCase.swift */, + 389E59AA2EDEE73500D0946D /* UploadUserImageUseCase.swift */, + 389E59A72EDEE6EF00D0946D /* ValidateNicknameUseCase.swift */, + 389E59A42EDEE38E00D0946D /* ValidateUserUseCase.swift */, + 389E59C52EDEECBC00D0946D /* WriteCardUseCase.swift */, + ); + path = Interfaces; + sourceTree = ""; + }; + 388D8ADD2E73E60B0044BA79 /* SwiftEntryKit */ = { + isa = PBXGroup; + children = ( + 38A721942E73E7010071E1D8 /* View+SwiftEntryKit.swift */, + 388D8ADE2E73E6130044BA79 /* SwiftEntryKit.swift */, + ); + path = SwiftEntryKit; sourceTree = ""; }; 389596A12D15A4CB000662B6 /* Notification */ = { isa = PBXGroup; children = ( - 382E15352D15A6460097B09C /* NotificationTabBarController.swift */, - 388698642D1998DB00008600 /* NotificationTabBarReactor.swift */, + 388698572D1982DE00008600 /* NotificationViewController.swift */, + 3886985E2D1984D600008600 /* NotificationViewReactor.swift */, 382E15382D15A6680097B09C /* cells */, - 388698562D19829400008600 /* Views */, ); path = Notification; sourceTree = ""; @@ -1881,45 +2395,50 @@ 38AA00002CAD1BB2002C5F1E /* Views */ = { isa = PBXGroup; children = ( - 38AA00042CAD96A6002C5F1E /* MoreBottomSheet */, + 38E928C52EB73FE500B3F00B /* WrittenTags */, + 38E928C12EB73D4B00B3F00B /* MemberInfoView.swift */, 38AA00012CAD1BCC002C5F1E /* LikeAndCommentView.swift */, + 38E928CF2EB75F9900B3F00B /* PungView.swift */, + 38E928D22EB7623A00B3F00B /* FloatingButton.swift */, ); path = Views; sourceTree = ""; }; - 38AA00042CAD96A6002C5F1E /* MoreBottomSheet */ = { + 38AA66202D3AA5EA00B3F6B2 /* Intro */ = { isa = PBXGroup; children = ( - 38AA00052CAD96E3002C5F1E /* MoreBottomSheetViewController.swift */, + 385071982CA2958100A7905A /* Launch */, + 2A5BB7B72CDB856D00E1C799 /* Onboarding */, ); - path = MoreBottomSheet; + path = Intro; sourceTree = ""; }; - 38AA66202D3AA5EA00B3F6B2 /* Intro */ = { + 38AE56572D0489E500CAA431 /* SOMDialogController */ = { isa = PBXGroup; children = ( - 385071982CA2958100A7905A /* Launch */, - 2A5BB7B72CDB856D00E1C799 /* Onboarding */, + 38AA66212D3AA86F00B3F6B2 /* SOMDialogAction.swift */, + 2A649ECE2CAE8970002D8284 /* SOMDialogViewController.swift */, + 38AE565B2D048B4800CAA431 /* SOMDialogViewController+Show.swift */, ); - path = Intro; + path = SOMDialogController; sourceTree = ""; }; - 38AA66242D3AC3E400B3F6B2 /* Views */ = { + 38AE77D22E7457E700B6FD13 /* Completed */ = { isa = PBXGroup; children = ( - 38AA66252D3AC3F500B3F6B2 /* DialogMessageView.swift */, + 38AE77D32E7457F400B6FD13 /* OnboardingCompletedViewController.swift */, + 38AE77D62E7459EA00B6FD13 /* OnboardingCompletedViewReactor.swift */, ); - path = Views; + path = Completed; sourceTree = ""; }; - 38AE56572D0489E500CAA431 /* SOMDialogController */ = { + 38AE77D92E745FF100B6FD13 /* Views */ = { isa = PBXGroup; children = ( - 38AA66212D3AA86F00B3F6B2 /* SOMDialogAction.swift */, - 2A649ECE2CAE8970002D8284 /* SOMDialogViewController.swift */, - 38AE565B2D048B4800CAA431 /* SOMDialogViewController+Show.swift */, + 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */, + 38AE77DA2E745FF700B6FD13 /* EnterMemberTransferTextFieldView.swift */, ); - path = SOMDialogController; + path = Views; sourceTree = ""; }; 38B6AACB2CA410C000CE6DB6 /* Main */ = { @@ -1928,7 +2447,7 @@ 38B6AACC2CA410D800CE6DB6 /* MainTabBarController.swift */, 38B6AAE12CA4787200CE6DB6 /* MainTabBarReactor.swift */, 38B6AACF2CA411DE00CE6DB6 /* Home */, - 38D6F17A2CC2401700E11530 /* WriteCard */, + 38D6F17A2CC2401700E11530 /* Write */, 2AFF95522CF3207F00CBFB12 /* Tags */, 382D5CF42CFE9B6400BFA23E /* Profile */, ); @@ -1938,30 +2457,19 @@ 38B6AACF2CA411DE00CE6DB6 /* Home */ = { isa = PBXGroup; children = ( - 38F70E5A2D1905D000B33C9D /* MainHomeTabBarController.swift */, - 38F70E5D2D190FBD00B33C9D /* MainHomeTabBarReactor.swift */, + 38D2FBC02E81234C006DD739 /* HomeViewController.swift */, + 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */, 38B8A5822CAE9CB7000AFE83 /* Cells */, - 38B6AAD32CA41B2C00CE6DB6 /* Views */, - 38F70E602D19112A00B33C9D /* Latest */, - 38F70E6A2D191D7D00B33C9D /* Popular */, - 38F70E712D191EE000B33C9D /* Distance */, 388009792CABEE18002A9209 /* Detail */, 389596A12D15A4CB000662B6 /* Notification */, ); path = Home; sourceTree = ""; }; - 38B6AAD32CA41B2C00CE6DB6 /* Views */ = { - isa = PBXGroup; - children = ( - 38B6AAD72CA424AE00CE6DB6 /* MoveTopButtonView.swift */, - ); - path = Views; - sourceTree = ""; - }; 38B6AADD2CA4775500CE6DB6 /* RxCocoa */ = { isa = PBXGroup; children = ( + 38787B862ED2230A004BBAA7 /* RxSwift */, 38B6AADE2CA4777200CE6DB6 /* UIViewController+Rx.swift */, 3866577D2CEF3554009F7F60 /* UIButton+Rx.swift */, 385009C12D363525007175A1 /* FilterNil.swift */, @@ -1969,21 +2477,6 @@ path = RxCocoa; sourceTree = ""; }; - 38B6AAE42CA47E8D00CE6DB6 /* Models */ = { - isa = PBXGroup; - children = ( - 38405CCA2CC611FD00612D1E /* BaseEmptyAndHeader.swift */, - 2AFD05442CFF75CA007C84AD /* Tags */, - 2A5BB7E52CDCDC2B00E1C799 /* Join */, - 38389B9A2CCCF4D2006728AF /* Auth */, - 38D5CE0D2CBCF7210054AB9A /* Card */, - 382E153D2D15AF310097B09C /* Notification */, - 3878D0502CFFC69D00F9522F /* Profile */, - 3803CF6E2D01598100FD90DB /* Settings */, - ); - path = Models; - sourceTree = ""; - }; 38B6AAE82CA47F5C00CE6DB6 /* Alamofire */ = { isa = PBXGroup; children = ( @@ -1995,16 +2488,7 @@ 38B6AAE92CA47F6200CE6DB6 /* Request */ = { isa = PBXGroup; children = ( - 2A5ABA332D464E0B00BF6C9B /* ConfigureRequest.swift */, - 384972A02CA4DEC00012FCA1 /* CardRequest.swift */, - 2AE6B1592CBEAEC000FA5C3C /* ReportRequest.swift */, - 38389B9B2CCCF98B006728AF /* AuthRequest.swift */, - 2ACBD4122CC944FB0057C013 /* UploadRequest.swift */, - 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */, - 3878D04D2CFFC5F300F9522F /* ProfileRequest.swift */, - 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */, - 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */, - 388698612D1986B100008600 /* NotificationRequest.swift */, + 38899EAB2E799E360030F7CA /* V2 */, ); path = Request; sourceTree = ""; @@ -2012,8 +2496,8 @@ 38B8A5822CAE9CB7000AFE83 /* Cells */ = { isa = PBXGroup; children = ( - 38B8A5832CAE9CC4000AFE83 /* MainHomeViewCell.swift */, - 38572CDD2D2254E800B07C69 /* PlaceholderViewCell.swift */, + 38B8A5832CAE9CC4000AFE83 /* HomeViewCell.swift */, + 38572CDD2D2254E800B07C69 /* HomePlaceholderViewCell.swift */, ); path = Cells; sourceTree = ""; @@ -2031,22 +2515,65 @@ 38B8BE452D1ECBBF0084569C /* Models */ = { isa = PBXGroup; children = ( - 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */, + 38B8BE462D1ECBDA0084569C /* PushNotificationInfo.swift */, ); path = Models; sourceTree = ""; }; + 38C2A7FE2EC098FB00B941A2 /* BlockUsers */ = { + isa = PBXGroup; + children = ( + 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */, + 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */, + 38C2A8062EC0BB8B00B941A2 /* Cells */, + ); + path = BlockUsers; + sourceTree = ""; + }; + 38C2A7FF2EC09A4D00B941A2 /* Views */ = { + isa = PBXGroup; + children = ( + 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */, + 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 38C2A8062EC0BB8B00B941A2 /* Cells */ = { + isa = PBXGroup; + children = ( + 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */, + 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 38C2D40F2CFE9ED800CEA092 /* Cells */ = { isa = PBXGroup; children = ( - 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */, - 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */, - 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */, - 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */, + 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */, + 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */, + 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */, + 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */, + 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */, ); path = Cells; sourceTree = ""; }; + 38C9AF2B2E96A37E00B401C0 /* Tags */ = { + isa = PBXGroup; + children = ( + 3880EF7F2EA0DB0300D88608 /* WriteCardTagsDelegate.swift */, + 38C9AF2C2E96A3D600B401C0 /* WriteCardTagModel.swift */, + 38C9AF2F2E96A49B00B401C0 /* WriteCardTag.swift */, + 38C9AF322E96A82600B401C0 /* WriteCardTags.swift */, + 38FCF4182E9F88E3003AC3D8 /* WriteCardTags+Rx.swift */, + 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */, + 38C9AF382E96AB8800B401C0 /* WriteCardTagFooter.swift */, + ); + path = Tags; + sourceTree = ""; + }; 38CC49802CDE382C007A0145 /* SOMPresentationController */ = { isa = PBXGroup; children = ( @@ -2064,14 +2591,20 @@ 38AE56572D0489E500CAA431 /* SOMDialogController */, 38CC49802CDE382C007A0145 /* SOMPresentationController */, 3880098C2CABF4AD002A9209 /* SOMTags */, - 3878F46F2CA3F01100AA46A2 /* SOMCard */, - 2A048E792C9BDF1000FFD485 /* SOMLocationFilter */, + 38E5AA822E8D28DB005D676B /* SOMPageViews */, 385053502C92DBCA00C80B02 /* SOMTabBarController */, - 38D563792D16D58B006265AA /* SOMSwipeTabBar */, + 38D2FBC92E81B0B2006DD739 /* SOMSwipableTabBar */, + 38D563792D16D58B006265AA /* SOMStickyTabBar */, 3878FE0B2D0365B000D8955C /* SOMNavigationBar */, 38D488C92D0C557300F2D38D /* SOMButton.swift */, + 3878F4702CA3F03400AA46A2 /* SOMCard.swift */, + 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */, + 383088082EDC7B8200D99D88 /* SOMMessageBubbleView.swift */, 38773E7B2CB3ACB2004815CD /* SOMRefreshControl.swift */, 38D055C22CD862FE00E75590 /* SOMActivityIndicatorView.swift */, + 38D522672E742F550044911B /* SOMLoadingIndicatorView.swift */, + 38A721982E73EA610071E1D8 /* SOMBottomFloatView.swift */, + 38E928D82EB7726E00B3F00B /* SOMBottomToastView.swift */, ); path = Components; sourceTree = ""; @@ -2087,46 +2620,47 @@ name = Frameworks; sourceTree = ""; }; - 38D563792D16D58B006265AA /* SOMSwipeTabBar */ = { + 38D2FBC92E81B0B2006DD739 /* SOMSwipableTabBar */ = { isa = PBXGroup; children = ( - 38D5637D2D17152F006265AA /* SOMSwipeTabBarDelegate.swift */, - 38D563832D1719B1006265AA /* SOMSwipeTabBarItem.swift */, - 38D5637A2D16D72D006265AA /* SOMSwipeTabBar.swift */, + 38D2FBD02E81B9B0006DD739 /* SOMSwipableTabBarDelegate.swift */, + 38D2FBCD2E81B529006DD739 /* SOMSwipableTabBar.swift */, + 38D2FBCA2E81B0DE006DD739 /* SOMSwipableTabBarItem.swift */, ); - path = SOMSwipeTabBar; + path = SOMSwipableTabBar; sourceTree = ""; }; - 38D5CE0D2CBCF7210054AB9A /* Card */ = { + 38D563792D16D58B006265AA /* SOMStickyTabBar */ = { isa = PBXGroup; children = ( - 38608B2F2CB5195D0066BB40 /* Card.swift */, - 38F720B72CD4F16500DF32B5 /* CardProtocol.swift */, - 38F720A02CD4F15900DF32B5 /* Detail */, - 38F720A42CD4F15900DF32B5 /* MainHome */, - 38F131862CC7B7AB000D0475 /* WriteCard */, + 38D5637D2D17152F006265AA /* SOMStickyTabBarDelegate.swift */, + 38D5637A2D16D72D006265AA /* SOMStickyTabBar.swift */, + 38D563832D1719B1006265AA /* SOMStickyTabBarItem.swift */, ); - path = Card; + path = SOMStickyTabBar; sourceTree = ""; }; - 38D6F17A2CC2401700E11530 /* WriteCard */ = { + 38D6F17A2CC2401700E11530 /* Write */ = { isa = PBXGroup; children = ( 38D6F17B2CC2406700E11530 /* WriteCardViewController.swift */, 3887D0322CC5335200FB52E1 /* WriteCardViewReactor.swift */, - 2AE6B1602CBFB7C000FA5C3C /* UploadCard */, 38D6F17E2CC2412500E11530 /* Views */, ); - path = WriteCard; + path = Write; sourceTree = ""; }; 38D6F17E2CC2412500E11530 /* Views */ = { isa = PBXGroup; children = ( - 381A1D682CC38E9B005FDB8E /* TextField */, + 386E96702E9BC008005E047D /* RelatedTags */, + 386E96692E9A51C5005E047D /* SelectOptions */, + 381854A42E9954DF00424D71 /* SelectTypography */, + 3818549A2E992EC400424D71 /* SelectImage */, + 38C9AF2B2E96A37E00B401C0 /* Tags */, 381A1D672CC38E8E005FDB8E /* TextView */, - 38572CDA2D22464F00B07C69 /* PungTimeView.swift */, 3887D0352CC5335D00FB52E1 /* WriteCardView.swift */, + 38F339912EE328710066A5F7 /* WriteCardGuideView.swift */, ); path = Views; sourceTree = ""; @@ -2158,66 +2692,55 @@ path = Push; sourceTree = ""; }; - 38F131862CC7B7AB000D0475 /* WriteCard */ = { - isa = PBXGroup; - children = ( - 38F131872CC7B7E0000D0475 /* RelatedTagResponse.swift */, - 2ACBD4152CC9631B0057C013 /* UploadCard */, - ); - path = WriteCard; - sourceTree = ""; - }; - 38F70E602D19112A00B33C9D /* Latest */ = { - isa = PBXGroup; - children = ( - 38F70E612D19113E00B33C9D /* MainHomeLatestViewController.swift */, - 38F70E642D19161800B33C9D /* MainHomeLatestViewReactor.swift */, - ); - path = Latest; - sourceTree = ""; - }; - 38F70E6A2D191D7D00B33C9D /* Popular */ = { + 38E5AA822E8D28DB005D676B /* SOMPageViews */ = { isa = PBXGroup; children = ( - 38F70E6B2D191D9A00B33C9D /* MainHomePopularViewController.swift */, - 38F70E6E2D191DFB00B33C9D /* MainHomePopularViewReactor.swift */, + 385C01B32E8EA1B1003C7894 /* SOMPageViewsDelegate.swift */, + 385C01AD2E8E8C6A003C7894 /* SOMPageModel.swift */, + 385C01B02E8E8DD4003C7894 /* SOMPageView.swift */, + 385C01B62E8EA1EB003C7894 /* SOMPageViews.swift */, ); - path = Popular; + path = SOMPageViews; sourceTree = ""; }; - 38F70E712D191EE000B33C9D /* Distance */ = { + 38E928C52EB73FE500B3F00B /* WrittenTags */ = { isa = PBXGroup; children = ( - 388698502D191F2100008600 /* MainHomeDistanceViewController.swift */, - 388698532D191F4B00008600 /* MainHomeDistanceViewReactor.swift */, + 38E928C62EB73FF300B3F00B /* WrittenTagModel.swift */, + 38E928C92EB7401F00B3F00B /* WrittenTag.swift */, + 38E928CC2EB7408E00B3F00B /* WrittenTags.swift */, ); - path = Distance; + path = WrittenTags; sourceTree = ""; }; - 38F720A02CD4F15900DF32B5 /* Detail */ = { + 38EC8CFE2ED44643009C2857 /* Search+Collect */ = { isa = PBXGroup; children = ( - 38F7209A2CD4F15900DF32B5 /* CardSummaryResponse.swift */, - 38F7209B2CD4F15900DF32B5 /* CommentCardResponse.swift */, - 38F7209E2CD4F15900DF32B5 /* DetailCardResponse.swift */, + 38EC8CFF2ED44658009C2857 /* TagSearchCollectViewController.swift */, + 38EC8D022ED44664009C2857 /* TagSearchCollectViewReactor.swift */, ); - path = Detail; + path = "Search+Collect"; sourceTree = ""; }; - 38F720A42CD4F15900DF32B5 /* MainHome */ = { + 38F161452ECDA8B3003BADB6 /* Cells */ = { isa = PBXGroup; children = ( - 38F720A12CD4F15900DF32B5 /* distanceCardResponse.swift */, - 38F720A22CD4F15900DF32B5 /* LatestCardResponse.swift */, - 38F720A32CD4F15900DF32B5 /* PopularCardResponse.swift */, + 38B21C012ECEF45D00990F49 /* FavoriteTagViewModel.swift */, + 3894EDE22ED4B2BA0024213E /* FavoriteTagsViewModel.swift */, + 38F161492ECDAD29003BADB6 /* FavoriteTagViewCell.swift */, + 38B21C0A2ECEFF9F00990F49 /* FavoriteTagsView.swift */, + 3803B91D2ECF3A6B009D14B9 /* FavoriteTagHeaderView.swift */, + 38F161462ECDA8D1003BADB6 /* FavoriteTagPlaceholderViewCell.swift */, + 38B21C072ECEF7CF00990F49 /* PopularTagViewCell.swift */, + 38B21C0D2ECF0F1800990F49 /* PopularTagsView.swift */, + 3803B91A2ECF3937009D14B9 /* PopularTagHeaderView.swift */, ); - path = MainHome; + path = Cells; sourceTree = ""; }; 38F88EBC2D2C1E19002AD7A8 /* Models */ = { isa = PBXGroup; children = ( - 38F88EBD2D2C1E22002AD7A8 /* Version.swift */, 38E9CE0F2D376E0E00E85A2D /* PushTokenSet.swift */, ); path = Models; @@ -2226,6 +2749,7 @@ 38FDC2B42C9E745B00C094C2 /* Base */ = { isa = PBXGroup; children = ( + 383EC6172E7A544B00EC2D1E /* DIContainer */, 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */, 38FDC2C62C9E764300C094C2 /* BaseNavigationViewController.swift */, ); @@ -2246,6 +2770,7 @@ 385441932C870544004E2BB0 /* Frameworks */, 385441942C870544004E2BB0 /* Resources */, 63E8EF9638A3441082507A5C /* [CP] Embed Pods Frameworks */, + 384164332EC9D47A00114AF4 /* Run Crashlytics */, ); buildRules = ( ); @@ -2303,7 +2828,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1530; - LastUpgradeCheck = 1530; + LastUpgradeCheck = 1600; TargetAttributes = { 3857BC302D4D1A7B008D4264 = { CreatedOnToolsVersion = 15.3; @@ -2342,10 +2867,13 @@ buildActionMask = 2147483647; files = ( 385441952C870544004E2BB0 /* Assets.xcassets in Resources */, - 38D3CB172CC2362B001EC280 /* Hakgyoansim-Bold.ttf in Resources */, + 386712CE2E977EC200541389 /* Yoonwoo.ttf in Resources */, + 38D2FBC42E81AD26006DD739 /* refrech_control_lottie.json in Resources */, + 386712D02E977F1B00541389 /* RIDIBatang.otf in Resources */, 385441962C870544004E2BB0 /* Base in Resources */, - 38D3CB192CC2362B001EC280 /* Hakgyoansim-Light.ttf in Resources */, + 386712CA2E977E9D00541389 /* Kkukkkuk.ttf in Resources */, 2A62805B2D084FEB00803BE9 /* GoogleService-Info.plist in Resources */, + 3887176A2E7BD7AC00C6143B /* loading_indicator_lottie.json in Resources */, 388DA1022C8F538400A9DD56 /* PretendardVariable.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2362,10 +2890,13 @@ buildActionMask = 2147483647; files = ( 387894462D31788800F69487 /* GoogleService-Info.plist in Resources */, + 386712CD2E977EC200541389 /* Yoonwoo.ttf in Resources */, 387FBAF92C8702C200A5E139 /* Assets.xcassets in Resources */, - 38D3CB162CC2362B001EC280 /* Hakgyoansim-Bold.ttf in Resources */, + 386712D12E977F1B00541389 /* RIDIBatang.otf in Resources */, + 38D2FBC52E81AD26006DD739 /* refrech_control_lottie.json in Resources */, + 386712CB2E977E9D00541389 /* Kkukkkuk.ttf in Resources */, 387FBAFC2C8702C200A5E139 /* Base in Resources */, - 38D3CB182CC2362B001EC280 /* Hakgyoansim-Light.ttf in Resources */, + 3887176B2E7BD7AC00C6143B /* loading_indicator_lottie.json in Resources */, 388DA1012C8F538400A9DD56 /* PretendardVariable.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2472,6 +3003,27 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/SwiftLint/swiftlint lint\n"; }; + 384164332EC9D47A00114AF4 /* Run Crashlytics */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", + "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + name = "Run Crashlytics"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n# 프로젝트의 dSYM 파일을 처리하고 파일을 Crashlytics에 업로드\n\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; + }; 63E8EF9638A3441082507A5C /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2537,264 +3089,398 @@ files = ( 38D8E2922CCD232B00CE2E0A /* AuthManager.swift in Sources */, 38B8A5892CAEA5F9000AFE83 /* DetailViewCell.swift in Sources */, - 3878D0572CFFCBDA00F9522F /* CommentHistoryResponse.swift in Sources */, 389EF81F2D2F469B00E053AE /* CocoaLumberjack.swift in Sources */, - 381DEA8E2CD4BE55009F1FE9 /* SignInResponse.swift in Sources */, - 381A1D662CC38E7D005FDB8E /* WriteTagTextField.swift in Sources */, 3802BDB92D0AF2F7001256EA /* PushManager+Rx.swift in Sources */, - 387D852D2D08320A005D9D22 /* SOMCardModel.swift in Sources */, + 3880EF802EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */, 385620F72CA19EA900E0AB5A /* Alamofire_constants.swift in Sources */, 3817016F2CD882C2005FC220 /* TimeoutInterceptor.swift in Sources */, 38AE565D2D048B4800CAA431 /* SOMDialogViewController+Show.swift in Sources */, - 3878D0762CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */, + 38E928CB2EB7402200B3F00B /* WrittenTag.swift in Sources */, + 38C9AF3C2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */, + 3878D0762CFFE01500F9522F /* SettingVersionCellView.swift in Sources */, 3878D08D2CFFF0BF00F9522F /* AnnouncementViewControler.swift in Sources */, 38601E1C2D313A8200A465A9 /* SOMNavigationBar+Rx.swift in Sources */, 38FD4DB22D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */, + 3803B91C2ECF3944009D14B9 /* PopularTagHeaderView.swift in Sources */, + 383EC61A2E7A547900EC2D1E /* BaseAssembler.swift in Sources */, + 3894EDE42ED4B2BB0024213E /* FavoriteTagsViewModel.swift in Sources */, 38CC49832CDE3854007A0145 /* SOMPresentationController.swift in Sources */, - 2AE6B1672CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift in Sources */, 3836ACB52C8F045300A3C566 /* Typography.swift in Sources */, + 38F376112ECB78A600E4A41D /* TagCardInfoResponse.swift in Sources */, + 38E928CD2EB7409100B3F00B /* WrittenTags.swift in Sources */, 38389BA02CCCFB7D006728AF /* AuthKeyChain.swift in Sources */, - 2A048E852C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift in Sources */, - 2A5ABA342D464E0B00BF6C9B /* ConfigureRequest.swift in Sources */, + 38899E902E7951200030F7CA /* KeyInfoResponse.swift in Sources */, 388371FA2C8C8EB1004212EB /* SooumStyle.swift in Sources */, - 38F720B42CD4F15900DF32B5 /* LatestCardResponse.swift in Sources */, + 386867A72E9E932B00171A5E /* WriteCardSelectImageView+Rx.swift in Sources */, + 3803B91F2ECF3A75009D14B9 /* FavoriteTagHeaderView.swift in Sources */, + 385C01B12E8E8DD8003C7894 /* SOMPageView.swift in Sources */, 3803CF6A2D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */, + 381B83E52EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */, + 389E59CF2EDEEDD500D0946D /* FetchFollowUseCase.swift in Sources */, + 389E59D92EDEEEC500D0946D /* UpdateNotifyUseCase.swift in Sources */, 3878F4782CA3F08300AA46A2 /* UIView.swift in Sources */, - 2ACBD4182CC963390057C013 /* DefaultCardImageResponse.swift in Sources */, - 3803CF782D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */, - 38816DA32D004DED00EB87D6 /* UpdateProfileView.swift in Sources */, 3816E23B2D3BF402004CC196 /* TermsOfServiceCellView+Rx.swift in Sources */, - 2AFD054A2CFF7687007C84AD /* RecommendTagsResponse.swift in Sources */, 38773E7D2CB3ACB2004815CD /* SOMRefreshControl.swift in Sources */, + 3889A2572E79BA160030F7CA /* UserRemoteDataSourceImpl.swift in Sources */, + 385C01B42E8EA1B7003C7894 /* SOMPageViewsDelegate.swift in Sources */, + 3889A24D2E79AEB30030F7CA /* AppVersionUseCaseImpl.swift in Sources */, 38CC49862CDE3885007A0145 /* SOMTransitioningDelegate.swift in Sources */, 388DA0FC2C8F521300A9DD56 /* FontContainer.swift in Sources */, + 389E59C32EDEEC7B00D0946D /* CardImageUseCase.swift in Sources */, 384972A12CA4DEC00012FCA1 /* CardRequest.swift in Sources */, + 381B83E02EBC72B400C84015 /* FollowInfo.swift in Sources */, 38D488CB2D0C557300F2D38D /* SOMButton.swift in Sources */, - 38BE72182CC696E9002662DD /* WriteTagTextField+Rx.swift in Sources */, + 38C2A7F62EC08FF600B941A2 /* BlockUserInfo.swift in Sources */, 388FCAD12CFAC2BF0012C4D6 /* Notification.swift in Sources */, + 38F339922EE328750066A5F7 /* WriteCardGuideView.swift in Sources */, 3802BDB62D0AF16A001256EA /* PushManager.swift in Sources */, 38B6AADC2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift in Sources */, - 38AA00072CAD96E3002C5F1E /* MoreBottomSheetViewController.swift in Sources */, - 38C2D4122CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */, 38B543E02D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */, + 38787B752ED1E5B3004BBAA7 /* SearchTermViewCell.swift in Sources */, 3887D0342CC5335200FB52E1 /* WriteCardViewReactor.swift in Sources */, - 3878D05D2CFFD10D00F9522F /* FollowingResponse.swift in Sources */, 38D6F17D2CC2406700E11530 /* WriteCardViewController.swift in Sources */, + 381854982E992E9900424D71 /* WriteCardSelectImageView.swift in Sources */, 385053592C92DD2300C80B02 /* SOMTabBarController.swift in Sources */, + 386E966E2E9A53D6005E047D /* SelectOptionsView.swift in Sources */, + 389E59DF2EDEEF7C00D0946D /* TransferAccountUseCase.swift in Sources */, + 38AE85332EDFFAC400029E4C /* UpdateFollowUseCaseImpl.swift in Sources */, + 383EC61D2E7A548E00EC2D1E /* BaseDIContainer.swift in Sources */, 385009C32D363525007175A1 /* FilterNil.swift in Sources */, - 3816C05D2CCDDF3D00C8688C /* ReAuthenticationResponse.swift in Sources */, 38FDC2B72C9E746B00C094C2 /* BaseViewController.swift in Sources */, 38CC49892CDE3972007A0145 /* SOMPresentationController+Show.swift in Sources */, - 38F70E5C2D1905D000B33C9D /* MainHomeTabBarController.swift in Sources */, 38E7FBF02D3CF6BC00A359CD /* SOMDialogAction.swift in Sources */, - 38AA66272D3AC3F500B3F6B2 /* DialogMessageView.swift in Sources */, 3878D07E2CFFE6E500F9522F /* IssueMemberTransferViewController.swift in Sources */, 3878B8632D0DC8BD00B3B128 /* UIViewController+Toast.swift in Sources */, - 2A44A4352CAC21A500DC463E /* SignUpResponse.swift in Sources */, + 389E59AF2EDEE8BD00D0946D /* FetchCardUseCase.swift in Sources */, + 38C2A7D82EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */, + 38F1614B2ECDAD34003BADB6 /* FavoriteTagViewCell.swift in Sources */, 38E9CE112D376E0E00E85A2D /* PushTokenSet.swift in Sources */, - 38F70E702D191DFB00B33C9D /* MainHomePopularViewReactor.swift in Sources */, - 3878D0982CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */, - 38F720AE2CD4F15900DF32B5 /* DetailCardResponse.swift in Sources */, - 2AFD056A2D03264C007C84AD /* AddFavoriteTagResponse.swift in Sources */, - 2AFD05532D007F2F007C84AD /* TagSearchViewReactor.swift in Sources */, - 2A44A42B2CAC09AE00DC463E /* RSAKeyResponse.swift in Sources */, - 38572CD92D2230C900B07C69 /* NotificationAllowResponse.swift in Sources */, - 382E15402D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift in Sources */, + 386867A42E9E378200171A5E /* Array.swift in Sources */, + 38899E6F2E79400C0030F7CA /* ImageUrlInfoResponse.swift in Sources */, + 38AE852B2EDFF9CC00029E4C /* ReportCardUseCaseImpl.swift in Sources */, + 3889A2772E79C29F0030F7CA /* NotificationRemoteDataSoruceImpl.swift in Sources */, + 38F3760D2ECB779E00E4A41D /* FavoriteTagInfoResponse.swift in Sources */, + 38C2A7EE2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */, + 38E928D32EB7624300B3F00B /* FloatingButton.swift in Sources */, + 38FEBE552E865121002916A8 /* FollowNotificationInfoResponse.swift in Sources */, + 38EBA90F2EB39920008B28F4 /* PostingPermission.swift in Sources */, + 38899E9C2E7954D90030F7CA /* DeletedNotificationInfoResponse.swift in Sources */, + 38899E8C2E794E690030F7CA /* AppVersionStatusResponse.swift in Sources */, + 38C9AF2D2E96A3E500B401C0 /* WriteCardTagModel.swift in Sources */, + 38A721962E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */, + 380F422B2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */, 388698632D1986B100008600 /* NotificationRequest.swift in Sources */, - 2A980BA52D803EEA007DFA45 /* SOMEvent.swift in Sources */, - 38C2D4152CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */, - 38F720B22CD4F15900DF32B5 /* distanceCardResponse.swift in Sources */, - 38FD4DB52D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */, + 38E928DA2EB7727400B3F00B /* SOMBottomToastView.swift in Sources */, + 3803B9232ECF52CE009D14B9 /* TagCollectCardViewCell.swift in Sources */, + 38B65E7C2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */, + 38D2FBCE2E81B52F006DD739 /* SOMSwipableTabBar.swift in Sources */, + 38FD4DB52D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */, + 389E59A82EDEE6F600D0946D /* ValidateNicknameUseCase.swift in Sources */, 382E153B2D15A67A0097B09C /* NotificationViewCell.swift in Sources */, + 38C2A80C2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */, 388009922CABF855002A9209 /* SOMTagModel.swift in Sources */, - 2AFD05502CFF79D8007C84AD /* TagsViewReactor.swift in Sources */, 3862C0E02C9EB6670023C046 /* UIViewController+PushAndPop.swift in Sources */, - 2AFF95712CF5E8DE00CBFB12 /* TagSearchViewController.swift in Sources */, - 2AFD05642D00A1E1007C84AD /* TagDetailCardResponse.swift in Sources */, + 38FD56252EC9FAA400EC6106 /* String.swift in Sources */, 3836ACB82C8F04CD00A3C566 /* UILabel+Observer.swift in Sources */, - 2A5BB7E82CDCDC3600E1C799 /* NicknameValidationResponse.swift in Sources */, - 2AFF95622CF33A3900CBFB12 /* FavoriteTagView.swift in Sources */, + 383EC6122E7A4F6B00EC2D1E /* AuthLocalDataSource.swift in Sources */, + 38C9AF3A2E96AB9100B401C0 /* WriteCardTagFooter.swift in Sources */, + 385C01B82E8EA1EF003C7894 /* SOMPageViews.swift in Sources */, + 389E59D32EDEEE4100D0946D /* UpdateFollowUseCase.swift in Sources */, + 385C01AE2E8E8C6F003C7894 /* SOMPageModel.swift in Sources */, 2A032EFE2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift in Sources */, + 38787B822ED1EB21004BBAA7 /* TagCollectCardsView.swift in Sources */, + 38C2A8022EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */, 388C96372CCE41700061C598 /* AuthInfo.swift in Sources */, 3878D0892CFFEF0F00F9522F /* TermsOfServiceTextCellView+Rx.swift in Sources */, - 38F720B62CD4F15900DF32B5 /* PopularCardResponse.swift in Sources */, 3878D0702CFFDF9600F9522F /* SettingTextCellView.swift in Sources */, 2A5BB7BF2CDB870000E1C799 /* OnboardingGuideMessageView.swift in Sources */, - 388A2D2E2D00A45800E2F2F0 /* writtenCardResponse.swift in Sources */, + 389E59CC2EDEED6500D0946D /* FetchUserInfoUseCase.swift in Sources */, + 38FEBE612E8661F4002916A8 /* NoticeInfo.swift in Sources */, 3878D07A2CFFE1E800F9522F /* ResignViewController.swift in Sources */, - 38738D4C2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift in Sources */, + 38899EAD2E79A09B0030F7CA /* UserRequest.swift in Sources */, + 3889A2722E79C03B0030F7CA /* AuthRepository.swift in Sources */, 38A627182CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift in Sources */, + 38AE77D82E7459F400B6FD13 /* OnboardingCompletedViewReactor.swift in Sources */, 3887D03A2CC5504500FB52E1 /* UITextField+Typography.swift in Sources */, - 2AE6B15B2CBEAEC000FA5C3C /* ReportRequest.swift in Sources */, - 2A5BB7CE2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift in Sources */, + 3889A2872E79D8090030F7CA /* AuthUseCase.swift in Sources */, + 2A5BB7CE2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift in Sources */, 3893B6D22D36739500F2004C /* CompositeManager.swift in Sources */, + 380F42302E884FBC009AC59E /* CardRepository.swift in Sources */, 3800575D2D9C12CB00E58A19 /* DefinedError.swift in Sources */, - 38F131892CC7B7E0000D0475 /* RelatedTagResponse.swift in Sources */, + 387FA11E2E88DDC1004DF7CE /* HomeViewReactor.swift in Sources */, + 38899E942E79518F0030F7CA /* CommonNotificationInfo.swift in Sources */, + 389E59C92EDEED2B00D0946D /* FetchTagUseCase.swift in Sources */, 2AFD056E2D048CAF007C84AD /* TagRequest.swift in Sources */, + 38CA91F42EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */, + 38E928D02EB75FA300B3F00B /* PungView.swift in Sources */, + 38EC8D042ED44669009C2857 /* TagSearchCollectViewReactor.swift in Sources */, + 3879B4B82EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */, + 38E928C82EB73FF800B3F00B /* WrittenTagModel.swift in Sources */, 388698602D1984D600008600 /* NotificationViewReactor.swift in Sources */, + 38899E862E794CEE0030F7CA /* NetworkManager_FCM.swift in Sources */, + 38EBA9112EB399A1008B28F4 /* PostingPermissionResponse.swift in Sources */, 38C2D4212CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */, + 38AE85392EDFFBBF00029E4C /* UpdateTagFavoriteUseCaseImpl.swift in Sources */, + 38AE85452EDFFCF900029E4C /* ValidateUserUseCaseImpl.swift in Sources */, 38816D9F2D004A5E00EB87D6 /* UpdateProfileViewController.swift in Sources */, 38D6F1842CC243DB00E11530 /* UITextView+Typography.swift in Sources */, - 2AFF95792CF5F0B000CBFB12 /* TagPreviewCardView.swift in Sources */, - 38572CDF2D2254E800B07C69 /* PlaceholderViewCell.swift in Sources */, + 38572CDF2D2254E800B07C69 /* HomePlaceholderViewCell.swift in Sources */, 38B6AACE2CA410D800CE6DB6 /* MainTabBarController.swift in Sources */, - 2AE6B1512CBCC2F600FA5C3C /* ReportTableViewCell.swift in Sources */, + 38787B8C2ED22A29004BBAA7 /* SearchTermPlaceholderViewCell.swift in Sources */, + 381854AA2E99574100424D71 /* SelectTypographyView.swift in Sources */, 388371FD2C8C8F11004212EB /* UIColor+SOOUM.swift in Sources */, - 2AE6B1802CBFEA5200FA5C3C /* ToggleView.swift in Sources */, 3878D0732CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */, 3878D0912CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */, - 2AFF956C2CF5E00600CBFB12 /* RecommendTagView.swift in Sources */, + 38C2A8042EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */, + 3880EF7D2EA0DA7400D88608 /* WritrCardTextViewDelegate.swift in Sources */, + 38787B882ED22324004BBAA7 /* RxSwift+Unretained.swift in Sources */, 38B543E32D46179500DDF2C5 /* AuthManagerConfiguration.swift in Sources */, 38D055C42CD862FE00E75590 /* SOMActivityIndicatorView.swift in Sources */, + 38AE85182EDF437400029E4C /* FetchCardUseCaseImpl.swift in Sources */, + 3880EF7B2EA0D17E00D88608 /* RelatedTagsView+Rx.swift in Sources */, 38B543EF2D46506300DDF2C5 /* ManagerType.swift in Sources */, 3850719B2CA295A800A7905A /* LaunchScreenViewController.swift in Sources */, 38CE94C02C904D460004B238 /* SOMNavigationBar.swift in Sources */, + 3874B5612ECB25D1004CC22A /* SettingsLocalDataSource.swift in Sources */, 2A5BB7D22CDC7ADC00E1C799 /* OnboardingViewController.swift in Sources */, - 2AFF955B2CF3227900CBFB12 /* TagSearchTextFieldView.swift in Sources */, - 2A45B36D2CE3A3E30071026A /* ProfileImageSettingViewReactor.swift in Sources */, + 3889A26E2E79BE9F0030F7CA /* AuthRemoteDataSourceImpl.swift in Sources */, + 2A45B36D2CE3A3E30071026A /* OnboardingProfileImageSettingViewReactor.swift in Sources */, + 38C2A7E12EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */, 38D5CE0C2CBCE8CA0054AB9A /* SimpleDefaults.swift in Sources */, + 383EC6232E7A56CE00EC2D1E /* AppDIContainer.swift in Sources */, 382D5CF72CFE9B8600BFA23E /* ProfileViewController.swift in Sources */, - 388698552D191F4B00008600 /* MainHomeDistanceViewReactor.swift in Sources */, + 38C2A7EB2EC074A200B941A2 /* SettingsRepository.swift in Sources */, + 38899E892E794D620030F7CA /* NetworkManager_Version.swift in Sources */, + 3803B92B2ECF5580009D14B9 /* TagCollectViewController.swift in Sources */, 385620F32CA19D2D00E0AB5A /* Alamofire_Request.swift in Sources */, + 389E59C72EDEECBF00D0946D /* WriteCardUseCase.swift in Sources */, 388A2D312D00D6A100E2F2F0 /* FollowViewReactor.swift in Sources */, 2A649ED42CAE990B002D8284 /* SOMDialogViewController.swift in Sources */, 3893B6CF2D36728000F2004C /* ManagerProvider.swift in Sources */, - 2AFF95692CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift in Sources */, + 38AE85372EDFFAF900029E4C /* UpdateNotifyUseCaseImpl.swift in Sources */, + 38C2A7FC2EC0925C00B941A2 /* WithdrawType.swift in Sources */, + 38C2A7DC2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */, + 38A7219A2E73EA6F0071E1D8 /* SOMBottomFloatView.swift in Sources */, 3866577F2CEF3554009F7F60 /* UIButton+Rx.swift in Sources */, 385441912C870544004E2BB0 /* AppDelegate.swift in Sources */, + 389E59B52EDEEA8200D0946D /* FetchCardDetailUseCase.swift in Sources */, + 389E59E42EDEF03000D0946D /* LocationUseCase.swift in Sources */, + 38D522692E742F610044911B /* SOMLoadingIndicatorView.swift in Sources */, 38D869642CF821F900BF87DA /* UserDefaults.swift in Sources */, - 2AE6B1902CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift in Sources */, - 3878D0602CFFD45100F9522F /* FollowerResponse.swift in Sources */, + 380F422D2E884F3D009AC59E /* CardRemoteDataSourceImpl.swift in Sources */, + 38C9AF332E96A82900B401C0 /* WriteCardTags.swift in Sources */, + 38F3398F2EE31C870066A5F7 /* IsCardDeletedResponse.swift in Sources */, + 38899E672E7939600030F7CA /* CheckAvailableResponse.swift in Sources */, + 38AE77DB2E745FFF00B6FD13 /* EnterMemberTransferTextFieldView.swift in Sources */, + 3889A2432E79AD7D0030F7CA /* AppVersionRepository.swift in Sources */, + 381B83E32EBC736800C84015 /* ProfileInfoResponse.swift in Sources */, + 381854A02E99340600424D71 /* WriteCardUserImageCell.swift in Sources */, + 38C9AF152E9665C900B401C0 /* TagInfoResponse.swift in Sources */, 381701722CD88374005FC220 /* CompositeInterceptor.swift in Sources */, 2A5BB7CA2CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift in Sources */, + 3803B92D2ECF5589009D14B9 /* TagCollectViewReactor.swift in Sources */, 385620F02CA19C9500E0AB5A /* NetworkManager.swift in Sources */, + 380F42282E884B80009AC59E /* BaseCardInfo.swift in Sources */, + 3889A24B2E79AE960030F7CA /* AppVersionUseCase.swift in Sources */, 2A980BA12D803EB1007DFA45 /* AnalyticsEventProtocol.swift in Sources */, - 3803CF712D0159A500FD90DB /* SettingsResponse.swift in Sources */, + 38F161472ECDA8F0003BADB6 /* FavoriteTagPlaceholderViewCell.swift in Sources */, + 389E59BB2EDEEB8300D0946D /* BlockUserUseCase.swift in Sources */, 2A5BB7E12CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */, - 3803CF7E2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */, - 38F70E5F2D190FBD00B33C9D /* MainHomeTabBarReactor.swift in Sources */, - 3834FADE2D11C5AC00C9108D /* SimpleCache.swift in Sources */, + 38D8FE8D2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */, + 38C2A7DF2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */, 388009982CAC20EC002A9209 /* SOMTags+Rx.swift in Sources */, - 2A980BA92D803F04007DFA45 /* GAManager.swift in Sources */, - 2AFF95752CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift in Sources */, - 2AFD05472CFF75DD007C84AD /* FavoriteTagsResponse.swift in Sources */, - 38B6AAD92CA424AE00CE6DB6 /* MoveTopButtonView.swift in Sources */, - 2AE6B16E2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift in Sources */, + 38E928DC2EB7921200B3F00B /* UIRefreshControl.swift in Sources */, + 38899EAA2E799C630030F7CA /* VersionRequest.swift in Sources */, + 381B83DD2EBC707A00C84015 /* ProfileInfo.swift in Sources */, + 381E7C1A2ECCB1A700E80249 /* TagViewController.swift in Sources */, + 38FEBE5B2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */, + 38D8F55E2EC4F38700DED428 /* SimpleReachability.swift in Sources */, + 2A980BA92D803F04007DFA45 /* GAHelper.swift in Sources */, + 38B21C082ECEF7D400990F49 /* PopularTagViewCell.swift in Sources */, + 3880EF782EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */, + 381B83F32EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */, + 38FEBE642E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */, + 38899E722E79402C0030F7CA /* ImageUrlInfo.swift in Sources */, + 3889A25C2E79BB340030F7CA /* UserRepository.swift in Sources */, + 38899E7E2E794B420030F7CA /* SignUpResponse.swift in Sources */, + 38CA91F72EBDD342002C261A /* ProfileViewHeader.swift in Sources */, + 38AE850C2EDF41B700029E4C /* CardImageUseCaseImpl.swift in Sources */, + 381E7C232ECCC63E00E80249 /* SearchViewButton.swift in Sources */, + 38B21C022ECEF46200990F49 /* FavoriteTagViewModel.swift in Sources */, 38AA00032CAD1BCC002C5F1E /* LikeAndCommentView.swift in Sources */, + 38D2FBD12E81B9B7006DD739 /* SOMSwipableTabBarDelegate.swift in Sources */, + 389E59B12EDEEA3A00D0946D /* FetchNoticeUseCase.swift in Sources */, + 38D2FBC12E812354006DD739 /* HomeViewController.swift in Sources */, 3816C0612CCDE35300C8688C /* ErrorInterceptor.swift in Sources */, 3803CF7B2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */, - 38D5637C2D16D72D006265AA /* SOMSwipeTabBar.swift in Sources */, - 3878D04F2CFFC5F300F9522F /* ProfileRequest.swift in Sources */, + 381B83E82EBC75D700C84015 /* ProfileCardInfo.swift in Sources */, + 38D5637C2D16D72D006265AA /* SOMStickyTabBar.swift in Sources */, 38FDC2C82C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */, + 3803B9312ECF5F21009D14B9 /* SearchTextFieldView.swift in Sources */, 3880097C2CABEE3D002A9209 /* DetailViewController.swift in Sources */, - 2AE6B1762CBFD59B00FA5C3C /* ImageCollectionViewCell.swift in Sources */, - 2AE6B1642CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift in Sources */, - 2AE6B1722CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift in Sources */, - 382E15432D15BA490097B09C /* NotificationWithReportViewCell.swift in Sources */, - 2A45B3702CE4C5510071026A /* RegisterUserResponse.swift in Sources */, + 3889A2802E79D0250030F7CA /* Token.swift in Sources */, + 38787B7E2ED1E8F4004BBAA7 /* TagSearchViewReactor.swift in Sources */, + 38F161432ECDA858003BADB6 /* SearchViewButton+Rx.swift in Sources */, + 38AE850F2EDF420700029E4C /* DeleteCardUseCaseImpl.swift in Sources */, + 3879B4B52EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */, 388DA0FF2C8F526C00A9DD56 /* UIFont.swift in Sources */, - 2AE6B1932CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift in Sources */, + 38C9AF202E9669F600B401C0 /* TagRepository.swift in Sources */, + 38E928C22EB73D6B00B3F00B /* MemberInfoView.swift in Sources */, 38F88EBF2D2C1E22002AD7A8 /* Version.swift in Sources */, + 3887176D2E7BDBAE00C6143B /* NicknameResponse.swift in Sources */, + 3880EF722EA0CDA100D88608 /* RelatedTagView.swift in Sources */, + 38B21C0C2ECEFFAA00990F49 /* FavoriteTagsView.swift in Sources */, 2A980B9E2D803E9D007DFA45 /* FirebaseLoggable.swift in Sources */, - 38121E2A2CA6A52400602499 /* UIRefreshControl.swift in Sources */, 3830FFA72CEC6E3100ABA9FD /* Kingfisher.swift in Sources */, 3836ACBB2C8F050D00A3C566 /* UILabel+Typography.swift in Sources */, - 2A44A4382CAC227300DC463E /* BaseAuthResponse.swift in Sources */, - 2AFD05562D0082DE007C84AD /* SearchTagsResponse.swift in Sources */, 385053562C92DCF900C80B02 /* SOMTabBar.swift in Sources */, + 38899E9A2E7954680030F7CA /* BlockedNotificationInfoResponse.swift in Sources */, + 38F3760A2ECB772A00E4A41D /* FavoriteTagInfo.swift in Sources */, 2AE6B14D2CBC160C00FA5C3C /* ReportViewReactor.swift in Sources */, - 38FD4DAC2D032CF000BF5FF1 /* AnnouncementResponse.swift in Sources */, + 38D8FE912EBE664D00F32D02 /* SOMNicknameTextField.swift in Sources */, + 3889A2892E79D8220030F7CA /* AuthUseCaseImpl.swift in Sources */, 3880098F2CABF4C2002A9209 /* SOMTag.swift in Sources */, 3803CF892D01914200FD90DB /* ResignViewReactor.swift in Sources */, - 38F70E632D19113E00B33C9D /* MainHomeLatestViewController.swift in Sources */, + 389E59BD2EDEEBF300D0946D /* ReportCardUseCase.swift in Sources */, 388A2D342D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift in Sources */, 38B543E62D4617CB00DDF2C5 /* PushManagerConfiguration.swift in Sources */, + 389E59B72EDEEAFC00D0946D /* UpdateCardLikeUseCase.swift in Sources */, + 389E59A52EDEE39500D0946D /* ValidateUserUseCase.swift in Sources */, + 3889A2462E79ADCE0030F7CA /* AppVersionRepositoryImpl.swift in Sources */, 2AE6B14A2CBC15BF00FA5C3C /* ReportViewController.swift in Sources */, - 2AFD055A2D008D23007C84AD /* TagDetailViewController.swift in Sources */, - 2AE6B1552CBCC34B00FA5C3C /* ReportReasonView.swift in Sources */, - 2AFF95562CF3222400CBFB12 /* TagsViewController.swift in Sources */, + 388D8AE02E73E6190044BA79 /* SwiftEntryKit.swift in Sources */, + 38899E962E7953310030F7CA /* NotificationInfoResponse.swift in Sources */, + 38FEBE5F2E86612C002916A8 /* NoticeViewCell.swift in Sources */, + 38D4780A2EBBABF60041FF6C /* EntranceCardType.swift in Sources */, + 38C9AF182E96693600B401C0 /* TagRemoteDataSource.swift in Sources */, 38121E322CA6C77500602499 /* Double.swift in Sources */, - 38405CCC2CC611FD00612D1E /* BaseEmptyAndHeader.swift in Sources */, + 380F42252E884AE5009AC59E /* CardRemoteDataSource.swift in Sources */, + 381B83EC2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */, + 38EC8D012ED44661009C2857 /* TagSearchCollectViewController.swift in Sources */, 38E9CE1A2D37FED000E85A2D /* AddingTokenInterceptor.swift in Sources */, 38F88EBB2D2C1CB8002AD7A8 /* Info.swift in Sources */, - 381A1D6B2CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift in Sources */, 2A5BB7D62CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift in Sources */, 38E9CE142D37711600E85A2D /* OnboardingViewReactor.swift in Sources */, - 2AFD05672D01CB30007C84AD /* TagInfoResponse.swift in Sources */, - 2AE6B17C2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift in Sources */, + 38E928B92EB715C900B3F00B /* ReortType.swift in Sources */, + 38AE853D2EDFFBF700029E4C /* UpdateUserInfoUseCaseImpl.swift in Sources */, 38F3D9312D06C2370049F575 /* SOMAnimationTransitioning.swift in Sources */, 38B8A58F2CAEB61A000AFE83 /* DetailViewFooterCell.swift in Sources */, - 2AFD056D2D048C84007C84AD /* FavoriteTagTableViewCell.swift in Sources */, + 383088092EDC7B8C00D99D88 /* SOMMessageBubbleView.swift in Sources */, 2A5BB7BA2CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift in Sources */, + 38787B7B2ED1E8B3004BBAA7 /* TagSearchViewController.swift in Sources */, 385441922C870544004E2BB0 /* SceneDelegate.swift in Sources */, - 38D563852D1719B1006265AA /* SOMSwipeTabBarItem.swift in Sources */, + 389E59C12EDEEC4900D0946D /* DeleteCardUseCase.swift in Sources */, + 38AE85162EDF42B700029E4C /* FetchCardDetailUseCaseImpl.swift in Sources */, + 38787B782ED1E719004BBAA7 /* SearchTermsView.swift in Sources */, + 38C9AF0F2E96602300B401C0 /* DefaultImagesResponse.swift in Sources */, + 38D563852D1719B1006265AA /* SOMStickyTabBarItem.swift in Sources */, + 38C2A7E72EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */, 389EF8182D2F450000E053AE /* Log.swift in Sources */, - 382E15372D15A6460097B09C /* NotificationTabBarController.swift in Sources */, - 38F720A82CD4F15900DF32B5 /* CommentCardResponse.swift in Sources */, - 38C2D41B2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */, + 3889A26B2E79BD450030F7CA /* AuthRemoteDataSource.swift in Sources */, 389681112CAFBD6A00FFD89F /* DetailViewReactor.swift in Sources */, + 3889A2932E79D8F80030F7CA /* NotificationUseCase.swift in Sources */, 3878D0822CFFEC6900F9522F /* TermsOfServiceViewController.swift in Sources */, - 38572CDC2D22464F00B07C69 /* PungTimeView.swift in Sources */, - 38F720B92CD4F16500DF32B5 /* CardProtocol.swift in Sources */, + 38B21C0E2ECF0F1D00990F49 /* PopularTagsView.swift in Sources */, + 38C2A8122EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */, 38121E352CA6DA4000602499 /* Date.swift in Sources */, - 2A34AFB62D144F08007BD7E7 /* EmptyTagDetailTableViewCell.swift in Sources */, - 3803CF752D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */, - 38C2D4182CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */, + 38C2A7FA2EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */, + 38899E6B2E793AFD0030F7CA /* CheckAvailable.swift in Sources */, + 38C9AF242E966A1B00B401C0 /* TagRepositoryImpl.swift in Sources */, + 38FCF4192E9F88EA003AC3D8 /* WriteCardTags+Rx.swift in Sources */, + 38C2D4182CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */, + 389E59DC2EDEEF3600D0946D /* FetchBlockUserUseCase.swift in Sources */, 381DEA8C2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */, - 388698662D1998DB00008600 /* NotificationTabBarReactor.swift in Sources */, + 3818549D2E992F7D00424D71 /* WriteCardDefaultImageCell.swift in Sources */, + 3889A2752E79C1D80030F7CA /* NotificationRemoteDataSource.swift in Sources */, + 38AE85222EDFF88C00029E4C /* FetchTagUseCaseImpl.swift in Sources */, + 386712C42E97734B00541389 /* UITextField.swift in Sources */, + 38C9AF0B2E965EFB00B401C0 /* DefaultImages.swift in Sources */, 381A1D752CC3D799005FDB8E /* SOMTagsDelegate.swift in Sources */, - 2A048E7C2C9BDF5F00FFD485 /* SOMLocationFilter.swift in Sources */, + 38AE85092EDF414400029E4C /* BlockUserUseCaseImpl.swift in Sources */, + 3874B5642ECB2613004CC22A /* SettingsLocalDataSourceImpl.swift in Sources */, 3803CF832D017DB800FD90DB /* EnterMemberTransferViewController.swift in Sources */, - 38F720A62CD4F15900DF32B5 /* CardSummaryResponse.swift in Sources */, 388693A02CF77FA7005F9EF3 /* UIApplication+Top.swift in Sources */, - 38D6F1872CC24C4F00E11530 /* WriteCardTextViewDelegate.swift in Sources */, - 38B8BE482D1ECBDA0084569C /* NotificationInfo.swift in Sources */, - 38D5637F2D17152F006265AA /* SOMSwipeTabBarDelegate.swift in Sources */, + 38B8BE482D1ECBDA0084569C /* PushNotificationInfo.swift in Sources */, + 38D5637F2D17152F006265AA /* SOMStickyTabBarDelegate.swift in Sources */, + 38C2A80E2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */, 388372022C8C8FCF004212EB /* UIColor.swift in Sources */, 38B543E92D4617EA00DDF2C5 /* NetworkManagerConfiguration.swift in Sources */, + 38C9AF112E96656600B401C0 /* TagInfo.swift in Sources */, + 38AE852E2EDFFA3C00029E4C /* TransferAccountUseCaseImpl.swift in Sources */, + 38AE85432EDFFCA600029E4C /* ValidateNicknameUseCaseImpl.swift in Sources */, 388698592D1982DE00008600 /* NotificationViewController.swift in Sources */, 38389B9D2CCCF98B006728AF /* AuthRequest.swift in Sources */, - 38F70E6D2D191D9A00B33C9D /* MainHomePopularViewController.swift in Sources */, 385E65A42CBE56D00032E120 /* Coordinate.swift in Sources */, + 38E928B62EB711E200B3F00B /* DetailCardInfo.swift in Sources */, 388009952CABFAAA002A9209 /* SOMTags.swift in Sources */, 3878F4752CA3F06C00AA46A2 /* UIStackView.swift in Sources */, 3878D0862CFFED7800F9522F /* TermsOfServiceTextCellView.swift in Sources */, - 2ACBD4142CC944FB0057C013 /* UploadRequest.swift in Sources */, - 2ACBD41B2CCA03790057C013 /* ImageURLWithName.swift in Sources */, + 38899E832E794C360030F7CA /* LoginResponse.swift in Sources */, + 38D478072EBBAA0B0041FF6C /* WriteCardResponse.swift in Sources */, + 38E928C02EB72D3D00B3F00B /* DetailCardInfoResponse.swift in Sources */, 38B6AAE02CA4777200CE6DB6 /* UIViewController+Rx.swift in Sources */, - 2ACBD41E2CCAB3490057C013 /* PresignedStorageResponse.swift in Sources */, - 2A5BB7DA2CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */, - 3878D0682CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */, - 38F006AB2D395A7F001AC5F7 /* SuspensionResponse.swift in Sources */, - 2AE6B1792CBFE49D00FA5C3C /* SelectFontTableViewCell.swift in Sources */, + 38899EA42E799B260030F7CA /* AppVersionRemoteDataSource.swift in Sources */, + 38899EA62E799BD60030F7CA /* AppVersionRemoteDataSourceImpl.swift in Sources */, + 38AE77D42E74580000B6FD13 /* OnboardingCompletedViewController.swift in Sources */, 389EF81B2D2F454600E053AE /* Log+Extract.swift in Sources */, + 38AE85482EDFFD7E00029E4C /* WriteCardUseCaseImpl.swift in Sources */, 381701792CD88854005FC220 /* LogginMonitor.swift in Sources */, - 388698522D191F2100008600 /* MainHomeDistanceViewController.swift in Sources */, + 386E966B2E9A51D9005E047D /* SelectOptionItem.swift in Sources */, 388DA1052C8F545E00A9DD56 /* Typography+SOOUM.swift in Sources */, - 2AFD05612D009FA1007C84AD /* TagDetailViewrReactor.swift in Sources */, + 38D2FBCC2E81B0E5006DD739 /* SOMSwipableTabBarItem.swift in Sources */, + 3880EF752EA0CEEE00D88608 /* RelatedTagsView.swift in Sources */, 3887D0372CC5335D00FB52E1 /* WriteCardView.swift in Sources */, + 381E7C1C2ECCB1AD00E80249 /* TagViewReactor.swift in Sources */, + 38AE85252EDFF90400029E4C /* FetchUserInfoUseCaseImpl.swift in Sources */, + 38C9AF1B2E96696C00B401C0 /* TagRemoteDataSourceImpl.swift in Sources */, + 3889A2952E79D9250030F7CA /* NotificationUseCaseImpl.swift in Sources */, 38B8A58C2CAEA79A000AFE83 /* DetailViewFooter.swift in Sources */, + 3889A2512E79B3260030F7CA /* UserRemoteDataSource.swift in Sources */, + 380F42222E87ECA3009AC59E /* CompositeNotificationInfo.swift in Sources */, + 38AE85132EDF424800029E4C /* FetchBlockUserUseCaseImpl.swift in Sources */, + 38899E5E2E7937E50030F7CA /* NicknameValidateResponse.swift in Sources */, + 38AE851C2EDFF7E000029E4C /* FetchFollowUseCaseImpl.swift in Sources */, 385053532C92DBE200C80B02 /* SOMTabBarItem.swift in Sources */, - 3803CF6D2D0156FC00FD90DB /* SettingsRequest.swift in Sources */, - 2A5BB7E42CDCD97300E1C799 /* JoinRequest.swift in Sources */, - 2AFD055E2D009513007C84AD /* TagDetailNavigationBarView.swift in Sources */, - 2AFF95652CF33D9F00CBFB12 /* TagsHeaderView.swift in Sources */, + 38C2A8092EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */, + 3880EF6F2EA0CD7100D88608 /* RelatedTagViewModel.swift in Sources */, + 3889A28F2E79D8860030F7CA /* NotificationRepositoryImpl.swift in Sources */, + 38B65E7A2E72A29F00DF6919 /* OnboardingNumberingView.swift in Sources */, + 3889A28C2E79D86B0030F7CA /* NotificationRepository.swift in Sources */, + 38AE853F2EDFFC3600029E4C /* UploadUserImageUseCaseImpl.swift in Sources */, 38B543EC2D461B1A00DDF2C5 /* LocationManagerConfigruation.swift in Sources */, 38A5D1552C8CB12300B68363 /* UIImage+SOOUM.swift in Sources */, - 385602B72D2FB18400118530 /* NotiPlaceholderViewCell.swift in Sources */, - 3878D0542CFFC6C100F9522F /* ProfileResponse.swift in Sources */, + 385602B72D2FB18400118530 /* NotificationPlaceholderViewCell.swift in Sources */, + 389E59E12EDEEFA500D0946D /* UpdateTagFavoriteUseCase.swift in Sources */, + 383EC6152E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */, + 38D8F5592EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */, + 387B738A2EED71510055E384 /* GAEvent+SOOUM.swift in Sources */, + 38C9AF302E96A49F00B401C0 /* WriteCardTag.swift in Sources */, + 38AE85312EDFFA9500029E4C /* UpdateCardLikeUseCaseImpl.swift in Sources */, + 389E59D52EDEEE6B00D0946D /* UpdateUserInfoUseCase.swift in Sources */, + 3889A27D2E79C56E0030F7CA /* ToeknResponse.swift in Sources */, 3878D06C2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */, + 3803B9262ECF530C009D14B9 /* TagCollectPlaceholderViewCell.swift in Sources */, + 38AE851E2EDFF84700029E4C /* FetchNoticeUseCaseImpl.swift in Sources */, 38FD4DAF2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift in Sources */, 3802BDAD2D0AC1FB001256EA /* UIImage.swift in Sources */, - 38F70E662D19161800B33C9D /* MainHomeLatestViewReactor.swift in Sources */, - 381DEA8D2CD4BE4A009F1FE9 /* Card.swift in Sources */, + 3889A2622E79BB5B0030F7CA /* UserRepositoryImpl.swift in Sources */, + 3889A2842E79D7D40030F7CA /* AuthRepositoryImpl.swift in Sources */, + 383EC6202E7A564600EC2D1E /* AppAssembler.swift in Sources */, + 38B35D092EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */, 384972A42CA54DC10012FCA1 /* UIImgeView.swift in Sources */, 38D6F1812CC2413400E11530 /* WriteCardTextView.swift in Sources */, 3816E2382D3BEE7E004CC196 /* TermsOfServiceCellView.swift in Sources */, + 38AE77DE2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */, + 38C2A7E42EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */, 3878F4722CA3F03400AA46A2 /* SOMCard.swift in Sources */, 3803CF862D017DC700FD90DB /* EnterMemberTransferViewReactor.swift in Sources */, 38B6AAE32CA4787200CE6DB6 /* MainTabBarReactor.swift in Sources */, - 38B8A5852CAE9CC4000AFE83 /* MainHomeViewCell.swift in Sources */, + 38899E592E7936DD0030F7CA /* SooumStyle_V2.swift in Sources */, + 38AE85272EDFF95500029E4C /* LocationUseCaseImpl.swift in Sources */, + 380F42342E884FDC009AC59E /* CardRepositoryImpl.swift in Sources */, + 38FCF41C2EA00625003AC3D8 /* UITtextView.swift in Sources */, + 389E59AC2EDEE74B00D0946D /* UploadUserImageUseCase.swift in Sources */, + 38B8A5852CAE9CC4000AFE83 /* HomeViewCell.swift in Sources */, 3878D0642CFFD66700F9522F /* FollowViewController.swift in Sources */, 38026E402CA2B45A0045E1CE /* LocationManager.swift in Sources */, 381A1D782CC3DA99005FDB8E /* SOMTagsLayout.swift in Sources */, @@ -2825,267 +3511,401 @@ buildActionMask = 2147483647; files = ( 38FD4DAE2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift in Sources */, - 2A44A4342CAC21A500DC463E /* SignUpResponse.swift in Sources */, 385620F62CA19EA900E0AB5A /* Alamofire_constants.swift in Sources */, 38F88EBA2D2C1CB8002AD7A8 /* Info.swift in Sources */, - 3834FADD2D11C5AC00C9108D /* SimpleCache.swift in Sources */, - 38D563842D1719B1006265AA /* SOMSwipeTabBarItem.swift in Sources */, + 38D563842D1719B1006265AA /* SOMStickyTabBarItem.swift in Sources */, 2A5BB7BE2CDB870000E1C799 /* OnboardingGuideMessageView.swift in Sources */, + 3880EF812EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */, 3836ACB42C8F045300A3C566 /* Typography.swift in Sources */, - 2AFD05632D00A1E1007C84AD /* TagDetailCardResponse.swift in Sources */, - 2A048E842C9BE01300FFD485 /* SOMLocationFilterCollectionViewCell.swift in Sources */, 38AE565C2D048B4800CAA431 /* SOMDialogViewController+Show.swift in Sources */, + 38E928CA2EB7402200B3F00B /* WrittenTag.swift in Sources */, 385E65A32CBE56D00032E120 /* Coordinate.swift in Sources */, + 38C9AF3D2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */, 3878FE0D2D0365C800D8955C /* SOMNavigationBar+Rx.swift in Sources */, - 2AE6B1632CBFB7FB00FA5C3C /* UploadCardBottomSheetViewController.swift in Sources */, - 38FD4DB42D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */, - 3878D04E2CFFC5F300F9522F /* ProfileRequest.swift in Sources */, + 38FD4DB42D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */, + 383EC6192E7A547900EC2D1E /* BaseAssembler.swift in Sources */, + 3803B91B2ECF3944009D14B9 /* PopularTagHeaderView.swift in Sources */, 3878D0722CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */, - 38121E292CA6A52400602499 /* UIRefreshControl.swift in Sources */, + 3894EDE32ED4B2BB0024213E /* FavoriteTagsViewModel.swift in Sources */, 388371F92C8C8EB1004212EB /* SooumStyle.swift in Sources */, + 38F376122ECB78A600E4A41D /* TagCardInfoResponse.swift in Sources */, + 38E928CE2EB7409100B3F00B /* WrittenTags.swift in Sources */, 38389B9C2CCCF98B006728AF /* AuthRequest.swift in Sources */, - 2A5ABA352D464E0B00BF6C9B /* ConfigureRequest.swift in Sources */, 38D6F17C2CC2406700E11530 /* WriteCardViewController.swift in Sources */, + 38899E8F2E7951200030F7CA /* KeyInfoResponse.swift in Sources */, + 386867A82E9E932B00171A5E /* WriteCardSelectImageView+Rx.swift in Sources */, 3878F4772CA3F08300AA46A2 /* UIView.swift in Sources */, - 388698512D191F2100008600 /* MainHomeDistanceViewController.swift in Sources */, + 3803B91E2ECF3A75009D14B9 /* FavoriteTagHeaderView.swift in Sources */, + 385C01B22E8E8DD8003C7894 /* SOMPageView.swift in Sources */, 3830FFA62CEC6E3100ABA9FD /* Kingfisher.swift in Sources */, - 3878D0672CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */, + 381B83E62EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */, + 389E59D02EDEEDD500D0946D /* FetchFollowUseCase.swift in Sources */, + 389E59D82EDEEEC500D0946D /* UpdateNotifyUseCase.swift in Sources */, 381701782CD88854005FC220 /* LogginMonitor.swift in Sources */, 3816E23A2D3BF402004CC196 /* TermsOfServiceCellView+Rx.swift in Sources */, 2A649ECF2CAE8970002D8284 /* SOMDialogViewController.swift in Sources */, 3803CF822D017DB800FD90DB /* EnterMemberTransferViewController.swift in Sources */, 2AE6B14C2CBC160C00FA5C3C /* ReportViewReactor.swift in Sources */, 388009912CABF855002A9209 /* SOMTagModel.swift in Sources */, - 38FD4DAB2D032CF000BF5FF1 /* AnnouncementResponse.swift in Sources */, + 3889A2562E79BA160030F7CA /* UserRemoteDataSourceImpl.swift in Sources */, + 385C01B52E8EA1B7003C7894 /* SOMPageViewsDelegate.swift in Sources */, + 389E59C42EDEEC7B00D0946D /* CardImageUseCase.swift in Sources */, + 3889A24E2E79AEB30030F7CA /* AppVersionUseCaseImpl.swift in Sources */, 388FCAD02CFAC2BF0012C4D6 /* Notification.swift in Sources */, - 38608B302CB5195D0066BB40 /* Card.swift in Sources */, + 381B83DF2EBC72B400C84015 /* FollowInfo.swift in Sources */, 388C96362CCE41700061C598 /* AuthInfo.swift in Sources */, - 2AFF95742CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift in Sources */, - 2AFF95642CF33D9F00CBFB12 /* TagsHeaderView.swift in Sources */, - 2AE6B15A2CBEAEC000FA5C3C /* ReportRequest.swift in Sources */, + 38C2A7F72EC08FF600B941A2 /* BlockUserInfo.swift in Sources */, + 38F339932EE328750066A5F7 /* WriteCardGuideView.swift in Sources */, 388DA0FB2C8F521000A9DD56 /* FontContainer.swift in Sources */, - 38572CD82D2230C900B07C69 /* NotificationAllowResponse.swift in Sources */, 38B543DF2D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */, - 38D5637E2D17152F006265AA /* SOMSwipeTabBarDelegate.swift in Sources */, - 2AE6B17B2CBFE9ED00FA5C3C /* UploadCardSettingTableViewCell.swift in Sources */, - 38F70E5B2D1905D000B33C9D /* MainHomeTabBarController.swift in Sources */, + 38D5637E2D17152F006265AA /* SOMStickyTabBarDelegate.swift in Sources */, + 38787B762ED1E5B3004BBAA7 /* SearchTermViewCell.swift in Sources */, + 381854992E992E9900424D71 /* WriteCardSelectImageView.swift in Sources */, 385009C22D363525007175A1 /* FilterNil.swift in Sources */, + 386E966F2E9A53D6005E047D /* SelectOptionsView.swift in Sources */, 38B6AADB2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift in Sources */, + 383EC61C2E7A548E00EC2D1E /* BaseDIContainer.swift in Sources */, + 389E59DE2EDEEF7C00D0946D /* TransferAccountUseCase.swift in Sources */, + 38AE85342EDFFAC400029E4C /* UpdateFollowUseCaseImpl.swift in Sources */, 2A5BB7D12CDC7ADC00E1C799 /* OnboardingViewController.swift in Sources */, 3887D0392CC5504500FB52E1 /* UITextField+Typography.swift in Sources */, 3803CF7A2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */, - 38C2D4172CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */, + 38C2D4172CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */, 38E7FBEF2D3CF6BB00A359CD /* SOMDialogAction.swift in Sources */, - 38AA66262D3AC3F500B3F6B2 /* DialogMessageView.swift in Sources */, 38AA00022CAD1BCC002C5F1E /* LikeAndCommentView.swift in Sources */, - 2AE6B1542CBCC34B00FA5C3C /* ReportReasonView.swift in Sources */, + 38C2A7D92EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */, + 389E59AE2EDEE8BD00D0946D /* FetchCardUseCase.swift in Sources */, 38D5CE0B2CBCE8CA0054AB9A /* SimpleDefaults.swift in Sources */, + 38F1614A2ECDAD34003BADB6 /* FavoriteTagViewCell.swift in Sources */, 38E9CE102D376E0E00E85A2D /* PushTokenSet.swift in Sources */, - 2AFD05602D009FA1007C84AD /* TagDetailViewrReactor.swift in Sources */, + 386867A52E9E378200171A5E /* Array.swift in Sources */, + 38F3760E2ECB779E00E4A41D /* FavoriteTagInfoResponse.swift in Sources */, + 38AE852A2EDFF9CC00029E4C /* ReportCardUseCaseImpl.swift in Sources */, 385053582C92DD2300C80B02 /* SOMTabBarController.swift in Sources */, - 381A1D6A2CC398B3005FDB8E /* WriteTagTextFieldDelegate.swift in Sources */, - 38F70E622D19113E00B33C9D /* MainHomeLatestViewController.swift in Sources */, - 38F70E6F2D191DFB00B33C9D /* MainHomePopularViewReactor.swift in Sources */, + 38899E6E2E79400C0030F7CA /* ImageUrlInfoResponse.swift in Sources */, + 3889A2782E79C29F0030F7CA /* NotificationRemoteDataSoruceImpl.swift in Sources */, + 38C2A7ED2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */, + 38FEBE542E865121002916A8 /* FollowNotificationInfoResponse.swift in Sources */, + 38E928D42EB7624300B3F00B /* FloatingButton.swift in Sources */, + 38899E9D2E7954D90030F7CA /* DeletedNotificationInfoResponse.swift in Sources */, 3878D0792CFFE1E800F9522F /* ResignViewController.swift in Sources */, - 38F720B12CD4F15900DF32B5 /* distanceCardResponse.swift in Sources */, - 38816DA22D004DED00EB87D6 /* UpdateProfileView.swift in Sources */, + 38EBA90E2EB39920008B28F4 /* PostingPermission.swift in Sources */, + 38899E8D2E794E690030F7CA /* AppVersionStatusResponse.swift in Sources */, + 38A721952E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */, + 38C9AF2E2E96A3E500B401C0 /* WriteCardTagModel.swift in Sources */, + 380F422A2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */, 38FDC2B62C9E746B00C094C2 /* BaseViewController.swift in Sources */, - 2A980BA42D803EEA007DFA45 /* SOMEvent.swift in Sources */, - 2AFD05492CFF7687007C84AD /* RecommendTagsResponse.swift in Sources */, - 2AE6B17F2CBFEA5200FA5C3C /* ToggleView.swift in Sources */, - 38601E1B2D3139D000A465A9 /* RecommendTagView.swift in Sources */, + 3803B9242ECF52CE009D14B9 /* TagCollectCardViewCell.swift in Sources */, + 38B65E7D2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */, + 38E928D92EB7727400B3F00B /* SOMBottomToastView.swift in Sources */, + 389E59A92EDEE6F600D0946D /* ValidateNicknameUseCase.swift in Sources */, + 38D2FBCF2E81B52F006DD739 /* SOMSwipableTabBar.swift in Sources */, 388009942CABFAAA002A9209 /* SOMTags.swift in Sources */, + 38C2A80B2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */, 3862C0DF2C9EB6670023C046 /* UIViewController+PushAndPop.swift in Sources */, - 38F720B52CD4F15900DF32B5 /* PopularCardResponse.swift in Sources */, 38D6F1832CC243DB00E11530 /* UITextView+Typography.swift in Sources */, - 38D6F1862CC24C4F00E11530 /* WriteCardTextViewDelegate.swift in Sources */, + 38FD56242EC9FAA400EC6106 /* String.swift in Sources */, 382D5CF62CFE9B8600BFA23E /* ProfileViewController.swift in Sources */, 38773E7C2CB3ACB2004815CD /* SOMRefreshControl.swift in Sources */, 3817016E2CD882C2005FC220 /* TimeoutInterceptor.swift in Sources */, + 389E59D22EDEEE4100D0946D /* UpdateFollowUseCase.swift in Sources */, + 383EC6112E7A4F6B00EC2D1E /* AuthLocalDataSource.swift in Sources */, 388A2D302D00D6A100E2F2F0 /* FollowViewReactor.swift in Sources */, + 38787B812ED1EB21004BBAA7 /* TagCollectCardsView.swift in Sources */, 389EF81E2D2F469B00E053AE /* CocoaLumberjack.swift in Sources */, + 38C9AF392E96AB9100B401C0 /* WriteCardTagFooter.swift in Sources */, + 385C01B72E8EA1EF003C7894 /* SOMPageViews.swift in Sources */, + 385C01AF2E8E8C6F003C7894 /* SOMPageModel.swift in Sources */, 3880098E2CABF4C2002A9209 /* SOMTag.swift in Sources */, - 2A5BB7E32CDCD97300E1C799 /* JoinRequest.swift in Sources */, + 389E59CD2EDEED6500D0946D /* FetchUserInfoUseCase.swift in Sources */, + 38C2A8012EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */, 38C2D4202CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */, - 2A44A4372CAC227300DC463E /* BaseAuthResponse.swift in Sources */, 38CC49822CDE3854007A0145 /* SOMPresentationController.swift in Sources */, + 38FEBE622E8661F4002916A8 /* NoticeInfo.swift in Sources */, 3802BDAC2D0AC1FB001256EA /* UIImage.swift in Sources */, 389EF81A2D2F454600E053AE /* Log+Extract.swift in Sources */, - 38C2D4142CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */, + 38899EAE2E79A09B0030F7CA /* UserRequest.swift in Sources */, + 3889A2712E79C03B0030F7CA /* AuthRepository.swift in Sources */, 3803CF882D01914200FD90DB /* ResignViewReactor.swift in Sources */, + 38AE77D72E7459F400B6FD13 /* OnboardingCompletedViewReactor.swift in Sources */, 38816D9E2D004A5E00EB87D6 /* UpdateProfileViewController.swift in Sources */, 38D8E2912CCD232B00CE2E0A /* AuthManager.swift in Sources */, + 3889A2862E79D8090030F7CA /* AuthUseCase.swift in Sources */, 3886985F2D1984D600008600 /* NotificationViewReactor.swift in Sources */, - 2AFD05692D03264C007C84AD /* AddFavoriteTagResponse.swift in Sources */, + 389E59CA2EDEED2B00D0946D /* FetchTagUseCase.swift in Sources */, + 380F42312E884FBC009AC59E /* CardRepository.swift in Sources */, + 387FA11D2E88DDC1004DF7CE /* HomeViewReactor.swift in Sources */, 3800575C2D9C12CB00E58A19 /* DefinedError.swift in Sources */, + 38EC8D032ED44669009C2857 /* TagSearchCollectViewReactor.swift in Sources */, + 38899E932E79518F0030F7CA /* CommonNotificationInfo.swift in Sources */, 3893B6D12D36739500F2004C /* CompositeManager.swift in Sources */, 3836ACB72C8F04CD00A3C566 /* UILabel+Observer.swift in Sources */, 3816C0602CCDE35300C8688C /* ErrorInterceptor.swift in Sources */, - 3803CF7D2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */, - 2AFF955A2CF3227900CBFB12 /* TagSearchTextFieldView.swift in Sources */, - 2A44A42D2CAC14C800DC463E /* SignInResponse.swift in Sources */, - 38BE72172CC696E9002662DD /* WriteTagTextField+Rx.swift in Sources */, - 38572CDB2D22464F00B07C69 /* PungTimeView.swift in Sources */, + 38899E872E794CEE0030F7CA /* NetworkManager_FCM.swift in Sources */, + 3879B4B92EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */, + 38AE853A2EDFFBBF00029E4C /* UpdateTagFavoriteUseCaseImpl.swift in Sources */, + 38AE85462EDFFCF900029E4C /* ValidateUserUseCaseImpl.swift in Sources */, + 38CA91F32EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */, + 38E928D12EB75FA300B3F00B /* PungView.swift in Sources */, + 38E928C72EB73FF800B3F00B /* WrittenTagModel.swift in Sources */, 381A1D772CC3DA99005FDB8E /* SOMTagsLayout.swift in Sources */, + 38787B8B2ED22A29004BBAA7 /* SearchTermPlaceholderViewCell.swift in Sources */, + 38EBA9122EB399A1008B28F4 /* PostingPermissionResponse.swift in Sources */, 38CC49882CDE3972007A0145 /* SOMPresentationController+Show.swift in Sources */, 38B6AACD2CA410D800CE6DB6 /* MainTabBarController.swift in Sources */, - 382E153F2D15AF4F0097B09C /* CommentHistoryInNotiResponse.swift in Sources */, + 381854A92E99574100424D71 /* SelectTypographyView.swift in Sources */, 3878D0632CFFD66700F9522F /* FollowViewController.swift in Sources */, 38F88EBE2D2C1E22002AD7A8 /* Version.swift in Sources */, - 381A1D652CC38E7D005FDB8E /* WriteTagTextField.swift in Sources */, + 38787B892ED22324004BBAA7 /* RxSwift+Unretained.swift in Sources */, 38B543E22D46179500DDF2C5 /* AuthManagerConfiguration.swift in Sources */, 38B8A58B2CAEA79A000AFE83 /* DetailViewFooter.swift in Sources */, + 38AE85192EDF437400029E4C /* FetchCardUseCaseImpl.swift in Sources */, 38B543EE2D46506300DDF2C5 /* ManagerType.swift in Sources */, + 38C2A8052EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */, 2A5BB7E02CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */, 388371FC2C8C8F11004212EB /* UIColor+SOOUM.swift in Sources */, + 3880EF7E2EA0DA7400D88608 /* WritrCardTextViewDelegate.swift in Sources */, 381A1D742CC3D799005FDB8E /* SOMTagsDelegate.swift in Sources */, - 2AE6B1782CBFE49D00FA5C3C /* SelectFontTableViewCell.swift in Sources */, + 3889A26F2E79BE9F0030F7CA /* AuthRemoteDataSourceImpl.swift in Sources */, + 3880EF7A2EA0D17E00D88608 /* RelatedTagsView+Rx.swift in Sources */, 3850719A2CA295A800A7905A /* LaunchScreenViewController.swift in Sources */, + 3874B5602ECB25D1004CC22A /* SettingsLocalDataSource.swift in Sources */, 38D055C32CD862FE00E75590 /* SOMActivityIndicatorView.swift in Sources */, + 383EC6242E7A56CE00EC2D1E /* AppDIContainer.swift in Sources */, 38CE94BF2C904D460004B238 /* SOMNavigationBar.swift in Sources */, - 2A45B36F2CE4C5510071026A /* RegisterUserResponse.swift in Sources */, + 38899E8A2E794D620030F7CA /* NetworkManager_Version.swift in Sources */, + 3803B92A2ECF5580009D14B9 /* TagCollectViewController.swift in Sources */, 388698582D1982DE00008600 /* NotificationViewController.swift in Sources */, + 389E59C62EDEECBF00D0946D /* WriteCardUseCase.swift in Sources */, + 38C2A7E22EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */, 38121E342CA6DA4000602499 /* Date.swift in Sources */, - 38F720A52CD4F15900DF32B5 /* CardSummaryResponse.swift in Sources */, - 3878D0752CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */, + 3878D0752CFFE01500F9522F /* SettingVersionCellView.swift in Sources */, + 38AE85362EDFFAF900029E4C /* UpdateNotifyUseCaseImpl.swift in Sources */, + 38C2A7EA2EC074A200B941A2 /* SettingsRepository.swift in Sources */, + 38A721992E73EA6F0071E1D8 /* SOMBottomFloatView.swift in Sources */, 3893B6CE2D36728000F2004C /* ManagerProvider.swift in Sources */, - 2AFF95682CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift in Sources */, 3880097B2CABEE3D002A9209 /* DetailViewController.swift in Sources */, + 38D522682E742F610044911B /* SOMLoadingIndicatorView.swift in Sources */, + 389E59B42EDEEA8200D0946D /* FetchCardDetailUseCase.swift in Sources */, + 389E59E52EDEF03000D0946D /* LocationUseCase.swift in Sources */, + 38C2A7FD2EC0925C00B941A2 /* WithdrawType.swift in Sources */, + 38C2A7DB2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */, 38D869632CF821F900BF87DA /* UserDefaults.swift in Sources */, + 380F422E2E884F3D009AC59E /* CardRemoteDataSourceImpl.swift in Sources */, + 38F339902EE31C870066A5F7 /* IsCardDeletedResponse.swift in Sources */, + 38899E662E7939600030F7CA /* CheckAvailableResponse.swift in Sources */, + 38C9AF342E96A82900B401C0 /* WriteCardTags.swift in Sources */, 385620F22CA19D2D00E0AB5A /* Alamofire_Request.swift in Sources */, + 38AE77DC2E745FFF00B6FD13 /* EnterMemberTransferTextFieldView.swift in Sources */, 38389B9F2CCCFB7D006728AF /* AuthKeyChain.swift in Sources */, + 3889A2442E79AD7D0030F7CA /* AppVersionRepository.swift in Sources */, + 38C9AF142E9665C900B401C0 /* TagInfoResponse.swift in Sources */, 3878B8622D0DC8BD00B3B128 /* UIViewController+Toast.swift in Sources */, - 2AFD05522D007F2F007C84AD /* TagSearchViewReactor.swift in Sources */, + 3803B92E2ECF5589009D14B9 /* TagCollectViewReactor.swift in Sources */, + 3818549F2E99340600424D71 /* WriteCardUserImageCell.swift in Sources */, + 381B83E22EBC736800C84015 /* ProfileInfoResponse.swift in Sources */, 387FBAF02C8702C100A5E139 /* AppDelegate.swift in Sources */, + 380F42272E884B80009AC59E /* BaseCardInfo.swift in Sources */, + 38F161482ECDA8F0003BADB6 /* FavoriteTagPlaceholderViewCell.swift in Sources */, + 389E59BA2EDEEB8300D0946D /* BlockUserUseCase.swift in Sources */, + 3889A24A2E79AE960030F7CA /* AppVersionUseCase.swift in Sources */, 2A980BA02D803EB1007DFA45 /* AnalyticsEventProtocol.swift in Sources */, 38B8A58E2CAEB61A000AFE83 /* DetailViewFooterCell.swift in Sources */, 2A5BB7C92CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift in Sources */, - 3803CF6C2D0156FC00FD90DB /* SettingsRequest.swift in Sources */, 2AE6B1492CBC15BF00FA5C3C /* ReportViewController.swift in Sources */, 3887D0332CC5335200FB52E1 /* WriteCardViewReactor.swift in Sources */, 38D6F1802CC2413400E11530 /* WriteCardTextView.swift in Sources */, - 2A980BA82D803F04007DFA45 /* GAManager.swift in Sources */, - 387D852C2D08320A005D9D22 /* SOMCardModel.swift in Sources */, - 2AFD055D2D009513007C84AD /* TagDetailNavigationBarView.swift in Sources */, + 381E7C192ECCB1A700E80249 /* TagViewController.swift in Sources */, + 38899EA92E799C630030F7CA /* VersionRequest.swift in Sources */, + 38D8FE8E2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */, + 38C2A7DE2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */, + 38B21C092ECEF7D400990F49 /* PopularTagViewCell.swift in Sources */, + 38FEBE5C2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */, + 2A980BA82D803F04007DFA45 /* GAHelper.swift in Sources */, + 38E928DD2EB7921200B3F00B /* UIRefreshControl.swift in Sources */, + 38FEBE652E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */, + 381B83DC2EBC707A00C84015 /* ProfileInfo.swift in Sources */, + 38D8F55F2EC4F38700DED428 /* SimpleReachability.swift in Sources */, + 38899E712E79402C0030F7CA /* ImageUrlInfo.swift in Sources */, + 38AE850D2EDF41B700029E4C /* CardImageUseCaseImpl.swift in Sources */, + 381E7C242ECCC63E00E80249 /* SearchViewButton.swift in Sources */, + 38B21C032ECEF46200990F49 /* FavoriteTagViewModel.swift in Sources */, + 3880EF772EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */, + 381B83F22EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */, + 389E59B22EDEEA3A00D0946D /* FetchNoticeUseCase.swift in Sources */, + 3889A25D2E79BB340030F7CA /* UserRepository.swift in Sources */, 385620EF2CA19C9500E0AB5A /* NetworkManager.swift in Sources */, + 38899E7D2E794B420030F7CA /* SignUpResponse.swift in Sources */, 3803CF692D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */, - 38F720B82CD4F16500DF32B5 /* CardProtocol.swift in Sources */, - 38F131882CC7B7E0000D0475 /* RelatedTagResponse.swift in Sources */, - 38405CCB2CC611FD00612D1E /* BaseEmptyAndHeader.swift in Sources */, + 38D2FBD22E81B9B7006DD739 /* SOMSwipableTabBarDelegate.swift in Sources */, + 38CA91F62EBDD342002C261A /* ProfileViewHeader.swift in Sources */, + 3803B9322ECF5F21009D14B9 /* SearchTextFieldView.swift in Sources */, + 38D2FBC22E812354006DD739 /* HomeViewController.swift in Sources */, 381701712CD88374005FC220 /* CompositeInterceptor.swift in Sources */, - 38B6AAD82CA424AE00CE6DB6 /* MoveTopButtonView.swift in Sources */, + 38787B7F2ED1E8F4004BBAA7 /* TagSearchViewReactor.swift in Sources */, + 38F161442ECDA858003BADB6 /* SearchViewButton+Rx.swift in Sources */, + 38AE85102EDF420700029E4C /* DeleteCardUseCaseImpl.swift in Sources */, 38FDC2C72C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */, - 3803CF702D0159A500FD90DB /* SettingsResponse.swift in Sources */, - 38D5637B2D16D72D006265AA /* SOMSwipeTabBar.swift in Sources */, - 382E15422D15BA490097B09C /* NotificationWithReportViewCell.swift in Sources */, - 2AE6B1712CBFD04900FA5C3C /* SelectDefaultImageTableViewCell.swift in Sources */, - 2AE6B1662CBFB81000FA5C3C /* UploadCardBottomSheetViewReactor.swift in Sources */, + 381B83E92EBC75D700C84015 /* ProfileCardInfo.swift in Sources */, + 38D5637B2D16D72D006265AA /* SOMStickyTabBar.swift in Sources */, 38FD4DB12D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */, - 2ACBD41D2CCAB3490057C013 /* PresignedStorageResponse.swift in Sources */, - 2ACBD4172CC963390057C013 /* DefaultCardImageResponse.swift in Sources */, - 38AA00062CAD96E3002C5F1E /* MoreBottomSheetViewController.swift in Sources */, + 3889A2812E79D0250030F7CA /* Token.swift in Sources */, + 3879B4B62EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */, + 38C9AF212E9669F600B401C0 /* TagRepository.swift in Sources */, + 38B21C0B2ECEFFAA00990F49 /* FavoriteTagsView.swift in Sources */, + 3887176E2E7BDBAE00C6143B /* NicknameResponse.swift in Sources */, + 38E928C32EB73D6B00B3F00B /* MemberInfoView.swift in Sources */, 2A980B9D2D803E9D007DFA45 /* FirebaseLoggable.swift in Sources */, + 3880EF712EA0CDA100D88608 /* RelatedTagView.swift in Sources */, 382E153A2D15A67A0097B09C /* NotificationViewCell.swift in Sources */, 3886939F2CF77FA7005F9EF3 /* UIApplication+Top.swift in Sources */, - 3878D05F2CFFD45100F9522F /* FollowerResponse.swift in Sources */, - 3878D0972CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */, - 2A44A42A2CAC09AE00DC463E /* RSAKeyResponse.swift in Sources */, 2A5BB7B92CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift in Sources */, - 2AFD05462CFF75DD007C84AD /* FavoriteTagsResponse.swift in Sources */, - 2AFD054F2CFF79D8007C84AD /* TagsViewReactor.swift in Sources */, - 2ACBD4132CC944FB0057C013 /* UploadRequest.swift in Sources */, - 388698652D1998DB00008600 /* NotificationTabBarReactor.swift in Sources */, + 38899E992E7954680030F7CA /* BlockedNotificationInfoResponse.swift in Sources */, + 38F3760B2ECB772A00E4A41D /* FavoriteTagInfo.swift in Sources */, + 3889A28A2E79D8220030F7CA /* AuthUseCaseImpl.swift in Sources */, + 38D8FE902EBE664C00F32D02 /* SOMNicknameTextField.swift in Sources */, + 389E59BE2EDEEBF300D0946D /* ReportCardUseCase.swift in Sources */, 388DA0FE2C8F526C00A9DD56 /* UIFont.swift in Sources */, - 38F720AD2CD4F15900DF32B5 /* DetailCardResponse.swift in Sources */, 38B543E52D4617CB00DDF2C5 /* PushManagerConfiguration.swift in Sources */, - 2AE6B1502CBCC2F600FA5C3C /* ReportTableViewCell.swift in Sources */, - 2AFF95552CF3222400CBFB12 /* TagsViewController.swift in Sources */, - 2AE6B1922CC1286D00FA5C3C /* SelectMyImageTableViewCell.swift in Sources */, - 2AE6B18F2CC121BB00FA5C3C /* BottomSheetSegmentTableViewCell.swift in Sources */, + 389E59B82EDEEAFC00D0946D /* UpdateCardLikeUseCase.swift in Sources */, + 389E59A62EDEE39500D0946D /* ValidateUserUseCase.swift in Sources */, + 3889A2472E79ADCE0030F7CA /* AppVersionRepositoryImpl.swift in Sources */, + 388D8ADF2E73E6190044BA79 /* SwiftEntryKit.swift in Sources */, + 38899E972E7953310030F7CA /* NotificationInfoResponse.swift in Sources */, + 38FEBE5E2E86612C002916A8 /* NoticeViewCell.swift in Sources */, 38F3D9302D06C2370049F575 /* SOMAnimationTransitioning.swift in Sources */, - 385602B62D2FB18400118530 /* NotiPlaceholderViewCell.swift in Sources */, + 38C9AF172E96693600B401C0 /* TagRemoteDataSource.swift in Sources */, + 38D4780B2EBBABF60041FF6C /* EntranceCardType.swift in Sources */, + 385602B62D2FB18400118530 /* NotificationPlaceholderViewCell.swift in Sources */, 38E9CE192D37FED000E85A2D /* AddingTokenInterceptor.swift in Sources */, + 380F42242E884AE5009AC59E /* CardRemoteDataSource.swift in Sources */, + 38EC8D002ED44661009C2857 /* TagSearchCollectViewController.swift in Sources */, 3802BDB82D0AF2F7001256EA /* PushManager+Rx.swift in Sources */, - 3878D05C2CFFD10D00F9522F /* FollowingResponse.swift in Sources */, - 38F70E652D19161800B33C9D /* MainHomeLatestViewReactor.swift in Sources */, + 381B83EB2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */, 38E9CE132D37711600E85A2D /* OnboardingViewReactor.swift in Sources */, 3836ACBA2C8F050D00A3C566 /* UILabel+Typography.swift in Sources */, 38CC49852CDE3885007A0145 /* SOMTransitioningDelegate.swift in Sources */, + 38AE853C2EDFFBF700029E4C /* UpdateUserInfoUseCaseImpl.swift in Sources */, 388A2D332D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift in Sources */, - 38C2D41A2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */, + 38E928BA2EB715C900B3F00B /* ReortType.swift in Sources */, + 3830880A2EDC7B8C00D99D88 /* SOMMessageBubbleView.swift in Sources */, 3887D0362CC5335D00FB52E1 /* WriteCardView.swift in Sources */, - 2A5BB7CD2CDBB7D100E1C799 /* ProfileImageSettingViewController.swift in Sources */, + 38787B7C2ED1E8B3004BBAA7 /* TagSearchViewController.swift in Sources */, + 2A5BB7CD2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift in Sources */, + 389E59C02EDEEC4900D0946D /* DeleteCardUseCase.swift in Sources */, + 38AE85152EDF42B700029E4C /* FetchCardDetailUseCaseImpl.swift in Sources */, 385053552C92DCF900C80B02 /* SOMTabBar.swift in Sources */, + 38787B792ED1E719004BBAA7 /* SearchTermsView.swift in Sources */, 387FBAF22C8702C100A5E139 /* SceneDelegate.swift in Sources */, - 2A048E7B2C9BDF5F00FFD485 /* SOMLocationFilter.swift in Sources */, 38121E312CA6C77500602499 /* Double.swift in Sources */, - 3878D0532CFFC6C100F9522F /* ProfileResponse.swift in Sources */, - 2AE6B1752CBFD59B00FA5C3C /* ImageCollectionViewCell.swift in Sources */, - 38601E1A2D3139BB00A465A9 /* TagInfoResponse.swift in Sources */, - 382E15362D15A6460097B09C /* NotificationTabBarController.swift in Sources */, - 2A34AFB52D144F08007BD7E7 /* EmptyTagDetailTableViewCell.swift in Sources */, - 3803CF742D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */, - 2AFF95702CF5E8DE00CBFB12 /* TagSearchViewController.swift in Sources */, + 38C9AF0E2E96602300B401C0 /* DefaultImagesResponse.swift in Sources */, + 3889A26C2E79BD450030F7CA /* AuthRemoteDataSource.swift in Sources */, + 38C2A7E82EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */, + 3889A2922E79D8F80030F7CA /* NotificationUseCase.swift in Sources */, + 38B21C0F2ECF0F1D00990F49 /* PopularTagsView.swift in Sources */, 384972A32CA54DC10012FCA1 /* UIImgeView.swift in Sources */, + 38899E6C2E793AFD0030F7CA /* CheckAvailable.swift in Sources */, + 38C2A8112EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */, 3802BDB12D0AE900001256EA /* PushManager.swift in Sources */, - 2A5BB7E72CDCDC3600E1C799 /* NicknameValidationResponse.swift in Sources */, 388372012C8C8FCF004212EB /* UIColor.swift in Sources */, - 3803CF772D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */, - 3816C05C2CCDDF3D00C8688C /* ReAuthenticationResponse.swift in Sources */, + 38C2A7F92EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */, + 38C9AF232E966A1B00B401C0 /* TagRepositoryImpl.swift in Sources */, + 38FCF41A2E9F88EA003AC3D8 /* WriteCardTags+Rx.swift in Sources */, + 389E59DB2EDEEF3600D0946D /* FetchBlockUserUseCase.swift in Sources */, + 3889A2742E79C1D80030F7CA /* NotificationRemoteDataSource.swift in Sources */, + 3818549C2E992F7D00424D71 /* WriteCardDefaultImageCell.swift in Sources */, 3878D07D2CFFE6E500F9522F /* IssueMemberTransferViewController.swift in Sources */, + 38AE85212EDFF88C00029E4C /* FetchTagUseCaseImpl.swift in Sources */, + 386712C52E97734B00541389 /* UITextField.swift in Sources */, + 38C9AF0C2E965EFB00B401C0 /* DefaultImages.swift in Sources */, 381DEA8B2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */, - 3878D0562CFFCBDA00F9522F /* CommentHistoryResponse.swift in Sources */, + 38AE850A2EDF414400029E4C /* BlockUserUseCaseImpl.swift in Sources */, 3878D08C2CFFF0BF00F9522F /* AnnouncementViewControler.swift in Sources */, - 2ACBD41A2CCA03790057C013 /* ImageURLWithName.swift in Sources */, - 38B8BE472D1ECBDA0084569C /* NotificationInfo.swift in Sources */, + 3874B5632ECB2613004CC22A /* SettingsLocalDataSourceImpl.swift in Sources */, + 38B8BE472D1ECBDA0084569C /* PushNotificationInfo.swift in Sources */, 3878F4742CA3F06C00AA46A2 /* UIStackView.swift in Sources */, - 2A5BB7D92CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */, 38B543E82D4617EA00DDF2C5 /* NetworkManagerConfiguration.swift in Sources */, - 38F70E6C2D191D9A00B33C9D /* MainHomePopularViewController.swift in Sources */, 3878D0902CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */, + 38C2A80F2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */, + 38C9AF122E96656600B401C0 /* TagInfo.swift in Sources */, 2AFD054C2CFF76CB007C84AD /* TagRequest.swift in Sources */, + 38AE852D2EDFFA3C00029E4C /* TransferAccountUseCaseImpl.swift in Sources */, + 38AE85422EDFFCA600029E4C /* ValidateNicknameUseCaseImpl.swift in Sources */, 38B6AADF2CA4777200CE6DB6 /* UIViewController+Rx.swift in Sources */, 389681102CAFBD6A00FFD89F /* DetailViewReactor.swift in Sources */, + 38E928B72EB711E200B3F00B /* DetailCardInfo.swift in Sources */, 388DA1042C8F545E00A9DD56 /* Typography+SOOUM.swift in Sources */, - 38C2D4112CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */, - 2AFF955D2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift in Sources */, + 38899E842E794C360030F7CA /* LoginResponse.swift in Sources */, 385053522C92DBE200C80B02 /* SOMTabBarItem.swift in Sources */, + 38D478082EBBAA0B0041FF6C /* WriteCardResponse.swift in Sources */, + 38E928BF2EB72D3D00B3F00B /* DetailCardInfoResponse.swift in Sources */, 38A5D1542C8CB11E00B68363 /* UIImage+SOOUM.swift in Sources */, - 38F70E5E2D190FBD00B33C9D /* MainHomeTabBarReactor.swift in Sources */, 38601E182D31399400A465A9 /* CardRequest.swift in Sources */, - 2A5BB7FA2CE277AF00E1C799 /* ProfileImageSettingViewReactor.swift in Sources */, - 38F006AA2D395A7F001AC5F7 /* SuspensionResponse.swift in Sources */, + 38899EA32E799B260030F7CA /* AppVersionRemoteDataSource.swift in Sources */, + 2A5BB7FA2CE277AF00E1C799 /* OnboardingProfileImageSettingViewReactor.swift in Sources */, + 38899EA72E799BD60030F7CA /* AppVersionRemoteDataSourceImpl.swift in Sources */, + 38AE77D52E74580000B6FD13 /* OnboardingCompletedViewController.swift in Sources */, 3878D06F2CFFDF9600F9522F /* SettingTextCellView.swift in Sources */, - 38B8A5842CAE9CC4000AFE83 /* MainHomeViewCell.swift in Sources */, - 2AFF95612CF33A3900CBFB12 /* FavoriteTagView.swift in Sources */, + 38AE85492EDFFD7E00029E4C /* WriteCardUseCaseImpl.swift in Sources */, + 38B8A5842CAE9CC4000AFE83 /* HomeViewCell.swift in Sources */, 3803CF852D017DC700FD90DB /* EnterMemberTransferViewReactor.swift in Sources */, + 386E966C2E9A51D9005E047D /* SelectOptionItem.swift in Sources */, 38D488CA2D0C557300F2D38D /* SOMButton.swift in Sources */, + 38D2FBCB2E81B0E5006DD739 /* SOMSwipableTabBarItem.swift in Sources */, + 3880EF742EA0CEEE00D88608 /* RelatedTagsView.swift in Sources */, + 381E7C1D2ECCB1AD00E80249 /* TagViewReactor.swift in Sources */, + 38AE85242EDFF90400029E4C /* FetchUserInfoUseCaseImpl.swift in Sources */, 3878F4712CA3F03400AA46A2 /* SOMCard.swift in Sources */, 3878D06B2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */, + 38C9AF1A2E96696C00B401C0 /* TagRemoteDataSourceImpl.swift in Sources */, + 3889A2962E79D9250030F7CA /* NotificationUseCaseImpl.swift in Sources */, 3878D0882CFFEF0F00F9522F /* TermsOfServiceTextCellView+Rx.swift in Sources */, - 2AE6B16D2CBFBC7600FA5C3C /* UploadCardBottomSheetSegmentView.swift in Sources */, - 38F720A72CD4F15900DF32B5 /* CommentCardResponse.swift in Sources */, + 38AE85122EDF424800029E4C /* FetchBlockUserUseCaseImpl.swift in Sources */, + 3889A2502E79B3260030F7CA /* UserRemoteDataSource.swift in Sources */, + 38AE851B2EDFF7E000029E4C /* FetchFollowUseCaseImpl.swift in Sources */, + 380F42212E87ECA3009AC59E /* CompositeNotificationInfo.swift in Sources */, + 38899E5F2E7937E50030F7CA /* NicknameValidateResponse.swift in Sources */, + 38C2A8082EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */, + 3880EF6E2EA0CD7100D88608 /* RelatedTagViewModel.swift in Sources */, + 3889A2902E79D8860030F7CA /* NotificationRepositoryImpl.swift in Sources */, 38B6AAE22CA4787200CE6DB6 /* MainTabBarReactor.swift in Sources */, + 38AE85402EDFFC3600029E4C /* UploadUserImageUseCaseImpl.swift in Sources */, + 38B65E792E72A29F00DF6919 /* OnboardingNumberingView.swift in Sources */, 2A5BB7D52CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift in Sources */, - 2AFF95782CF5F0B000CBFB12 /* TagPreviewCardView.swift in Sources */, + 3889A28D2E79D86B0030F7CA /* NotificationRepository.swift in Sources */, + 389E59E22EDEEFA500D0946D /* UpdateTagFavoriteUseCase.swift in Sources */, 38B543EB2D461B1A00DDF2C5 /* LocationManagerConfigruation.swift in Sources */, 38026E3F2CA2B45A0045E1CE /* LocationManager.swift in Sources */, - 388A2D2D2D00A45800E2F2F0 /* writtenCardResponse.swift in Sources */, + 387B73892EED71510055E384 /* GAEvent+SOOUM.swift in Sources */, + 383EC6162E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */, + 38AE85302EDFFA9500029E4C /* UpdateCardLikeUseCaseImpl.swift in Sources */, + 389E59D62EDEEE6B00D0946D /* UpdateUserInfoUseCase.swift in Sources */, 3878D0852CFFED7800F9522F /* TermsOfServiceTextCellView.swift in Sources */, + 38D8F5582EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */, + 3803B9272ECF530C009D14B9 /* TagCollectPlaceholderViewCell.swift in Sources */, + 38AE851F2EDFF84700029E4C /* FetchNoticeUseCaseImpl.swift in Sources */, + 38C9AF312E96A49F00B401C0 /* WriteCardTag.swift in Sources */, + 3889A27E2E79C56E0030F7CA /* ToeknResponse.swift in Sources */, 3878D0812CFFEC6900F9522F /* TermsOfServiceViewController.swift in Sources */, - 38F720B32CD4F15900DF32B5 /* LatestCardResponse.swift in Sources */, 2A032EFD2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift in Sources */, + 3889A2632E79BB5B0030F7CA /* UserRepositoryImpl.swift in Sources */, 38A627172CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift in Sources */, - 2AFD05552D0082DE007C84AD /* SearchTagsResponse.swift in Sources */, + 3889A2832E79D7D40030F7CA /* AuthRepositoryImpl.swift in Sources */, + 383EC6212E7A564600EC2D1E /* AppAssembler.swift in Sources */, + 38B35D082EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */, 38B8A5882CAEA5F9000AFE83 /* DetailViewCell.swift in Sources */, 389EF8172D2F450000E053AE /* Log.swift in Sources */, 3816E2372D3BEE7E004CC196 /* TermsOfServiceCellView.swift in Sources */, - 38738D4B2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift in Sources */, + 38C2A7E52EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */, + 38AE77DF2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */, 388698622D1986B100008600 /* NotificationRequest.swift in Sources */, - 38572CDE2D2254E800B07C69 /* PlaceholderViewCell.swift in Sources */, + 38572CDE2D2254E800B07C69 /* HomePlaceholderViewCell.swift in Sources */, + 38AE85282EDFF95500029E4C /* LocationUseCaseImpl.swift in Sources */, + 38899E582E7936DD0030F7CA /* SooumStyle_V2.swift in Sources */, + 389E59AB2EDEE74B00D0946D /* UploadUserImageUseCase.swift in Sources */, + 38FCF41D2EA00625003AC3D8 /* UITtextView.swift in Sources */, + 380F42332E884FDC009AC59E /* CardRepositoryImpl.swift in Sources */, 388009972CAC20EC002A9209 /* SOMTags+Rx.swift in Sources */, - 388698542D191F4B00008600 /* MainHomeDistanceViewReactor.swift in Sources */, - 38601E192D3139A500A465A9 /* TagDetailViewController.swift in Sources */, 3866577E2CEF3554009F7F60 /* UIButton+Rx.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3122,7 +3942,7 @@ CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources/SOOUM-Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1012000; + CURRENT_PROJECT_VERSION = 200000; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Develop/Info-dev.plist"; @@ -3145,18 +3965,19 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.12.0; + MARKETING_VERSION = 2.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D DEVELOP"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SOOUM_APP_ID = 99FRG743RX.com.sooum.dev; - SOOUM_CLARITY_ID = qrggvyniav; + SOOUM_CLARITY_ID = ukvzck1lyo; SOOUM_DISPLAY_NAME = "[D]SOOUM"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; - SOOUM_SERVER_ENDPOINT = "ec2-52-79-234-222.ap-northeast-2.compute.amazonaws.com:8080"; + SOOUM_SERVER_ENDPOINT = "test-core.sooum.org:555"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -3173,7 +3994,7 @@ CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources/SOOUM-Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1012000; + CURRENT_PROJECT_VERSION = 200000; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Develop/Info-dev.plist"; @@ -3196,18 +4017,19 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.12.0; + MARKETING_VERSION = 2.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D DEVELOP"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SOOUM_APP_ID = 99FRG743RX.com.sooum.dev; - SOOUM_CLARITY_ID = qrggvyniav; + SOOUM_CLARITY_ID = ukvzck1lyo; SOOUM_DISPLAY_NAME = "[D]SOOUM"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; - SOOUM_SERVER_ENDPOINT = "ec2-52-79-234-222.ap-northeast-2.compute.amazonaws.com:8080"; + SOOUM_SERVER_ENDPOINT = "test-core.sooum.org:555"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -3411,7 +4233,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources//SOOUM.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 104000; + CURRENT_PROJECT_VERSION = 200000; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Production/Info-prod.plist"; @@ -3433,17 +4255,18 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 2.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D PRODUCTION"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.prod; PRODUCT_NAME = "$(TARGET_NAME)"; SOOUM_APP_ID = 6740403078; - SOOUM_CLARITY_ID = qrghkiok4a; + SOOUM_CLARITY_ID = ukvz4wjnqm; SOOUM_DISPLAY_NAME = "숨"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; SOOUM_SERVER_ENDPOINT = api.sooum.org; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -3459,7 +4282,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources//SOOUM.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 104000; + CURRENT_PROJECT_VERSION = 200000; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Production/Info-prod.plist"; @@ -3481,17 +4304,18 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 2.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D PRODUCTION"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.prod; PRODUCT_NAME = "$(TARGET_NAME)"; SOOUM_APP_ID = 6740403078; - SOOUM_CLARITY_ID = qrghkiok4a; + SOOUM_CLARITY_ID = ukvz4wjnqm; SOOUM_DISPLAY_NAME = "숨"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; SOOUM_SERVER_ENDPOINT = api.sooum.org; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-Dev.xcscheme b/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-Dev.xcscheme index 080b4f60..79ef35ab 100644 --- a/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-Dev.xcscheme +++ b/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-Dev.xcscheme @@ -1,6 +1,6 @@ + + diff --git a/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-DevTests.xcscheme b/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-DevTests.xcscheme index 84f50d03..1bd3b513 100644 --- a/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-DevTests.xcscheme +++ b/SOOUM/SOOUM.xcodeproj/xcshareddata/xcschemes/SOOUM-DevTests.xcscheme @@ -1,6 +1,6 @@ - - - - IDEDidComputeMac32BitWarning - - - diff --git a/SOOUM/SOOUM/App/AppDelegate.swift b/SOOUM/SOOUM/App/AppDelegate.swift index 40ec65dc..d23f38a8 100644 --- a/SOOUM/SOOUM/App/AppDelegate.swift +++ b/SOOUM/SOOUM/App/AppDelegate.swift @@ -16,12 +16,13 @@ import FirebaseMessaging import RxSwift import CocoaLumberjack +import Kingfisher @main class AppDelegate: UIResponder, UIApplicationDelegate { - let provider: ManagerProviderType = ManagerProviderContainer() + let appDIContainer: AppDIContainerable = AppDIContainer() /// APNS 등록 완료 핸들러 var registerRemoteNotificationCompletion: ((Error?) -> Void)? @@ -45,8 +46,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Initalize token self.initializeTokenWhenFirstLaunch() - // Set managers - self.provider.initialize() + // Set Kinfisher caching limit + self.setupKingfisherCacheLimit() FirebaseApp.configure() // 파이어베이스 Meesaging 설정 @@ -83,7 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: UNUserNotificationCenterDelegate { - /// Foreground(앱 켜진 상태)에서도 알림 오는 설정 + /// Foreground(앱 켜진 상태)에서 알림 왔을 때, 설정 및 데이터 수신 func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -91,9 +92,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { // 계정 이관 성공 시 (런치 화면 > 온보딩 화면)으로 전환 let userInfo = notification.request.content.userInfo - self.setupOnboardingWhenTransferSuccessed(userInfo) + guard let infoDic = userInfo as? [String: Any] else { return } + + let info = PushNotificationInfo(infoDic) + if info.isTransfered { self.setupOnboarding() } - let options: UNNotificationPresentationOptions = [.sound, .list, .banner] + var options: UNNotificationPresentationOptions + if let isReAddedNotifications = userInfo["isReAddedNotifications"] as? Bool, isReAddedNotifications { + options = [.list] + } else { + options = [.sound, .list, .banner] + } completionHandler(options) } @@ -103,14 +112,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { + // 계정 이관 성공 알림일 경우 온보딩 화면, 아닐 경우 메인 홈 탭바 화면 전환 let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo - if let infoDic = userInfo as? [String: Any] { - - let info = NotificationInfo(infoDic) - // 계정 이관 성공 알림일 경우 (런치 화면 > 온보딩 화면), 아닐 경우 메인 홈 탭바 화면 전환 - self.provider.pushManager.setupRootViewController(info, terminated: info.isTransfered) + guard let infoDic = userInfo as? [String: Any] else { return } + + let info = PushNotificationInfo(infoDic) + if info.isTransfered { + self.setupOnboarding() + } else { + self.setupMainTabBar(info) } - + completionHandler() } @@ -121,7 +133,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate { fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { // 계정 이관 성공 시 (런치 화면 > 온보딩 화면)으로 전환 - self.setupOnboardingWhenTransferSuccessed(userInfo) + guard let infoDic = userInfo as? [String: Any] else { return } + + let info = PushNotificationInfo(infoDic) + if info.isTransfered { self.setupLaunchScreen(info) } completionHandler(.newData) } @@ -149,7 +164,8 @@ extension AppDelegate: MessagingDelegate { apns: deviceToken, fcm: Messaging.messaging().fcmToken ) - self.provider.networkManager.registerFCMToken(with: current, #function) + let provider = self.appDIContainer.rootContainer.resolve(ManagerProviderType.self) + provider.networkManager.registerFCMToken(with: current, #function) self.registerRemoteNotificationCompletion?(nil) } @@ -160,7 +176,8 @@ extension AppDelegate: MessagingDelegate { apns: messaging.apnsToken, fcm: fcmToken ) - self.provider.networkManager.registerFCMToken(with: current, #function) + let provider = self.appDIContainer.rootContainer.resolve(ManagerProviderType.self) + provider.networkManager.registerFCMToken(with: current, #function) } } @@ -184,13 +201,52 @@ extension AppDelegate { AuthKeyChain.shared.delete(.refreshToken) } - private func setupOnboardingWhenTransferSuccessed(_ userInfo: [AnyHashable: Any]?) { - guard let infoDic = userInfo as? [String: Any] else { return } + private func setupKingfisherCacheLimit() { + let cache = ImageCache.default - let info = NotificationInfo(infoDic) - if info.isTransfered { - - self.provider.pushManager.setupRootViewController(info, terminated: true) - } + /// 500MB + let diskLimit: UInt = 500 * 1024 * 1024 + cache.diskStorage.config.sizeLimit = diskLimit + /// 디스크 캐시는 일주일 제한 + cache.diskStorage.config.expiration = .days(7) + /// 100MB + let memoryLimit: Int = 100 * 1024 * 1024 + cache.memoryStorage.config.totalCostLimit = memoryLimit + } + + func setupOnboarding() { + + guard let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + let onboardingViewController = OnboardingViewController() + onboardingViewController.reactor = OnboardingViewReactor(dependencies: self.appDIContainer) + onboardingViewController.modalTransitionStyle = .crossDissolve + window.rootViewController = UINavigationController(rootViewController: onboardingViewController) + } + + func setupLaunchScreen(_ info: PushNotificationInfo) { + + guard let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + let launchScreenViewController = LaunchScreenViewController() + launchScreenViewController.reactor = LaunchScreenViewReactor(dependencies: self.appDIContainer, pushInfo: info) + launchScreenViewController.modalTransitionStyle = .crossDissolve + window.rootViewController = UINavigationController(rootViewController: launchScreenViewController) + } + + func setupMainTabBar(_ info: PushNotificationInfo) { + + guard let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + let mainTabBarController = MainTabBarController() + mainTabBarController.reactor = MainTabBarReactor(dependencies: self.appDIContainer, pushInfo: info) + mainTabBarController.modalTransitionStyle = .crossDissolve + window.rootViewController = UINavigationController(rootViewController: mainTabBarController) } } diff --git a/SOOUM/SOOUM/App/Dependencies/AppAssembler.swift b/SOOUM/SOOUM/App/Dependencies/AppAssembler.swift new file mode 100644 index 00000000..a81b7735 --- /dev/null +++ b/SOOUM/SOOUM/App/Dependencies/AppAssembler.swift @@ -0,0 +1,286 @@ +// +// AppAssembler.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +final class AppAssembler: BaseAssemblerable { + + // TODO: 임시, 추후 Coordinator 및 VIPER 적용 시 분기 + func assemble(container: BaseDIContainerable) { + + + // MARK: Services + + container.register(ManagerProviderType.self, factory: { _ in ManagerProviderContainer() }) + + + // MARK: AppVersionRepository + + container.register(AppVersionRemoteDataSource.self, factory: { resolver in + AppVersionRemoteDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + container.register(AppVersionRepository.self, factory: { resolver in + AppVersionRepositoryImpl(remoteDataSource: resolver.resolve(AppVersionRemoteDataSource.self)) + }) + + + // MARK: AuthRepository + + container.register(AuthRemoteDataSource.self, factory: { resolver in + AuthRemoteDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + container.register(AuthLocalDataSource.self, factory: { resolver in + AuthLocalDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + + container.register(AuthRepository.self, factory: { resolver in + AuthRepositoryImpl( + remoteDataSource: resolver.resolve(AuthRemoteDataSource.self), + localDataSource: resolver.resolve(AuthLocalDataSource.self) + ) + }) + + + // MARK: UserRepository + + container.register(UserRemoteDataSource.self, factory: { resolver in + UserRemoteDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + + container.register(UserRepository.self, factory: { resolver in + UserRepositoryImpl(remoteDataSource: resolver.resolve(UserRemoteDataSource.self)) + }) + + + // MARK: NotificationRepository + + container.register(NotificationRemoteDataSource.self, factory: { resolver in + NotificationRemoteDataSoruceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + + container.register(NotificationRepository.self, factory: { resolver in + NotificationRepositoryImpl(remoteDataSource: resolver.resolve(NotificationRemoteDataSource.self)) + }) + + + // MARK: CardRepository + + container.register(CardRemoteDataSource.self, factory: { resolver in + CardRemoteDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + + container.register(CardRepository.self, factory: { resolver in + CardRepositoryImpl(remoteDataSource: resolver.resolve(CardRemoteDataSource.self)) + }) + + + // MARK: TagRepository + + container.register(TagRemoteDataSource.self, factory: { resolver in + TagRemoteDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + + container.register(TagRepository.self, factory: { resolver in + TagRepositoryImpl(remoteDataSource: resolver.resolve(TagRemoteDataSource.self)) + }) + + + // MARK: SettingsRepository + + container.register(SettingsRemoteDataSource.self, factory: { resolver in + SettingsRemoteDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + container.register(SettingsLocalDataSource.self, factory: { resolver in + SettingsLocalDataSourceImpl(provider: resolver.resolve(ManagerProviderType.self)) + }) + + container.register(SettingsRepository.self, factory: { resolver in + SettingsRepositoryImpl( + remoteDataSource: resolver.resolve(SettingsRemoteDataSource.self), + localDataSource: resolver.resolve(SettingsLocalDataSource.self) + ) + }) + + + // MARK: AppVersionUseCase + + container.register(AppVersionUseCase.self, factory: { resolver in + AppVersionUseCaseImpl(repository: resolver.resolve(AppVersionRepository.self)) + }) + + + // MARK: AuthUseCase + + container.register(AuthUseCase.self, factory: { resolver in + AuthUseCaseImpl(repository: resolver.resolve(AuthRepository.self)) + }) + + + // MARK: BlockUserUseCase + + container.register(BlockUserUseCase.self, factory: { resolver in + BlockUserUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: CardImageUseCase + + container.register(CardImageUseCase.self, factory: { resolver in + CardImageUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + + + // MARK: DeleteCardUseCase + + container.register(DeleteCardUseCase.self, factory: { resolver in + DeleteCardUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + + + // MARK: FetchBlockUserUseCase + + container.register(FetchBlockUserUseCase.self, factory: { resolver in + FetchBlockUserUseCaseImpl(repository: resolver.resolve(SettingsRepository.self)) + }) + + + // MARK: FetchCardDetailUseCase + + container.register(FetchCardDetailUseCase.self, factory: { resolver in + FetchCardDetailUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + + + // MARK: FetchCardUseCase + + container.register(FetchCardUseCase.self, factory: { resolver in + FetchCardUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + + + // MARK: FetchFollowUseCase + + container.register(FetchFollowUseCase.self, factory: { resolver in + FetchFollowUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: FetchNoticeUseCase + + container.register(FetchNoticeUseCase.self, factory: { resolver in + FetchNoticeUseCaseImpl(repository: resolver.resolve(NotificationRepository.self)) + }) + + + // MARK: FetchTagUseCase + + container.register(FetchTagUseCase.self, factory: { resolver in + FetchTagUseCaseImpl(repository: resolver.resolve(TagRepository.self)) + }) + + + // MARK: FetchUserInfoUseCase + + container.register(FetchUserInfoUseCase.self, factory: { resolver in + FetchUserInfoUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: LocationUseCase + + container.register(LocationUseCase.self, factory: { resolver in + LocationUseCaseImpl(repository: resolver.resolve(SettingsRepository.self)) + }) + + + // MARK: NotificationUseCase + + container.register(NotificationUseCase.self, factory: { resolver in + NotificationUseCaseImpl(repository: resolver.resolve(NotificationRepository.self)) + }) + + + // MARK: ReportCardUseCase + + container.register(ReportCardUseCase.self, factory: { resolver in + ReportCardUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + + + // MARK: TransferAccountUseCase + + container.register(TransferAccountUseCase.self, factory: { resolver in + TransferAccountUseCaseImpl(repository: resolver.resolve(SettingsRepository.self)) + }) + + + // MARK: UpdateCardLikeUseCase + + container.register(UpdateCardLikeUseCase.self, factory: { resolver in + UpdateCardLikeUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + + + // MARK: UpdateFollowUseCase + + container.register(UpdateFollowUseCase.self, factory: { resolver in + UpdateFollowUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: UpdateNotifyUseCase + + container.register(UpdateNotifyUseCase.self, factory: { resolver in + UpdateNotifyUseCaseImpl(repository: resolver.resolve(SettingsRepository.self)) + }) + + + // MARK: UpdateTagFavoriteUseCase + + container.register(UpdateTagFavoriteUseCase.self, factory: { resolver in + UpdateTagFavoriteUseCaseImpl(repository: resolver.resolve(TagRepository.self)) + }) + + + // MARK: UpdateUserInfoUseCases + + container.register(UpdateUserInfoUseCase.self, factory: { resolver in + UpdateUserInfoUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: UploadUserImageUseCase + + container.register(UploadUserImageUseCase.self, factory: { resolver in + UploadUserImageUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: ValidateNicknameUseCase + + container.register(ValidateNicknameUseCase.self, factory: { resolver in + ValidateNicknameUseCaseImpl(repository: resolver.resolve(UserRepository.self)) + }) + + + // MARK: ValidateUserUseCase + + container.register(ValidateUserUseCase.self, factory: { resolver in + ValidateUserUseCaseImpl( + user: resolver.resolve(UserRepository.self), + settings: resolver.resolve(SettingsRepository.self) + ) + }) + + + // MARK: WriteCardUseCase + + container.register(WriteCardUseCase.self, factory: { resolver in + WriteCardUseCaseImpl(repository: resolver.resolve(CardRepository.self)) + }) + } +} diff --git a/SOOUM/SOOUM/App/Dependencies/AppDIContainer.swift b/SOOUM/SOOUM/App/Dependencies/AppDIContainer.swift new file mode 100644 index 00000000..05ca4547 --- /dev/null +++ b/SOOUM/SOOUM/App/Dependencies/AppDIContainer.swift @@ -0,0 +1,27 @@ +// +// AppDIContainer.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +protocol AppDIContainerable { + + var rootContainer: BaseDIContainerable { get } +} + +final class AppDIContainer: AppDIContainerable { + + var rootContainer: BaseDIContainerable + + init() { + + self.rootContainer = BaseDIContainer() + + let appAssembler = AppAssembler() + + appAssembler.assemble(container: self.rootContainer) + } +} diff --git a/SOOUM/SOOUM/App/SceneDelegate.swift b/SOOUM/SOOUM/App/SceneDelegate.swift index 2873f331..b20fc9a3 100644 --- a/SOOUM/SOOUM/App/SceneDelegate.swift +++ b/SOOUM/SOOUM/App/SceneDelegate.swift @@ -23,24 +23,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = (scene as? UIWindowScene) else { return } guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } - window = UIWindow(frame: windowScene.coordinateSpace.bounds) - window?.windowScene = windowScene + self.window = UIWindow(frame: windowScene.coordinateSpace.bounds) + self.window?.windowScene = windowScene let viewController = LaunchScreenViewController() - viewController.reactor = LaunchScreenViewReactor(provider: appDelegate.provider) + viewController.reactor = LaunchScreenViewReactor(dependencies: appDelegate.appDIContainer) - window?.rootViewController = viewController - window?.backgroundColor = .white - window?.makeKeyAndVisible() + self.window?.rootViewController = viewController + self.window?.backgroundColor = .white + self.window?.makeKeyAndVisible() /// 앱이 완전히 종료되었을 때 push notification에 대한 응답을 했을 때 실행할 코드 작성 if let response: UNNotificationResponse = connectionOptions.notificationResponse { let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo - - if let infoDic: [String: Any] = userInfo as? [String: Any] { - - let info = NotificationInfo(infoDic) - appDelegate.provider.pushManager.setupRootViewController(info, terminated: true) + if let infoDic = userInfo as? [String: Any] { + let info = PushNotificationInfo(infoDic) + appDelegate.setupLaunchScreen(info) } } } diff --git a/SOOUM/SOOUM/Base/BaseNavigationViewController.swift b/SOOUM/SOOUM/Base/BaseNavigationViewController.swift index 3219bc2d..6cfc6d33 100644 --- a/SOOUM/SOOUM/Base/BaseNavigationViewController.swift +++ b/SOOUM/SOOUM/Base/BaseNavigationViewController.swift @@ -33,7 +33,7 @@ class BaseNavigationViewController: BaseViewController { $0.isHidden = true } - private(set) var navigationPopWithBottomBarHidden: Bool = false + private(set) var navigationPopWithBottomBarHidden: Bool = true private(set) var navigationPopGestureEnabled: Bool = true private(set) var navigationBarHeight: CGFloat = SOMNavigationBar.height @@ -101,14 +101,13 @@ class BaseNavigationViewController: BaseViewController { $0.trailing.equalTo(self.navigationBar.snp.trailing) $0.height.equalTo(1.4) } + // 로딩 뷰는 항상 최상단에 표시 + self.view.bringSubviewToFront(self.loadingIndicatorView) } override func bind() { super.bind() - self.navigationController?.delegate = self - self.navigationController?.interactivePopGestureRecognizer?.delegate = self - self.backButton.rx.tap .subscribe(with: self) { object, _ in object.navigationPop( @@ -147,7 +146,14 @@ class BaseNavigationViewController: BaseViewController { extension BaseNavigationViewController: UIGestureRecognizerDelegate { - public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return self.navigationPopGestureEnabled + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { return self.navigationPopGestureEnabled } } diff --git a/SOOUM/SOOUM/Base/BaseViewController.swift b/SOOUM/SOOUM/Base/BaseViewController.swift index aa8ceb40..9b0ef414 100644 --- a/SOOUM/SOOUM/Base/BaseViewController.swift +++ b/SOOUM/SOOUM/Base/BaseViewController.swift @@ -7,26 +7,41 @@ import UIKit +import Network + import RxKeyboard import RxSwift import SnapKit import Then +import Lottie class BaseViewController: UIViewController { + + enum Text { + static let bottomToastEntryName: String = "bottomToastEntryName" + static let instabilityNetworkToastTitle: String = "네트워크 연결이 원활하지 않습니다. 네트워크 확인 후 재접속해주세요" + } var disposeBag = DisposeBag() + private let monitor = NWPathMonitor() + + private let instabilityNetworkToastView = SOMBottomToastView(title: Text.instabilityNetworkToastTitle, actions: nil) + let activityIndicatorView = SOMActivityIndicatorView() + let loadingIndicatorView = SOMLoadingIndicatorView() private(set) var isEndEditingWhenWillDisappear: Bool = true + private(set) var bottomToastMessageOffset: CGFloat = 88 + 8 - override var hidesBottomBarWhenPushed: Bool { - didSet { - NotificationCenter.default.post(name: .hidesBottomBarWhenPushedDidChange, object: self) - } - } + // TODO: 임시, 탭바 숨기지 않음 + // override var hidesBottomBarWhenPushed: Bool { + // didSet { + // NotificationCenter.default.post(name: .hidesBottomBarWhenPushedDidChange, object: self) + // } + // } init() { super.init(nibName: nil, bundle: nil) @@ -35,20 +50,16 @@ class BaseViewController: UIViewController { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Show deinit class name + /// show deinit class name and remove all observer deinit { - NotificationCenter.default.removeObserver( - self, - name: .hidesBottomBarWhenPushedDidChange, - object: nil - ) + NotificationCenter.default.removeObserver(self) Log.debug("Deinit: ", type(of: self).description().components(separatedBy: ".").last ?? "") } override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .white + self.view.backgroundColor = .som.v2.white self.setupConstraints() self.activityIndicatorView.color = .black @@ -58,6 +69,11 @@ class BaseViewController: UIViewController { $0.centerX.equalTo(self.view.safeAreaLayoutGuide.snp.centerX) $0.centerY.equalTo(self.view.safeAreaLayoutGuide.snp.centerY) } + + self.view.addSubview(self.loadingIndicatorView) + self.loadingIndicatorView.snp.makeConstraints { + $0.edges.equalToSuperview() + } self.bind() @@ -70,6 +86,19 @@ class BaseViewController: UIViewController { object.updatedKeyboard(withoutBottomSafeInset: withoutBottomSafeInset) } .disposed(by: self.disposeBag) + + SimpleReachability.shared.isConnected + .skip(1) + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, _ in + guard object.isViewLoaded, object.view.window != nil else { return } + + var wrapper: SwiftEntryKitViewWrapper = object.instabilityNetworkToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: object.bottomToastMessageOffset, displayDuration: 4) + } + .disposed(by: self.disposeBag) } /// Set auto layouts diff --git a/SOOUM/SOOUM/Base/DIContainer/BaseAssembler.swift b/SOOUM/SOOUM/Base/DIContainer/BaseAssembler.swift new file mode 100644 index 00000000..e4cb1c70 --- /dev/null +++ b/SOOUM/SOOUM/Base/DIContainer/BaseAssembler.swift @@ -0,0 +1,30 @@ +// +// BaseAssembler.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +/// 여러 의존성 등록 로직을 한 곳에 모아 관리하는 책임을 가집니다. +protocol BaseAssemblerable: AnyObject { + /// 추가 의존성을 `rootContainer`에 등록합니다. + /// - Parameter container: 의존성을 등록 혹은 반환합니다. + /// + /// ``` + /// DataSource: container.register(AnyDataSource.self) { _ in + /// AnyDataSourceImpl() + /// } + /// Repository: container.register(AnyRespository.self) { resolver in + /// AnyRespositoryImpl(resolver.resolve(AnyDataSource.self)) + /// } + /// UseCase: container.register(AnyUseCase.self) { resolver in + /// AnyUseCaseImpl(resolver.resolve(AnyRespository.self)) + /// } + /// Reactor: container.register(AnyReactor.self) { resolver in + /// AnyReactor(resolver.resolve(AnyUseCase.self)) + /// } + /// ``` + func assemble(container: BaseDIContainerable) +} diff --git a/SOOUM/SOOUM/Base/DIContainer/BaseDIContainer.swift b/SOOUM/SOOUM/Base/DIContainer/BaseDIContainer.swift new file mode 100644 index 00000000..27f23241 --- /dev/null +++ b/SOOUM/SOOUM/Base/DIContainer/BaseDIContainer.swift @@ -0,0 +1,65 @@ +// +// BaseDIContainer.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +/// DI 컨테이너가 수행해야 할 기능을 정의하는 프로토콜입니다. +/// `register`: 의존성을 등록합니다. +/// `resolve`: 등록된 의존성을 반환합니다. +protocol BaseDIContainerable: AnyObject { + + /// 서비스 타입과 해당 서비스를 생성하는 클로저(factory)를 등록합니다. + /// - Parameters: + /// - type: 등록할 서비스의 프로토콜 타입입니다. + /// - factory: 서비스를 생성하는 클로저입니다. 이 클로저는 자기 자신(컨테이너)을 파라미터로 받아, + /// 다른 의존성을 해결(resolve)하는 데 사용할 수 있습니다. + func register(_ type: Service.Type, factory: @escaping (BaseDIContainerable) -> Service) + + /// 등록된 서비스 타입의 인스턴스를 반환합니다. + /// - Parameter type: 해결(resolve)하려는 서비스의 프로토콜 타입입니다. + /// - Returns: 등록된 서비스의 인스턴스를 반환합니다. 만약 등록되지 않았다면 앱이 강제 종료됩니다. (개발 단계에서 의존성 설정 오류를 빠르게 파악하기 위함) + func resolve(_ type: Service.Type) -> Service +} + +/// `BaseDIContainerable`의 실제 구현 클래스입니다. +final class BaseDIContainer: BaseDIContainerable { + + // 부모 컨테이너에 대한 참조입니다. + private weak var parent: BaseDIContainerable? + // 등록된 서비스의 생성 클로저(factory)를 저장하는 딕셔너리입니다. + // 키는 서비스 타입의 이름(String), 값은 Any를 반환하는 클로저입니다. + private var factories: [String: (BaseDIContainerable) -> Any] = [:] + + /// 초기화 시 부모 컨테이너를 주입받을 수 있습니다. + /// - Parameter parent: 부모 컨테이너. nil일 경우, 최상위 컨테이너가 됩니다. + init(_ parent: BaseDIContainerable? = nil) { + self.parent = parent + } + + func register(_ type: Service.Type, factory: @escaping (BaseDIContainerable) -> Service) { + let key = String(describing: type) + self.factories[key] = factory + } + + func resolve(_ type: Service.Type) -> Service { + let key = String(describing: type) + // 1. 현재 컨테이너에서 의존성 해결을 시도합니다. + if let factory = self.factories[key] { + // factory는 (DIContainerProtocol) -> Any 타입을 가지므로, + // 실제 서비스 타입(Service)으로 캐스팅하여 반환합니다. + // register 함수에서 타입을 보장하므로 강제 캐스팅(!)이 안전합니다. + return factory(self) as! Service + } + // 2. 현재 컨테이너에서 찾지 못했고, 부모가 있다면 부모에게 해결을 위임합니다. + if let parent = self.parent { + return parent.resolve(type) + } + + // 해당 의존성이 등록되지 않은 경우, 개발자가 실수를 바로 인지할 수 있도록 fatalError를 발생시킵니다. + fatalError("Dependency for \(key) not registered.") + } +} diff --git a/SOOUM/SOOUM/Managers/AuthManager/AuthManager.swift b/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift similarity index 54% rename from SOOUM/SOOUM/Managers/AuthManager/AuthManager.swift rename to SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift index 6098360d..a9aa1485 100644 --- a/SOOUM/SOOUM/Managers/AuthManager/AuthManager.swift +++ b/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift @@ -24,9 +24,11 @@ protocol AuthManagerDelegate: AnyObject { var hasToken: Bool { get } func convertPEMToSecKey(pemString: String) -> SecKey? func encryptUUIDWithPublicKey(publicKey: SecKey) -> String? - func join() -> Observable + func publicKey() -> Observable + func available() -> Observable + func join(nickname: String, profileImageName: String?) -> Observable func certification() -> Observable - func reAuthenticate(_ accessToken: String, _ completion: @escaping (AuthResult) -> Void) + func reAuthenticate(_ token: Token, _ completion: @escaping (AuthResult) -> Void) func initializeAuthInfo() func updateTokens(_ token: Token) func authPayloadByAccess() -> [String: String] @@ -48,7 +50,7 @@ class AuthManager: CompositeManager { var hasToken: Bool { let token = self.authInfo.token - return !token.accessToken.isEmpty && !token.refreshToken.isEmpty + return token.accessToken.isEmpty == false && token.refreshToken.isEmpty == false } override init(provider: ManagerTypeDelegate, configure: AuthManagerConfiguration) { @@ -106,63 +108,117 @@ extension AuthManager: AuthManagerDelegate { // MARK: Account Verification - func join() -> Observable { + func publicKey() -> Observable { - guard let provider = self.provider else { return .just(false) } + guard let provider = self.provider else { return .just(nil) } - return provider.networkManager.request(RSAKeyResponse.self, request: AuthRequest.getPublicKey) + let request: AuthRequest = .publicKey + return provider.networkManager.fetch(KeyInfoResponse.self, request: request) .map(\.publicKey) + } + + func available() -> Observable { + + return self.publicKey() + .withUnretained(self) + .flatMapLatest { object, publicKey -> Observable in + + if let publicKey = publicKey, + let secKey = object.convertPEMToSecKey(pemString: publicKey), + let encryptedDeviceId = object.encryptUUIDWithPublicKey(publicKey: secKey), + let provider = object.provider { + + let request: UserRequest = .checkAvailable(encryptedDeviceId: encryptedDeviceId) + return provider.networkManager.perform(CheckAvailableResponse.self, request: request) + } else { + return .just(CheckAvailableResponse.emptyValue()) + } + } + } + + func join(nickname: String, profileImageName: String?) -> Observable { + + return self.publicKey() .withUnretained(self) .flatMapLatest { object, publicKey -> Observable in - if let secKey = object.convertPEMToSecKey(pemString: publicKey), - let encryptedDeviceId = object.encryptUUIDWithPublicKey(publicKey: secKey) { + if let publicKey = publicKey, + let secKey = object.convertPEMToSecKey(pemString: publicKey), + let encryptedDeviceId = object.encryptUUIDWithPublicKey(publicKey: secKey), + let provider = object.provider { let request: AuthRequest = .signUp( encryptedDeviceId: encryptedDeviceId, - isAllowNotify: true, - isAllowTermOne: true, - isAllowTermTwo: true, - isAllowTermThree: true + isNotificationAgreed: provider.pushManager.notificationStatus, + nickname: nickname, + profileImageName: profileImageName ) - return provider.networkManager.request(SignUpResponse.self, request: request) - .map { response in - object.authInfo.updateToken(response.token) + return provider.networkManager.perform(SignUpResponse.self, request: request) + .map(\.token) + .flatMapLatest { token -> Observable in + + // 사용자 닉네임 업데이트 + UserDefaults.standard.nickname = nickname + + // session token 업데이트 + object.authInfo.updateToken(token) // FCM token 업데이트 - object.provider?.networkManager.registerFCMToken(from: #function) - return true + provider.networkManager.registerFCMToken(from: #function) + return .just(true) } + } else { + return .just(false) } - return .just(false) } } func certification() -> Observable { - guard let provider = self.provider else { return .just(false) } - - return provider.networkManager.request(RSAKeyResponse.self, request: AuthRequest.getPublicKey) - .map(\.publicKey) + return self.publicKey() .withUnretained(self) .flatMapLatest { object, publicKey -> Observable in - - if let secKey = object.convertPEMToSecKey(pemString: publicKey), - let encryptedDeviceId = object.encryptUUIDWithPublicKey(publicKey: secKey) { + + if let publicKey = publicKey, + let secKey = object.convertPEMToSecKey(pemString: publicKey), + let encryptedDeviceId = object.encryptUUIDWithPublicKey(publicKey: secKey), + let provider = object.provider { let request: AuthRequest = .login(encryptedDeviceId: encryptedDeviceId) - return provider.networkManager.request(SignInResponse.self, request: request) - .map { response -> Bool in - if response.isRegistered, let token = response.token { + return provider.networkManager.perform(LoginResponse.self, request: request) + .map(\.token) + .withUnretained(object) + .flatMapLatest { object, token -> Observable in + + // session token 업데이트 + object.authInfo.updateToken(token) + + // FCM token 업데이트 + provider.networkManager.registerFCMToken(from: #function) + return .just(true) + } + .catch { error in + + let errorCode = (error as NSError).code + if case 404 = errorCode { - object.authInfo.updateToken(token) + // session token 삭제 + object.authInfo.initAuthInfo() - // FCM token 업데이트 - object.provider?.networkManager.registerFCMToken(from: #function) - return true - } else { - return false + // onboarding screen 전환 + DispatchQueue.main.async { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, + let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + let onBoardingViewController = OnboardingViewController() + onBoardingViewController.reactor = OnboardingViewReactor(dependencies: appDelegate.appDIContainer) + onBoardingViewController.modalTransitionStyle = .crossDissolve + window.rootViewController = UINavigationController(rootViewController: onBoardingViewController) + } } + return .just(false) } } else { return .just(false) @@ -177,29 +233,27 @@ extension AuthManager: AuthManagerDelegate { 3. 재인증 완료된 후, 이전의 호출이 이전 토큰을 가지고 시도할 수 있기 때문에, 호출의 토큰과 현재 토큰이 같은 때만 통과시킨다 4. RefreshToken 도 유효하지 않다면 로그인 시도 */ - func reAuthenticate(_ accessToken: String, _ completion: @escaping (AuthResult) -> Void) { - - let token = self.authInfo.token + func reAuthenticate(_ token: Token, _ completion: @escaping (AuthResult) -> Void) { - guard token.refreshToken.isEmpty == false else { + guard self.authInfo.token.isEmpty == false else { let error = NSError( domain: "SOOUM", code: -99, - userInfo: [NSLocalizedDescriptionKey: "Refresh token not found"] + userInfo: [NSLocalizedDescriptionKey: "tokens not found"] ) completion(.failure(error)) return } /// 1개 이상의 API에서 reAuthenticate 요청 했을 때, - /// 기존 요청이 끝날 떄까지 대기 + /// 처음 요청이 끝날 떄까지 대기 guard self.isReAuthenticating == false else { self.pendingResults.append(completion) return } /// AccessToken이 업데이트 됐다면, 즉시 성공 처리 - guard accessToken == token.accessToken else { + guard token == self.authInfo.token else { completion(.success) return } @@ -209,30 +263,27 @@ extension AuthManager: AuthManagerDelegate { guard let provider = self.provider else { return } - let request: AuthRequest = .reAuthenticationWithRefreshSession - provider.networkManager.request(ReAuthenticationResponse.self, request: request) - .map(\.accessToken) + let request: AuthRequest = .reAuthenticationWithRefreshSession(token: token) + provider.networkManager.perform(TokenResponse.self, request: request) + .map(\.token) .subscribe( with: self, - onNext: { object, accessToken in - if accessToken.isEmpty { + onNext: { object, token in + + if token.accessToken.isEmpty && token.refreshToken.isEmpty { let error = NSError( domain: "SOOUM", code: -99, userInfo: [NSLocalizedDescriptionKey: "Session not refresh"] ) + object.excutePendingResults(.failure(error)) } else { - object.updateTokens( - .init( - accessToken: accessToken, - refreshToken: token.refreshToken - ) - ) + object.updateTokens(token) // FCM token 업데이트 - object.provider?.networkManager.registerFCMToken(from: #function) + provider.networkManager.registerFCMToken(from: #function) object.excutePendingResults(.success) } @@ -240,24 +291,14 @@ extension AuthManager: AuthManagerDelegate { object.isReAuthenticating = false }, onError: { object, error in - - let errorCode = (error as NSError).code - switch errorCode { - case 403: - object.certification() - .subscribe(onNext: { isRegistered in - if isRegistered { - object.excutePendingResults(.success) - } else { - object.excutePendingResults(.failure(error)) - } - }) - .disposed(by: self.disposeBag) - default: - break - } - - object.isReAuthenticating = false + /// 재인증 과정이 실패하면 항상 재로그인 시도 + object.certification() + .subscribe(onNext: { isRegistered in + object.excutePendingResults(isRegistered ? .success : .failure(error)) + + object.isReAuthenticating = false + }) + .disposed(by: object.disposeBag) } ) .disposed(by: self.disposeBag) diff --git a/SOOUM/SOOUM/Managers/AuthManager/AuthManagerConfiguration.swift b/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManagerConfiguration.swift similarity index 100% rename from SOOUM/SOOUM/Managers/AuthManager/AuthManagerConfiguration.swift rename to SOOUM/SOOUM/Data/Managers/AuthManager/AuthManagerConfiguration.swift diff --git a/SOOUM/SOOUM/Managers/AuthManager/Models/AuthInfo.swift b/SOOUM/SOOUM/Data/Managers/AuthManager/Models/AuthInfo.swift similarity index 100% rename from SOOUM/SOOUM/Managers/AuthManager/Models/AuthInfo.swift rename to SOOUM/SOOUM/Data/Managers/AuthManager/Models/AuthInfo.swift diff --git a/SOOUM/SOOUM/Managers/CompositeManager.swift b/SOOUM/SOOUM/Data/Managers/CompositeManager.swift similarity index 100% rename from SOOUM/SOOUM/Managers/CompositeManager.swift rename to SOOUM/SOOUM/Data/Managers/CompositeManager.swift diff --git a/SOOUM/SOOUM/Managers/LocationManager/LocationManager.swift b/SOOUM/SOOUM/Data/Managers/LocationManager/LocationManager.swift similarity index 92% rename from SOOUM/SOOUM/Managers/LocationManager/LocationManager.swift rename to SOOUM/SOOUM/Data/Managers/LocationManager/LocationManager.swift index 2fb42f27..6734e411 100644 --- a/SOOUM/SOOUM/Managers/LocationManager/LocationManager.swift +++ b/SOOUM/SOOUM/Data/Managers/LocationManager/LocationManager.swift @@ -20,7 +20,7 @@ enum AuthStatus { protocol LocationManagerDelegate: AnyObject { var coordinate: Coordinate { get } - var hasCoordinate: Bool { get } + var hasPermission: Bool { get } func requestLocationPermission() func checkLocationAuthStatus() -> AuthStatus } @@ -34,8 +34,9 @@ class LocationManager: CompositeManager { return coordinate } - var hasCoordinate: Bool { - return self.coordinate.latitude.isEmpty == false && self.coordinate.longitude.isEmpty == false + var hasPermission: Bool { + return self.checkLocationAuthStatus() == .authorizedAlways || + self.checkLocationAuthStatus() == .authorizedWhenInUse } override init(provider: ManagerTypeDelegate, configure: LocationManagerConfigruation) { @@ -63,6 +64,8 @@ extension LocationManager: LocationManagerDelegate { private func updateLocationAuthStatus() { let authStatus = self.convertLocationAuthStatus(self.locationManager.authorizationStatus) + guard self.locationAuthStatus != authStatus else { return } + self.locationAuthStatus = authStatus Log.debug("Change location auth status", authStatus) diff --git a/SOOUM/SOOUM/Managers/LocationManager/LocationManagerConfigruation.swift b/SOOUM/SOOUM/Data/Managers/LocationManager/LocationManagerConfigruation.swift similarity index 100% rename from SOOUM/SOOUM/Managers/LocationManager/LocationManagerConfigruation.swift rename to SOOUM/SOOUM/Data/Managers/LocationManager/LocationManagerConfigruation.swift diff --git a/SOOUM/SOOUM/Managers/LocationManager/Models/Coordinate.swift b/SOOUM/SOOUM/Data/Managers/LocationManager/Models/Coordinate.swift similarity index 100% rename from SOOUM/SOOUM/Managers/LocationManager/Models/Coordinate.swift rename to SOOUM/SOOUM/Data/Managers/LocationManager/Models/Coordinate.swift diff --git a/SOOUM/SOOUM/Managers/ManagerConfiguration.swift b/SOOUM/SOOUM/Data/Managers/ManagerConfiguration.swift similarity index 100% rename from SOOUM/SOOUM/Managers/ManagerConfiguration.swift rename to SOOUM/SOOUM/Data/Managers/ManagerConfiguration.swift diff --git a/SOOUM/SOOUM/Managers/ManagerProvider.swift b/SOOUM/SOOUM/Data/Managers/ManagerProvider.swift similarity index 80% rename from SOOUM/SOOUM/Managers/ManagerProvider.swift rename to SOOUM/SOOUM/Data/Managers/ManagerProvider.swift index d14f1359..143b00dd 100644 --- a/SOOUM/SOOUM/Managers/ManagerProvider.swift +++ b/SOOUM/SOOUM/Data/Managers/ManagerProvider.swift @@ -23,13 +23,3 @@ final class ManagerProviderContainer: ManagerProviderType { var networkManager: NetworkManagerDelegate { self.managerType.networkManager } var locationManager: LocationManagerDelegate { self.managerType.locationManager } } - -extension ManagerProviderType { - - func initialize() { - _ = self.authManager - _ = self.pushManager - _ = self.networkManager - _ = self.locationManager - } -} diff --git a/SOOUM/SOOUM/Managers/ManagerType.swift b/SOOUM/SOOUM/Data/Managers/ManagerType.swift similarity index 100% rename from SOOUM/SOOUM/Managers/ManagerType.swift rename to SOOUM/SOOUM/Data/Managers/ManagerType.swift diff --git a/SOOUM/SOOUM/Managers/NetworkManager/DefinedError.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/DefinedError.swift similarity index 77% rename from SOOUM/SOOUM/Managers/NetworkManager/DefinedError.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/DefinedError.swift index c4adae21..eee81cc8 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/DefinedError.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/DefinedError.swift @@ -7,14 +7,19 @@ import Foundation +import Alamofire + enum DefinedError: Error, LocalizedError { case badRequest case unauthorized case payment case forbidden + case notFound case teapot + case invlid case locked + case invalidMethod(HTTPMethod) case unknown(Int) static func error(with statusCode: Int) -> Self { @@ -27,8 +32,12 @@ enum DefinedError: Error, LocalizedError { return .payment case 403: return .forbidden + case 404: + return .notFound case 418: return .teapot + case 422: + return .invlid case 423: return .locked default: @@ -46,10 +55,16 @@ enum DefinedError: Error, LocalizedError { return "Delete parent card: HTTP 402 received." case .forbidden: return "Expire RefreshToken: HTTP 403 received." + case .notFound: + return "Not Found: HTTP 404 received" case .teapot: return "Stop using RefreshToken: HTTP 418 received." + case .invlid: + return "Invlid Image: HTTP 422 received." case .locked: return "LOCKED: HTTP 423 received." + case let .invalidMethod(httpMethod): + return "Invalid Method: HTTPMethod \(httpMethod) was not expected" case let .unknown(statusCode): return "Unknown error: HTTP \(statusCode) received." } @@ -61,8 +76,11 @@ enum DefinedError: Error, LocalizedError { case .unauthorized: 401 case .payment: 402 case .forbidden: 403 + case .notFound: 404 case .teapot: 418 + case .invlid: 422 case .locked: 423 + case .invalidMethod: -99 case let .unknown(statusCode): statusCode } diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/AddingTokenInterceptor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/AddingTokenInterceptor.swift similarity index 74% rename from SOOUM/SOOUM/Managers/NetworkManager/Interceptor/AddingTokenInterceptor.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/AddingTokenInterceptor.swift index 6bc05f39..ae25141e 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/AddingTokenInterceptor.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/AddingTokenInterceptor.swift @@ -5,14 +5,11 @@ // Created by 오현식 on 1/15/25. // -import Foundation - import Alamofire - -class AddingTokenInterceptor: RequestInterceptor { +final class AddingTokenInterceptor: RequestInterceptor { - private let provider: ManagerTypeDelegate + private weak var provider: ManagerTypeDelegate? init(provider: ManagerTypeDelegate) { self.provider = provider @@ -24,11 +21,11 @@ class AddingTokenInterceptor: RequestInterceptor { switch authorizationType { case "access": - let authPayloadForAccess = self.provider.authManager.authPayloadByAccess() + let authPayloadForAccess = self.provider?.authManager.authPayloadByAccess() ?? ["Authorization": "Bearer "] let authKeyForAccess = authPayloadForAccess.keys.first! as String request.setValue(authPayloadForAccess[authKeyForAccess], forHTTPHeaderField: authKeyForAccess) case "refresh": - let authPayloadForRefresh = self.provider.authManager.authPayloadByRefresh() + let authPayloadForRefresh = self.provider?.authManager.authPayloadByRefresh() ?? ["Authorization": "Bearer "] let authKeyForRefresh = authPayloadForRefresh.keys.first! as String request.setValue(authPayloadForRefresh[authKeyForRefresh], forHTTPHeaderField: authKeyForRefresh) default: diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/CompositeInterceptor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/CompositeInterceptor.swift similarity index 90% rename from SOOUM/SOOUM/Managers/NetworkManager/Interceptor/CompositeInterceptor.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/CompositeInterceptor.swift index 79faa3b9..28eed697 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/CompositeInterceptor.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/CompositeInterceptor.swift @@ -5,20 +5,15 @@ // Created by 오현식 on 11/4/24. // -import Foundation - import Alamofire - -class CompositeInterceptor: RequestInterceptor { +final class CompositeInterceptor: RequestInterceptor { - private let provider: ManagerTypeDelegate private let interceptors: [RequestInterceptor] private let timeoutInterval: TimeInterval = 20.0 init(provider: ManagerTypeDelegate) { - self.provider = provider self.interceptors = [ AddingTokenInterceptor(provider: provider), @@ -45,9 +40,9 @@ class CompositeInterceptor: RequestInterceptor { } } -extension CompositeInterceptor { +private extension CompositeInterceptor { - private func adapts( + func adapts( _ urlRequest: URLRequest, index: Int, session: Session, diff --git a/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift new file mode 100644 index 00000000..4b857a38 --- /dev/null +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift @@ -0,0 +1,206 @@ +// +// ErrorInterceptor.swift +// SOOUM +// +// Created by 오현식 on 10/27/24. +// + +import Alamofire + +final class ErrorInterceptor: RequestInterceptor { + + enum Text { + static let networkErrorDialogTitle: String = "네트워크 상태가 불안정해요" + static let networkErrorDialogMessage: String = "네트워크 연결상태를 확인 후 다시 시도해 주세요." + static let confirmActionTitle: String = "확인" + + static let unknownErrorDialogTitle: String = "일시적인 오류가 발생했어요" + static let unknownErrorDialogMessage: String = "같은 문제가 반복된다면 ‘문의하기'를 눌러 숨 팀에 알려주세요." + static let closeActionButtonTitle: String = "닫기" + static let inquiryActionTitle: String = "문의하기" + + static let adminMailStrUrl: String = "sooum1004@gmail.com" + static let identificationInfo: String = "식별 정보: " + static let inquiryMailTitle: String = "[문의하기]" + static let inquiryMailGuideMessage: String = """ + \n + 문의 내용: 식별 정보 삭제에 주의하여 주시고, 이곳에 자유롭게 문의하실 내용을 적어주세요. + 단, 본 양식에 비방, 욕설, 허위 사실 유포 등의 부적절한 내용이 포함될 경우, + 관련 법령에 따라 민·형사상 법적 조치가 이루어질 수 있음을 알려드립니다. + """ + } + + private let retryLimit: Int = 1 + + private weak var provider: ManagerTypeDelegate? + + init(provider: ManagerTypeDelegate) { + self.provider = provider + } + + func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) { + + /// API 호출 중 네트워크 오류 발생 + if let afError = error.asAFError, + case let .sessionTaskFailed(underlyingError) = afError, + let urlError = underlyingError as? URLError { + + let networkErrors = [ + URLError.timedOut, + URLError.notConnectedToInternet, + URLError.networkConnectionLost, + URLError.cannotConnectToHost + ] + if networkErrors.contains(urlError.code) { + self.showNetworkErrorDialog() + completion(.doNotRetry) + return + } + } + + guard let response = request.task?.response as? HTTPURLResponse else { + completion(.doNotRetryWithError(error)) + return + } + + switch response.statusCode { + /// AccessToken 재인증 + case 401: + // 재인증 과정은 1번만 진행한다. + guard request.retryCount < self.retryLimit else { + let retryError = NSError( + domain: "SOOUM", + code: -99, + userInfo: [ + NSLocalizedDescriptionKey: "Retry error: ReAuthenticate process is performed only once." + ] + ) + completion(.doNotRetryWithError(retryError)) + return + } + + guard let provider = self.provider else { + let retryError = NSError( + domain: "SOOUM", + code: -99, + userInfo: [ + NSLocalizedDescriptionKey: "Retry error: `self` deallocated before network response received." + ] + ) + completion(.doNotRetryWithError(retryError)) + return + } + + let token = provider.authManager.authInfo.token + provider.authManager.reAuthenticate(token) { result in + + switch result { + case .success: + completion(.retry) + case let .failure(error): + Log.error("ReAuthenticate failed. \(error.localizedDescription)") + completion(.doNotRetry) + } + } + return + case 418: + self.goToOnboarding() + completion(.doNotRetry) + return + case 500: + self.showUnknownErrorDialog() + completion(.doNotRetry) + return + default: + break + } + + completion(.doNotRetryWithError(error)) + } + + + // MARK: Error handling + + func showNetworkErrorDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss() + } + ) + + DispatchQueue.main.async { + SOMDialogViewController.show( + title: Text.networkErrorDialogTitle, + message: Text.networkErrorDialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } + } + + func showUnknownErrorDialog() { + + let closeAction = SOMDialogAction( + title: Text.closeActionButtonTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + + let inquireAction = SOMDialogAction( + title: Text.inquiryActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + let subject = Text.inquiryMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let guideMessage = """ + \(Text.identificationInfo) + \(self.provider?.authManager.authInfo.token.refreshToken ?? "")\n + \(Text.inquiryMailGuideMessage) + """ + let body = guideMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let mailToString = "mailto:\(Text.adminMailStrUrl)?subject=\(subject)&body=\(body)" + + if let mailtoUrl = URL(string: mailToString), + UIApplication.shared.canOpenURL(mailtoUrl) { + + UIApplication.shared.open(mailtoUrl, options: [:], completionHandler: nil) + } + } + } + ) + + DispatchQueue.main.async { + SOMDialogViewController.show( + title: Text.unknownErrorDialogTitle, + message: Text.unknownErrorDialogMessage, + textAlignment: .left, + actions: [closeAction, inquireAction] + ) + } + } + + + // MARK: go to onboarding + + func goToOnboarding() { + + self.provider?.authManager.initializeAuthInfo() + + DispatchQueue.main.async { + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, + let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + let onBoardingViewController = OnboardingViewController() + onBoardingViewController.reactor = OnboardingViewReactor(dependencies: appDelegate.appDIContainer) + onBoardingViewController.modalTransitionStyle = .crossDissolve + window.rootViewController = UINavigationController(rootViewController: onBoardingViewController) + } + } +} diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/TimeoutInterceptor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/TimeoutInterceptor.swift similarity index 88% rename from SOOUM/SOOUM/Managers/NetworkManager/Interceptor/TimeoutInterceptor.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/TimeoutInterceptor.swift index 96283d9e..4e0a3634 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/TimeoutInterceptor.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/TimeoutInterceptor.swift @@ -5,12 +5,9 @@ // Created by 오현식 on 11/4/24. // -import Foundation - import Alamofire - -class TimeoutInterceptor: RequestInterceptor { +final class TimeoutInterceptor: RequestInterceptor { private let timeoutInterval: TimeInterval diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Models/PushTokenSet.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Models/PushTokenSet.swift similarity index 100% rename from SOOUM/SOOUM/Managers/NetworkManager/Models/PushTokenSet.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/Models/PushTokenSet.swift diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Monitor/LogginMonitor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Monitor/LogginMonitor.swift similarity index 97% rename from SOOUM/SOOUM/Managers/NetworkManager/Monitor/LogginMonitor.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/Monitor/LogginMonitor.swift index 8e80d61b..67da7fd1 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/Monitor/LogginMonitor.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Monitor/LogginMonitor.swift @@ -5,12 +5,9 @@ // Created by 오현식 on 11/4/24. // -import Foundation - import Alamofire - -class LogginMonitor: EventMonitor { +final class LogginMonitor: EventMonitor { private let formatter: DateFormatter = { let formatter = DateFormatter() diff --git a/SOOUM/SOOUM/Managers/NetworkManager/NetworkManager.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift similarity index 55% rename from SOOUM/SOOUM/Managers/NetworkManager/NetworkManager.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift index 9d167ff3..58ce8009 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/NetworkManager.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift @@ -8,20 +8,27 @@ import Foundation import Alamofire -import FirebaseMessaging import RxSwift protocol NetworkManagerDelegate: AnyObject { + func request(request: BaseRequest) -> Observable func request(_ object: T.Type, request: BaseRequest) -> Observable func upload( _ data: Data, to url: URLConvertible - ) -> Observable> + ) -> Observable> + func fetch(_ object: T.Type, request: BaseRequest) -> Observable + func perform(_ request: BaseRequest) -> Observable + func perform(_ object: T.Type, request: BaseRequest) -> Observable + func registerFCMToken(with tokenSet: PushTokenSet, _ function: String) func registerFCMToken(from function: String) + + func version() -> Observable> + func updateCheck() -> Observable } class NetworkManager: CompositeManager { @@ -57,6 +64,40 @@ extension NetworkManager: NetworkManagerDelegate { // MARK: Request network sevice + func request(request: BaseRequest) -> Observable { + return Observable.create { [weak self] observer -> Disposable in + + let task = self?.session.request(request) + .validate(statusCode: 200..<300) + .response { response in + let statusCode = response.response?.statusCode ?? 0 + + switch response.result { + case .success: + if let nsError = self?.setupError(with: statusCode) { + Log.error(nsError.localizedDescription) + observer.onError(nsError) + } else { + observer.onNext(statusCode) + observer.onCompleted() + } + case let .failure(error): + if let nsError = self?.setupError(with: statusCode) { + Log.error(nsError.localizedDescription) + observer.onError(nsError) + } else { + Log.error("Network or response format error: with \(error.localizedDescription)") + observer.onError(error) + } + } + } + + return Disposables.create { + task?.cancel() + } + } + } + func request(_ object: T.Type, request: BaseRequest) -> Observable { return Observable.create { [weak self] observer -> Disposable in @@ -98,16 +139,23 @@ extension NetworkManager: NetworkManagerDelegate { func upload( _ data: Data, to url: URLConvertible - ) -> Observable> { + ) -> Observable> { return Observable.create { [weak self] observer -> Disposable in let task = self?.session.upload(data, to: url, method: .put) .validate(statusCode: 200..<500) .response { response in + let statusCode = response.response?.statusCode ?? 0 + switch response.result { case .success: - observer.onNext(.success(())) - observer.onCompleted() + if let nsError = self?.setupError(with: statusCode) { + Log.error(nsError.localizedDescription) + observer.onError(nsError) + } else { + observer.onNext(.success(statusCode)) + observer.onCompleted() + } case let .failure(error): Log.error("Network or response format error: \(error)") observer.onError(error) @@ -119,61 +167,31 @@ extension NetworkManager: NetworkManagerDelegate { } } } -} - - -// MARK: Register FCM token - -extension NetworkManager { - - static var registeredToken: PushTokenSet? - static var fcmDisposeBag = DisposeBag() - func registerFCMToken(with tokenSet: PushTokenSet, _ function: String) { + func fetch(_ object: T.Type, request: BaseRequest) -> Observable { - // AccessToken이 없는 경우 업데이트에 실패하므로 무시 - guard let provider = self.provider, provider.authManager.hasToken else { - Log.info("Can't upload fcm token without authorization token. (from: \(function))") - return + guard request.method == .get else { + return Observable.error(DefinedError.invalidMethod(request.method)) } + return self.request(object, request: request) + } + + func perform(_ request: BaseRequest) -> Observable { - let prevTokenSet: PushTokenSet? = Self.registeredToken - // TODO: 이전에 업로드 성공한 토큰이 다시 등록되는 경우 무시, 계정 이관 이슈로 중복 토큰도 항상 업데이트 - // guard tokenSet != Self.registeredToken else { - // Log.info("Ignored already registered token set. (from: \(`func`))") - // return - // } - - guard let fcmToken = tokenSet.fcm, let apns = tokenSet.apns else { return } - Log.info("Firebase registration token: \(fcmToken) [with \(apns)] (from: \(function))") - - // 서버에 FCM token 등록 - if let fcmToken = tokenSet.fcm, let provider = self.provider { - - let request: AuthRequest = .updateFCM(fcmToken: fcmToken) - provider.networkManager.request(Empty.self, request: request) - .subscribe( - onNext: { _ in - Log.info("Update FCM token to server with", fcmToken) - }, - onError: { _ in - Log.error("Failed to update FCM token to server: not found user") - } - ) - .disposed(by: Self.fcmDisposeBag) - } else { - - Self.registeredToken = prevTokenSet - Log.info("Failed to update FCM token to server: not found device unique id") + guard request.method == .post || request.method == .patch || request.method == .delete else { + return Observable.error(DefinedError.invalidMethod(request.method)) } + + return self.request(request: request) } - func registerFCMToken(from func: String) { - let tokenSet = PushTokenSet( - apns: nil, - fcm: Messaging.messaging().fcmToken - ) - self.registerFCMToken(with: tokenSet, `func`) + func perform(_ object: T.Type, request: BaseRequest) -> Observable { + + guard request.method == .post || request.method == .patch || request.method == .delete else { + return Observable.error(DefinedError.invalidMethod(request.method)) + } + + return self.request(object, request: request) } } diff --git a/SOOUM/SOOUM/Managers/NetworkManager/NetworkManagerConfiguration.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManagerConfiguration.swift similarity index 57% rename from SOOUM/SOOUM/Managers/NetworkManager/NetworkManagerConfiguration.swift rename to SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManagerConfiguration.swift index 88235bba..09564ff2 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/NetworkManagerConfiguration.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManagerConfiguration.swift @@ -38,16 +38,36 @@ struct NetworkManagerConfiguration: ManagerConfiguration { self.sessionDelegate = sessionDelegate self.sessionDelegateQueue = sessionDelegateQueue - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - /// UTC 기준 - formatter.timeZone = .Korea - formatter.locale = .Korea + let fullFormatter = DateFormatter() + fullFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" + fullFormatter.locale = .Korea + fullFormatter.timeZone = .Korea + + let shortFormatter = DateFormatter() + shortFormatter.dateFormat = "yyyy-MM-dd" + shortFormatter.locale = .Korea + shortFormatter.timeZone = .Korea self.decoder = JSONDecoder() - self.decoder.dateDecodingStrategy = .formatted(formatter) + self.decoder.dateDecodingStrategy = .custom { decoder in + let singleContainer = try decoder.singleValueContainer() + let dateString = try singleContainer.decode(String.self) + + if let date = fullFormatter.date(from: dateString) { + return date + } + + if let date = shortFormatter.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError( + in: singleContainer, + debugDescription: "Date string \(dateString) cannot be decoded" + ) + } self.encoder = JSONEncoder() - self.encoder.dateEncodingStrategy = .formatted(formatter) + self.encoder.dateEncodingStrategy = .formatted(fullFormatter) } } diff --git a/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager_FCM.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager_FCM.swift new file mode 100644 index 00000000..c8a0fe94 --- /dev/null +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager_FCM.swift @@ -0,0 +1,69 @@ +// +// NetworkManager_FCM.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +import Alamofire +import FirebaseMessaging +import RxSwift + + +// MARK: Register FCM token + +extension NetworkManager { + + static var registeredToken: PushTokenSet? + static var fcmDisposeBag = DisposeBag() + + func registerFCMToken(with tokenSet: PushTokenSet, _ function: String) { + + // AccessToken이 없는 경우 업데이트에 실패하므로 무시 + guard let provider = self.provider, provider.authManager.hasToken else { + Log.info("Can't upload fcm token without authorization token. (from: \(function))") + return + } + + + let prevTokenSet: PushTokenSet? = Self.registeredToken + // TODO: 이전에 업로드 성공한 토큰이 다시 등록되는 경우 무시, 계정 이관 이슈로 중복 토큰도 항상 업데이트 + // guard tokenSet != Self.registeredToken else { + // Log.info("Ignored already registered token set. (from: \(`func`))") + // return + // } + + guard let fcmToken = tokenSet.fcm, let apns = tokenSet.apns else { return } + Log.info("Firebase registration token: \(fcmToken) [with \(apns)] (from: \(function))") + + // 서버에 FCM token 등록 + if let fcmToken = tokenSet.fcm { + + let request: UserRequest = .updateFCMToken(fcmToken: fcmToken) + provider.networkManager.perform(Empty.self, request: request) + .subscribe( + onNext: { _ in + Log.info("Update FCM token to server with", fcmToken) + }, + onError: { _ in + Log.error("Failed to update FCM token to server: not found user") + } + ) + .disposed(by: Self.fcmDisposeBag) + } else { + + Self.registeredToken = prevTokenSet + Log.info("Failed to update FCM token to server: not found device unique id") + } + } + + func registerFCMToken(from func: String) { + let tokenSet = PushTokenSet( + apns: nil, + fcm: Messaging.messaging().fcmToken + ) + self.registerFCMToken(with: tokenSet, `func`) + } +} diff --git a/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager_Version.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager_Version.swift new file mode 100644 index 00000000..53c8f361 --- /dev/null +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager_Version.swift @@ -0,0 +1,29 @@ +// +// NetworkManager_Version.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +import Alamofire +import RxSwift + + +// MARK: Version + +extension NetworkManager { + + func version() -> Observable> { + + let request = VersionRequest.version + return self.fetch(AppVersionStatusResponse.self, request: request) + .map { return .success($0) } + .catch { return .just(.failure($0)) } + } + + func updateCheck() -> Observable { + return self.version().map { (try? $0.get()) ?? AppVersionStatusResponse.emptyValue() } + } +} diff --git a/SOOUM/SOOUM/Data/Managers/PushManager/Models/PushNotificationInfo.swift b/SOOUM/SOOUM/Data/Managers/PushManager/Models/PushNotificationInfo.swift new file mode 100644 index 00000000..9fe06152 --- /dev/null +++ b/SOOUM/SOOUM/Data/Managers/PushManager/Models/PushNotificationInfo.swift @@ -0,0 +1,35 @@ +// +// NotificationInfo.swift +// SOOUM +// +// Created by 오현식 on 12/27/24. +// + +import Foundation + +class PushNotificationInfo { + + let notificationType: CommonNotificationInfo.NotificationType + let notificationId: String? + let targetCardId: String? + + var isTransfered: Bool { + return self.notificationType == .transferSuccess + } + + init(_ info: [String: Any]) { + let notificationType = info["notificationType"] as? String ?? "" + self.notificationType = CommonNotificationInfo.NotificationType(rawValue: notificationType) ?? .none + self.notificationId = info["notificationId"] as? String + self.targetCardId = info["targetCardId"] as? String + + Log.info( + """ + PushNotificationInfo: + notificationType: \(self.notificationType) + notificationId: \(self.notificationId ?? "") + targetCardId: \(self.targetCardId ?? "") + """ + ) + } +} diff --git a/SOOUM/SOOUM/Managers/PushManager/PushManager+Rx.swift b/SOOUM/SOOUM/Data/Managers/PushManager/PushManager+Rx.swift similarity index 80% rename from SOOUM/SOOUM/Managers/PushManager/PushManager+Rx.swift rename to SOOUM/SOOUM/Data/Managers/PushManager/PushManager+Rx.swift index 80bc2f51..03b95046 100644 --- a/SOOUM/SOOUM/Managers/PushManager/PushManager+Rx.swift +++ b/SOOUM/SOOUM/Data/Managers/PushManager/PushManager+Rx.swift @@ -11,8 +11,8 @@ import RxSwift extension PushManagerDelegate { func switchNotification(on: Bool) -> Observable { - return .create { observer in - self.switchNotification(isOn: on) { error in + return Observable.create { [weak self] observer in + self?.switchNotification(isOn: on) { error in if let error: Error = error { observer.onNext(error) } else { diff --git a/SOOUM/SOOUM/Managers/PushManager/PushManager.swift b/SOOUM/SOOUM/Data/Managers/PushManager/PushManager.swift similarity index 52% rename from SOOUM/SOOUM/Managers/PushManager/PushManager.swift rename to SOOUM/SOOUM/Data/Managers/PushManager/PushManager.swift index f9358e8b..71573db9 100644 --- a/SOOUM/SOOUM/Managers/PushManager/PushManager.swift +++ b/SOOUM/SOOUM/Data/Managers/PushManager/PushManager.swift @@ -11,7 +11,7 @@ import UIKit protocol PushManagerDelegate: AnyObject { var window: UIWindow? { get } - func setupRootViewController(_ info: NotificationInfo?, terminated: Bool) + // func setupRootViewController(_ info: NotificationInfo?, terminated: Bool) var canReceiveNotifications: Bool { get } var notificationStatus: Bool { get } @@ -47,44 +47,46 @@ extension PushManager: PushManagerDelegate { // MARK: Navigation - func setupRootViewController(_ info: NotificationInfo?, terminated: Bool) { - - DispatchQueue.main.async { [weak self] in - if self?.window != nil { - if terminated { - self?.setupLaunchScreenViewController(info) - } else { - self?.setupMainTabBarController(info) - } - } - } - } + // func setupRootViewController(_ info: NotificationInfo?, terminated: Bool) { + // + // DispatchQueue.main.async { [weak self] in + // if self?.window != nil { + // if terminated { + // self?.setupLaunchScreenViewController(info) + // } else { + // self?.setupMainTabBarController(info) + // } + // } + // } + // } - fileprivate func setupLaunchScreenViewController(_ pushInfo: NotificationInfo?) { - - guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } - let provider = appDelegate.provider - - let launchScreenReactor = LaunchScreenViewReactor(provider: provider, pushInfo: pushInfo) - let launchScreenViewController = LaunchScreenViewController() - launchScreenViewController.reactor = launchScreenReactor - - let navigationController = UINavigationController(rootViewController: launchScreenViewController) - self.window?.rootViewController = navigationController - } + // fileprivate func setupLaunchScreenViewController(_ pushInfo: NotificationInfo?) { + // + // guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } + // let appDIContainer = appDelegate.appDIContainer + // + // _ = appDIContainer.setupLaunchContainer(with: pushInfo) + // + // let launchScreenReactor = LaunchScreenViewReactor(provider: provider, pushInfo: pushInfo) + // let launchScreenViewController = LaunchScreenViewController() + // launchScreenViewController.reactor = launchScreenReactor + // + // let navigationController = UINavigationController(rootViewController: launchScreenViewController) + // self.window?.rootViewController = navigationController + // } - fileprivate func setupMainTabBarController(_ pushInfo: NotificationInfo?) { - - guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } - let provider = appDelegate.provider - - let mainTabBarReactor = MainTabBarReactor(provider: provider, pushInfo: pushInfo) - let mainTabBarController = MainTabBarController() - mainTabBarController.reactor = mainTabBarReactor - - let navigationController = UINavigationController(rootViewController: mainTabBarController) - self.window?.rootViewController = navigationController - } + // fileprivate func setupMainTabBarController(_ pushInfo: NotificationInfo?) { + // + // guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return } + // let provider = appDelegate.provider + // + // let mainTabBarReactor = MainTabBarReactor(provider: provider, pushInfo: pushInfo) + // let mainTabBarController = MainTabBarController() + // mainTabBarController.reactor = mainTabBarReactor + // + // let navigationController = UINavigationController(rootViewController: mainTabBarController) + // self.window?.rootViewController = navigationController + // } // MARK: Notification @@ -164,20 +166,55 @@ extension PushManager: PushManagerDelegate { } } + // TODO: 임시, removeDeliveredNotifications(withIdentifiers: ) 의도한 동작 X + // 모든 알림을 삭제 후 특정 알림을 제외한 알림을 재 요청한다. func deleteNotification(notificationId: String) { - UNUserNotificationCenter.current().getDeliveredNotifications { notifications in - let matchedIds = notifications - .filter { - ($0.request.content.userInfo["notificationId"] as? String) == notificationId + let current = UNUserNotificationCenter.current() + + Log.debug("remove notification with userInfo ID: \(notificationId)") + + current.getDeliveredNotifications { notifications in + + guard notifications.isEmpty == false else { + Log.debug("No delivered notifications to remove.") + return + } + + let requestsToKeep = notifications + .filter { ($0.request.content.userInfo["notificationId"] as? String) != notificationId } + .map { + let identifier = $0.request.identifier + let contentToKeep = $0.request.content.mutableCopy() as! UNMutableNotificationContent + contentToKeep.userInfo["isReAddedNotifications"] = true + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) + + // 아주 짧은 시간 뒤에 다시 발송 + return UNNotificationRequest( + identifier: identifier, + content: contentToKeep, + trigger: trigger + ) } - .map { $0.request.identifier } + Log.debug("Preparing to remove all notifications and re-add \(requestsToKeep.count) notifications.") - if !matchedIds.isEmpty { - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: matchedIds) + current.removeAllDeliveredNotifications() + Log.debug("Called removeAllDeliveredNotifications.") + + if requestsToKeep.isEmpty { + Log.debug("No notifications to re-add.") + } else { + requestsToKeep.forEach { request in + current.add(request) { error in + if let error = error { + Log.error("Error re-adding notification (ID: \(request.identifier)): \(error.localizedDescription)") + } else { + Log.debug("Successfully re-added notification (ID: \(request.identifier))") + } + } + } } } } - } extension PushManagerDelegate { diff --git a/SOOUM/SOOUM/Managers/PushManager/PushManagerConfiguration.swift b/SOOUM/SOOUM/Data/Managers/PushManager/PushManagerConfiguration.swift similarity index 100% rename from SOOUM/SOOUM/Managers/PushManager/PushManagerConfiguration.swift rename to SOOUM/SOOUM/Data/Managers/PushManager/PushManagerConfiguration.swift diff --git a/SOOUM/SOOUM/Data/Models/Responses/AppVersionStatusResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/AppVersionStatusResponse.swift new file mode 100644 index 00000000..390bf185 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/AppVersionStatusResponse.swift @@ -0,0 +1,28 @@ +// +// AppVersionStatusResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct AppVersionStatusResponse { + + let version: Version +} + +extension AppVersionStatusResponse: EmptyResponse { + + static func emptyValue() -> AppVersionStatusResponse { + return AppVersionStatusResponse(version: Version.defaultValue) + } +} + +extension AppVersionStatusResponse: Decodable { + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.version = try singleContainer.decode(Version.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/BlockUsersInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/BlockUsersInfoResponse.swift new file mode 100644 index 00000000..232c6abd --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/BlockUsersInfoResponse.swift @@ -0,0 +1,32 @@ +// +// BlockUsersInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Alamofire + +struct BlockUsersInfoResponse { + + let blockUsers: [BlockUserInfo] +} + +extension BlockUsersInfoResponse: EmptyResponse { + + static func emptyValue() -> BlockUsersInfoResponse { + BlockUsersInfoResponse(blockUsers: []) + } +} + +extension BlockUsersInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case blockUsers + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.blockUsers = try singleContainer.decode([BlockUserInfo].self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/BlockedNotificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/BlockedNotificationInfoResponse.swift new file mode 100644 index 00000000..1520eb63 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/BlockedNotificationInfoResponse.swift @@ -0,0 +1,40 @@ +// +// BlockedNotificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct BlockedNotificationInfoResponse: Hashable, Equatable { + + let notificationInfo: CommonNotificationInfo + let blockExpirationDateTime: Date +} + +extension BlockedNotificationInfoResponse: EmptyResponse { + + static func emptyValue() -> BlockedNotificationInfoResponse { + BlockedNotificationInfoResponse( + notificationInfo: CommonNotificationInfo.defaultValue, + blockExpirationDateTime: Date() + ) + } +} + +extension BlockedNotificationInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case notificationInfo + case blockExpirationDateTime + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode(CommonNotificationInfo.self) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.blockExpirationDateTime = try container.decode(Date.self, forKey: .blockExpirationDateTime) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/CheckAvailableResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/CheckAvailableResponse.swift new file mode 100644 index 00000000..82684d86 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/CheckAvailableResponse.swift @@ -0,0 +1,28 @@ +// +// CheckAvailableResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct CheckAvailableResponse { + + let checkAvailable: CheckAvailable +} + +extension CheckAvailableResponse: EmptyResponse { + + static func emptyValue() -> CheckAvailableResponse { + CheckAvailableResponse(checkAvailable: CheckAvailable.defaultValue) + } +} + +extension CheckAvailableResponse: Decodable { + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.checkAvailable = try singleContainer.decode(CheckAvailable.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/CompositeNotificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/CompositeNotificationInfoResponse.swift new file mode 100644 index 00000000..87e4dfb4 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/CompositeNotificationInfoResponse.swift @@ -0,0 +1,32 @@ +// +// CompositeNotificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/26/25. +// + +import Alamofire + +struct CompositeNotificationInfoResponse { + + let notificationInfo: [CompositeNotificationInfo] +} + +extension CompositeNotificationInfoResponse: EmptyResponse { + + static func emptyValue() -> CompositeNotificationInfoResponse { + CompositeNotificationInfoResponse(notificationInfo: []) + } +} + +extension CompositeNotificationInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case notificationInfo + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode([CompositeNotificationInfo].self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/DefaultImagesResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/DefaultImagesResponse.swift new file mode 100644 index 00000000..263fd3c5 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/DefaultImagesResponse.swift @@ -0,0 +1,20 @@ +// +// DefaultImagesResponse.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Alamofire + +struct DefaultImagesResponse: Decodable { + + let defaultImages: DefaultImages +} + +extension DefaultImagesResponse: EmptyResponse { + + static func emptyValue() -> DefaultImagesResponse { + DefaultImagesResponse(defaultImages: DefaultImages.defaultValue) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/DeletedNotificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/DeletedNotificationInfoResponse.swift new file mode 100644 index 00000000..b6f98fb5 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/DeletedNotificationInfoResponse.swift @@ -0,0 +1,28 @@ +// +// DeleteNotificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct DeletedNotificationInfoResponse: Hashable, Equatable { + + let notificationInfo: CommonNotificationInfo +} + +extension DeletedNotificationInfoResponse: EmptyResponse { + + static func emptyValue() -> DeletedNotificationInfoResponse { + DeletedNotificationInfoResponse(notificationInfo: CommonNotificationInfo.defaultValue) + } +} + +extension DeletedNotificationInfoResponse: Decodable { + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode(CommonNotificationInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/DetailCardInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/DetailCardInfoResponse.swift new file mode 100644 index 00000000..ace75cae --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/DetailCardInfoResponse.swift @@ -0,0 +1,32 @@ +// +// DetailCardInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import Alamofire + +struct DetailCardInfoResponse { + + let cardInfos: DetailCardInfo +} + +extension DetailCardInfoResponse: EmptyResponse { + + static func emptyValue() -> DetailCardInfoResponse { + DetailCardInfoResponse(cardInfos: DetailCardInfo.defaultValue) + } +} + +extension DetailCardInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case cardInfos + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.cardInfos = try singleContainer.decode(DetailCardInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/FavoriteTagInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/FavoriteTagInfoResponse.swift new file mode 100644 index 00000000..59e99cfb --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/FavoriteTagInfoResponse.swift @@ -0,0 +1,27 @@ +// +// FavoriteTagInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/18/25. +// + +import Alamofire + +struct FavoriteTagInfoResponse { + + let tagInfos: [FavoriteTagInfo] +} + +extension FavoriteTagInfoResponse: EmptyResponse { + + static func emptyValue() -> FavoriteTagInfoResponse { + FavoriteTagInfoResponse(tagInfos: []) + } +} + +extension FavoriteTagInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case tagInfos = "favoriteTags" + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/FollowInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/FollowInfoResponse.swift new file mode 100644 index 00000000..37462924 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/FollowInfoResponse.swift @@ -0,0 +1,32 @@ +// +// FollowInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct FollowInfoResponse { + + let followInfos: [FollowInfo] +} + +extension FollowInfoResponse: EmptyResponse { + + static func emptyValue() -> FollowInfoResponse { + FollowInfoResponse(followInfos: []) + } +} + +extension FollowInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case followInfos + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.followInfos = try singleContainer.decode([FollowInfo].self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift new file mode 100644 index 00000000..bc65da64 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift @@ -0,0 +1,44 @@ +// +// FollowNotificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/26/25. +// + +import Alamofire + +struct FollowNotificationInfoResponse: Hashable, Equatable { + + let notificationInfo: CommonNotificationInfo + let nickname: String + let userId: String +} + +extension FollowNotificationInfoResponse: EmptyResponse { + + static func emptyValue() -> FollowNotificationInfoResponse { + FollowNotificationInfoResponse( + notificationInfo: CommonNotificationInfo.defaultValue, + nickname: "", + userId: "" + ) + } +} + +extension FollowNotificationInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case notificationInfo + case nickname = "nickName" + case userId + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode(CommonNotificationInfo.self) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.userId = String(try container.decode(Int64.self, forKey: .userId)) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/HomeCardInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/HomeCardInfoResponse.swift new file mode 100644 index 00000000..c26736de --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/HomeCardInfoResponse.swift @@ -0,0 +1,32 @@ +// +// BaseCardInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import Alamofire + +struct BaseCardInfoResponse { + + let cardInfos: [BaseCardInfo] +} + +extension BaseCardInfoResponse: EmptyResponse { + + static func emptyValue() -> BaseCardInfoResponse { + BaseCardInfoResponse(cardInfos: []) + } +} + +extension BaseCardInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case cardInfos + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.cardInfos = try singleContainer.decode([BaseCardInfo].self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/ImageUrlInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/ImageUrlInfoResponse.swift new file mode 100644 index 00000000..4457b6b2 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/ImageUrlInfoResponse.swift @@ -0,0 +1,28 @@ +// +// ImageUrlInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct ImageUrlInfoResponse { + + let imageUrlInfo: ImageUrlInfo +} + +extension ImageUrlInfoResponse: EmptyResponse { + + static func emptyValue() -> ImageUrlInfoResponse { + ImageUrlInfoResponse(imageUrlInfo: ImageUrlInfo.defaultValue) + } +} + +extension ImageUrlInfoResponse: Decodable { + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.imageUrlInfo = try singleContainer.decode(ImageUrlInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/IsCardDeletedResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/IsCardDeletedResponse.swift new file mode 100644 index 00000000..4f8d811d --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/IsCardDeletedResponse.swift @@ -0,0 +1,32 @@ +// +// IsCardDeletedResponse.swift +// SOOUM +// +// Created by 오현식 on 12/5/25. +// + +import Alamofire + +struct IsCardDeletedResponse { + + let isDeleted: Bool +} + +extension IsCardDeletedResponse: EmptyResponse { + + static func emptyValue() -> IsCardDeletedResponse { + IsCardDeletedResponse(isDeleted: false) + } +} + +extension IsCardDeletedResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case isDeleted + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.isDeleted = try container.decode(Bool.self, forKey: .isDeleted) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/KeyInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/KeyInfoResponse.swift new file mode 100644 index 00000000..6d462a29 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/KeyInfoResponse.swift @@ -0,0 +1,13 @@ +// +// KeyInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct KeyInfoResponse: Decodable { + + let publicKey: String +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/LoginResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/LoginResponse.swift new file mode 100644 index 00000000..a782ef41 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/LoginResponse.swift @@ -0,0 +1,32 @@ +// +// LoginResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct LoginResponse { + + let token: Token +} + +extension LoginResponse: EmptyResponse { + + static func emptyValue() -> LoginResponse { + LoginResponse(token: Token.defaultValue) + } +} + +extension LoginResponse: Decodable { + + enum CodingKeys: CodingKey { + case token + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.token = try singleContainer.decode(Token.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/NicknameResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/NicknameResponse.swift new file mode 100644 index 00000000..942bbc7e --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/NicknameResponse.swift @@ -0,0 +1,33 @@ +// +// NicknameResponse.swift +// SOOUM +// +// Created by 오현식 on 9/18/25. +// + +import Alamofire + +struct NicknameResponse { + + let nickname: String +} + +extension NicknameResponse: EmptyResponse { + + static func emptyValue() -> NicknameResponse { + NicknameResponse(nickname: "") + } +} + +extension NicknameResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case nickname + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.nickname = try container.decode(String.self, forKey: .nickname) + } +} + diff --git a/SOOUM/SOOUM/Data/Models/Responses/NicknameValidateResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/NicknameValidateResponse.swift new file mode 100644 index 00000000..980b99b2 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/NicknameValidateResponse.swift @@ -0,0 +1,20 @@ +// +// NicknameValidateResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct NicknameValidateResponse: Decodable { + + let isAvailable: Bool +} + +extension NicknameValidateResponse: EmptyResponse { + + static func emptyValue() -> NicknameValidateResponse { + NicknameValidateResponse(isAvailable: false) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/NoticeInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/NoticeInfoResponse.swift new file mode 100644 index 00000000..98b4bd5b --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/NoticeInfoResponse.swift @@ -0,0 +1,32 @@ +// +// NoticeInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/26/25. +// + +import Alamofire + +struct NoticeInfoResponse { + + let noticeInfos: [NoticeInfo] +} + +extension NoticeInfoResponse: EmptyResponse { + + static func emptyValue() -> NoticeInfoResponse { + NoticeInfoResponse(noticeInfos: []) + } +} + +extension NoticeInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case noticeInfos = "notices" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.noticeInfos = try container.decode([NoticeInfo].self, forKey: .noticeInfos) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/NotificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/NotificationInfoResponse.swift new file mode 100644 index 00000000..103faa2f --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/NotificationInfoResponse.swift @@ -0,0 +1,44 @@ +// +// NotificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct NotificationInfoResponse: Hashable, Equatable { + + let notificationInfo: CommonNotificationInfo + let targetCardId: String + let nickName: String +} + +extension NotificationInfoResponse: EmptyResponse { + + static func emptyValue() -> NotificationInfoResponse { + NotificationInfoResponse( + notificationInfo: CommonNotificationInfo.defaultValue, + targetCardId: "", + nickName: "" + ) + } +} + +extension NotificationInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case notificationInfo + case targetCardId + case nickName + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode(CommonNotificationInfo.self) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.targetCardId = String(try container.decode(Int64.self, forKey: .targetCardId)) + self.nickName = try container.decode(String.self, forKey: .nickName) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/PostingPermissionResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/PostingPermissionResponse.swift new file mode 100644 index 00000000..572e2d66 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/PostingPermissionResponse.swift @@ -0,0 +1,28 @@ +// +// PostingPermissionResponse.swift +// SOOUM +// +// Created by 오현식 on 10/30/25. +// + +import Alamofire + +struct PostingPermissionResponse { + + let postingPermission: PostingPermission +} + +extension PostingPermissionResponse: EmptyResponse { + + static func emptyValue() -> PostingPermissionResponse { + PostingPermissionResponse(postingPermission: PostingPermission.defaultValue) + } +} + +extension PostingPermissionResponse: Decodable { + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.postingPermission = try singleContainer.decode(PostingPermission.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/ProfileCardInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/ProfileCardInfoResponse.swift new file mode 100644 index 00000000..44d3edff --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/ProfileCardInfoResponse.swift @@ -0,0 +1,32 @@ +// +// ProfileCardInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct ProfileCardInfoResponse { + + let cardInfos: [ProfileCardInfo] +} + +extension ProfileCardInfoResponse: EmptyResponse { + + static func emptyValue() -> ProfileCardInfoResponse { + ProfileCardInfoResponse(cardInfos: []) + } +} + +extension ProfileCardInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case cardInfos = "cardContents" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cardInfos = try container.decode([ProfileCardInfo].self, forKey: .cardInfos) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/ProfileInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/ProfileInfoResponse.swift new file mode 100644 index 00000000..5c9a403c --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/ProfileInfoResponse.swift @@ -0,0 +1,32 @@ +// +// ProfileInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct ProfileInfoResponse { + + let profileInfo: ProfileInfo +} + +extension ProfileInfoResponse: EmptyResponse { + + static func emptyValue() -> ProfileInfoResponse { + ProfileInfoResponse(profileInfo: ProfileInfo.defaultValue) + } +} + +extension ProfileInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case profileInfo + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.profileInfo = try singleContainer.decode(ProfileInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/RejoinableDateInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/RejoinableDateInfoResponse.swift new file mode 100644 index 00000000..0985897f --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/RejoinableDateInfoResponse.swift @@ -0,0 +1,32 @@ +// +// RejoinableDateInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Alamofire + +struct RejoinableDateInfoResponse { + + let rejoinableDate: RejoinableDateInfo +} + +extension RejoinableDateInfoResponse: EmptyResponse { + + static func emptyValue() -> RejoinableDateInfoResponse { + RejoinableDateInfoResponse(rejoinableDate: RejoinableDateInfo.defaultValue) + } +} + +extension RejoinableDateInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case rejoinableDate + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.rejoinableDate = try singleContainer.decode(RejoinableDateInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/SignUpResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/SignUpResponse.swift new file mode 100644 index 00000000..f1b2b45f --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/SignUpResponse.swift @@ -0,0 +1,28 @@ +// +// SignUpResponse.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +struct SignUpResponse { + + let token: Token +} + +extension SignUpResponse: EmptyResponse { + + static func emptyValue() -> SignUpResponse { + SignUpResponse(token: Token.defaultValue) + } +} + +extension SignUpResponse: Decodable { + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.token = try singleContainer.decode(Token.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/TagCardInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/TagCardInfoResponse.swift new file mode 100644 index 00000000..d5d822a8 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/TagCardInfoResponse.swift @@ -0,0 +1,35 @@ +// +// TagCardInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/18/25. +// + +import Alamofire + +struct TagCardInfoResponse { + + let cardInfos: [ProfileCardInfo] + let isFavorite: Bool +} + +extension TagCardInfoResponse: EmptyResponse { + + static func emptyValue() -> TagCardInfoResponse { + TagCardInfoResponse(cardInfos: [], isFavorite: false) + } +} + +extension TagCardInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case cardInfos = "cardContents" + case isFavorite + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cardInfos = try container.decode([ProfileCardInfo].self, forKey: .cardInfos) + self.isFavorite = try container.decode(Bool.self, forKey: .isFavorite) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/TagInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/TagInfoResponse.swift new file mode 100644 index 00000000..261b2fc5 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/TagInfoResponse.swift @@ -0,0 +1,19 @@ +// +// TagInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Alamofire + +struct TagInfoResponse: Decodable { + let tagInfos: [TagInfo] +} + +extension TagInfoResponse: EmptyResponse { + + static func emptyValue() -> TagInfoResponse { + TagInfoResponse(tagInfos: []) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/TagNofificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/TagNofificationInfoResponse.swift new file mode 100644 index 00000000..363220da --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/TagNofificationInfoResponse.swift @@ -0,0 +1,44 @@ +// +// TagNofificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Alamofire + +struct TagNofificationInfoResponse: Hashable, Equatable { + + let notificationInfo: CommonNotificationInfo + let targetCardId: String + let tagContent: String +} + +extension TagNofificationInfoResponse: EmptyResponse { + + static func emptyValue() -> TagNofificationInfoResponse { + TagNofificationInfoResponse( + notificationInfo: CommonNotificationInfo.defaultValue, + targetCardId: "", + tagContent: "" + ) + } +} + +extension TagNofificationInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case notificationInfo + case targetCardId + case tagContent + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode(CommonNotificationInfo.self) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.targetCardId = String(try container.decode(Int64.self, forKey: .targetCardId)) + self.tagContent = try container.decode(String.self, forKey: .tagContent) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/ToeknResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/ToeknResponse.swift new file mode 100644 index 00000000..2cb91f02 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/ToeknResponse.swift @@ -0,0 +1,32 @@ +// +// TokenResponse.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Alamofire + +struct TokenResponse { + + let token: Token +} + +extension TokenResponse: EmptyResponse { + + static func emptyValue() -> TokenResponse { + TokenResponse(token: Token.defaultValue) + } +} + +extension TokenResponse: Decodable { + + enum CodingKeys: CodingKey { + case token + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.token = try singleContainer.decode(Token.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/TransferCodeInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/TransferCodeInfoResponse.swift new file mode 100644 index 00000000..8954fa03 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/TransferCodeInfoResponse.swift @@ -0,0 +1,32 @@ +// +// TransferCodeInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Alamofire + +struct TransferCodeInfoResponse { + + let transferInfo: TransferCodeInfo +} + +extension TransferCodeInfoResponse: EmptyResponse { + + static func emptyValue() -> TransferCodeInfoResponse { + TransferCodeInfoResponse(transferInfo: TransferCodeInfo.defaultValue) + } +} + +extension TransferCodeInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case transferInfo + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.transferInfo = try singleContainer.decode(TransferCodeInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/WriteCardResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/WriteCardResponse.swift new file mode 100644 index 00000000..6d199afa --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/WriteCardResponse.swift @@ -0,0 +1,32 @@ +// +// WriteCardResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct WriteCardResponse { + + let cardId: String +} + +extension WriteCardResponse: EmptyResponse { + + static func emptyValue() -> WriteCardResponse { + WriteCardResponse(cardId: "") + } +} + +extension WriteCardResponse: Decodable { + + enum CodingKeys: CodingKey { + case cardId + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cardId = String(try container.decode(Int64.self, forKey: .cardId)) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift new file mode 100644 index 00000000..dd8576b2 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift @@ -0,0 +1,24 @@ +// +// AppVersionRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +import RxSwift + +class AppVersionRepositoryImpl: AppVersionRepository { + + private let remoteDataSource: AppVersionRemoteDataSource + + init(remoteDataSource: AppVersionRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func version() -> Observable { + + return self.remoteDataSource.version() + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift new file mode 100644 index 00000000..1bb3ed8a --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift @@ -0,0 +1,56 @@ +// +// AuthRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +class AuthRepositoryImpl: AuthRepository { + + private let remoteDataSource: AuthRemoteDataSource + private let localDataSource: AuthLocalDataSource + + init(remoteDataSource: AuthRemoteDataSource, localDataSource: AuthLocalDataSource) { + self.remoteDataSource = remoteDataSource + self.localDataSource = localDataSource + } + + func signUp(nickname: String, profileImageName: String?) -> Observable { + + self.remoteDataSource.signUp(nickname: nickname, profileImageName: profileImageName) + } + + func login() -> Observable { + + self.remoteDataSource.login() + } + + func withdraw(reaseon: String) -> Observable { + + self.remoteDataSource.withdraw(reaseon: reaseon) + } + + func initializeAuthInfo() { + + self.localDataSource.initializeAuthInfo() + } + + func hasToken() -> Bool { + + self.localDataSource.hasToken() + } + + func tokens() -> Token { + + self.localDataSource.tokens() + } + + func encryptedDeviceId() -> Observable { + + self.localDataSource.encryptedDeviceId() + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift new file mode 100644 index 00000000..3adcdb2c --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift @@ -0,0 +1,159 @@ +// +// CardRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import Foundation + +import RxSwift + +class CardRepositoryImpl: CardRepository { + + private let remoteDataSource: CardRemoteDataSource + + init(remoteDataSource: CardRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + + // MARK: Home + + func latestCard(lastId: String?, latitude: String?, longitude: String?) -> Observable { + + return self.remoteDataSource.latestCard(lastId: lastId, latitude: latitude, longitude: longitude) + } + + func popularCard(latitude: String?, longitude: String?) -> Observable { + + return self.remoteDataSource.popularCard(latitude: latitude, longitude: longitude) + } + + func distanceCard(lastId: String?, latitude: String, longitude: String, distanceFilter: String) -> Observable { + + return self.remoteDataSource.distanceCard(lastId: lastId, latitude: latitude, longitude: longitude, distanceFilter: distanceFilter) + } + + + // MARK: Detail + + func detailCard(id: String, latitude: String?, longitude: String?) -> Observable { + + return self.remoteDataSource.detailCard(id: id, latitude: latitude, longitude: longitude) + } + + func isCardDeleted(id: String) -> Observable { + + return self.remoteDataSource.isCardDeleted(id: id) + } + + func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable { + + return self.remoteDataSource.commentCard(id: id, lastId: lastId, latitude: latitude, longitude: longitude) + } + + func deleteCard(id: String) -> Observable { + + return self.remoteDataSource.deleteCard(id: id) + } + + func updateLike(id: String, isLike: Bool) -> Observable { + + return self.remoteDataSource.updateLike(id: id, isLike: isLike) + } + + func reportCard(id: String, reportType: String) -> Observable { + + return self.remoteDataSource.reportCard(id: id, reportType: reportType) + } + + + // MARK: Write + + func defaultImages() -> Observable { + + return self.remoteDataSource.defaultImages() + } + + func presignedURL() -> Observable { + + return self.remoteDataSource.presignedURL() + } + + func uploadImage(_ data: Data, with url: URL) -> Observable> { + + return self.remoteDataSource.uploadImage(data, with: url) + } + + func writeCard( + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + isStory: Bool, + tags: [String] + ) -> Observable { + + return self.remoteDataSource.writeCard( + isDistanceShared: isDistanceShared, + latitude: latitude, + longitude: longitude, + content: content, + font: font, + imgType: imgType, + imgName: imgName, + isStory: isStory, + tags: tags + ) + } + + func writeComment( + id: String, + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + tags: [String] + ) -> Observable { + + return self.remoteDataSource.writeComment( + id: id, + isDistanceShared: isDistanceShared, + latitude: latitude, + longitude: longitude, + content: content, + font: font, + imgType: imgType, + imgName: imgName, + tags: tags + ) + } + + + // MARK: Tag + + func tagCards(tagId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.tagCards(tagId: tagId, lastId: lastId) + } + + + // MARK: My + + func feedCards(userId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.feedCards(userId: userId, lastId: lastId) + } + + func myCommentCards(lastId: String?) -> Observable { + + return self.remoteDataSource.myCommentCards(lastId: lastId) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Locals/AuthLocalDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Locals/AuthLocalDataSourceImpl.swift new file mode 100644 index 00000000..acd9a081 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Locals/AuthLocalDataSourceImpl.swift @@ -0,0 +1,49 @@ +// +// AuthLocalDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import RxSwift + +class AuthLocalDataSourceImpl: AuthLocalDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func initializeAuthInfo() { + + self.provider.authManager.initializeAuthInfo() + } + + func hasToken() -> Bool { + + let authInfo = self.provider.authManager.authInfo + return authInfo.token.accessToken.isEmpty == false && authInfo.token.refreshToken.isEmpty == false + } + + func tokens() -> Token { + + return self.provider.authManager.authInfo.token + } + + func encryptedDeviceId() -> Observable { + + return self.provider.authManager.publicKey() + .withUnretained(self) + .flatMapLatest { object, publicKey -> Observable in + if let publicKey = publicKey, + let secKey = object.provider.authManager.convertPEMToSecKey(pemString: publicKey) { + + let encryptedDeviceId = object.provider.authManager.encryptUUIDWithPublicKey(publicKey: secKey) + return .just(encryptedDeviceId) + } else { + return .just(nil) + } + } + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Locals/Interfaces/AuthLocalDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Locals/Interfaces/AuthLocalDataSource.swift new file mode 100644 index 00000000..337b705d --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Locals/Interfaces/AuthLocalDataSource.swift @@ -0,0 +1,16 @@ +// +// AuthLocalDataSource.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import RxSwift + +protocol AuthLocalDataSource { + + func initializeAuthInfo() + func hasToken() -> Bool + func tokens() -> Token + func encryptedDeviceId() -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Locals/Interfaces/SettingsLocalDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Locals/Interfaces/SettingsLocalDataSource.swift new file mode 100644 index 00000000..90662677 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Locals/Interfaces/SettingsLocalDataSource.swift @@ -0,0 +1,19 @@ +// +// SettingsLocalDataSource.swift +// SOOUM +// +// Created by 오현식 on 11/17/25. +// + +import RxSwift + +protocol SettingsLocalDataSource { + + func notificationStatus() -> Bool + func switchNotification(on: Bool) -> Observable + + func coordinate() -> Coordinate + func hasPermission() -> Bool + func requestLocationPermission() + func checkLocationAuthStatus() -> AuthStatus +} diff --git a/SOOUM/SOOUM/Data/Repositories/Locals/SettingsLocalDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Locals/SettingsLocalDataSourceImpl.swift new file mode 100644 index 00000000..b5a37480 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Locals/SettingsLocalDataSourceImpl.swift @@ -0,0 +1,47 @@ +// +// SettingsLocalDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 11/17/25. +// + +import RxSwift + +class SettingsLocalDataSourceImpl: SettingsLocalDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func notificationStatus() -> Bool { + + return self.provider.pushManager.notificationStatus + } + + func switchNotification(on: Bool) -> Observable { + + return self.provider.pushManager.switchNotification(on: on) + } + + func coordinate() -> Coordinate { + + return self.provider.locationManager.coordinate + } + + func hasPermission() -> Bool { + + return self.provider.locationManager.hasPermission + } + + func requestLocationPermission() { + + self.provider.locationManager.requestLocationPermission() + } + + func checkLocationAuthStatus() -> AuthStatus { + + return self.provider.locationManager.checkLocationAuthStatus() + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift new file mode 100644 index 00000000..190e1086 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift @@ -0,0 +1,39 @@ +// +// NotificationRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +class NotificationRepositoryImpl: NotificationRepository { + + private let remoteDataSource: NotificationRemoteDataSource + + init(remoteDataSource: NotificationRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func unreadNotifications(lastId: String?) -> Observable { + + return self.remoteDataSource.unreadNotifications(lastId: lastId) + } + + func readNotifications(lastId: String?) -> Observable { + + return self.remoteDataSource.readNotifications(lastId: lastId) + } + + func requestRead(notificationId: String) -> Observable { + + return self.remoteDataSource.requestRead(notificationId: notificationId) + } + + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable { + + return self.remoteDataSource.notices(lastId: lastId, size: size, requestType: requestType) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift new file mode 100644 index 00000000..88674029 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift @@ -0,0 +1,24 @@ +// +// AppVersionRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +import RxSwift + +class AppVersionRemoteDataSourceImpl: AppVersionRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func version() -> Observable { + + return self.provider.networkManager.updateCheck() + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift new file mode 100644 index 00000000..c274ec1f --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift @@ -0,0 +1,36 @@ +// +// AuthRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +class AuthRemoteDataSourceImpl: AuthRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func signUp(nickname: String, profileImageName: String?) -> Observable { + + return self.provider.authManager.join(nickname: nickname, profileImageName: profileImageName) + } + + func login() -> Observable { + + return self.provider.authManager.certification() + } + + func withdraw(reaseon: String) -> Observable { + + let token = self.provider.authManager.authInfo.token + let request: AuthRequest = .withdraw(token: token, reason: reaseon) + return self.provider.networkManager.perform(request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift new file mode 100644 index 00000000..79e33bd6 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift @@ -0,0 +1,175 @@ +// +// CardRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import Foundation + +import RxSwift + +class CardRemoteDataSourceImpl: CardRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + + // MARK: Home + + func latestCard(lastId: String?, latitude: String?, longitude: String?) -> Observable { + + let request: CardRequest = .latestCard(lastId: lastId, latitude: latitude, longitude: longitude) + return self.provider.networkManager.fetch(BaseCardInfoResponse.self, request: request) + } + + func popularCard(latitude: String?, longitude: String?) -> Observable { + + let request: CardRequest = .popularCard(latitude: latitude, longitude: longitude) + return self.provider.networkManager.fetch(BaseCardInfoResponse.self, request: request) + } + + func distanceCard(lastId: String?, latitude: String, longitude: String, distanceFilter: String) -> Observable { + + let request: CardRequest = .distancCard(lastId: lastId, latitude: latitude, longitude: longitude, distanceFilter: distanceFilter) + return self.provider.networkManager.fetch(BaseCardInfoResponse.self, request: request) + } + + + // MARK: Detail + + func detailCard(id: String, latitude: String?, longitude: String?) -> Observable { + + let requset: CardRequest = .detailCard(id: id, latitude: latitude, longitude: longitude) + return self.provider.networkManager.fetch(DetailCardInfoResponse.self, request: requset) + } + + func isCardDeleted(id: String) -> Observable { + + let request: CardRequest = .isCardDeleted(id: id) + return self.provider.networkManager.fetch(IsCardDeletedResponse.self, request: request) + } + + func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable { + + let request: CardRequest = .commentCard(id: id, lastId: lastId, latitude: latitude, longitude: longitude) + return self.provider.networkManager.fetch(BaseCardInfoResponse.self, request: request) + } + + func deleteCard(id: String) -> Observable { + + let request: CardRequest = .deleteCard(id: id) + return self.provider.networkManager.perform(request) + } + + func updateLike(id: String, isLike: Bool) -> Observable { + + let request: CardRequest = .updateLike(id: id, isLike: isLike) + return self.provider.networkManager.perform(request) + } + + func reportCard(id: String, reportType: String) -> Observable { + + let request: CardRequest = .reportCard(id: id, reportType: reportType) + return self.provider.networkManager.perform(request) + } + + + // MARK: Write + + func defaultImages() -> Observable { + + let request: CardRequest = .defaultImages + return self.provider.networkManager.fetch(DefaultImagesResponse.self, request: request) + } + + func presignedURL() -> Observable { + + let request: CardRequest = .presignedURL + return self.provider.networkManager.fetch(ImageUrlInfoResponse.self, request: request) + } + + func uploadImage(_ data: Data, with url: URL) -> Observable> { + + return self.provider.networkManager.upload(data, to: url) + } + + func writeCard( + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + isStory: Bool, + tags: [String] + ) -> Observable { + + let request: CardRequest = .writeCard( + isDistanceShared: isDistanceShared, + latitude: latitude, + longitude: longitude, + content: content, + font: font, + imgType: imgType, + imgName: imgName, + isStory: isStory, + tags: tags + ) + return self.provider.networkManager.perform(WriteCardResponse.self, request: request) + } + + func writeComment( + id: String, + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + tags: [String] + ) -> Observable { + + let request: CardRequest = .writeComment( + id: id, + isDistanceShared: isDistanceShared, + latitude: latitude, + longitude: longitude, + content: content, + font: font, + imgType: imgType, + imgName: imgName, + tags: tags + ) + return self.provider.networkManager.perform(WriteCardResponse.self, request: request) + } + + + // MARK: Tag + + func tagCards(tagId: String, lastId: String?) -> Observable { + + let requset: TagRequest = .tagCards(tagId: tagId, lastId: lastId) + return self.provider.networkManager.fetch(TagCardInfoResponse.self, request: requset) + } + + + // MARK: My + + func feedCards(userId: String, lastId: String?) -> Observable { + + let request: UserRequest = .feedCards(userId: userId, lastId: lastId) + return self.provider.networkManager.fetch(ProfileCardInfoResponse.self, request: request) + } + + func myCommentCards(lastId: String?) -> Observable { + + let request: UserRequest = .myCommentCards(lastId: lastId) + return self.provider.networkManager.fetch(ProfileCardInfoResponse.self, request: request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift new file mode 100644 index 00000000..73966508 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift @@ -0,0 +1,15 @@ +// +// AppVersionRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +import RxSwift + +protocol AppVersionRemoteDataSource { + + func version() -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift new file mode 100644 index 00000000..01f9aebd --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift @@ -0,0 +1,17 @@ +// +// AuthRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +protocol AuthRemoteDataSource { + + func signUp(nickname: String, profileImageName: String?) -> Observable + func login() -> Observable + func withdraw(reaseon: String) -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift new file mode 100644 index 00000000..9d072d16 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift @@ -0,0 +1,70 @@ +// +// CardRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import Foundation + +import RxSwift + +protocol CardRemoteDataSource { + + + // MARK: Home + + func latestCard(lastId: String?, latitude: String?, longitude: String?) -> Observable + func popularCard(latitude: String?, longitude: String?) -> Observable + func distanceCard(lastId: String?, latitude: String, longitude: String, distanceFilter: String) -> Observable + + + // MARK: Detail + + func detailCard(id: String, latitude: String?, longitude: String?) -> Observable + func isCardDeleted(id: String) -> Observable + func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable + func deleteCard(id: String) -> Observable + func updateLike(id: String, isLike: Bool) -> Observable + func reportCard(id: String, reportType: String) -> Observable + + + // MARK: Write + + func defaultImages() -> Observable + func presignedURL() -> Observable + func uploadImage(_ data: Data, with url: URL) -> Observable> + func writeCard( + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + isStory: Bool, + tags: [String] + ) -> Observable + func writeComment( + id: String, + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + tags: [String] + ) -> Observable + + + // MARK: Tag + + func tagCards(tagId: String, lastId: String?) -> Observable + + + // MARK: My + + func feedCards(userId: String, lastId: String?) -> Observable + func myCommentCards(lastId: String?) -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift new file mode 100644 index 00000000..e0a8e627 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift @@ -0,0 +1,18 @@ +// +// NotificationRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +protocol NotificationRemoteDataSource { + + func unreadNotifications(lastId: String?) -> Observable + func readNotifications(lastId: String?) -> Observable + func requestRead(notificationId: String) -> Observable + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/SettingsRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/SettingsRemoteDataSource.swift new file mode 100644 index 00000000..97e5e1e0 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/SettingsRemoteDataSource.swift @@ -0,0 +1,20 @@ +// +// SettingsRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +protocol SettingsRemoteDataSource { + + func rejoinableDate() -> Observable + func issue() -> Observable + func enter(code: String, encryptedDeviceId: String) -> Observable + func update() -> Observable + func blockUsers(lastId: String?) -> Observable + func updateNotify(isAllowNotify: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/TagRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/TagRemoteDataSource.swift new file mode 100644 index 00000000..85fe744a --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/TagRemoteDataSource.swift @@ -0,0 +1,16 @@ +// +// TagRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import RxSwift + +protocol TagRemoteDataSource { + + func related(keyword: String, size: Int) -> Observable + func favorites() -> Observable + func updateFavorite(tagId: String, isFavorite: Bool) -> Observable + func ranked() -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift new file mode 100644 index 00000000..a79916f6 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift @@ -0,0 +1,28 @@ +// +// UserRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +protocol UserRemoteDataSource { + + func checkAvailable() -> Observable + func nickname() -> Observable + func validateNickname(nickname: String) -> Observable + func updateNickname(nickname: String) -> Observable + func presignedURL() -> Observable + func uploadImage(_ data: Data, with url: URL) -> Observable> + func updateImage(imageName: String) -> Observable + func postingPermission() -> Observable + func profile(userId: String?) -> Observable + func updateMyProfile(nickname: String?, imageName: String?) -> Observable + func followers(userId: String, lastId: String?) -> Observable + func followings(userId: String, lastId: String?) -> Observable + func updateFollowing(userId: String, isFollow: Bool) -> Observable + func updateBlocked(id: String, isBlocked: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift new file mode 100644 index 00000000..051f5be5 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift @@ -0,0 +1,43 @@ +// +// NotificationRemoteDataSoruceImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +class NotificationRemoteDataSoruceImpl: NotificationRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func unreadNotifications(lastId: String?) -> Observable { + + let request: NotificationRequest = .unreadNotifications(lastId: lastId) + return self.provider.networkManager.fetch(CompositeNotificationInfoResponse.self, request: request) + } + + func readNotifications(lastId: String?) -> Observable { + + let request: NotificationRequest = .readNotifications(lastId: lastId) + return self.provider.networkManager.fetch(CompositeNotificationInfoResponse.self, request: request) + } + + func requestRead(notificationId: String) -> Observable { + + let request: NotificationRequest = .requestRead(notificationId: notificationId) + return self.provider.networkManager.perform(request) + } + + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable { + + let request: NotificationRequest = .notices(lastId: lastId, size: size, requestType: requestType) + return self.provider.networkManager.fetch(NoticeInfoResponse.self, request: request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/SettingsRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/SettingsRemoteDataSourceImpl.swift new file mode 100644 index 00000000..81e98ce6 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/SettingsRemoteDataSourceImpl.swift @@ -0,0 +1,55 @@ +// +// SettingsRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +class SettingsRemoteDataSourceImpl: SettingsRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func rejoinableDate() -> Observable { + + let request: SettingsRequest = .rejoinableDate + return self.provider.networkManager.fetch(RejoinableDateInfoResponse.self, request: request) + } + + func issue() -> Observable { + + let request: SettingsRequest = .transferIssue + return self.provider.networkManager.fetch(TransferCodeInfoResponse.self, request: request) + } + + func enter(code: String, encryptedDeviceId: String) -> Observable { + + let request: SettingsRequest = .transferEnter(code: code, encryptedDeviceId: encryptedDeviceId) + return self.provider.networkManager.perform(request) + } + + func update() -> Observable { + + let request: SettingsRequest = .transferUpdate + return self.provider.networkManager.perform(TransferCodeInfoResponse.self, request: request) + } + + func blockUsers(lastId: String?) -> Observable { + + let request: SettingsRequest = .blockUsers(lastId: lastId) + return self.provider.networkManager.fetch(BlockUsersInfoResponse.self, request: request) + } + + func updateNotify(isAllowNotify: Bool) -> Observable { + + let request: UserRequest = .updateNotify(isAllowNotify: isAllowNotify) + return self.provider.networkManager.perform(request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/TagRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/TagRemoteDataSourceImpl.swift new file mode 100644 index 00000000..26995e52 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/TagRemoteDataSourceImpl.swift @@ -0,0 +1,43 @@ +// +// TagRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Foundation + +import RxSwift + +class TagRemoteDataSourceImpl: TagRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func related(keyword: String, size: Int) -> Observable { + + let request: TagRequest = .related(keyword: keyword, size: size) + return self.provider.networkManager.perform(TagInfoResponse.self, request: request) + } + + func favorites() -> Observable { + + let request: TagRequest = .favorites + return self.provider.networkManager.fetch(FavoriteTagInfoResponse.self, request: request) + } + + func updateFavorite(tagId: String, isFavorite: Bool) -> Observable { + + let request: TagRequest = .updateFavorite(tagId: tagId, isFavorite: isFavorite) + return self.provider.networkManager.perform(request) + } + + func ranked() -> Observable { + + let request: TagRequest = .ranked + return self.provider.networkManager.fetch(TagInfoResponse.self, request: request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift new file mode 100644 index 00000000..ddb408ae --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift @@ -0,0 +1,101 @@ +// +// UserRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +class UserRemoteDataSourceImpl: UserRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func checkAvailable() -> Observable { + + return self.provider.authManager.available() + } + + func nickname() -> Observable { + + let request: UserRequest = .nickname + return self.provider.networkManager.fetch(NicknameResponse.self, request: request) + } + + func validateNickname(nickname: String) -> Observable { + + let request: UserRequest = .validateNickname(nickname: nickname) + return self.provider.networkManager.perform(NicknameValidateResponse.self, request: request) + } + + func updateNickname(nickname: String) -> Observable { + + let request: UserRequest = .updateNickname(nickname: nickname) + return self.provider.networkManager.perform(request) + } + + func presignedURL() -> Observable { + + let request: UserRequest = .presignedURL + return self.provider.networkManager.fetch(ImageUrlInfoResponse.self, request: request) + } + + func uploadImage(_ data: Data, with url: URL) -> Observable> { + + return self.provider.networkManager.upload(data, to: url) + } + + func updateImage(imageName: String) -> Observable { + + let request: UserRequest = .updateImage(imageName: imageName) + return self.provider.networkManager.perform(request) + } + + func postingPermission() -> Observable { + + let request: UserRequest = .postingPermission + return self.provider.networkManager.fetch(PostingPermissionResponse.self, request: request) + } + + func profile(userId: String?) -> Observable { + + let request: UserRequest = .profile(userId: userId) + return self.provider.networkManager.fetch(ProfileInfoResponse.self, request: request) + } + + func updateMyProfile(nickname: String?, imageName: String?) -> Observable { + + let request: UserRequest = .updateMyProfile(nickname: nickname, imageName: imageName) + return self.provider.networkManager.perform(request) + } + + func followers(userId: String, lastId: String?) -> Observable { + + let request: UserRequest = .followers(userId: userId, lastId: lastId) + return self.provider.networkManager.fetch(FollowInfoResponse.self, request: request) + } + + func followings(userId: String, lastId: String?) -> Observable { + + let request: UserRequest = .followings(userId: userId, lastId: lastId) + return self.provider.networkManager.fetch(FollowInfoResponse.self, request: request) + } + + func updateFollowing(userId: String, isFollow: Bool) -> Observable { + + let request: UserRequest = .updateFollowing(userId: userId, isFollow: isFollow) + return self.provider.networkManager.perform(request) + } + + func updateBlocked(id: String, isBlocked: Bool) -> Observable { + + let request: UserRequest = .updateBlocked(id: id, isBlocked: isBlocked) + return self.provider.networkManager.perform(request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/SettingsRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/SettingsRepositoryImpl.swift new file mode 100644 index 00000000..5e11bad2 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/SettingsRepositoryImpl.swift @@ -0,0 +1,81 @@ +// +// SettingsRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +class SettingsRepositoryImpl: SettingsRepository { + + private let remoteDataSource: SettingsRemoteDataSource + private let localDataSource: SettingsLocalDataSource + + init(remoteDataSource: SettingsRemoteDataSource, localDataSource: SettingsLocalDataSource) { + self.remoteDataSource = remoteDataSource + self.localDataSource = localDataSource + } + + func rejoinableDate() -> Observable { + + return self.remoteDataSource.rejoinableDate() + } + + func issue() -> Observable { + + return self.remoteDataSource.issue() + } + + func enter(code: String, encryptedDeviceId: String) -> Observable { + + return self.remoteDataSource.enter(code: code, encryptedDeviceId: encryptedDeviceId) + } + + func update() -> Observable { + + return self.remoteDataSource.update() + } + + func blockUsers(lastId: String?) -> Observable { + + return self.remoteDataSource.blockUsers(lastId: lastId) + } + + func updateNotify(isAllowNotify: Bool) -> Observable { + + return self.remoteDataSource.updateNotify(isAllowNotify: isAllowNotify) + } + + func notificationStatus() -> Bool { + + return self.localDataSource.notificationStatus() + } + + func switchNotification(on: Bool) -> Observable { + + return self.localDataSource.switchNotification(on: on) + } + + func coordinate() -> Coordinate { + + return self.localDataSource.coordinate() + } + + func hasPermission() -> Bool { + + return self.localDataSource.hasPermission() + } + + func requestLocationPermission() { + + self.localDataSource.requestLocationPermission() + } + + func checkLocationAuthStatus() -> AuthStatus { + + return self.localDataSource.checkLocationAuthStatus() + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/TagRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/TagRepositoryImpl.swift new file mode 100644 index 00000000..41d214ef --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/TagRepositoryImpl.swift @@ -0,0 +1,39 @@ +// +// TagRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Foundation + +import RxSwift + +class TagRepositoryImpl: TagRepository { + + private let remoteDataSource: TagRemoteDataSource + + init(remoteDataSource: TagRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func related(keyword: String, size: Int) -> Observable { + + return self.remoteDataSource.related(keyword: keyword, size: size) + } + + func favorites() -> Observable { + + return self.remoteDataSource.favorites() + } + + func updateFavorite(tagId: String, isFavorite: Bool) -> Observable { + + return self.remoteDataSource.updateFavorite(tagId: tagId, isFavorite: isFavorite) + } + + func ranked() -> Observable { + + return self.remoteDataSource.ranked() + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift new file mode 100644 index 00000000..3e34fca6 --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift @@ -0,0 +1,89 @@ +// +// UserRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +class UserRepositoryImpl: UserRepository { + + private let remoteDataSource: UserRemoteDataSource + + init(remoteDataSource: UserRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func checkAvailable() -> Observable { + + return self.remoteDataSource.checkAvailable() + } + + func nickname() -> Observable { + + return self.remoteDataSource.nickname() + } + + func validateNickname(nickname: String) -> Observable { + + return self.remoteDataSource.validateNickname(nickname: nickname) + } + + func updateNickname(nickname: String) -> Observable { + + return self.remoteDataSource.updateNickname(nickname: nickname) + } + + func presignedURL() -> Observable { + + return self.remoteDataSource.presignedURL() + } + + func uploadImage(_ data: Data, with url: URL) -> Observable> { + + return self.remoteDataSource.uploadImage(data, with: url) + } + + func updateImage(imageName: String) -> Observable { + + return self.remoteDataSource.updateImage(imageName: imageName) + } + + func postingPermission() -> Observable { + + return self.remoteDataSource.postingPermission() + } + + func profile(userId: String?) -> Observable { + + return self.remoteDataSource.profile(userId: userId) + } + + func updateMyProfile(nickname: String?, imageName: String?) -> Observable { + + return self.remoteDataSource.updateMyProfile(nickname: nickname, imageName: imageName) + } + + func followers(userId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.followers(userId: userId, lastId: lastId) + } + + func followings(userId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.followings(userId: userId, lastId: lastId) + } + + func updateFollowing(userId: String, isFollow: Bool) -> Observable { + + return self.remoteDataSource.updateFollowing(userId: userId, isFollow: isFollow) + } + + func updateBlocked(id: String, isBlocked: Bool) -> Observable { + + return self.remoteDataSource.updateBlocked(id: id, isBlocked: isBlocked) + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMActivityIndicatorView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMActivityIndicatorView.swift index 75bb28f6..21507717 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMActivityIndicatorView.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMActivityIndicatorView.swift @@ -71,13 +71,13 @@ class SOMActivityIndicatorView: UIActivityIndicatorView { self.tintColor = .clear - self.addSubviews(self.backgroundView) + self.addSubview(self.backgroundView) self.backgroundView.snp.makeConstraints { $0.center.equalToSuperview() $0.size.equalTo(40) } - self.backgroundView.addSubviews(self.imageView) + self.backgroundView.addSubview(self.imageView) self.imageView.snp.makeConstraints { $0.center.equalToSuperview() $0.size.equalTo(28) diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMBottomFloatView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMBottomFloatView.swift new file mode 100644 index 00000000..872a7c19 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMBottomFloatView.swift @@ -0,0 +1,140 @@ +// +// SOMBottomFloatView.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import SnapKit +import Then + +class SOMBottomFloatView: UIView { + + + // MARK: Views + + private let container = UIStackView().then { + $0.axis = .vertical + } + + + // MARK: Variables + + private var actions: [FloatAction]? + + + // MARK: Initalization + + convenience init(actions: [FloatAction]) { + self.init(frame: .zero) + + self.actions = actions + self.setupActions(actions) + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension SOMBottomFloatView { + + func setupConstraints() { + + self.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width - 16 * 2) + } + + let handleView = UIView().then { + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 2 + } + self.addSubview(handleView) + handleView.snp.makeConstraints { + $0.top.equalToSuperview().offset(14) + $0.centerX.equalToSuperview() + $0.width.equalTo(44) + $0.height.equalTo(4) + } + + self.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalTo(handleView.snp.bottom).offset(14) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + } + + func setupActions(_ actions: [FloatAction]) { + + self.container.arrangedSubviews.forEach { $0.removeFromSuperview() } + + actions.forEach { action in + + let button = SOMButton().then { + $0.image = action.image + + $0.title = action.title + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = action.foregroundColor + $0.backgroundColor = .som.v2.white + $0.inset = .init(top: 12, left: 16, bottom: 12, right: 16) + + $0.contentHorizontalAlignment = .left + + $0.isEnabled = action.isEnabled + + $0.tag = action.tag + $0.addTarget(self, action: #selector(self.tap(_:)), for: .touchUpInside) + } + button.snp.makeConstraints { + $0.height.equalTo(48) + } + + self.container.addArrangedSubview(button) + } + } + + @objc + func tap(_ button: UIButton) { + if let action = self.actions?.first(where: { $0.tag == button.tag }) { + action.action() + } + } +} + +extension SOMBottomFloatView { + + struct FloatAction { + let tag: Int + let image: UIImage? + let foregroundColor: UIColor + let isEnabled: Bool + let title: String + let action: (() -> Void) + + init( + title: String, + image: UIImage? = nil, + foregroundColor: UIColor = .som.v2.gray500, + isEnabled: Bool = true, + action: @escaping (() -> Void) + ) { + self.tag = UUID().hashValue + self.title = title + self.image = image + self.foregroundColor = foregroundColor + self.isEnabled = isEnabled + self.action = action + } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift new file mode 100644 index 00000000..7d3b3999 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift @@ -0,0 +1,129 @@ +// +// SOMBottomToastView.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import UIKit + +import SnapKit +import Then + +class SOMBottomToastView: UIView { + + + // MARK: Views + + private let container = UIView() + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.caption2 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority + } + + + // MARK: Variables + + private var actions: [ToastAction]? + + + // MARK: Initalization + + convenience init(title: String, actions: [ToastAction]?) { + self.init(frame: .zero) + + self.actions = actions + self.setupActions(title: title, actions) + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension SOMBottomToastView { + + func setupConstraints() { + + self.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width - 8 * 2) + $0.height.equalTo(40) + } + + self.addSubview(self.container) + self.container.snp.makeConstraints { + $0.centerY.trailing.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + } + } + + func setupActions(title: String, _ actions: [ToastAction]?) { + + self.container.subviews.forEach { $0.removeFromSuperview() } + + self.titleLabel.text = title + self.titleLabel.typography = .som.v2.caption2 + + self.container.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + } + + guard let actions = actions else { return } + + actions.forEach { action in + + let button = SOMButton().then { + $0.title = action.title + $0.typography = .som.v2.caption1 + $0.foregroundColor = .som.v2.pMain + + $0.inset = .init(top: 11, left: 10, bottom: 11, right: 10) + + $0.tag = action.tag + $0.addTarget(self, action: #selector(self.tap(_:)), for: .touchUpInside) + } + + self.container.addSubview(button) + button.snp.makeConstraints { + $0.verticalEdges.trailing.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.titleLabel.snp.trailing) + $0.height.equalTo(40) + } + } + } + + @objc + func tap(_ button: UIButton) { + if let action = self.actions?.first(where: { $0.tag == button.tag }) { + action.action() + } + } +} + +extension SOMBottomToastView { + + struct ToastAction { + let tag: Int + let title: String + let action: (() -> Void) + + init( + title: String, + action: @escaping (() -> Void) + ) { + self.tag = UUID().hashValue + self.title = title + self.action = action + } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift index b65e5a05..8edf088d 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift @@ -7,105 +7,198 @@ import UIKit - class SOMButton: UIButton { var title: String? { didSet { if oldValue != self.title { - self.setConfiguration() + self.setNeedsUpdateConfiguration() } } } - var image: UIImage? { + var typography: Typography? { didSet { - if oldValue != self.image { - self.setConfiguration() + if oldValue != self.typography { + self.setNeedsUpdateConfiguration() } } } - var typography: Typography? { + var hasUnderlined: Bool? { didSet { - if oldValue != self.typography { - self.setConfiguration() + if oldValue != self.hasUnderlined { + self.setNeedsUpdateConfiguration() } } } - var foregroundColor: UIColor? { + var inset: UIEdgeInsets? { didSet { - if oldValue != self.foregroundColor { - self.setConfiguration() + if oldValue != self.inset { + self.setNeedsUpdateConfiguration() } } } - var hasUnderlined: Bool? { + var image: UIImage? { didSet { - if oldValue != self.hasUnderlined { - self.setConfiguration() + if oldValue != self.image { + self.setNeedsUpdateConfiguration() + } + } + } + + var imagePlacement: NSDirectionalRectEdge? { + didSet { + if oldValue != self.imagePlacement { + self.setNeedsUpdateConfiguration() + } + } + } + + var foregroundColor: UIColor? { + didSet { + if oldValue != self.foregroundColor { + self.setNeedsUpdateConfiguration() } } } override init(frame: CGRect) { super.init(frame: .zero) + self.setupConfiguration() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +private extension SOMButton { - private func setConfiguration() { + func setupConfiguration() { var configuration = UIButton.Configuration.plain() + configuration.contentInsets = .zero - // 이미지 설정 - if let image = self.image { - configuration.image = image + self.configuration = configuration + self.backgroundColor = .clear + self.layer.cornerRadius = 10 + self.clipsToBounds = true + + self.configurationUpdateHandler = { [weak self] button in + guard let self = self else { return } + + var updatedConfig = button.configuration + + updatedConfig?.background.backgroundColor = self.backgroundColor + updatedConfig?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in + // 비활성화 상태일 때, backgroundColor + if button.isEnabled == false { + switch self.backgroundColor { + case .som.v2.black: return .som.v2.gray200 + case .som.v2.gray100: return .som.v2.gray200 + case .som.v2.white: return .som.v2.white + default: return .clear + } + } + // 선택된 상태일 때, backgroundColor + if button.isSelected { return .som.v2.pLight1 } + // 하이라이트 상태일 때, backgroundColor + if button.isHighlighted { + switch self.backgroundColor { + case .som.v2.black: return .som.v2.gray600 + case .som.v2.gray100: return .som.v2.gray200 + case .som.v2.white: return .som.v2.gray100 + case .som.v2.rMain: return .som.v2.rDark + default: return .clear + } + } + // 기본 상태일 때, backgroundColor + return self.backgroundColor ?? .clear + } - if let foregroundColor = self.foregroundColor { - configuration.image?.withTintColor(foregroundColor) - configuration.imageColorTransformer = UIConfigurationColorTransformer { _ in - foregroundColor + updatedConfig?.background.strokeWidth = 1 + updatedConfig?.background.strokeColor = self.backgroundColor ?? .clear + updatedConfig?.background.strokeColorTransformer = UIConfigurationColorTransformer { _ in + // 비활성화 상태일 때, backgroundColor + if button.isEnabled == false { + switch self.backgroundColor { + case .som.v2.black: return .som.v2.gray200 + case .som.v2.gray100: return .som.v2.gray200 + case .som.v2.white: return .som.v2.white + default: return .clear + } + } + // 선택된 상태일 때, backgroundColor + if button.isSelected { return .som.v2.pMain } + // 하이라이트 상태일 때, backgroundColor + if button.isHighlighted { + switch self.backgroundColor { + case .som.v2.black: return .som.v2.gray600 + case .som.v2.gray100: return .som.v2.gray200 + case .som.v2.white: return .som.v2.gray100 + case .som.v2.rMain: return .som.v2.rDark + default: return .clear + } } + // 기본 상태일 때, backgroundColor + return self.backgroundColor ?? .clear } + + updatedConfig?.background.cornerRadius = 10 + + self.applyConfiguration(to: &updatedConfig) + button.configuration = updatedConfig + } + } + + func applyConfiguration(to configuration: inout UIButton.Configuration?) { + + var foregroundColor: UIColor { + if self.isEnabled == false { + switch self.foregroundColor { + case .som.v2.white: return .som.v2.gray400 + case .som.v2.gray600: return .som.v2.gray400 + case .som.v2.gray500: return .som.v2.gray300 + default: return .som.v2.gray300 + } + } + + return self.foregroundColor ?? .som.v2.white + } + + if let image = self.image { + configuration?.image = image + configuration?.imageColorTransformer = UIConfigurationColorTransformer { _ in foregroundColor } + configuration?.imagePadding = 8 + configuration?.imagePlacement = self.imagePlacement ?? .leading } - // 타이틀 설정 if let title = self.title, let typography = self.typography { var attributes = typography.attributes attributes.updateValue(typography.font, forKey: .font) - - if let foregroundColor = self.foregroundColor { - attributes.updateValue(foregroundColor, forKey: .foregroundColor) - } + attributes.updateValue(foregroundColor, forKey: .foregroundColor) if self.hasUnderlined == true { attributes.updateValue(NSUnderlineStyle.single.rawValue, forKey: .underlineStyle) - attributes.updateValue(foregroundColor ?? UIColor.som.gray400, forKey: .underlineColor) + attributes.updateValue(foregroundColor, forKey: .underlineColor) } - configuration.attributedTitle = .init(title, attributes: AttributeContainer(attributes)) - configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { _ in - AttributeContainer(attributes) + if let inset = self.inset { + configuration?.contentInsets = .init( + top: inset.top, + leading: inset.left, + bottom: inset.bottom, + trailing: inset.right + ) } - if image == nil { - configuration.titleAlignment = .center - } else { - configuration.imagePadding = 2 + configuration?.attributedTitle = .init(title, attributes: AttributeContainer(attributes)) + configuration?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { _ in + AttributeContainer(attributes) } } - - if self.backgroundColor == nil { - self.backgroundColor = .clear - - configuration.contentInsets = .zero - } - - self.configuration = configuration } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift new file mode 100644 index 00000000..00de5313 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift @@ -0,0 +1,483 @@ +// +// SOMCard.swift +// SOOUM +// +// Created by JDeoks on 9/14/24. +// + +import UIKit + +import SnapKit +import Then + +import RxSwift + +class SOMCard: UIView { + + enum Text { + static let adminTitle: String = "sooum" + static let pungedCardText: String = "카드가 삭제되었어요" + } + + enum CardType { + case feed + case comment + } + + + // MARK: Views + + private let shadowbackgroundView = UIView().then { + $0.backgroundColor = .som.v2.white + $0.layer.cornerRadius = 16 + } + + private let borderBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.white + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 1 + $0.clipsToBounds = true + } + + /// 배경 이미지 + private let rootContainerImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.layer.masksToBounds = true + } + + // 본문 dim 배경 + private let cardTextBackgroundBlurView = UIView().then { + $0.backgroundColor = .som.v2.dim + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + + /// 본문 표시 라벨 (스크롤 X) + private let cardTextContentLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.body1 + $0.textAlignment = .center + $0.numberOfLines = 4 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority + } + + /// 펑 시간, 거리, 시간, 좋아요 수, 답글 수 정보를 담는 뷰 + private let cardInfoContainer = UIView().then { + $0.backgroundColor = .som.v2.white + } + /// 펑 시간, 거리, 시간을 담는 스택 뷰 + private let cardInfoLeadingStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + /// 좋아요 수, 답글 수를 담는 스택 뷰 + private let cardInfoTrailingStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + + /// 어드민 정보 표시 스택뷰 + private let adminStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 2 + $0.alignment = .center + } + /// 어드민 정보 아이콘 + private let adminImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.official)))) + $0.tintColor = .som.v2.black + } + /// 어드민 정보 라벨 + private let adminLabel = UILabel().then { + $0.text = Text.adminTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.caption2 + } + /// 어드민 닷 + private let firstDot = UIView().then { + $0.backgroundColor = .som.v2.gray500 + $0.layer.cornerRadius = 1 + } + /// 펑 남은시간 표시 스택뷰 + private let cardPungTimeStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 2 + $0.alignment = .center + } + /// 펑 남은시간 표시 아이콘 + private let cardPungTimeImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.timer)))) + $0.tintColor = .som.v2.pMain + } + /// 펑 남은시간 표시 라벨 + private let cardPungTimeLabel = UILabel().then { + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.caption2 + } + /// 펑 남은시간 닷 + private let secondDot = UIView().then { + $0.backgroundColor = .som.v2.gray500 + $0.layer.cornerRadius = 1 + } + /// 거리 정보 표시 스택뷰 + private let distanceInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 2 + $0.alignment = .center + } + /// 거리 정보 아이콘 + private let distanceImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.location)))) + $0.tintColor = .som.v2.gray500 + } + /// 거리 정보 라벨 + private let distanceLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + /// 거리 정보 닷 + private let thirdDot = UIView().then { + $0.backgroundColor = .som.v2.gray500 + $0.layer.cornerRadius = 1 + } + /// 시간 정보 표시 라벨 + private let timeLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + /// 좋아요 정보 표시 스택뷰 + private let likeInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 2 + $0.alignment = .center + } + /// 좋아요 정보 표시 아이콘 + private let likeImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.heart)))) + $0.tintColor = .som.v2.gray500 + } + /// 좋아요 정보 표시 라벨 + private let likeLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + /// 답카드 정보 표시 스택뷰 + private let commentInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 2 + $0.alignment = .center + } + /// 답카드 정보 표시 아이콘 + private let commentImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.message_circle)))) + $0.tintColor = .som.v2.gray500 + } + /// 답카드 정보 표시 라벨 + private let commentLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + + + // MARK: Variables + + private(set) var model: BaseCardInfo = .defaultValue + private(set) var cardType: CardType + + + // MARK: Constraints + + // TODO: 카드 본문 높이 계산 Constraint + private var contentHeightConstraint: Constraint? + + /// 펑 이벤트 처리 위해 추가 + var serialTimer: Disposable? + var disposeBag = DisposeBag() + + + // MARK: Initialize + + init(type cardType: CardType = .feed) { + self.cardType = cardType + super.init(frame: .zero) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func layoutSubviews() { + super.layoutSubviews() + + self.shadowbackgroundView.setShadow( + radius: 6, + color: UIColor(hex: "#ABBED11A").withAlphaComponent(0.1), + blur: 16, + offset: .init(width: 0, height: 6) + ) + } + + + // MARK: private func + + private func setupConstraints() { + + // 백그라운드 그림자 뷰 + self.addSubview(self.shadowbackgroundView) + self.shadowbackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.addSubview(self.borderBackgroundView) + self.borderBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + // 배경 이미지 뷰 + self.borderBackgroundView.addSubview(self.rootContainerImageView) + self.rootContainerImageView.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview().offset(-34) + } + + // 하단 카드 정보 컨테이너 + self.borderBackgroundView.addSubview(self.cardInfoContainer) + self.cardInfoContainer.snp.makeConstraints { + $0.bottom.horizontalEdges.equalToSuperview() + $0.height.equalTo(34) + } + + // 좌측 + self.cardInfoContainer.addSubview(self.cardInfoLeadingStackView) + self.cardInfoLeadingStackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.height.equalTo(18) + } + + self.adminStackView.addArrangedSubview(self.adminImageView) + self.adminStackView.addArrangedSubview(self.adminLabel) + self.adminImageView.snp.makeConstraints { + $0.size.equalTo(16) + } + + self.cardPungTimeStackView.addArrangedSubview(self.cardPungTimeImageView) + self.cardPungTimeStackView.addArrangedSubview(self.cardPungTimeLabel) + self.cardPungTimeImageView.snp.makeConstraints { + $0.size.equalTo(16) + } + + self.distanceInfoStackView.addArrangedSubview(self.distanceImageView) + self.distanceInfoStackView.addArrangedSubview(self.distanceLabel) + self.distanceImageView.snp.makeConstraints { + $0.size.equalTo(14) + } + + self.cardInfoLeadingStackView.addArrangedSubview(self.adminStackView) + self.cardInfoLeadingStackView.addArrangedSubview(self.firstDot) + self.firstDot.snp.makeConstraints { + $0.size.equalTo(2) + } + + self.cardInfoLeadingStackView.addArrangedSubview(self.distanceInfoStackView) + self.cardInfoLeadingStackView.addArrangedSubview(self.thirdDot) + self.thirdDot.snp.makeConstraints { + $0.size.equalTo(2) + } + + self.cardInfoLeadingStackView.addArrangedSubview(self.timeLabel) + self.cardInfoLeadingStackView.addArrangedSubview(self.secondDot) + self.secondDot.snp.makeConstraints { + $0.size.equalTo(2) + } + + self.cardInfoLeadingStackView.addArrangedSubview(self.cardPungTimeStackView) + + // 우측 + self.cardInfoContainer.addSubview(self.cardInfoTrailingStackView) + self.cardInfoTrailingStackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.cardInfoLeadingStackView.snp.trailing).offset(4) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(18) + } + + self.likeInfoStackView.addArrangedSubview(self.likeImageView) + self.likeInfoStackView.addArrangedSubview(self.likeLabel) + self.likeImageView.snp.makeConstraints { + $0.size.equalTo(14) + } + + self.commentInfoStackView.addArrangedSubview(self.commentImageView) + self.commentInfoStackView.addArrangedSubview(self.commentLabel) + self.commentImageView.snp.makeConstraints { + $0.size.equalTo(14) + } + + self.cardInfoTrailingStackView.addArrangedSubview(self.likeInfoStackView) + self.cardInfoTrailingStackView.addArrangedSubview(self.commentInfoStackView) + + // 카드 문구 + self.rootContainerImageView.addSubview(self.cardTextBackgroundBlurView) + self.cardTextBackgroundBlurView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(32) + $0.trailing.equalToSuperview().offset(-32) + } + + self.cardTextBackgroundBlurView.addSubview(self.cardTextContentLabel) + self.cardTextContentLabel.snp.makeConstraints { + let verticalOffset: CGFloat = self.cardType == .feed ? 20 : 16 + $0.top.equalToSuperview().offset(verticalOffset) + $0.bottom.equalToSuperview().offset(-verticalOffset) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + self.contentHeightConstraint = $0.height.equalTo(Typography.som.v2.body1.lineHeight).constraint + } + } + + + // MARK: Public func + + /// 이 컴포넌트를 사용하는 재사용 셀에서 호출 + func prepareForReuse() { + self.serialTimer?.dispose() + self.disposeBag = DisposeBag() + + self.adminLabel.text = nil + self.cardPungTimeLabel.text = nil + self.distanceLabel.text = nil + self.timeLabel.text = nil + self.likeLabel.text = nil + self.commentLabel.text = nil + } + + /// 홈피드 모델 초기화 + func setModel(model: BaseCardInfo) { + + self.model = model + + let borderColor = model.isAdminCard ? UIColor.som.v2.pMain : UIColor.som.v2.gray100 + self.borderBackgroundView.layer.borderColor = borderColor.cgColor + + // 카드 배경 이미지 + self.rootContainerImageView.setImage(strUrl: model.cardImgURL, with: model.cardImgName) + + // 카드 본문 + let typography: Typography + switch model.font { + case .pretendard: typography = .som.v2.body1 + case .ridi: typography = .som.v2.ridiCard + case .yoonwoo: typography = .som.v2.yoonwooCard + case .kkookkkook: typography = .som.v2.kkookkkookCard + } + self.cardTextContentLabel.text = model.cardContent + self.cardTextContentLabel.typography = typography + self.updateContentHeight(model.cardContent, with: typography) + + // 하단 정보 + // 어드민, 펑 시간, 거리, 시간 + self.adminStackView.isHidden = model.isAdminCard == false + self.firstDot.isHidden = model.isAdminCard == false + self.cardPungTimeStackView.isHidden = model.storyExpirationTime == nil + self.secondDot.isHidden = model.storyExpirationTime == nil + self.distanceLabel.text = model.distance + self.distanceInfoStackView.isHidden = model.distance == nil + self.thirdDot.isHidden = model.distance == nil + self.timeLabel.text = model.createdAt.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + + // 좋아요 수, 답글 수 + let likeText = model.likeCnt > 99 ? "99+" : "\(model.likeCnt)" + self.likeLabel.text = likeText + self.likeLabel.typography = .som.v2.caption2 + + let commentText = model.commentCnt > 99 ? "99+" : "\(model.commentCnt)" + self.commentLabel.text = commentText + self.commentLabel.typography = .som.v2.caption2 + + // 스토리 정보 설정 + self.subscribePungTime(model.storyExpirationTime) + } + + private func updateContentHeight(_ text: String, with typography: Typography) { + + UIView.performWithoutAnimation { + self.layoutIfNeeded() + } + + var attributes = typography.attributes + attributes.updateValue(typography.font, forKey: .font) + let attributedText = NSAttributedString( + string: text, + attributes: attributes + ) + /// screen width - SOMCard horizontal padding - text background dim view horizontal padding - text horizontal inset + let availableWidth = self.cardTextContentLabel.bounds.width + let size: CGSize = .init(width: availableWidth, height: .greatestFiniteMagnitude) + let boundingHeight = attributedText.boundingRect( + with: size, + options: [.usesLineFragmentOrigin], + context: nil + ).height + + let maxHeight = self.cardType == .feed ? typography.lineHeight * 3 : typography.lineHeight * 4 + let height = min(boundingHeight, maxHeight) + + self.contentHeightConstraint?.update(offset: height) + + UIView.performWithoutAnimation { + self.layoutIfNeeded() + } + } + + + // MARK: - 카드 펑 로직 + + /// 펑 이벤트 구독 + private func subscribePungTime(_ pungTime: Date?) { + self.serialTimer?.dispose() + self.serialTimer = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) + .withUnretained(self) + .startWith((self, 0)) + .map { object, _ in + guard let pungTime = pungTime else { + object.serialTimer?.dispose() + return "00:00:00" + } + + let currentDate = Date() + let remainingTime = currentDate.infoReadableTimeTakenFromThisForPung(to: pungTime) + if remainingTime == "00:00:00" { + object.serialTimer?.dispose() + object.updatePungUI() + } + + return remainingTime + } + .bind(to: self.cardPungTimeLabel.rx.text) + } + + /// 펑 ui 즉각적으로 업데이트 + private func updatePungUI() { + self.cardPungTimeLabel.text = "00:00:00" + self.rootContainerImageView.image = UIColor.som.v2.gray200.toImage + self.cardInfoContainer.subviews + .filter { $0 != self.cardInfoLeadingStackView } + .forEach { $0.removeFromSuperview() } + self.cardInfoLeadingStackView.subviews + .filter { $0 != self.cardPungTimeStackView } + .forEach { $0.removeFromSuperview() } + self.borderBackgroundView.removeFromSuperview() + + self.cardTextContentLabel.text = Text.pungedCardText + self.updateContentHeight(Text.pungedCardText, with: .som.v2.body1) + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMCard/SOMCard.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMCard/SOMCard.swift deleted file mode 100644 index 481a3dd7..00000000 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMCard/SOMCard.swift +++ /dev/null @@ -1,579 +0,0 @@ -// -// SOMCard.swift -// SOOUM -// -// Created by JDeoks on 9/14/24. -// - -import UIKit - -import RxSwift -import SnapKit -import Then - - -class SOMCard: UIView { - - enum Text { - static let pungedCardInMainHomeText: String = "삭제된 카드에요" - } - - var model: SOMCardModel? - - private var hasScrollEnabled: Bool - - private var contentHeightConstraint: Constraint? - private var scrollContentHieghtConstraint: Constraint? - - /// 펑 이벤트 처리 위해 추가 - var serialTimer: Disposable? - var disposeBag = DisposeBag() - - /// 배경 이미지 - let rootContainerImageView = UIImageView().then { - $0.backgroundColor = .clear - $0.layer.cornerRadius = 40 - $0.layer.masksToBounds = true - } - - /// 카드 펑 라벨 배경 - let cardPungTimeBackgroundView = UIView().then { - $0.backgroundColor = .som.blue300 - $0.layer.cornerRadius = 12 - $0.layer.masksToBounds = true - } - /// 카드 펑 남은시간 표시 라벨 - let cardPungTimeLabel = UILabel().then { - $0.typography = .som.body2WithBold - $0.textColor = .som.white - $0.textAlignment = .center - } - - /// pungTime != nil - /// 삭제(펑 됐을 때) 배경 - let pungedCardInMainHomeBackgroundView = UIView().then { - $0.backgroundColor = UIColor(hex: "#303030").withAlphaComponent(0.7) - $0.layer.cornerRadius = 40 - $0.isHidden = true - } - /// 삭제(펑 됐을 때) 라벨 - let pungedCardInMainHomeLabel = UILabel().then { - $0.text = Text.pungedCardInMainHomeText - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .som.body1WithBold - } - - /// 본문을 감싸는 불투명 컨테이너 뷰 - let cardTextBackgroundBlurView = UIVisualEffectView().then { - let blurEffect = UIBlurEffect(style: .dark) - $0.effect = blurEffect - $0.backgroundColor = .som.dim - $0.alpha = 0.8 - $0.layer.cornerRadius = 24 - $0.clipsToBounds = true - } - /// 본문 표시 라벨 (스크롤 X) - let cardTextContentLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.numberOfLines = 0 - $0.lineBreakMode = .byTruncatingTail - $0.typography = .som.body1WithBold - } - /// 본문 스크롤 텍스트 뷰 (스크롤 O) - let cardTextContentScrollView = UITextView().then { - $0.backgroundColor = .clear - $0.tintColor = .clear - - $0.textAlignment = .center - $0.textContainerInset = .init(top: 0, left: 16, bottom: 0, right: 16) - $0.textContainer.lineFragmentPadding = 0 - - $0.indicatorStyle = .white - $0.scrollIndicatorInsets = .init(top: 14, left: 0, bottom: 14, right: 0) - - $0.isScrollEnabled = false - $0.showsVerticalScrollIndicator = true - $0.showsHorizontalScrollIndicator = false - - $0.isEditable = false - } - - let cardGradientView = UIView().then { - $0.backgroundColor = .clear - } - - let cardGradientLayer = CAGradientLayer() - - /// 좋아요, 거리, 답카드, 시간 정보 포함하는 스택뷰 - let cardContentStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 8 - } - - /// 시간 정보 표시 스택뷰 - let timeInfoStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - } - - let timeImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.clock))) - $0.tintColor = .som.white - } - - let timeLabel = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.white - } - - /// 거리 정보 표시 스택뷰 - let distanceInfoStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - } - - let distanceImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.location))) - $0.tintColor = .som.white - } - - let distanceLabel = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.white - } - - /// 좋아요 정보 표시 스택뷰 - let likeInfoStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - } - - let likeImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.heart))) - $0.tintColor = .som.white - } - - let likeLabel = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.white - } - - /// 답카드 정보 표시 스택뷰 - let commentInfoStackView = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - } - - let commentImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.comment))) - $0.tintColor = .som.white - } - - let commentLabel = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.white - } - - - // MARK: - init - init(hasScrollEnabled: Bool = false) { - self.hasScrollEnabled = hasScrollEnabled - super.init(frame: .zero) - initUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - DispatchQueue.main.async { [weak self] in - self?.setGradientLayerFrame() - } - } - - /// 이 컴포넌트를 사용하는 재사용 셀에서 호출 - func prepareForReuse() { - serialTimer?.dispose() - disposeBag = DisposeBag() - } - - // MARK: - initUI - private func initUI() { - addSubviews() - initConstraint() - addGradient() - } - - private func addSubviews() { - self.addSubview(rootContainerImageView) - addPungedCardInMainHomeView() - addCardPungTimeLabel() - addCardTextContainerView() - addCardGradientView() - addCardContentStackView() - } - - private func addPungedCardInMainHomeView() { - self.addSubview(pungedCardInMainHomeBackgroundView) - pungedCardInMainHomeBackgroundView.addSubview(pungedCardInMainHomeLabel) - } - - private func addCardPungTimeLabel() { - rootContainerImageView.addSubview(cardPungTimeBackgroundView) - cardPungTimeBackgroundView.addSubview(cardPungTimeLabel) - } - - private func addCardTextContainerView() { - self.addSubview(cardTextBackgroundBlurView) - if hasScrollEnabled { - self.addSubview(cardTextContentScrollView) - } else { - self.addSubview(cardTextContentLabel) - } - } - - private func addCardContentStackView() { - rootContainerImageView.addSubview(cardContentStackView) - - cardContentStackView.addArrangedSubviews( - UIView(), - timeInfoStackView, - distanceInfoStackView, - likeInfoStackView, - commentInfoStackView - ) - - addTimeInfoStackView() - addDistanceInfoStackView() - addLikeInfoStackView() - addCommentInfoStackView() - } - - private func addCardGradientView() { - rootContainerImageView.addSubview(cardGradientView) - rootContainerImageView.bringSubviewToFront(cardGradientView) - } - - private func addTimeInfoStackView() { - timeInfoStackView.addArrangedSubviews(timeImageView, timeLabel) - } - - private func addDistanceInfoStackView() { - distanceInfoStackView.addArrangedSubviews(distanceImageView, distanceLabel) - } - - private func addLikeInfoStackView() { - likeInfoStackView.addArrangedSubviews(likeImageView, likeLabel) - } - - private func addCommentInfoStackView() { - commentInfoStackView.addArrangedSubviews(commentImageView, commentLabel) - } - - - // MARK: - initConstraint - - private func initConstraint() { - /// 홈피드 이미지 배경 - rootContainerImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - /// 삭제(펑 됐을 때) 라벨 - pungedCardInMainHomeBackgroundView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - pungedCardInMainHomeLabel.snp.makeConstraints { - $0.center.equalToSuperview() - } - - /// 펑 라벨 - cardPungTimeBackgroundView.snp.makeConstraints { - $0.top.equalToSuperview().offset(26) - $0.centerX.equalToSuperview() - $0.height.equalTo(25) - } - cardPungTimeLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.trailing.equalToSuperview().inset(10) - } - - /// 본문 라벨 - cardTextBackgroundBlurView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(40) - $0.trailing.equalToSuperview().offset(-40) - contentHeightConstraint = $0.height.equalTo(24 + 14 * 2).priority(.high).constraint - } - if hasScrollEnabled { - cardTextContentScrollView.snp.makeConstraints { - $0.top.equalTo(cardTextBackgroundBlurView.snp.top).offset(14) - $0.bottom.equalTo(cardTextBackgroundBlurView.snp.bottom).offset(-14) - $0.leading.equalTo(cardTextBackgroundBlurView.snp.leading) - $0.trailing.equalTo(cardTextBackgroundBlurView.snp.trailing) - } - } else { - cardTextContentLabel.snp.makeConstraints { - $0.top.equalTo(cardTextBackgroundBlurView.snp.top).offset(14) - $0.bottom.equalTo(cardTextBackgroundBlurView.snp.bottom).offset(-14) - $0.leading.equalTo(cardTextBackgroundBlurView.snp.leading).offset(16) - $0.trailing.equalTo(cardTextBackgroundBlurView.snp.trailing).offset(-16) - } - } - - /// 하단 그라디언트 뷰 - cardGradientView.snp.makeConstraints { - $0.bottom.leading.trailing.equalToSuperview() - $0.height.equalTo(60) - } - - /// 하단 컨텐트 뷰 - cardContentStackView.snp.makeConstraints { - $0.bottom.equalToSuperview().offset(-24) - $0.leading.equalToSuperview() - $0.trailing.equalToSuperview().offset(-26) - $0.height.height.equalTo(12) - } - timeImageView.snp.makeConstraints { - $0.height.width.equalTo(12) - } - distanceImageView.snp.makeConstraints { - $0.height.width.equalTo(12) - } - likeImageView.snp.makeConstraints { - $0.height.width.equalTo(12) - } - commentImageView.snp.makeConstraints { - $0.height.width.equalTo(12) - } - } - - /// cardGradientLayer - private func addGradient() { - - cardGradientLayer.colors = [ - UIColor.clear.cgColor, - UIColor.black.withAlphaComponent(0.6).cgColor - ] - cardGradientLayer.startPoint = CGPoint(x: 0.5, y: 0) - cardGradientLayer.endPoint = CGPoint(x: 0.5, y: 1) - cardGradientView.layer.insertSublayer(cardGradientLayer, at: 0) - } - - private func setGradientLayerFrame() { - - CATransaction.begin() - CATransaction.setDisableActions(true) - cardGradientLayer.frame = cardGradientView.bounds - CATransaction.commit() - } - - /// 홈피드 모델 초기화 - func setModel(model: SOMCardModel) { - - self.model = model - // 카드 배경 이미지 - rootContainerImageView.setImage(strUrl: model.data.backgroundImgURL.url) - - // 카드 본문 - updateContentHeight(model.data.content) - let typography: Typography = model.data.font == .pretendard ? .som.body1WithBold : .som.schoolBody1WithBold - if hasScrollEnabled { - var attributes = typography.attributes - attributes.updateValue(typography.font, forKey: .font) - attributes.updateValue(UIColor.som.white, forKey: .foregroundColor) - cardTextContentScrollView.attributedText = .init( - string: model.data.content, - attributes: attributes - ) - cardTextContentScrollView.textAlignment = .center - } else { - cardTextContentLabel.typography = typography - cardTextContentLabel.text = model.data.content - cardTextContentLabel.textAlignment = .center - cardTextContentLabel.lineBreakMode = .byTruncatingTail - } - - // 하단 정보 - likeImageView.image = model.data.isLiked ? - .init(.icon(.filled(.heart))) : - .init(.icon(.outlined(.heart))) - likeImageView.tintColor = model.data.isLiked ? .som.p300 : .som.white - commentImageView.image = model.data.isCommentWritten ? - .init(.icon(.filled(.comment))) : - .init(.icon(.outlined(.comment))) - commentImageView.tintColor = model.data.isCommentWritten ? .som.p300 : .som.white - - timeLabel.text = model.data.createdAt.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) - distanceInfoStackView.isHidden = model.data.distance == nil - distanceLabel.text = (model.data.distance ?? 0).infoReadableDistanceRangeFromThis() - likeLabel.text = model.data.likeCnt > 99 ? "99+" : "\(model.data.likeCnt)" - likeLabel.textColor = model.data.isLiked ? .som.p300 : .som.white - commentLabel.text = model.data.commentCnt > 99 ? "99+" : "\(model.data.commentCnt)" - commentLabel.textColor = model.data.isCommentWritten ? .som.p300 : .som.white - - // 스토리 정보 설정 - cardPungTimeBackgroundView.isHidden = model.data.storyExpirationTime == nil - self.subscribePungTime() - } - - func setData(tagCard: TagDetailCardResponse.TagFeedCard) { - - // 카드 배경 이미지 - rootContainerImageView.setImage(strUrl: tagCard.backgroundImgURL.href) - // 카드 본문 - updateContentHeight(tagCard.content) - let typography: Typography = tagCard.font == .pretendard ? .som.body1WithBold : .som.schoolBody1WithBold - if hasScrollEnabled { - var attributes = typography.attributes - attributes.updateValue(typography.font, forKey: .font) - attributes.updateValue(UIColor.som.white, forKey: .foregroundColor) - cardTextContentScrollView.attributedText = .init( - string: tagCard.content, - attributes: attributes - ) - cardTextContentScrollView.textAlignment = .center - } else { - cardTextContentLabel.typography = typography - cardTextContentLabel.text = tagCard.content - cardTextContentLabel.textAlignment = .center - cardTextContentLabel.lineBreakMode = .byTruncatingTail - } - // 하단 정보 - likeImageView.image = tagCard.isLiked ? - .init(.icon(.filled(.heart))) : - .init(.icon(.outlined(.heart))) - likeImageView.tintColor = tagCard.isLiked ? .som.p300 : .som.white - - commentImageView.image = tagCard.isCommentWritten ? - .init(.icon(.filled(.comment))) : - .init(.icon(.outlined(.comment))) - commentImageView.tintColor = tagCard.isCommentWritten ? .som.p300 : .som.white - - timeLabel.text = tagCard.createdAt.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) - distanceInfoStackView.isHidden = tagCard.distance == nil - distanceLabel.text = (tagCard.distance ?? 0).infoReadableDistanceRangeFromThis() - likeLabel.text = "\(tagCard.likeCnt)" - likeLabel.textColor = tagCard.isLiked ? .som.p300 : .som.white - commentLabel.text = "\(tagCard.commentCnt)" - commentLabel.textColor = tagCard.isCommentWritten ? .som.p300 : .som.white - - cardPungTimeBackgroundView.isHidden = true - } - - /// 카드 모드에 따라 스택뷰 순서 변경 - func changeOrderInCardContentStack(_ selectedIndex: Int) { - cardContentStackView.subviews.forEach { $0.removeFromSuperview() } - - switch selectedIndex { - case 1: - cardContentStackView.addArrangedSubviews( - UIView(), - likeInfoStackView, - commentInfoStackView, - timeInfoStackView, - distanceInfoStackView - ) - case 2: - cardContentStackView.addArrangedSubviews( - UIView(), - distanceInfoStackView, - timeInfoStackView, - likeInfoStackView, - commentInfoStackView - ) - default: - cardContentStackView.addArrangedSubviews( - UIView(), - timeInfoStackView, - distanceInfoStackView, - likeInfoStackView, - commentInfoStackView - ) - } - } - - // 상세보기 일 때, 좋아요, 코맨트 제거 - func removeLikeAndCommentInStack() { - - cardContentStackView.subviews.forEach { $0.removeFromSuperview() } - cardContentStackView.addArrangedSubviews(UIView(), distanceInfoStackView, timeInfoStackView) - } - - private func updateContentHeight(_ text: String) { - - layoutIfNeeded() - - let typography = Typography.som.body1WithBold - var attributes = typography.attributes - attributes.updateValue(typography.font, forKey: .font) - let attributedText = NSAttributedString( - string: text, - attributes: attributes - ) - - let availableWidth = UIScreen.main.bounds.width - 20 * 2 - 40 * 2 - 16 * 2 - let size: CGSize = .init(width: availableWidth, height: .greatestFiniteMagnitude) - let boundingRect = attributedText.boundingRect( - with: size, - options: [.usesLineFragmentOrigin], - context: nil - ) - let boundingHeight = boundingRect.height + 14 * 2 /// top, bottom inset - let backgroundHeight = rootContainerImageView.bounds.height - - let height = min(boundingHeight, backgroundHeight * 0.5) - - contentHeightConstraint?.deactivate() - cardTextBackgroundBlurView.snp.makeConstraints { - contentHeightConstraint = $0.height.equalTo(height).priority(.high).constraint - } - - if hasScrollEnabled { - cardTextContentScrollView.isScrollEnabled = boundingHeight > backgroundHeight * 0.5 - cardTextContentScrollView.isUserInteractionEnabled = true - cardTextContentScrollView.contentSize = .init( - width: cardTextContentScrollView.bounds.width, - height: boundingHeight - ) - } - } - - - // MARK: - 카드 펑 로직 - - /// 펑 이벤트 구독 - private func subscribePungTime() { - self.serialTimer?.dispose() - self.serialTimer = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) - .withUnretained(self) - .startWith((self, 0)) - .map { object, _ in - guard let pungTime = object.model?.pungTime else { - object.serialTimer?.dispose() - return "00 : 00 : 00" - } - - let currentDate = Date() - let remainingTime = currentDate.infoReadableTimeTakenFromThisForPung(to: pungTime) - if remainingTime == "00 : 00 : 00" { - object.serialTimer?.dispose() - object.updatePungUI() - } - - return remainingTime - } - .bind(to: self.cardPungTimeLabel.rx.text) - } - - /// 펑 ui 즉각적으로 업데이트 - private func updatePungUI() { - rootContainerImageView.subviews.forEach { $0.removeFromSuperview() } - pungedCardInMainHomeBackgroundView.isHidden = false - } -} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMCard/SOMCardModel.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMCard/SOMCardModel.swift deleted file mode 100644 index 19b749a9..00000000 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMCard/SOMCardModel.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// SOMCardModel.swift -// SOOUM -// -// Created by 오현식 on 10/3/24. -// - -import Foundation - - -struct SOMCardModel { - - /// 카드 정보 - let data: Card - /// 스토리 펑타임 - var pungTime: Date? - /// 현재 카드가 펑된 카드인지 확인 - var isPunged: Bool { - guard let pungTime = self.data.storyExpirationTime else { return false } - let remainingTime: TimeInterval = pungTime.timeIntervalSinceNow - return remainingTime <= 0.0 - } - - init(data: Card) { - self.data = data - self.pungTime = data.storyExpirationTime - } -} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift index 632883f2..115265c6 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift @@ -7,28 +7,30 @@ import UIKit - -class SOMDialogAction { +final class SOMDialogAction { enum Style { case primary + case red case gray var backgroundColor: UIColor { switch self { case .primary: - return .som.p300 + return .som.v2.black + case .red: + return .som.v2.rMain case .gray: - return .som.gray300 + return .som.v2.gray100 } } var foregroundColor: UIColor { switch self { - case .primary: - return .som.white + case .primary, .red: + return .som.v2.white case .gray: - return .som.gray700 + return .som.v2.gray600 } } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift index 124969b1..bac0b68a 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift @@ -8,12 +8,17 @@ import UIKit +// MARK: Show + extension SOMDialogViewController { + fileprivate static weak var displayedDialogViewController: SOMDialogViewController? + @discardableResult static func show( title: String, message: String, + textAlignment: NSTextAlignment = .center, actions: [SOMDialogAction], dismissesWhenBackgroundTouched: Bool = false, completion: ((SOMDialogViewController) -> Void)? = nil @@ -24,8 +29,13 @@ extension SOMDialogViewController { let dialogViewController = SOMDialogViewController( title: title, message: message, + textAlignment: textAlignment, completion: { alertController in window.windowScene = nil + /// Dismiss 된 alertController와 표시되었던 dialog가 같다면 제거 + if alertController == Self.displayedDialogViewController { + Self.displayedDialogViewController = nil + } completion?(alertController) } ) @@ -39,7 +49,8 @@ extension SOMDialogViewController { @discardableResult static func show( title: String, - messageView: UIView, + messageView: UIView?, + textAlignment: NSTextAlignment = .center, actions: [SOMDialogAction], dismissesWhenBackgroundTouched: Bool = false, completion: ((SOMDialogViewController) -> Void)? = nil @@ -50,8 +61,13 @@ extension SOMDialogViewController { let dialogViewController = SOMDialogViewController( title: title, messageView: messageView, + textAlignment: textAlignment, completion: { alertController in window.windowScene = nil + /// Dismiss 된 alertController와 표시되었던 dialog가 같다면 제거 + if alertController == Self.displayedDialogViewController { + Self.displayedDialogViewController = nil + } completion?(alertController) } ) @@ -76,6 +92,12 @@ extension SOMDialogViewController { } }() + /// 현재 표시된 dialog가 있다면 dismiss + if let displayedDialogViewController = Self.displayedDialogViewController { + displayedDialogViewController.dismiss(animated: false, completion: nil) + Self.displayedDialogViewController = nil + } + window.windowLevel = .alert window.backgroundColor = .clear window.rootViewController = rootViewController @@ -88,6 +110,29 @@ extension SOMDialogViewController { rootViewController.present(dialogViewController, animated: true, completion: nil) + /// 표시될 dialog 저장 + if let willDisplayDialogViewController = dialogViewController as? SOMDialogViewController { + self.displayedDialogViewController = willDisplayDialogViewController + } + return dialogViewController } } + + +// MARK: Dismiss + +extension SOMDialogViewController { + + static func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) { + + guard let dialog = self.displayedDialogViewController else { + completion?() + return + } + + self.displayedDialogViewController = nil + + dialog.dismiss(animated: animated) { completion?() } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift index 76fcd83d..bf869995 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift @@ -10,33 +10,32 @@ import UIKit import SnapKit import Then - -class SOMDialogViewController: UIViewController { +final class SOMDialogViewController: UIViewController { // MARK: Views /// container 밖의 영역 private let backgroundButton = UIButton().then { - $0.backgroundColor = .som.dim + $0.backgroundColor = .som.v2.dim } private let containerView = UIView().then { - $0.backgroundColor = .som.white + $0.backgroundColor = .som.v2.white $0.layer.cornerRadius = 20 } private let titleLabel = UILabel().then { - $0.textColor = .som.black - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.head3 $0.lineBreakMode = .byWordWrapping $0.lineBreakStrategy = .hangulWordPriority $0.numberOfLines = 0 } private let messageLabel = UILabel().then { - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.body1 $0.lineBreakMode = .byWordWrapping $0.lineBreakStrategy = .hangulWordPriority $0.numberOfLines = 0 @@ -58,7 +57,7 @@ class SOMDialogViewController: UIViewController { private var message: String? { set { if let message = newValue { - let attributes = Typography.som.body2WithRegular.attributes + let attributes = Typography.som.v2.body1.attributes self.messageLabel.attributedText = .init(string: message, attributes: attributes) } self.messageLabel.isHidden = (newValue == nil) @@ -104,22 +103,31 @@ class SOMDialogViewController: UIViewController { convenience init( title: String, message: String, + textAlignment: NSTextAlignment = .center, completion: ((SOMDialogViewController) -> Void)? = nil ) { - self.init(title: title, messageView: nil, completion: completion) + self.init(title: title, messageView: nil, textAlignment: textAlignment, completion: completion) self.message = message + self.messageLabel.textAlignment = textAlignment } - init(title: String, messageView: UIView?, completion: ((SOMDialogViewController) -> Void)? = nil) { + init( + title: String, + messageView: UIView?, + textAlignment: NSTextAlignment = .center, + completion: ((SOMDialogViewController) -> Void)? = nil + ) { self.messageView = messageView super.init(nibName: nil, bundle: nil) self.setupConstraints() - let attributes = Typography.som.body1WithBold.attributes + let attributes = Typography.som.v2.head3.attributes self.titleLabel.attributedText = .init(string: title, attributes: attributes) + self.titleLabel.textAlignment = textAlignment + self.completion = completion } @@ -149,16 +157,15 @@ class SOMDialogViewController: UIViewController { self.view.addSubview(self.containerView) self.containerView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(32) - $0.trailing.equalToSuperview().offset(-32) + $0.leading.equalToSuperview().offset(52) + $0.trailing.equalToSuperview().offset(-52) } self.containerView.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(20) - $0.leading.greaterThanOrEqualToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - $0.centerX.equalToSuperview() + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) } if let messageView = self.messageView { @@ -166,28 +173,27 @@ class SOMDialogViewController: UIViewController { self.containerView.addSubview(messageView) messageView.snp.makeConstraints { $0.top.equalTo(self.titleLabel.snp.bottom).offset(20) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) } } else { self.containerView.addSubview(self.messageLabel) self.messageLabel.snp.makeConstraints { - $0.top.equalTo(self.titleLabel.snp.bottom).offset(12) - $0.leading.greaterThanOrEqualToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - $0.centerX.equalToSuperview() + $0.top.equalTo(self.titleLabel.snp.bottom).offset(6) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) } } self.containerView.addSubview(self.buttonContainer) self.buttonContainer.snp.makeConstraints { let hasMessage = self.message != nil - $0.top.equalTo((self.messageView ?? self.messageLabel).snp.bottom).offset(hasMessage ? 20 : 27) - $0.bottom.equalToSuperview().offset(-14) - $0.leading.equalToSuperview().offset(14) - $0.trailing.equalToSuperview().offset(-14) - $0.height.equalTo(46) + $0.top.equalTo((self.messageView ?? self.messageLabel).snp.bottom).offset(hasMessage ? 20 : 24) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + $0.height.equalTo(48) } } @@ -200,11 +206,11 @@ class SOMDialogViewController: UIViewController { let button = SOMButton().then { $0.title = action.title - $0.typography = .som.body1WithBold + $0.typography = .som.v2.subtitle1 $0.foregroundColor = action.style.foregroundColor $0.backgroundColor = action.style.backgroundColor - $0.layer.cornerRadius = 12 + $0.layer.cornerRadius = 10 $0.clipsToBounds = true } button.tag = action.tag diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMLoadingIndicatorView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMLoadingIndicatorView.swift new file mode 100644 index 00000000..1115ab59 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMLoadingIndicatorView.swift @@ -0,0 +1,76 @@ +// +// SOMLoadingIndicatorWithLottie.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import SnapKit +import Then + +import Lottie + +class SOMLoadingIndicatorView: UIView { + + + // MARK: Views + + private let backgroundView = UIView().then { + $0.backgroundColor = .som.v2.dim + } + + private let animationView = LottieAnimationView(name: "loading_indicator_lottie").then { + $0.contentMode = .scaleAspectFit + $0.loopMode = .loop + } + + + // MARK: Init + + convenience init() { + self.init(frame: .zero) + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Public func + + func startAnimating() { + self.isHidden = false + self.animationView.play() + } + + func stopAnimating() { + self.isHidden = true + self.animationView.stop() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.isHidden = true + + self.addSubview(self.backgroundView) + self.backgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.backgroundView.addSubviews(self.animationView) + self.animationView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(60) + } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMLocationFilter/SOMLocationFilter.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMLocationFilter/SOMLocationFilter.swift deleted file mode 100644 index 69df9b9a..00000000 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMLocationFilter/SOMLocationFilter.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// SOMLocationFilter.swift -// SOOUM -// -// Created by JDeoks on 9/19/24. -// - -import UIKit - -import SnapKit -import Then - -protocol SOMLocationFilterDelegate: AnyObject { - - func filter( - _ filter: SOMLocationFilter, - didSelectDistanceAt distance: SOMLocationFilter.Distance - ) -} - -class SOMLocationFilter: UIView { - - enum Distance: String { - case under1Km = "UNDER_1" - case under5Km = "UNDER_5" - case under10Km = "UNDER_10" - case under20Km = "UNDER_20" - case under50Km = "UNDER_50" - - var text: String { - switch self { - case .under1Km: - "~ 1km" - case .under5Km: - "1km ~ 5km" - case .under10Km: - "5km ~ 10km" - case .under20Km: - "10km ~ 20km" - case .under50Km: - "20km ~ 50km" - } - } - } - - static let height: CGFloat = 54 - - /// 델리게이트 - weak var delegate: SOMLocationFilterDelegate? - - /// 거리 이넘 정보 들어있는 배열 - let distances: [Distance] = [.under1Km, .under5Km, .under10Km, .under20Km, .under50Km] - - /// 이전에 선택된 필터 - var prevDistance: Distance = .under1Km - /// 현재 선택된 필터 - var selectedDistance: Distance = .under1Km - - /// 로케이션 필터 버튼 컬렉션 뷰 - let locationFilterCollectionView = UICollectionView( - frame: .zero, - collectionViewLayout: UICollectionViewFlowLayout().then { - $0.scrollDirection = .horizontal // 스크롤 방향을 가로로 설정 - } - ).then { - $0.backgroundColor = .clear - $0.register( - SOMLocationFilterCollectionViewCell.self, - forCellWithReuseIdentifier: String(describing: SOMLocationFilterCollectionViewCell.self) - ) - } - - // MARK: - init - override init(frame: CGRect) { - super.init(frame: frame) - initUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - initUI - private func initUI() { - self.backgroundColor = .clear - locationFilterCollectionView.showsHorizontalScrollIndicator = false - addSubviews() - initDelegate() - initConstraint() - } - - private func addSubviews() { - self.addSubview(locationFilterCollectionView) - } - - // MARK: - initDelegate - private func initDelegate() { - locationFilterCollectionView.dataSource = self - locationFilterCollectionView.delegate = self - } - - // MARK: - initConstraint - private func initConstraint() { - locationFilterCollectionView.snp.makeConstraints { - $0.leading.equalToSuperview() - $0.top.equalToSuperview() - $0.trailing.equalToSuperview() - $0.bottom.equalToSuperview() - } - } -} - -// MARK: - UICollectionView -extension SOMLocationFilter: - UICollectionViewDataSource, - UICollectionViewDelegate, - UICollectionViewDelegateFlowLayout { - - // MARK: - DataSource - func collectionView( - _ collectionView: UICollectionView, - numberOfItemsInSection section: Int - ) -> Int { - return distances.count - } - - func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: - String(describing: SOMLocationFilterCollectionViewCell.self), - for: indexPath - ) as! SOMLocationFilterCollectionViewCell - - let distance = distances[indexPath.item] - let isSelected = distance == selectedDistance - cell.setData(distance: distance, isSelected: isSelected) - return cell - } - - // MARK: - Delegate - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - /// 새로 선택된 거리 필터 - let newDistance = distances[indexPath.item] - self.prevDistance = self.selectedDistance - self.selectedDistance = newDistance - self.locationFilterCollectionView.reloadData() - self.delegate?.filter(self, didSelectDistanceAt: newDistance) - } - - // MARK: - FlowLayout - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - - let label = UILabel().then { - let distance = distances[indexPath.item] - $0.typography = .som.body3WithRegular - $0.text = distance.text - } - label.sizeToFit() - return CGSize(width: label.bounds.width + 32, height: label.bounds.height + 24) - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - insetForSectionAt section: Int - ) -> UIEdgeInsets { - return UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - minimumInteritemSpacingForSectionAt section: Int - ) -> CGFloat { - return 8 - } -} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMLocationFilter/SOMLocationFilterCollectionViewCell.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMLocationFilter/SOMLocationFilterCollectionViewCell.swift deleted file mode 100644 index 466a7700..00000000 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMLocationFilter/SOMLocationFilterCollectionViewCell.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// SOMLocationFilterCollectionViewCell.swift -// SOOUM -// -// Created by JDeoks on 9/19/24. -// - -import UIKit - -import SnapKit -import Then - -class SOMLocationFilterCollectionViewCell: UICollectionViewCell { - - /// 거리 범위 텍스트 표시하는 라벨 - let label = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.p300 - } - - override init(frame: CGRect) { - super.init(frame: frame) - initUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setData(distance: SOMLocationFilter.Distance, isSelected: Bool) { - label.text = distance.text - label.textColor = isSelected ? .som.p300 : .som.gray600 - contentView.layer.borderColor = isSelected - ? UIColor.som.p300.cgColor - : UIColor.som.gray300.cgColor - } - - // MARK: - initUI - private func initUI() { - contentView.backgroundColor = .white - contentView.layer.cornerRadius = contentView.frame.height / 2 - contentView.layer.borderWidth = 1 - contentView.layer.borderColor = UIColor.som.p300.cgColor - addSubviews() - initConstraint() - } - - private func addSubviews() { - self.addSubview(label) - } - - // MARK: - initConstraint - private func initConstraint() { - label.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.centerY.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMMessageBubbleView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMMessageBubbleView.swift new file mode 100644 index 00000000..bd2e2c0d --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMMessageBubbleView.swift @@ -0,0 +1,86 @@ +// +// SOMMessageBubbleView.swift +// SOOUM +// +// Created by 오현식 on 11/30/25. +// + +import UIKit + +import SnapKit +import Then + +class SOMMessageBubbleView: UIView { + + + // MARK: Views + + private let messageBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 26 * 0.5 + } + + private let messageTailView = UIImageView().then { + $0.image = .init(.image(.v2(.message_tail))) + } + + private let messageLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.caption1 + } + + + // MARK: Variables + + var message: String? { + set { + self.messageLabel.text = newValue + self.messageLabel.typography = .som.v2.caption1 + } + get { + return self.messageLabel.text + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.isUserInteractionEnabled = false + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.messageBackgroundView) + self.messageBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(26) + } + + self.addSubview(self.messageTailView) + self.messageTailView.snp.makeConstraints { + $0.top.equalTo(self.messageBackgroundView.snp.bottom) + $0.centerX.equalToSuperview() + $0.width.equalTo(6) + $0.height.equalTo(3) + } + + self.messageBackgroundView.addSubview(self.messageLabel) + self.messageLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview().offset(-10) + } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMNavigationBar/SOMNavigationBar.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMNavigationBar/SOMNavigationBar.swift index 947a2eb9..8dd36754 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMNavigationBar/SOMNavigationBar.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMNavigationBar/SOMNavigationBar.swift @@ -24,7 +24,7 @@ class SOMNavigationBar: UIView { } /// 네비게이션 바 높이 - static let height: CGFloat = 44 + static let height: CGFloat = 48 private let centerContainer = UIView() private let leftContainer = UIStackView().then { @@ -54,8 +54,8 @@ class SOMNavigationBar: UIView { /// 타이틀 (text == label / logo == image) let titleLabel = UILabel().then { - $0.textColor = .som.black - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.title1 } var title: String? { set { self.titleLabel.text = newValue } @@ -72,26 +72,26 @@ class SOMNavigationBar: UIView { /// 네비게이션 바 뒤로가기 버튼 let backButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.arrowBack))) - $0.foregroundColor = .som.black + $0.image = .init(.icon(.v2(.outlined(.left)))) + $0.foregroundColor = .som.v2.black } var hidesBackButton: Bool { set { self.backButton.isHidden = newValue } get { self.backButton.isHidden } } - var spacing: CGFloat = 20 { + var spacing: CGFloat = 12 { didSet { self.refreshConstraints() } } - var leftInset: CGFloat = 20 { + var leftInset: CGFloat = 16 { didSet { self.refreshConstraints() } } - var rightInset: CGFloat = 20 { + var rightInset: CGFloat = 16 { didSet { self.refreshConstraints() } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMNicknameTextField.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMNicknameTextField.swift new file mode 100644 index 00000000..8a8cf141 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMNicknameTextField.swift @@ -0,0 +1,239 @@ +// +// SOMNicknameTextField.swift +// SOOUM +// +// Created by 오현식 on 11/8/25. +// + +import UIKit + +import SnapKit +import Then + +class SOMNicknameTextField: UIView { + + enum Constants { + static let maxCharacters: Int = 8 + } + + + // MARK: Views + + private lazy var textFieldBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 + + let gestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.touch) + ) + $0.addGestureRecognizer(gestureRecognizer) + } + + lazy var textField = UITextField().then { + let paragraphStyle = NSMutableParagraphStyle() + $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle + $0.defaultTextAttributes[.foregroundColor] = UIColor.som.v2.black + $0.defaultTextAttributes[.font] = Typography.som.v2.subtitle1.font + $0.tintColor = UIColor.som.v2.black + + $0.enablesReturnKeyAutomatically = true + $0.returnKeyType = .go + + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.spellCheckingType = .no + + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) + + $0.delegate = self + + $0.addTarget(self, action: #selector(self.textDidChanged(_:)), for: .editingChanged) + } + + private let guideMessageContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 6 + } + + private let errorImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.error)))) + $0.tintColor = .som.v2.rMain + $0.isHidden = true + } + + private let guideMessageLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + + private lazy var clearButton = SOMButton().then { + $0.image = .init(.icon(.v2(.outlined(.delete_full)))) + $0.foregroundColor = .som.v2.gray500 + + let gestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.clear) + ) + $0.addGestureRecognizer(gestureRecognizer) + } + + + // MARK: Variables + + var text: String? { + set { + self.textField.text = newValue + self.textField.sendActions(for: .editingChanged) + } + get { + return self.textField.text + } + } + + var guideMessage: String? { + set { + self.guideMessageLabel.text = newValue + } + get { + return self.guideMessageLabel.text + } + } + + var hasError: Bool { + set { + self.errorImageView.isHidden = newValue == false + self.guideMessageLabel.textColor = newValue == false ? .som.v2.gray500 : .som.v2.rMain + } + get { + self.errorImageView.isHidden == false + } + } + + + // MARK: Initalization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override var isFirstResponder: Bool { + return self.textField.isFirstResponder + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + return self.textField.becomeFirstResponder() + } + + @discardableResult + override func resignFirstResponder() -> Bool { + return self.textField.resignFirstResponder() + } + + + // MARK: Objc func + + @objc + private func touch(sender: UIGestureRecognizer) { + if self.textField.isFirstResponder == false { + self.textField.becomeFirstResponder() + } + } + + @objc + private func clear() { + self.clearButton.isHidden = true + self.text = nil + self.textField.sendActions(for: .editingChanged) + if self.textField.isFirstResponder == false { + self.textField.becomeFirstResponder() + } + } + + @objc + func textDidChanged(_ textField: UITextField) { + self.clearButton.isHidden = textField.text?.isEmpty ?? true + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.textFieldBackgroundView) + self.textFieldBackgroundView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(54) + } + self.textFieldBackgroundView.addSubview(self.textField) + self.textField.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(24) + } + + self.textFieldBackgroundView.addSubview(self.clearButton) + self.clearButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.textField.snp.trailing).offset(14) + $0.trailing.equalToSuperview().offset(-24) + $0.size.equalTo(24) + } + + self.addSubview(self.guideMessageContainer) + self.guideMessageContainer.snp.makeConstraints { + $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(8) + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + $0.height.equalTo(18) + } + self.guideMessageContainer.addArrangedSubview(self.errorImageView) + self.errorImageView.snp.makeConstraints { + $0.size.equalTo(16) + } + self.guideMessageContainer.addArrangedSubview(self.guideMessageLabel) + } +} + +extension SOMNicknameTextField: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + self.clearButton.isHidden = textField.text?.isEmpty ?? true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + self.clearButton.isHidden = textField.text?.isEmpty ?? true + } + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + + return textField.shouldChangeCharactersIn( + in: range, + replacementString: string, + maxCharacters: Constants.maxCharacters + ) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageModel.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageModel.swift new file mode 100644 index 00000000..14fdbc19 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageModel.swift @@ -0,0 +1,31 @@ +// +// SOMPageModel.swift +// SOOUM +// +// Created by 오현식 on 10/2/25. +// + +import UIKit + + +class SOMPageModel { + + let id: String + let data: NoticeInfo + + init(data: NoticeInfo) { + self.id = UUID().uuidString + self.data = data + } +} + +extension SOMPageModel: Hashable { + + static func == (lhs: SOMPageModel, rhs: SOMPageModel) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift new file mode 100644 index 00000000..fb923644 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift @@ -0,0 +1,105 @@ +// +// SOMPageView.swift +// SOOUM +// +// Created by 오현식 on 10/2/25. +// + +import UIKit + +import SnapKit +import Then + +class SOMPageView: UICollectionViewCell { + + + // MARK: Views + + private let iconView = UIImageView() + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2.withAlignment(.left) + } + + private let messageLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle3.withAlignment(.left) + } + + + // MARK: Variables + + private(set) var model: SOMPageModel? + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.iconView.image = nil + self.titleLabel.text = nil + self.messageLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { + + let iconBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 8 + } + iconBackgroundView.addSubview(self.iconView) + self.iconView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(20) + } + self.contentView.addSubview(iconBackgroundView) + iconBackgroundView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(28) + } + + let contentsContainer = UIStackView(arrangedSubviews: [self.titleLabel, self.messageLabel]).then { + $0.axis = .vertical + } + self.contentView.addSubview(contentsContainer) + contentsContainer.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(iconBackgroundView.snp.trailing).offset(10) + $0.trailing.greaterThanOrEqualToSuperview().offset(-16) + } + } + + + // MARK: Public func + + func setModel(_ model: SOMPageModel) { + + self.model = model + + self.iconView.image = model.data.noticeType.image + self.iconView.tintColor = model.data.noticeType.tintColor + + self.titleLabel.text = model.data.noticeType.title + self.titleLabel.typography = .som.v2.caption2.withAlignment(.left) + self.messageLabel.text = model.data.message + self.messageLabel.typography = .som.v2.subtitle3.withAlignment(.left) + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift new file mode 100644 index 00000000..0a163307 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift @@ -0,0 +1,336 @@ +// +// SOMPageViews.swift +// SOOUM +// +// Created by 오현식 on 10/2/25. +// + +import UIKit + +import SnapKit +import Then + +class SOMPageViews: UIView { + + enum Section: Int, CaseIterable { + case main + } + + enum Item: Hashable { + case main(SOMPageModel) + } + + + // MARK: Views + + private let indicatorContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 2 + } + + private let shadowbackgroundView = UIView().then { + $0.backgroundColor = .som.v2.white + $0.layer.cornerRadius = 16 + } + + private let layout = UICollectionViewFlowLayout().then { + $0.scrollDirection = .horizontal + $0.minimumLineSpacing = 0 + $0.minimumInteritemSpacing = 0 + $0.sectionInset = .zero + } + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: self.layout + ).then { + $0.backgroundColor = .clear + + $0.alwaysBounceHorizontal = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .zero + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.isPagingEnabled = true + + $0.register(SOMPageView.self, forCellWithReuseIdentifier: "page") + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + + if case let .main(model) = item { + let cell: SOMPageView = collectionView.dequeueReusableCell( + withReuseIdentifier: "page", + for: indexPath + ) as! SOMPageView + cell.setModel(model) + + return cell + } else { + return nil + } + } + + private var currentIndexForIndicator: Int = 0 { + didSet { + + guard oldValue != self.currentIndexForIndicator else { return } + + self.indicatorContainer.subviews.enumerated().forEach { index, indicator in + indicator.backgroundColor = index == self.currentIndexForIndicator ? .som.v2.gray600 : .som.v2.gray300 + indicator.snp.updateConstraints { + $0.width.equalTo(index == self.currentIndexForIndicator ? 8 : 4) + } + } + } + } + + private(set) var models: [SOMPageModel] = [] + + private var scrollTimer: Timer? + private var hasLayoutsubviews: Bool = false + + weak var delegate: SOMPageViewsDelegate? + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.stopAutoScroll() + } + + + // MARK: Override func + + override func layoutSubviews() { + super.layoutSubviews() + + self.shadowbackgroundView.setShadow( + radius: 6, + color: UIColor(hex: "#ABBED11A").withAlphaComponent(0.1), + blur: 16, + offset: .init(width: 0, height: 6) + ) + + if self.hasLayoutsubviews == false { + self.hasLayoutsubviews = true + self.startAutoScroll() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + /// 화면에서 사라졌을 때, 타이머 중지 + if self.window == nil { self.stopAutoScroll() } + if self.window != nil { self.startAutoScroll() } + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.shadowbackgroundView) + self.shadowbackgroundView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.bottom.equalToSuperview().offset(-10) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + self.shadowbackgroundView.addSubview(self.indicatorContainer) + self.indicatorContainer.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + self.shadowbackgroundView.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func startAutoScroll() { + + guard self.models.count > 1, self.hasLayoutsubviews else { return } + + self.stopAutoScroll() + + self.scrollTimer = Timer.scheduledTimer( + timeInterval: 5.0, + target: self, + selector: #selector(self.handelAutoScroll), + userInfo: nil, + repeats: true + ) + } + + func stopAutoScroll() { + + self.scrollTimer?.invalidate() + self.scrollTimer = nil + } + + + // MARK: Public func + + func setModels(_ models: [SOMPageModel]) { + + self.indicatorContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } + for index in 0.. 1 { + guard let first = models.first, let last = models.last else { return models } + + let leadingModel: SOMPageModel = SOMPageModel(data: last.data) + let trailingModel: SOMPageModel = SOMPageModel(data: first.data) + + return [leadingModel] + models + [trailingModel] + } else { + return models + } + } + + let modelsToItem = infiniteModels.map { Item.main($0) } + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + snapshot.appendItems(modelsToItem, toSection: .main) + self.dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + guard let self = self, models.count > 1 else { return } + + DispatchQueue.main.async { + let initialIndexPath: IndexPath = IndexPath(item: 1, section: Section.main.rawValue) + self.collectionView.scrollToItem( + at: initialIndexPath, + at: .centeredHorizontally, + animated: false + ) + } + } + } + + + // MARK: Objc func + + @objc + private func handelAutoScroll() { + + let cellWidth = self.collectionView.bounds.width + let currentIndex = Int(round( self.collectionView.contentOffset.x / cellWidth)) + + // 다음 인덱스 (무한 스크롤 배열을 기준으로) + let nextIndex = currentIndex + 1 + + let targetX = cellWidth * CGFloat(nextIndex) + let targetOffset = CGPoint(x: targetX, y: 0) + + // 애니메이션과 함께 다음 아이템으로 스크롤 + UIView.animate(withDuration: 0.5) { [weak self] in + self?.collectionView.contentOffset = targetOffset + } completion: { _ in + self.infiniteScroll(self.collectionView) + } + } +} + + +// MARK: UICollectionViewDelegateFlowLayout and UIScrollViewDelegate + +extension SOMPageViews: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + if case let .main(model) = item { + self.delegate?.pages(self, didTouch: model) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width: CGFloat = collectionView.bounds.width + let height: CGFloat = 71 + + return CGSize(width: width, height: height) + } + + + // MARK: UIScrollViewDelegate + /// 사용자 인터랙션 시 타이머 중지 + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + self.stopAutoScroll() + } + /// 사용자 인터랙션이 끝났을 때, 타이머 재시작 + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + + self.infiniteScroll(scrollView) + self.startAutoScroll() + } + + private func infiniteScroll(_ scrollView: UIScrollView) { + + let cellWidth: CGFloat = scrollView.bounds.width + let currentIndex: Int = Int(round(scrollView.contentOffset.x / cellWidth)) + + var targetIndex: Int? { + switch currentIndex { + case 0: return self.models.count + case self.models.count + 1: return 1 + default: return nil + } + } + + guard let targetIndex = targetIndex else { + self.currentIndexForIndicator = currentIndex - 1 + return + } + + let targetIndexPath: IndexPath = IndexPath(item: targetIndex, section: Section.main.rawValue) + self.collectionView.scrollToItem( + at: targetIndexPath, + at: .centeredHorizontally, + animated: false + ) + self.currentIndexForIndicator = targetIndex - 1 + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViewsDelegate.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViewsDelegate.swift new file mode 100644 index 00000000..c5930b86 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViewsDelegate.swift @@ -0,0 +1,18 @@ +// +// SOMPageViewsDelegate.swift +// SOOUM +// +// Created by 오현식 on 10/2/25. +// + +import Foundation + +protocol SOMPageViewsDelegate: AnyObject { + + func pages(_ tags: SOMPageViews, didTouch model: SOMPageModel) +} + +extension SOMPageViewsDelegate { + + func pages(_ tags: SOMPageViews, didTouch model: SOMPageModel) { } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPresentationController/SOMAnimationTransitioning.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPresentationController/SOMAnimationTransitioning.swift index 6261a83b..452a2df8 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMPresentationController/SOMAnimationTransitioning.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPresentationController/SOMAnimationTransitioning.swift @@ -42,9 +42,9 @@ extension SOMAnimationTransitioning: UIViewControllerAnimatedTransitioning { withDuration: self.transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut] - ) { + ) { [weak self] in - toView.frame.origin.y = containerView.bounds.height - self.initalHeight + toView.frame.origin.y = containerView.bounds.height - (self?.initalHeight ?? 0.0) } completion: { _ in transitionContext.completeTransition(transitionContext.transitionWasCancelled == false) } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMRefreshControl.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMRefreshControl.swift index ecbbc201..716500e2 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMRefreshControl.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMRefreshControl.swift @@ -10,22 +10,20 @@ import UIKit import SnapKit import Then +import Lottie class SOMRefreshControl: UIRefreshControl { - private let backgroundView = UIView().then { - $0.backgroundColor = .som.white - $0.layer.cornerRadius = 40 * 0.5 - } - private let imageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.refresh))) - $0.tintColor = .som.black + // MARK: Views + + private let animationView = LottieAnimationView(name: "refrech_control_lottie").then { $0.contentMode = .scaleAspectFit + $0.loopMode = .loop } - // MARK: init + // MARK: Initialize convenience override init() { self.init(frame: .zero) @@ -47,25 +45,22 @@ class SOMRefreshControl: UIRefreshControl { // MARK: Override func + override func layoutSubviews() { + super.layoutSubviews() + + self.subviews.filter { $0 != self.animationView }.forEach { $0.removeFromSuperview() } + } + override func beginRefreshing() { super.beginRefreshing() - self.animation(true) + self.animationView.alpha = 1.0 + self.animationView.play() + self.sendActions(for: .valueChanged) } override func endRefreshing() { super.endRefreshing() - self.animation(false) - } - - override func layoutSubviews() { - super.layoutSubviews() - - self.backgroundView.setShadow( - radius: 40 * 0.5, - color: UIColor.som.black.withAlphaComponent(0.25), - blur: 4, - offset: .init(width: 0, height: 4) - ) + self.animationView.stop() } @@ -76,32 +71,27 @@ class SOMRefreshControl: UIRefreshControl { self.addObserver(self, forKeyPath: #keyPath(isRefreshing), options: .new, context: nil) self.tintColor = .clear + self.backgroundColor = .clear - self.addSubview(self.backgroundView) - self.backgroundView.snp.makeConstraints { + self.addSubview(self.animationView) + self.animationView.snp.makeConstraints { $0.center.equalToSuperview() - $0.size.equalTo(40) - } - - self.backgroundView.addSubview(self.imageView) - self.imageView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.size.equalTo(28) + $0.size.equalTo(44) } } - private func animation(_ isRefreshing: Bool) { + + // MARK: Public func + + func updateProgress(offset contentOffsetY: CGFloat, topInset adjustedContentInsetTop: CGFloat) { - if isRefreshing { - let rotate = CABasicAnimation(keyPath: "transform.rotation.z") - rotate.fromValue = 0 - rotate.toValue = NSNumber(value: Double.pi * -2.0) - rotate.duration = 1 - rotate.repeatCount = Float.infinity - rotate.timingFunction = CAMediaTimingFunction(name: .linear) - self.imageView.layer.add(rotate, forKey: "rotate") - } else { - self.imageView.layer.removeAnimation(forKey: "rotate") - } + guard self.isRefreshing == false else { return } + + let offset = contentOffsetY + adjustedContentInsetTop + + let threshold: CGFloat = 60 + let alpha = min(max(-offset / threshold, 0.0), 1.0) + + self.animationView.alpha = alpha } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift new file mode 100644 index 00000000..cf057ed3 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift @@ -0,0 +1,240 @@ +// +// SOMStickyTabBar.swift +// SOOUM +// +// Created by 오현식 on 12/21/24. +// + +import UIKit + +import SnapKit +import Then + + +class SOMStickyTabBar: UIView { + + enum Constants { + static let height: CGFloat = 56 + + static let selectedColor: UIColor = UIColor.som.v2.black + static let unSelectedColor: UIColor = UIColor.som.v2.gray400 + } + + + // MARK: Views + + private let tabBarItemContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .equalSpacing + } + + private var tabBarItems: [UIView]? { + let items = self.tabBarItemContainer.arrangedSubviews + return items.isEmpty ? nil : items + } + + private let bottomSeperator = UIView().then { + $0.backgroundColor = .som.v2.gray200 + } + + private let selectedIndicator = UIView().then { + $0.backgroundColor = .som.v2.black + } + + + // MARK: Variables + + private let itemAlignment: NSTextAlignment + + var inset: UIEdgeInsets = .init(top: 0, left: 16, bottom: 0, right: 16) { + didSet { + self.refreshConstraints() + } + } + + var spacing: CGFloat = 24 { + didSet { + self.refreshConstraints() + } + } + + var items: [String] = [] { + didSet { + if self.items.isEmpty == false { + self.setTabBarItems(self.items) + } + } + } + + // Set item width with text and typography + var itemWidths: [CGFloat] { + if self.itemAlignment == .left { + return self.items.enumerated().map { index, item in + let typography: Typography = .som.v2.title2 + /// 실제 텍스트 가로 길이 + return (item as NSString).size(withAttributes: [.font: typography.font]).width + } + } else { + let horizontalInset = self.inset.left + self.inset.right + let totalSpacing = self.spacing * CGFloat(self.items.count - 1) + // 항상 화면의 가로 크기를 꽉 채운다고 가정 + let itemWidth = (UIScreen.main.bounds.width - horizontalInset - totalSpacing) / CGFloat(self.items.count) + return Array(repeating: itemWidth, count: self.items.count) + } + } + + var itemFrames: [CGRect] { + var itemFrames: [CGRect] = [] + var currentX: CGFloat = self.inset.left + for itemWidth in self.itemWidths { + let itemFrame = CGRect(x: currentX, y: 0, width: itemWidth, height: self.bounds.height) + itemFrames.append(itemFrame) + currentX += itemWidth + self.spacing + } + + return itemFrames + } + + var previousIndex: Int = 0 + var selectedIndex: Int = 0 + + + // MARK: Constraints + + private var tabBarItemContainerTopConstraint: Constraint? + private var tabBarItemContainerBottomConstraint: Constraint? + private var tabBarItemContainerLeadingConstraint: Constraint? + private var tabBarItemContainerTrailingConstraint: Constraint? + + private var selectedIndicatorLeadingConstraint: Constraint? + private var selectedIndicatorWidthConstraint: Constraint? + + + // MARK: Delegate + + weak var delegate: SOMStickyTabBarDelegate? + + + // MARK: Initialize + + init(alignment: NSTextAlignment = .left) { + self.itemAlignment = alignment + super.init(frame: .zero) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + + guard let touch = touches.first else { return } + + let location = touch.location(in: self) + + for (index, frame) in self.itemFrames.enumerated() { + // 현재 선택한 좌표가 아이템의 내부이고 선택된 아이템이 아닐 때 + if frame.contains(location), self.selectedIndex != index { + // 선택할 수 있는 상태일 때 + if self.delegate?.tabBar(self, shouldSelectTabAt: index) ?? true { + self.didSelectTabBarItem(index) + } + } + } + + super.touchesEnded(touches, with: event) + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.bottomSeperator) + self.bottomSeperator.snp.makeConstraints { + $0.bottom.leading.trailing.equalToSuperview() + $0.height.equalTo(1) + } + + self.addSubview(self.tabBarItemContainer) + self.tabBarItemContainer.snp.makeConstraints { + self.tabBarItemContainerTopConstraint = $0.top.equalToSuperview().offset(self.inset.top).constraint + self.tabBarItemContainerBottomConstraint = $0.bottom.equalToSuperview().offset(-self.inset.bottom).constraint + self.tabBarItemContainerLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint + self.tabBarItemContainerTrailingConstraint = $0.trailing.lessThanOrEqualToSuperview().offset(-self.inset.right).constraint + } + + self.addSubview(self.selectedIndicator) + self.selectedIndicator.snp.makeConstraints { + $0.bottom.equalToSuperview() + self.selectedIndicatorLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint + self.selectedIndicatorWidthConstraint = $0.width.equalTo(0).constraint + $0.height.equalTo(2) + } + } + + private func refreshConstraints() { + + self.tabBarItemContainer.spacing = self.spacing + + self.tabBarItemContainerTopConstraint?.deactivate() + self.tabBarItemContainerBottomConstraint?.deactivate() + self.tabBarItemContainerLeadingConstraint?.deactivate() + self.tabBarItemContainerTrailingConstraint?.deactivate() + self.tabBarItemContainer.snp.makeConstraints { + self.tabBarItemContainerTopConstraint = $0.top.equalToSuperview().offset(self.inset.top).constraint + self.tabBarItemContainerBottomConstraint = $0.bottom.equalToSuperview().offset(-self.inset.bottom).constraint + self.tabBarItemContainerLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint + self.tabBarItemContainerTrailingConstraint = $0.trailing.lessThanOrEqualToSuperview().offset(-self.inset.right).constraint + } + } + + private func setTabBarItems(_ items: [String]) { + + self.tabBarItemContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } + + items.enumerated().forEach { index, title in + + let item = SOMStickyTabBarItem(title: title) + item.updateState(color: index == self.selectedIndex ? Constants.selectedColor : Constants.unSelectedColor) + item.snp.makeConstraints { + $0.width.equalTo(self.itemWidths[index]) + } + + self.tabBarItemContainer.addArrangedSubview(item) + } + + self.selectedIndicatorWidthConstraint?.update(offset: self.itemWidths.first ?? 0) + } + + + // MARK: Public func + + func didSelectTabBarItem(_ index: Int, with animated: Bool = true) { + + self.tabBarItemContainer.arrangedSubviews.enumerated().forEach { + let selectedItem = $1 as? SOMStickyTabBarItem + selectedItem?.updateState(color: $0 == index ? Constants.selectedColor : Constants.unSelectedColor) + + /// leading inset + spacing + item widths + let prevItemWidths: CGFloat = (index > 0) ? self.itemWidths[0.. Bool + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) +} + +extension SOMStickyTabBarDelegate { + func tabBar(_ tabBar: SOMStickyTabBar, shouldSelectTabAt index: Int) -> Bool { true } + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) { } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBarItem.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBarItem.swift similarity index 52% rename from SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBarItem.swift rename to SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBarItem.swift index e964802b..2a572faa 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBarItem.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBarItem.swift @@ -1,5 +1,5 @@ // -// SOMSwipeTabBarItem.swift +// SOMStickyTabBarItem.swift // SOOUM // // Created by 오현식 on 12/22/24. @@ -11,9 +11,18 @@ import SnapKit import Then -class SOMSwipeTabBarItem: UIView { +class SOMStickyTabBarItem: UIView { - private let titleLabel = UILabel() + + // MARK: Views + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.title2 + } + + + // MARK: Initialize convenience init(title: String) { self.init(frame: .zero) @@ -31,23 +40,24 @@ class SOMSwipeTabBarItem: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.center.equalToSuperview() + $0.top.equalToSuperview().offset(16) + $0.bottom.equalToSuperview().offset(-16) + $0.horizontalEdges.equalToSuperview() } } - func updateState( - color textColor: UIColor, - typo typography: Typography, - with duration: TimeInterval - ) { + + // MARK: Public func + + func updateState(color textColor: UIColor) { - UIView.animate(withDuration: duration) { - self.titleLabel.textColor = textColor - self.titleLabel.typography = typography - } + self.titleLabel.textColor = textColor } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBar.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBar.swift new file mode 100644 index 00000000..0631ea96 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBar.swift @@ -0,0 +1,204 @@ +// +// SOMSwipableTabBar.swift +// SOOUM +// +// Created by 오현식 on 9/23/25. +// + +import UIKit + +import SnapKit +import Then + + +class SOMSwipableTabBar: UIView { + + enum Constants { + static let height: CGFloat = 56 + + static let selectedColor: UIColor = UIColor.som.v2.gray600 + static let unSelectedColor: UIColor = UIColor.som.v2.gray400 + + static let selectedBackgroundColor: UIColor = UIColor.som.v2.gray100 + + enum Text { + static let eventTitle: String = "이벤트" + } + } + + + // MARK: Views + + private let tabBarItemContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .equalSpacing + } + + private var tabBarItems: [UIView]? { + let items = self.tabBarItemContainer.arrangedSubviews + return items.isEmpty ? nil : items + } + + + // MARK: Variables + + var inset: UIEdgeInsets = .init(top: 9.5, left: 16, bottom: 9.5, right: 16) { + didSet { + self.refreshConstraints() + } + } + + var spacing: CGFloat = 0 { + didSet { + self.refreshConstraints() + } + } + + var items: [String] = [] { + didSet { + if self.items.isEmpty == false { + self.setTabBarItems(self.items) + } + } + } + + // Set item width with text and typography + var itemFrames: [CGRect] { + let itemWidths: [CGFloat] = self.items.enumerated().map { index, item in + let typography: Typography = .som.v2.subtitle3 + /// 실제 텍스트 가로 길이 + 패딩 + return (item as NSString).size(withAttributes: [.font: typography.font]).width + 10 * 2 + } + + var itemFrames: [CGRect] = [] + var currentX: CGFloat = self.inset.left + for itemWidth in itemWidths { + let itemFrame = CGRect(x: currentX, y: 0, width: itemWidth, height: self.bounds.height) + itemFrames.append(itemFrame) + currentX += itemWidth + } + + return itemFrames + } + + var previousIndex: Int = 0 + var selectedIndex: Int = 0 + + + // MARK: Constraint + + private var tabBarItemContainerTopConstraint: Constraint? + private var tabBarItemContainerBottomConstraint: Constraint? + private var tabBarItemContainerLeadingConstraint: Constraint? + private var tabBarItemContainerTrailingConstraint: Constraint? + + + // MARK: Delegate + + weak var delegate: SOMSwipableTabBarDelegate? + + + // MARK: Initialize + + init() { + super.init(frame: .zero) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + + guard let touch = touches.first else { return } + + let location = touch.location(in: self) + + for (index, frame) in self.itemFrames.enumerated() { + // 현재 선택한 좌표가 아이템의 내부이고 선택된 아이템이 아닐 때 + if frame.contains(location), self.selectedIndex != index { + // 선택할 수 있는 상태일 때 + if self.delegate?.tabBar(self, shouldSelectTabAt: index) ?? true { + self.didSelectTabBarItem(index) + } + } + } + + super.touchesEnded(touches, with: event) + } + + + // MARK: Private func + + private func setupConstraints() { + + self.snp.makeConstraints { + $0.height.equalTo(Constants.height) + } + + self.addSubview(self.tabBarItemContainer) + self.tabBarItemContainer.snp.makeConstraints { + self.tabBarItemContainerTopConstraint = $0.top.equalToSuperview().offset(self.inset.top).constraint + self.tabBarItemContainerBottomConstraint = $0.bottom.equalToSuperview().offset(-self.inset.bottom).constraint + self.tabBarItemContainerLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint + self.tabBarItemContainerTrailingConstraint = $0.trailing.lessThanOrEqualToSuperview().offset(-self.inset.right).constraint + } + } + + private func refreshConstraints() { + + self.tabBarItemContainer.spacing = self.spacing + + self.tabBarItemContainerTopConstraint?.deactivate() + self.tabBarItemContainerBottomConstraint?.deactivate() + self.tabBarItemContainerLeadingConstraint?.deactivate() + self.tabBarItemContainerTrailingConstraint?.deactivate() + self.tabBarItemContainer.snp.makeConstraints { + self.tabBarItemContainerTopConstraint = $0.top.equalToSuperview().offset(self.inset.top).constraint + self.tabBarItemContainerBottomConstraint = $0.bottom.equalToSuperview().offset(-self.inset.bottom).constraint + self.tabBarItemContainerLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint + self.tabBarItemContainerTrailingConstraint = $0.trailing.lessThanOrEqualToSuperview().offset(-self.inset.right).constraint + } + } + + private func setTabBarItems(_ items: [String]) { + + items.enumerated().forEach { index, title in + + let item = SOMSwipableTabBarItem(title: title) + item.updateState( + color: index == 0 ? Constants.selectedColor : Constants.unSelectedColor, + backgroundColor: index == 0 ? Constants.selectedBackgroundColor : nil + ) + item.isEventDotHidden = title != Constants.Text.eventTitle + + self.tabBarItemContainer.addArrangedSubview(item) + } + } + + + // MARK: Public func + + func didSelectTabBarItem(_ index: Int, onlyUpdateApperance: Bool = false) { + + self.tabBarItemContainer.arrangedSubviews.enumerated().forEach { + let selectedItem = $1 as? SOMSwipableTabBarItem + selectedItem?.updateState( + color: index == $0 ? Constants.selectedColor : Constants.unSelectedColor, + backgroundColor: index == $0 ? Constants.selectedBackgroundColor : nil + ) + } + + self.previousIndex = self.selectedIndex + self.selectedIndex = index + if onlyUpdateApperance == false { + self.delegate?.tabBar(self, didSelectTabAt: index) + } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBarDelegate.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBarDelegate.swift new file mode 100644 index 00000000..8068edc0 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBarDelegate.swift @@ -0,0 +1,18 @@ +// +// SOMSwipableTabBarDelegate.swift +// SOOUM +// +// Created by 오현식 on 9/23/25. +// + +import Foundation + +protocol SOMSwipableTabBarDelegate: AnyObject { + func tabBar(_ tabBar: SOMSwipableTabBar, shouldSelectTabAt index: Int) -> Bool + func tabBar(_ tabBar: SOMSwipableTabBar, didSelectTabAt index: Int) +} + +extension SOMSwipableTabBarDelegate { + func tabBar(_ tabBar: SOMSwipableTabBar, shouldSelectTabAt index: Int) -> Bool { true } + func tabBar(_ tabBar: SOMSwipableTabBar, didSelectTabAt index: Int) { } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBarItem.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBarItem.swift new file mode 100644 index 00000000..d205fd5a --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipableTabBar/SOMSwipableTabBarItem.swift @@ -0,0 +1,90 @@ +// +// SOMSwipableTabBarItem.swift +// SOOUM +// +// Created by 오현식 on 9/23/25. +// + +import UIKit + +import SnapKit +import Then + + +class SOMSwipableTabBarItem: UIView { + + + // MARK: Views + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle3 + } + + private let dotWithoutReadView = UIView().then { + $0.backgroundColor = .som.v2.rMain + $0.layer.cornerRadius = 5 * 0.5 + $0.isHidden = true + } + + + // MARK: Variables + + var isEventDotHidden: Bool = true { + didSet { + self.dotWithoutReadView.isHidden = self.isEventDotHidden + } + } + + + // MARK: Initialize + + convenience init(title: String) { + self.init(frame: .zero) + + self.titleLabel.text = title + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.layer.cornerRadius = 8 + self.clipsToBounds = true + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(8) + $0.bottom.equalToSuperview().offset(-8) + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview().offset(-10) + } + + self.addSubview(self.dotWithoutReadView) + self.dotWithoutReadView.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.trailing.equalToSuperview().offset(-3) + $0.size.equalTo(5) + } + } + + func updateState( + color textColor: UIColor, + backgroundColor: UIColor? = nil + ) { + + self.titleLabel.textColor = textColor + self.backgroundColor = backgroundColor ?? .clear + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBar.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBar.swift deleted file mode 100644 index ef9832d2..00000000 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBar.swift +++ /dev/null @@ -1,240 +0,0 @@ -// -// SOMSwipeTabBar.swift -// SOOUM -// -// Created by 오현식 on 12/21/24. -// - -import UIKit - -import SnapKit -import Then - - -class SOMSwipeTabBar: UIView { - - enum Height { - static let mainHome: CGFloat = 40 - static let notification: CGFloat = 38 - } - - private let tabBarItemContainer = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .fill - $0.distribution = .equalSpacing - } - - private var tabBarItems: [UIView]? { - let items = self.tabBarItemContainer.arrangedSubviews - return items.isEmpty ? nil : items - } - - private let bottomSeperator = UIView().then { - $0.backgroundColor = .som.gray200 - } - - private let selectedIndicator = UIView().then { - $0.backgroundColor = .som.p300 - } - - var inset: UIEdgeInsets = .init(top: 4, left: 12, bottom: 10, right: 0) { - didSet { - self.refreshConstraints() - } - } - - var spacing: CGFloat = 2 { - didSet { - self.refreshConstraints() - } - } - - var seperatorHeight: CGFloat = 1 { - didSet { - self.refreshConstraints() - } - } - - var seperatorColor: UIColor = .som.gray200 { - didSet { - self.bottomSeperator.backgroundColor = self.seperatorColor - } - } - - private var tabBarItemContainerTopConstraint: Constraint? - private var tabBarItemContainerBottomConstraint: Constraint? - private var tabBarItemContainerLeadingConstraint: Constraint? - - private var bottomSeperatorHeightConstraint: Constraint? - - private var selectedIndicatorLeadingConstraint: Constraint? - private var selectedIndicatorWidthConstraint: Constraint? - - private var itemAlignment: ItemAlignment - - private var defaultTypo: Typography - private var selectedTypo: Typography - - private var defaultColor: UIColor - private var selectedColor: UIColor - - var items: [String] = [] { - didSet { - if self.items.isEmpty == false { - self.setTabBarItems(self.items) - } - } - } - - var itemWidth: CGFloat { - let width = self.itemAlignment == .fill ? UIScreen.main.bounds.width / CGFloat(self.items.count) : 53 - return width - } - - weak var delegate: SOMSwipeTabBarDelegate? - - var previousIndex: Int = 0 - var selectedIndex: Int = 0 - - init(alignment: ItemAlignment) { - self.itemAlignment = alignment - self.selectedIndicator.isHidden = alignment == .left - - self.defaultTypo = alignment == .fill ? .som.body2WithRegular : .som.body2WithBold - self.selectedTypo = .som.body2WithBold - - self.defaultColor = alignment == .fill ? .som.gray400 : .som.gray500 - self.selectedColor = alignment == .fill ? .som.p300 : .som.black - - super.init(frame: .zero) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.addSubview(self.tabBarItemContainer) - self.tabBarItemContainer.snp.makeConstraints { - self.tabBarItemContainerTopConstraint = $0.top.equalToSuperview().offset(self.inset.top).constraint - self.tabBarItemContainerBottomConstraint = $0.bottom.equalToSuperview().offset(-self.inset.bottom).constraint - self.tabBarItemContainerLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint - $0.trailing.lessThanOrEqualToSuperview() - } - - self.addSubview(self.bottomSeperator) - self.bottomSeperator.snp.makeConstraints { - $0.bottom.leading.trailing.equalToSuperview() - self.bottomSeperatorHeightConstraint = $0.height.equalTo(1).constraint - } - - self.addSubview(self.selectedIndicator) - self.selectedIndicator.snp.makeConstraints { - $0.bottom.equalToSuperview() - self.selectedIndicatorLeadingConstraint = $0.leading.equalToSuperview().constraint - $0.height.equalTo(1.6) - } - } - - private func refreshConstraints() { - - self.tabBarItemContainer.spacing = self.spacing - - self.tabBarItemContainerTopConstraint?.deactivate() - self.tabBarItemContainerBottomConstraint?.deactivate() - self.tabBarItemContainerLeadingConstraint?.deactivate() - self.tabBarItemContainer.snp.makeConstraints { - self.tabBarItemContainerTopConstraint = $0.top.equalToSuperview().offset(self.inset.top).constraint - self.tabBarItemContainerBottomConstraint = $0.bottom.equalToSuperview().offset(-self.inset.bottom).constraint - self.tabBarItemContainerLeadingConstraint = $0.leading.equalToSuperview().offset(self.inset.left).constraint - } - - self.bottomSeperatorHeightConstraint?.deactivate() - self.bottomSeperator.snp.makeConstraints { - self.bottomSeperatorHeightConstraint = $0.height.equalTo(self.seperatorHeight).constraint - } - } - - private func setTabBarItems(_ items: [String]) { - - items.enumerated().forEach { index, title in - - let item = SOMSwipeTabBarItem(title: title) - item.updateState( - color: index == 0 ? self.selectedColor : self.defaultColor, - typo: index == 0 ? self.selectedTypo : self.defaultTypo, - with: 0 - ) - - item.snp.makeConstraints { - $0.width.equalTo(self.itemWidth) - } - self.tabBarItemContainer.addArrangedSubview(item) - - if self.itemAlignment == .fill { - - self.selectedIndicatorWidthConstraint?.deactivate() - self.selectedIndicator.snp.makeConstraints { - self.selectedIndicatorWidthConstraint = $0.width.equalTo(self.itemWidth).constraint - } - } - } - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - super.touchesEnded(touches, with: event) - - guard let touchArea = touches.first?.location(in: self), - self.tabBarItemContainer.frame.contains(touchArea) else { return } - - let convertTouchAreaInContainer = convert(touchArea, to: self.tabBarItemContainer).x - let index = Int(floor(convertTouchAreaInContainer / self.itemWidth)) - - if self.selectedIndex != index, - self.delegate?.tabBar(self, shouldSelectTabAt: index) ?? true { - self.didSelectTabBarItem(index) - } - } - - func didSelectTabBarItem(_ index: Int, animated: Bool = true) { - - let animationDuration: TimeInterval = animated ? 0.25 : 0 - - self.tabBarItemContainer.arrangedSubviews.enumerated().forEach { - let selectedItem = $1 as? SOMSwipeTabBarItem - selectedItem?.updateState( - color: $0 == index ? self.selectedColor : self.defaultColor, - typo: $0 == index ? self.selectedTypo : self.defaultTypo, - with: $0 == index ? animationDuration : 0 - ) - } - - if self.itemAlignment == .fill { - let indicatorLeadingOffset: CGFloat = self.itemWidth * CGFloat(index) - - self.selectedIndicatorLeadingConstraint?.deactivate() - self.selectedIndicator.snp.makeConstraints { - self.selectedIndicatorLeadingConstraint = $0.leading.equalToSuperview().offset(indicatorLeadingOffset).constraint - } - - UIView.animate(withDuration: animationDuration) { - self.layoutIfNeeded() - } - } - - self.previousIndex = self.selectedIndex - self.selectedIndex = index - self.delegate?.tabBar(self, didSelectTabAt: index) - } -} - -extension SOMSwipeTabBar { - - enum ItemAlignment { - case left - case fill - } -} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBarDelegate.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBarDelegate.swift deleted file mode 100644 index d076011d..00000000 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMSwipeTabBar/SOMSwipeTabBarDelegate.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// SOMSwipeTabBarDelegate.swift -// SOOUM -// -// Created by 오현식 on 12/22/24. -// - -import Foundation - - -protocol SOMSwipeTabBarDelegate: AnyObject { - func tabBar(_ tabBar: SOMSwipeTabBar, shouldSelectTabAt index: Int) -> Bool - func tabBar(_ tabBar: SOMSwipeTabBar, didSelectTabAt index: Int) -} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBar.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBar.swift index 6208d98c..16893a17 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBar.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBar.swift @@ -17,54 +17,73 @@ protocol SOMTabBarDelegate: AnyObject { } class SOMTabBar: UIView { - - static let height: CGFloat = 58 + + + // MARK: Views private var tabBarItemContainer = UIStackView().then { $0.axis = .horizontal $0.distribution = .equalSpacing $0.alignment = .center + $0.spacing = 12 } private let tabBarBackgroundView = UIView().then { - $0.backgroundColor = .clear - $0.layer.cornerRadius = 58 * 0.5 + $0.backgroundColor = .som.v2.white + $0.layer.cornerRadius = 20 + $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray200.cgColor $0.clipsToBounds = true } - private let blurView = UIVisualEffectView().then { - let blurEffect = UIBlurEffect(style: .regular) - $0.effect = blurEffect - $0.backgroundColor = UIColor.som.white.withAlphaComponent(0.7) - $0.alpha = 0.9 - } - var viewControllers: [UIViewController] = [] { didSet { guard self.viewControllers.isEmpty == false else { return } self.setTabBarItemConstraints() - self.setupConstraints() - self.didSelectTab(0) + self.didSelectTabBarItem(0) } } + + // MARK: Delegate + weak var delegate: SOMTabBarDelegate? - private let width: CGFloat = UIScreen.main.bounds.width - 20 * 2 - private var selectedIndex: Int = -1 - private var prevSelectedIndex: Int = -1 + // MARK: Variables - private var tabWidth: CGFloat { - self.width / CGFloat(self.numberOfItems) + var itemSpacing: CGFloat { + return (UIScreen.main.bounds.width - 16 * 2 - 77 * 4) / 3 + } + + var itemFrames: [CGRect] { + var itemFrames: [CGRect] = [] + var currentX: CGFloat = 16 + let itemWidth: CGFloat = 77 + for _ in 0.., with event: UIEvent?) { - self.tabBarBackgroundView.addSubview(self.blurView) - self.blurView.snp.makeConstraints { - $0.edges.equalToSuperview() - $0.width.equalTo(self.width) - $0.height.equalTo(58) - } + guard let touch = touches.first else { return } - self.tabBarBackgroundView.addSubview(self.tabBarItemContainer) - self.tabBarItemContainer.snp.makeConstraints { - $0.top.equalToSuperview().offset(4) - $0.bottom.equalToSuperview().offset(-4) - $0.leading.equalToSuperview().offset(4) - $0.trailing.equalToSuperview().offset(-4) + let location = touch.location(in: self) + + for (index, frame) in self.itemFrames.enumerated() { + // 현재 선택한 좌표가 아이템의 내부일 때 + if frame.contains(location) { + // 홈 탭을 다시 탭하면 scrollToTop + if index == 0, self.selectedIndex == index { + NotificationCenter.default.post(name: .scollingToTopWithAnimation, object: self) + } + // 이전에 선택된 아이템이 아닐 때 + if self.selectedIndex != index { + // 선택할 수 있는 상태일 때 + if self.delegate?.tabBar(self, shouldSelectTabAt: index) ?? true { + self.didSelectTabBarItem(index) + } + } + } } + super.touchesEnded(touches, with: event) + } + + + // MARK: Private func + + private func setupConstraints() { + self.addSubview(self.tabBarBackgroundView) self.tabBarBackgroundView.snp.makeConstraints { $0.edges.equalToSuperview() } + + self.tabBarBackgroundView.addSubview(self.tabBarItemContainer) + self.tabBarItemContainer.snp.makeConstraints { + $0.top.equalToSuperview().offset(8) + $0.bottom.equalToSuperview().offset(-34) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } } private func setTabBarItemConstraints() { + self.tabBarItemContainer.spacing = self.itemSpacing + self.viewControllers.forEach { - let tabBarItem = SOMTabBarItem() - tabBarItem.title = $0.tabBarItem.title - tabBarItem.image = $0.tabBarItem.image + let tabBarItem = SOMTabBarItem(title: $0.tabBarItem.title, image: $0.tabBarItem.image) self.tabBarItemContainer.addArrangedSubview(tabBarItem) } } - func didSelectTab(_ index: Int ) { - - guard index != self.selectedIndex else { return } - self.delegate?.tabBar(self, didSelectTabAt: index) + + // MARK: Public func + + func didSelectTabBarItem(_ index: Int) { self.tabBarItemContainer.arrangedSubviews.enumerated().forEach { guard let tabView = $1 as? SOMTabBarItem else { return } @@ -120,15 +164,6 @@ class SOMTabBar: UIView { self.prevSelectedIndex = self.selectedIndex self.selectedIndex = index - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - super.touchesBegan(touches, with: event) - - guard let touchArea = touches.first?.location(in: self).x else { return } - let index = Int(floor(touchArea / self.tabWidth)) - if self.delegate?.tabBar(self, shouldSelectTabAt: index) ?? false { - self.didSelectTab(index) - } + self.delegate?.tabBar(self, didSelectTabAt: index) } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift index c0112fcd..1f91632a 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift @@ -11,6 +11,8 @@ import SnapKit import Then +// MARK: SOMTabBarControllerDelegate + protocol SOMTabBarControllerDelegate: AnyObject { func tabBarController( @@ -27,6 +29,13 @@ protocol SOMTabBarControllerDelegate: AnyObject { class SOMTabBarController: UIViewController { + enum Text { + static let firstLaunchGuideMessage: String = "✨ 새로운 카드 만들기" + } + + + // MARK: Views + private lazy var tabBar = SOMTabBar().then { $0.delegate = self } @@ -35,28 +44,62 @@ class SOMTabBarController: UIViewController { $0.backgroundColor = .som.white } + private let messageBubbleView = SOMMessageBubbleView().then { + $0.message = Text.firstLaunchGuideMessage + } + + + // MARK: Variables + var viewControllers: [UIViewController] = [] { didSet { self.tabBar.viewControllers = self.viewControllers } } + var hasFirstLaunchGuide: Bool = true { + didSet { + if self.hasFirstLaunchGuide == false { + self.messageBubbleView.removeFromSuperview() + } + } + } + private var selectedIndex: Int = -1 var selectedViewController: UIViewController? weak var delegate: SOMTabBarControllerDelegate? + + // MARK: Deinitialize + + deinit { + // TODO: 임시, 탭바 숨기지 않음 + // NotificationCenter.default.removeObserver( + // self, + // name: .hidesBottomBarWhenPushedDidChange, + // object: nil + // ) + } + + + // MARK: Override func + override func viewDidLoad() { super.viewDidLoad() - NotificationCenter.default.addObserver( - self, - selector: #selector(self.hidesBottomBarWhenPushed(_:)), - name: .hidesBottomBarWhenPushedDidChange, - object: nil - ) + // TODO: 임시, 탭바 숨기지 않음 + // NotificationCenter.default.addObserver( + // self, + // selector: #selector(self.hidesBottomBarWhenPushed(_:)), + // name: .hidesBottomBarWhenPushedDidChange, + // object: nil + // ) self.setupConstraints() } + + // MARK: Private func + private func setupConstraints() { self.view.addSubview(self.container) @@ -65,34 +108,54 @@ class SOMTabBarController: UIViewController { } self.view.addSubview(self.tabBar) - self.view.bringSubviewToFront(self.tabBar) self.tabBar.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-4) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(SOMTabBar.height) + $0.bottom.horizontalEdges.equalToSuperview() + $0.height.equalTo(88) } - } - - @objc - private func hidesBottomBarWhenPushed(_ notification: Notification) { - - // 탭바컨트롤러의 탭바의 숨김처리를 위해 추가 - guard let viewController = notification.object as? UIViewController, - let selectedViewController = self.selectedViewController as? UINavigationController, - viewController == selectedViewController.topViewController - else { return } - let hidesTabBar = viewController.hidesBottomBarWhenPushed - self.tabBar.isHidden = hidesTabBar + self.view.addSubview(self.messageBubbleView) + self.messageBubbleView.snp.makeConstraints { + $0.bottom.equalTo(self.tabBar.snp.top).offset(4) + let offset = self.tabBar.itemFrames[1].maxX - self.tabBar.itemFrames[1].width * 0.5 + let width = (Text.firstLaunchGuideMessage as NSString).size( + withAttributes: [.font: Typography.som.v2.caption1.font] + ).width + 10 * 2 + $0.leading.equalToSuperview().offset(offset - width * 0.5) + } } + + // MARK: Objc func + + // TODO: 임시, 탭바 숨김 애니메이션 제거 + // @objc + // private func hidesBottomBarWhenPushed(_ notification: Notification) { + // + // // 탭바컨트롤러의 탭바의 숨김처리를 위해 추가 + // guard let viewController = notification.object as? UIViewController, + // let selectedViewController = self.selectedViewController as? UINavigationController, + // viewController == selectedViewController.topViewController + // else { return } + // + // let hidesTabBar = viewController.hidesBottomBarWhenPushed + // UIView.animate(withDuration: 0.25) { + // self.messageBubbleView.isHidden = hidesTabBar + // self.tabBar.frame.origin.y = hidesTabBar ? self.view.frame.maxY : self.view.frame.maxY - 88 + // } + // } + + + // MARK: Public func + func didSelectedIndex(_ index: Int) { - self.tabBar.didSelectTab(index) + self.tabBar.didSelectTabBarItem(index) } } + +// MARK: SOMTabBarDelegate + extension SOMTabBarController: SOMTabBarDelegate { func tabBar(_ tabBar: SOMTabBar, shouldSelectTabAt index: Int) -> Bool { diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift index 58db70e5..78bba187 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift @@ -13,30 +13,26 @@ import Then class SOMTabBarItem: UIView { - private let titleLabel = UILabel().then { - $0.textAlignment = .center - $0.typography = .som.caption - $0.textColor = .som.gray600 - } - var title: String? { - set { self.titleLabel.text = newValue } - get { self.titleLabel.text } - } + + // MARK: Views private let imageView = UIImageView().then { - $0.tintColor = .som.gray600 - } - var image: UIImage? { - didSet { self.imageView.image = self.image } + $0.tintColor = .som.v2.gray300 } - private let backgroundView = UIView().then { - $0.backgroundColor = .clear - $0.layer.cornerRadius = 50 * 0.5 + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 } - convenience init() { + + // MARK: Initialize + + convenience init(title: String?, image: UIImage?) { self.init(frame: .zero) + + self.titleLabel.text = title + self.imageView.image = image } override init(frame: CGRect) { @@ -50,39 +46,33 @@ class SOMTabBarItem: UIView { private func setupConstraints() { - let container = UIStackView(arrangedSubviews: [self.imageView, self.titleLabel]).then { - $0.axis = .vertical - $0.alignment = .center - } - self.backgroundView.addSubview(container) - container.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) - $0.bottom.equalToSuperview().offset(-8) - $0.leading.equalToSuperview().offset(12) - $0.trailing.equalToSuperview().offset(-12) + self.snp.makeConstraints { + $0.width.equalTo(77) + $0.height.equalTo(46) } + self.addSubview(self.imageView) self.imageView.snp.makeConstraints { + $0.top.centerX.equalToSuperview() $0.size.equalTo(24) } - self.addSubview(self.backgroundView) - self.backgroundView.snp.makeConstraints { - $0.edges.equalToSuperview() + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalTo(self.imageView.snp.bottom).offset(4) + $0.bottom.centerX.equalToSuperview() } } func tabBarItemSelected() { - self.titleLabel.textColor = .som.white - self.imageView.tintColor = .som.white - self.backgroundView.backgroundColor = .som.p300 + self.titleLabel.textColor = .som.v2.black + self.imageView.tintColor = .som.v2.black } func tabBarItemNotSelected() { - self.titleLabel.textColor = .som.gray600 - self.imageView.tintColor = .som.gray600 - self.backgroundView.backgroundColor = .clear + self.titleLabel.textColor = .som.v2.gray400 + self.imageView.tintColor = .som.v2.gray300 } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMTags/SOMTag.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMTags/SOMTag.swift index 3a38d615..14c01394 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMTags/SOMTag.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMTags/SOMTag.swift @@ -24,7 +24,7 @@ class SOMTag: UICollectionViewCell { weak var delegate: SOMTagDelegate? private lazy var removeButton = SOMButton().then { - $0.image = .init(.image(.cancelTag)) + $0.image = .init(.image(.defaultStyle(.cancelTag))) $0.layer.cornerRadius = 8 $0.clipsToBounds = true diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/GAEvent+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/GAEvent+SOOUM.swift new file mode 100644 index 00000000..e1d8e431 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Foundations/GAEvent+SOOUM.swift @@ -0,0 +1,130 @@ +// +// GAEvent+SOOUM.swift +// SOOUM +// +// Created by 오현식 on 12/13/25. +// + +enum GAEvent { + + + // MARK: SOOUM v1 + + // enum WriteCard: AnalyticsEventProtocol { + // /// 글쓰기 화면에서 태그를 추가하고 글을 작성하지 않음 + // case dismiss_with_tag(tag_count: Int, tag_texts: [String]) + // /// 글쓰기 화면에서 태그를 추가하고 글을 작성 + // case add_tag(tag_count: Int, tag_texts: [String]) + // } + + // enum Tag: AnalyticsEventProtocol { + // /// 태그를 클릭한 위치 + // enum ClickPositionKey { + // /// 카드 상세화면에서 태그 클릭 + // static let post = "post" + // /// 즐겨찾기 태그 목록에서 태그 클릭 + // static let favorite = "favorite" + // /// 즐겨찾기 태그 목록의 미리보기 카드 클릭 + // static let favorite_preview = "favorite_preview" + // /// 추천 태그 목록에서 태그 클릭 + // static let recommendation = "recommendation" + // /// 태그 검색 결과에서 태그 클릭 + // static let search_result = "search_result" + // } + // /// 태그를 클릭 + // case tag_click(tag_text: String, click_position: String) + // } + + // enum Comment: AnalyticsEventProtocol { + // /// 사용자가 댓글을 작성 + // /// + // /// - Parameters: + // /// - comment_length: 댓글 길이 + // /// - parent_post_id: 부모 글 ID + // /// - image_attached: 이미지 첨부 여부 + // case add_comment(comment_length: Int, parent_post_id: String, image_attached: Bool) + // } + + + // MARK: SOOUM v2 + + enum TabBar: AnalyticsEventProtocol { + /// 바텀 네비게이션에서 ‘카드추가’ 버튼 클릭 이벤트 + case moveToCreateFeedCardView_btn_click + } + + enum HomeView: AnalyticsEventProtocol { + /// 피드에서 홈 버튼을 클릭하여 피드 최상단으로 이동하는 이벤트 + case feedMoveToTop_home_btn_click + /// 피드 화면에서 카드 상세보기 이동 이벤트 + case feedToCardDetailView_card_click + /// 피드 화면에서 이벤트 이미지를 사용한 카드 상세보기 이동 이벤트 + case feedToCardDetailView_cardWithEventImg_click + } + + enum DetailView: AnalyticsEventProtocol { + /// 카드 상세 조회 클릭 이벤트 (파라미터로 어디서 조회 하는건지 넘겨주기 feed, comment, profile) + case cardDetailView_tracePath_click(previous_path: ScreenPath) + /// 카드 상세보기에서 댓글카드 작성 버튼(아이콘 버튼과 플로팅 버튼 모두 포함) 클릭 이벤트 + case moveToCreateCommentCardView_btn_click + /// 카드 상세보기에서 댓글카드 작성 버튼(좋아요 옆에 있는 버튼) 클릭 이벤트 + case moveToCreateCommentCardView_icon_btn_click + /// 카드 상세보기에서 우측 하단에 동그란 버튼(플로팅된 댓글카드 작성 버튼) 클릭 이벤트 + case moveToCreateCommentCardView_floating_btn_click + /// 이벤트 카드의 플로팅 버튼 클릭 이벤트 + case moveToCreateCommentCardView_withEventImg_floating_btn_click + /// 카드 상세보기 화면에서 태그 영역(특정 태그) 클릭 이벤트 + case cardDetailTag_btn_click(tag_name: String) + } + + enum WriteCardView: AnalyticsEventProtocol { + /// 피드 카드 작성 뷰에서 뒤로가기 버튼 클릭 이벤트 + case moveToCreateFeedCardView_cancel_btn_click + /// 댓글카드 작성 뷰에서 뒤로가기 버튼 클릭 이벤트 + case moveToCreateCommentCardView_cancel_btn_click + /// 피드 카드 작성 뷰에서 태그 추가를 키보드 엔터(완료)버튼 클릭 이벤트 + case multipleFeedTagCreation_enter_btn_click + /// 피드 카드 작성 뷰에서 기본 배경 이미지 카테고리 변경 클릭 이벤트 + case feedBackgroundCategory_tab_click + /// 카드 만들기(피드 카드 작성 뷰)에서 ‘이벤트’ 카테고리 버튼 클릭 이벤트 + case createFeedCardEventCategory_btn_click + /// 댓글카드 작성 뷰에서 기본 배경 이미지 카테고리 변경 클릭 이벤트 + case commentBackgroundCategory_tab_click + /// 피드 카드 작성 완료 버튼 클릭 이벤트 + case createFeedCard_btn_click + /// 거리 공유 옵션을 끈 상태로 피드 카드 작성 완료 버튼 클릭 이벤트 + case createFeedCardWithoutDistanceSharedOpt_btn_click + } + + enum TagView: AnalyticsEventProtocol { + /// 태그 즐겨찾기 등록 버튼 클릭 이벤트(즐겨찾기 취소는 해당 이벤트에 제외) + case favoriteTagRegister_btn_click + /// 태그 메뉴 화면에서 검색바 클릭 이벤트 + case tagMenuSearchBar_click + /// 인기 태그 영역에서 특정 태그 클릭 이벤트 + case popularTag_item_click + } + + enum TransferView: AnalyticsEventProtocol { + /// 계정이관코드 입력 후 완료 버튼 클릭하여 계정이관 성공한 경우의 이벤트 + case accountTransferSuccess + } +} + +extension GAEvent.DetailView { + + enum ScreenPath: String { + case home + case detail + case notification + case writeCard + case tag_collect + case tag_search_collect + case profile + } + + enum EnterTo: String { + case icon + case floating + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/SooumStyle.swift b/SOOUM/SOOUM/DesignSystem/Foundations/Style/SooumStyle.swift similarity index 99% rename from SOOUM/SOOUM/DesignSystem/Foundations/SooumStyle.swift rename to SOOUM/SOOUM/DesignSystem/Foundations/Style/SooumStyle.swift index b2f20934..48d300c6 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/SooumStyle.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/Style/SooumStyle.swift @@ -7,6 +7,7 @@ import Foundation + // MARK: Styles public protocol SOOUMStyleCompatible { diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/Style/SooumStyle_V2.swift b/SOOUM/SOOUM/DesignSystem/Foundations/Style/SooumStyle_V2.swift new file mode 100644 index 00000000..3f8803a5 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Foundations/Style/SooumStyle_V2.swift @@ -0,0 +1,17 @@ +// +// SooumStyle_V2.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + + +// MARK: V2 + +public struct V2Style { } + +public extension SOOUMStyle { + static var v2: V2Style.Type { V2Style.self } +} diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift index 77dea676..ca2eabad 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift @@ -12,7 +12,9 @@ class BuiltInFont: FontConrainer { enum FontType: String { case pretendard = "PretendardVariable" - case school = "HakgyoansimChilpanjiugaeTTF" + case yoonwoo = "OwnglyphYoonwooChae" + case ridi = "RIDIBatang" + case kkookkkook = "MemomentKkukkukkR" } var type: FontType @@ -50,20 +52,24 @@ class BuiltInFont: FontConrainer { fileprivate extension UIFont { static func builtInFontName(type fontType: BuiltInFont.FontType, with weight: Weight) -> String { - let weightName: String = { - switch weight { - case .thin: return "Thin" - case .ultraLight: return "ExtraLight" - case .light: return fontType == .pretendard ? "Light" : "L" - case .medium: return "Medium" - case .semibold: return "SemiBold" - case .bold: return fontType == .pretendard ? "Bold" : "B" - case .heavy: return "ExtraBold" - case .black: return "Black" - default: return "Regular" - } - }() - return "\(fontType.rawValue)-\(weightName)" + if case .pretendard = fontType { + let weightName: String = { + switch weight { + case .thin: return "Thin" + case .ultraLight: return "ExtraLight" + case .light: return "Light" + case .medium: return "Medium" + case .semibold: return "SemiBold" + case .bold: return "Bold" + case .heavy: return "ExtraBold" + case .black: return "Black" + default: return "Regular" + } + }() + return "\(fontType.rawValue)-\(weightName)" + } else { + return "\(fontType.rawValue)" + } } static func builtInFont(type fontType: BuiltInFont.FontType, ofSize fontSize: CGFloat, weight: Weight) -> UIFont { @@ -75,6 +81,9 @@ fileprivate extension UIFont { extension Typography: SOOUMStyleCompatible { } extension SOOUMStyle where Base == Typography { + + // MARK: v1 + // Pretendard static var head1WithBold: Typography = .init( fontContainer: BuiltInFont(size: 22, weight: .semibold), @@ -139,15 +148,238 @@ extension SOOUMStyle where Base == Typography { // shcool - static var schoolBody1WithBold: Typography = .init( - fontContainer: BuiltInFont(type: .school, size: 18, weight: .bold), - lineHeight: 25, - letterSpacing: -0.004 + // static var schoolBody1WithBold: Typography = .init( + // fontContainer: BuiltInFont(type: .school, size: 18, weight: .bold), + // lineHeight: 25, + // letterSpacing: -0.004 + // ) + + // static var schoolBody1WithLight: Typography = .init( + // fontContainer: BuiltInFont(type: .school, size: 18, weight: .light), + // lineHeight: 25, + // letterSpacing: -0.004 + // ) +} + +extension V2Style where Base == Typography { + + + // MARK: v2 + + // Pretandard + /// Size: 28, Line height: 39 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var head1: Typography = .init( + fontContainer: BuiltInFont(size: 28, weight: .bold), + lineHeight: 39, + letterSpacing: -0.025 + ) + /// Size: 24, Line height: 34 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var head2: Typography = .init( + fontContainer: BuiltInFont(size: 24, weight: .bold), + lineHeight: 34, + letterSpacing: -0.025 + ) + /// Size: 20, Line height: 28 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var head3: Typography = .init( + fontContainer: BuiltInFont(size: 20, weight: .bold), + lineHeight: 28, + letterSpacing: -0.025 + ) + /// Size: 18, Line height: 27 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var title1: Typography = .init( + fontContainer: BuiltInFont(size: 18, weight: .semibold), + lineHeight: 27, + letterSpacing: -0.025 + ) + /// Size: 16, Line height: 24 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var title2: Typography = .init( + fontContainer: BuiltInFont(size: 16, weight: .semibold), + lineHeight: 24, + letterSpacing: -0.025 + ) + /// Size: 16, Line height: 24 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var subtitle1: Typography = .init( + fontContainer: BuiltInFont(size: 16, weight: .medium), + lineHeight: 24, + letterSpacing: -0.025 + ) + /// Size: 14, Line height: 21 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var subtitle2: Typography = .init( + fontContainer: BuiltInFont(size: 14, weight: .bold), + lineHeight: 21, + letterSpacing: -0.025 + ) + /// Size: 14, Line height: 21 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var subtitle3: Typography = .init( + fontContainer: BuiltInFont(size: 14, weight: .semibold), + lineHeight: 21, + letterSpacing: -0.025 + ) + /// Size: 14, Line height: 21 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var body1: Typography = .init( + fontContainer: BuiltInFont(size: 14, weight: .medium), + lineHeight: 21, + letterSpacing: -0.025 + ) + /// Size: 14, Line height: 21 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var body2: Typography = .init( + fontContainer: BuiltInFont(size: 14, weight: .regular), + lineHeight: 21, + letterSpacing: -0.025 + ) + /// Size: 12, Line height: 18 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var caption1: Typography = .init( + fontContainer: BuiltInFont(size: 12, weight: .semibold), + lineHeight: 18, + letterSpacing: -0.025 + ) + /// Size: 12, Line height: 18 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var caption2: Typography = .init( + fontContainer: BuiltInFont(size: 12, weight: .medium), + lineHeight: 18, + letterSpacing: -0.025 + ) + /// Size: 10, Line height: 15 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var caption3: Typography = .init( + fontContainer: BuiltInFont(size: 10, weight: .medium), + lineHeight: 15, + letterSpacing: -0.025 + ) + /// Size: 5, Line height: 8 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var caption4: Typography = .init( + fontContainer: BuiltInFont(size: 5, weight: .medium), + lineHeight: 8, + letterSpacing: -0.025 ) - static var schoolBody1WithLight: Typography = .init( - fontContainer: BuiltInFont(type: .school, size: 18, weight: .light), - lineHeight: 25, - letterSpacing: -0.004 + + // RIDIBatang + /// Size: 15, Line height: 23 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var ridiButton: Typography = .init( + fontContainer: BuiltInFont(type: .ridi, size: 15, weight: .regular), + lineHeight: 23, + letterSpacing: -0.025 + ) + /// Size: 13, Line height: 20 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var ridiCard: Typography = .init( + fontContainer: BuiltInFont(type: .ridi, size: 13, weight: .regular), + lineHeight: 20, + letterSpacing: -0.025 + ) + /// Size: 11, Line height: 17 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var ridiTag: Typography = .init( + fontContainer: BuiltInFont(type: .ridi, size: 11, weight: .regular), + lineHeight: 17, + letterSpacing: -0.025 + ) + /// Size: 5, Line height: 8 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var ridiProfile: Typography = .init( + fontContainer: BuiltInFont(type: .ridi, size: 5, weight: .regular), + lineHeight: 8, + letterSpacing: -0.025 + ) + + // Yoonwoo + /// Size: 20, Line height: 22 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var yoonwooButton: Typography = .init( + fontContainer: BuiltInFont(type: .yoonwoo, size: 20, weight: .regular), + lineHeight: 22, + letterSpacing: 0 + ) + /// Size: 18, Line height: 20 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var yoonwooCard: Typography = .init( + fontContainer: BuiltInFont(type: .yoonwoo, size: 18, weight: .regular), + lineHeight: 20, + letterSpacing: 0 + ) + /// Size: 16, Line height: 18 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var yoonwooTag: Typography = .init( + fontContainer: BuiltInFont(type: .yoonwoo, size: 16, weight: .regular), + lineHeight: 18, + letterSpacing: -0.025 + ) + /// Size: 7, Line height: 8 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var yoonwooProfile: Typography = .init( + fontContainer: BuiltInFont(type: .yoonwoo, size: 7, weight: .regular), + lineHeight: 8, + letterSpacing: -0.025 + ) + + // Kkukkukk + /// Size: 16, Line height: 22 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var kkookkkookButton: Typography = .init( + fontContainer: BuiltInFont(type: .kkookkkook, size: 16, weight: .regular), + lineHeight: 22, + letterSpacing: 0 + ) + /// Size: 14, Line height: 20 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var kkookkkookCard: Typography = .init( + fontContainer: BuiltInFont(type: .kkookkkook, size: 14, weight: .regular), + lineHeight: 20, + letterSpacing: 0 + ) + /// Size: 12, Line height: 17 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var kkookkkookTag: Typography = .init( + fontContainer: BuiltInFont(type: .kkookkkook, size: 12, weight: .regular), + lineHeight: 17, + letterSpacing: -0.025 + ) + /// Size: 5, Line height: 7 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var kkookkkookProfile: Typography = .init( + fontContainer: BuiltInFont(type: .kkookkkook, size: 5, weight: .regular), + lineHeight: 7, + letterSpacing: -0.025 ) } diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/UIColor+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/UIColor+SOOUM.swift index 628e786c..ccd2bc69 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/UIColor+SOOUM.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/UIColor+SOOUM.swift @@ -47,3 +47,38 @@ extension SOOUMStyle where Base == UIColor { // Dim static let dim = UIColor(red: 0, green: 0, blue: 0, alpha: 0.5) } + +extension V2Style where Base == UIColor { + + // Gray Scale + static let white = UIColor(hex: "#FFFFFF") + static let gray100 = UIColor(hex: "#F5F7FA") + static let gray200 = UIColor(hex: "#E4EAF1") + static let gray300 = UIColor(hex: "#BFC9D3") + static let gray400 = UIColor(hex: "#919DA9") + static let gray500 = UIColor(hex: "#5D6369") + static let gray600 = UIColor(hex: "#3A3F44") + static let black = UIColor(hex: "#212121") + + // Primary + static let pLight1 = UIColor(hex: "#D7F1F9") + static let pLight2 = UIColor(hex: "#8CE1F4") + static let pMain = UIColor(hex: "#20C6EC") + static let pDark = UIColor(hex: "#07ABD0") + + // Success + static let gLight = UIColor(hex: "#D3F5EB") + static let gMain = UIColor(hex: "#009262") + + // Warning + static let yLight = UIColor(hex: "#FFF0D7") + static let yMain = UIColor(hex: "#FFB240") + + // Danger + static let rLight = UIColor(hex: "#FFE1DF") + static let rMain = UIColor(hex: "#E84B3D") + static let rDark = UIColor(hex: "#CC392C") + + // Dim + static let dim = UIColor(r: 0, g: 0, b: 0, a: 0.6) +} diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift index 93f6b851..5cac3e9e 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift @@ -12,11 +12,12 @@ extension UIImage { enum SOOUMType: Equatable { case icon(IconStyle) case image(ImageStyle) - case logo + case logo(LogoStyle) enum IconStyle { case filled(Filled) case outlined(Outlined) + case v2(V2IconStyle) enum Filled: String { case addCard @@ -64,29 +65,59 @@ extension UIImage { var imageName: String { switch self { - case .filled(let filled): + case let .filled(filled): return "\(filled.rawValue)_filled" - case .outlined(let outlined): + case let .outlined(outlined): return "\(outlined.rawValue)_outlined" + case let .v2(iconStyle): + return "v2_\(iconStyle.imageName)" } } } - enum ImageStyle: String { - case cancelTag - case errorTriangle - case login - case sooumLogo + enum ImageStyle { + case defaultStyle(DefaultStyle) + case v2(V2ImageStyle) + + enum DefaultStyle: String { + case cancelTag + case errorTriangle + case login + case sooumLogo + } + + var imageName: String { + switch self { + case let .defaultStyle(defaultStyle): + return defaultStyle.rawValue + case let .v2(imageStyle): + return "v2_\(imageStyle.rawValue)" + } + } + } + + enum LogoStyle { + case logo + case v2(V2LogoStyle) + + var imageName: String { + switch self { + case .logo: + return "logo" + case let .v2(logoStyle): + return "v2_\(logoStyle.rawValue)" + } + } } var imageName: String { switch self { - case .icon(let iconStyle): + case let .icon(iconStyle): return iconStyle.imageName - case .image(let imageStyle): - return imageStyle.rawValue - case .logo: - return "logo" + case let .image(imageStyle): + return imageStyle.imageName + case let .logo(logoStyle): + return logoStyle.imageName } } @@ -99,3 +130,109 @@ extension UIImage { self.init(named: som.imageName) } } + + +// MARK: V2 + +extension UIImage.SOOUMType { + + enum V2IconStyle { + case filled(Filled) + case outlined(Outlined) + + enum Filled: String { + case bell + case bomb + case camera + case card + case danger + case headset + case heart + case hide + case home + case image + case info + case location + case lock + case mail + case message_circle + case message_square + case notice + case official + case settings + case star + case tag + case time + case tool + case trash + case user + case users + case write + } + + enum Outlined: String { + case bell + case camera + case check + case danger + case delete_full + case delete + case down + case error + case eye + case flag + case hash + case heart + case hide + case home + case image + case left + case location + case message_circle + case message_square + case more + case plus + case right + case search + case settings + case star + case swap + case tag + case time + case timer + case trash + case up + case user + case write + } + + var imageName: String { + switch self { + case let .filled(filled): + return "\(filled.rawValue)_filled" + case let .outlined(outlined): + return "\(outlined.rawValue)_outlined" + } + } + } + + enum V2ImageStyle: String { + case onboarding + case onboarding_finish + case check_square_light + case detail_delete_card + case guide_write_card + case message_tail + case placeholder_home + case placeholder_notification + case prev_card_button + case profile_large + case profile_medium + case profile_small + } + + enum V2LogoStyle: String { + case logo_white + case logo_black + } +} diff --git a/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift b/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift new file mode 100644 index 00000000..300ec4aa --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift @@ -0,0 +1,123 @@ +// +// BaseCardInfo.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import Foundation + +struct BaseCardInfo: Hashable { + let id: String + let likeCnt: Int + let commentCnt: Int + let cardImgName: String + let cardImgURL: String + let cardContent: String + let font: Font + let distance: String? + let createdAt: Date + let storyExpirationTime: Date? + let isAdminCard: Bool +} + +extension BaseCardInfo { + + func updateLikeCnt(_ likeCnt: Int) -> BaseCardInfo { + + return BaseCardInfo( + id: self.id, + likeCnt: likeCnt, + commentCnt: self.commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard + ) + } + + func updateCommentCnt(_ commentCnt: Int) -> BaseCardInfo { + + return BaseCardInfo( + id: self.id, + likeCnt: self.likeCnt, + commentCnt: commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard + ) + } +} + +extension BaseCardInfo { + /// 사용하는 폰트 + enum Font: String, Decodable { + case pretendard = "PRETENDARD" + case yoonwoo = "YOONWOO" + case ridi = "RIDI" + case kkookkkook = "KKOOKKKOOK" + } + /// 사용할 이미지 유형 + enum ImageType: String, Decodable { + case `default` = "DEFAULT" + case user = "USER" + } +} + +extension BaseCardInfo { + + static var defaultValue: BaseCardInfo = BaseCardInfo( + id: "", + likeCnt: 0, + commentCnt: 0, + cardImgName: "", + cardImgURL: "", + cardContent: "", + font: .pretendard, + distance: nil, + createdAt: Date(), + storyExpirationTime: nil, + isAdminCard: false + ) +} + +extension BaseCardInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "cardId" + case likeCnt + case commentCnt = "commentCardCnt" + case cardImgName + case cardImgURL = "cardImgUrl" + case cardContent + case font + case distance + case createdAt + case storyExpirationTime + case isAdminCard + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.likeCnt = try container.decode(Int.self, forKey: .likeCnt) + self.commentCnt = try container.decode(Int.self, forKey: .commentCnt) + self.cardImgName = try container.decode(String.self, forKey: .cardImgName) + self.cardImgURL = try container.decode(String.self, forKey: .cardImgURL) + self.cardContent = try container.decode(String.self, forKey: .cardContent) + self.font = try container.decode(Font.self, forKey: .font) + self.distance = try container.decodeIfPresent(String.self, forKey: .distance) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.storyExpirationTime = try container.decodeIfPresent(Date.self, forKey: .storyExpirationTime) + self.isAdminCard = try container.decode(Bool.self, forKey: .isAdminCard) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/BlockUserInfo.swift b/SOOUM/SOOUM/Domain/Models/BlockUserInfo.swift new file mode 100644 index 00000000..649eb281 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/BlockUserInfo.swift @@ -0,0 +1,44 @@ +// +// BlockUserInfo.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +struct BlockUserInfo: Hashable { + + let id: String + let userId: String + let nickname: String + let profileImageUrl: String? +} + +extension BlockUserInfo { + + static var defaultValue: BlockUserInfo = BlockUserInfo( + id: "", + userId: "", + nickname: "", + profileImageUrl: nil + ) +} + +extension BlockUserInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "blockId" + case userId = "blockMemberId" + case nickname = "blockMemberNickname" + case profileImageUrl = "blockMemberProfileImageUrl" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.userId = String(try container.decode(Int64.self, forKey: .userId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImageUrl = try container.decodeIfPresent(String.self, forKey: .profileImageUrl) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/CheckAvailable.swift b/SOOUM/SOOUM/Domain/Models/CheckAvailable.swift new file mode 100644 index 00000000..0661fb17 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/CheckAvailable.swift @@ -0,0 +1,44 @@ +// +// CheckAvailable.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +struct CheckAvailable: Equatable { + + let rejoinAvailableAt: Date? + let banned: Bool + let withdrawn: Bool + let registered: Bool +} + +extension CheckAvailable { + + static var defaultValue: CheckAvailable = CheckAvailable( + rejoinAvailableAt: nil, + banned: false, + withdrawn: false, + registered: false + ) +} + +extension CheckAvailable: Decodable { + + enum CodingKeys: String, CodingKey { + case rejoinAvailableAt + case banned + case withdrawn + case registered + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.rejoinAvailableAt = try container.decodeIfPresent(Date.self, forKey: .rejoinAvailableAt) + self.banned = try container.decode(Bool.self, forKey: .banned) + self.withdrawn = try container.decode(Bool.self, forKey: .withdrawn) + self.registered = try container.decode(Bool.self, forKey: .registered) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift b/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift new file mode 100644 index 00000000..a965e875 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift @@ -0,0 +1,57 @@ +// +// NotificationInfo.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +struct CommonNotificationInfo: Hashable, Equatable { + + let notificationId: String + let notificationType: NotificationType + let createTime: Date +} + +extension CommonNotificationInfo { + + static var defaultValue: CommonNotificationInfo = CommonNotificationInfo( + notificationId: "", + notificationType: .none, + createTime: Date() + ) +} + +extension CommonNotificationInfo { + + enum NotificationType: String { + + case feedLike = "FEED_LIKE" + case commentLike = "COMMENT_LIKE" + case commentWrite = "COMMENT_WRITE" + case blocked = "BLOCKED" + case deleted = "DELETED" + case transferSuccess = "TRANSFER_SUCCESS" + case follow = "FOLLOW" + case tagUsage = "TAG_USAGE" + case none = "NONE" + } +} + +extension CommonNotificationInfo.NotificationType: Decodable { } +extension CommonNotificationInfo: Decodable { + + enum CodingKeys: CodingKey { + case notificationId + case notificationType + case createTime + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.notificationId = String(try container.decode(Int64.self, forKey: .notificationId)) + self.notificationType = try container.decode(NotificationType.self, forKey: .notificationType) + self.createTime = try container.decode(Date.self, forKey: .createTime) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift b/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift new file mode 100644 index 00000000..de22a37d --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift @@ -0,0 +1,54 @@ +// +// CompositeNotificationInfo.swift +// SOOUM +// +// Created by 오현식 on 9/27/25. +// + +import Foundation + +enum CompositeNotificationInfo: Hashable, Equatable { + case `default`(NotificationInfoResponse) + case follow(FollowNotificationInfoResponse) + case deleted(DeletedNotificationInfoResponse) + case blocked(BlockedNotificationInfoResponse) + case tag(TagNofificationInfoResponse) +} + +extension CompositeNotificationInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case notificationType + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let notificationType = try container.decode(CommonNotificationInfo.NotificationType.self, forKey: .notificationType) + + switch notificationType { + case .follow: + let notification = try FollowNotificationInfoResponse(from: decoder) + self = .follow(notification) + case .deleted: + let notification = try DeletedNotificationInfoResponse(from: decoder) + self = .deleted(notification) + case .blocked: + let notification = try BlockedNotificationInfoResponse(from: decoder) + self = .blocked(notification) + case .tagUsage: + let notification = try TagNofificationInfoResponse(from: decoder) + self = .tag(notification) + case .feedLike, .commentLike, .commentWrite: + let notification = try NotificationInfoResponse(from: decoder) + self = .default(notification) + // TODO: TRANSFER_SUCCESS 는 아직 정해지지 않음 + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported notification type" + ) + ) + } + } +} diff --git a/SOOUM/SOOUM/Domain/Models/DefaultImages.swift b/SOOUM/SOOUM/Domain/Models/DefaultImages.swift new file mode 100644 index 00000000..1fec5afe --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/DefaultImages.swift @@ -0,0 +1,55 @@ +// +// DefaultImages.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Foundation + +struct DefaultImages: Equatable { + let abstract: [ImageUrlInfo] + let nature: [ImageUrlInfo] + let sensitivity: [ImageUrlInfo] + let food: [ImageUrlInfo] + let color: [ImageUrlInfo] + let memo: [ImageUrlInfo] + let event: [ImageUrlInfo]? +} + +extension DefaultImages { + + static var defaultValue: DefaultImages = DefaultImages( + abstract: [], + nature: [], + sensitivity: [], + food: [], + color: [], + memo: [], + event: nil + ) +} + +extension DefaultImages: Decodable { + + enum CodingKeys: String, CodingKey { + case abstract = "ABSTRACT" + case nature = "NATURE" + case sensitivity = "SENSITIVITY" + case food = "FOOD" + case color = "COLOR" + case memo = "MEMO" + case event = "EVENT" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.abstract = try container.decode([ImageUrlInfo].self, forKey: .abstract) + self.nature = try container.decode([ImageUrlInfo].self, forKey: .nature) + self.sensitivity = try container.decode([ImageUrlInfo].self, forKey: .sensitivity) + self.food = try container.decode([ImageUrlInfo].self, forKey: .food) + self.color = try container.decode([ImageUrlInfo].self, forKey: .color) + self.memo = try container.decode([ImageUrlInfo].self, forKey: .memo) + self.event = try container.decodeIfPresent([ImageUrlInfo].self, forKey: .event) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift b/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift new file mode 100644 index 00000000..a309e057 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift @@ -0,0 +1,215 @@ +// +// DetailCardInfo.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import Foundation + +struct DetailCardInfo: Hashable { + let id: String + let likeCnt: Int + let commentCnt: Int + let cardImgName: String + let cardImgURL: String + let cardContent: String + let font: BaseCardInfo.Font + let distance: String? + let createdAt: Date + let storyExpirationTime: Date? + let isAdminCard: Bool + let isReported: Bool + let memberId: String + let nickname: String + let profileImgURL: String? + let isLike: Bool + let isCommentWritten: Bool + let tags: [Tag] + let isOwnCard: Bool + let visitedCnt: String + /// 이전 카드 정보 + let prevCardInfo: PrevCardInfo? +} + +extension DetailCardInfo { + + func updateLikeCnt(_ likeCnt: Int, with isLike: Bool) -> DetailCardInfo { + + return DetailCardInfo( + id: self.id, + likeCnt: likeCnt, + commentCnt: self.commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard, + isReported: self.isReported, + memberId: self.memberId, + nickname: self.nickname, + profileImgURL: self.profileImgURL, + isLike: isLike, + isCommentWritten: self.isCommentWritten, + tags: self.tags, + isOwnCard: self.isOwnCard, + visitedCnt: self.visitedCnt, + prevCardInfo: self.prevCardInfo + ) + } + + func updateCommentCnt(_ commentCnt: Int) -> DetailCardInfo { + + return DetailCardInfo( + id: self.id, + likeCnt: self.likeCnt, + commentCnt: commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard, + isReported: self.isReported, + memberId: self.memberId, + nickname: self.nickname, + profileImgURL: self.profileImgURL, + isLike: self.isLike, + isCommentWritten: self.isCommentWritten, + tags: self.tags, + isOwnCard: self.isOwnCard, + visitedCnt: self.visitedCnt, + prevCardInfo: self.prevCardInfo + ) + } +} + +extension DetailCardInfo { + /// 작성된 태그 + struct Tag: Hashable { + let id: String + let title: String + } + /// 이전 카드 정보 + struct PrevCardInfo: Hashable { + let prevCardId: String + let isPrevCardDeleted: Bool + let prevCardImgURL: String? + } +} + +extension DetailCardInfo.Tag: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "tagId" + case title = "name" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.title = try container.decode(String.self, forKey: .title) + } +} + +extension DetailCardInfo.PrevCardInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case prevCardId = "previousCardId" + case isPrevCardDeleted = "isPreviousCardDeleted" + case prevCardImgURL = "previousCardImgUrl" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.prevCardId = try container.decode(String.self, forKey: .prevCardId) + self.isPrevCardDeleted = try container.decode(Bool.self, forKey: .isPrevCardDeleted) + self.prevCardImgURL = try container.decodeIfPresent(String.self, forKey: .prevCardImgURL) + } +} + +extension DetailCardInfo { + + static var defaultValue: DetailCardInfo = DetailCardInfo( + id: "", + likeCnt: 0, + commentCnt: 0, + cardImgName: "", + cardImgURL: "", + cardContent: "", + font: .pretendard, + distance: nil, + createdAt: Date(), + storyExpirationTime: nil, + isAdminCard: false, + isReported: false, + memberId: "", + nickname: "", + profileImgURL: nil, + isLike: false, + isCommentWritten: false, + tags: [], + isOwnCard: false, + visitedCnt: "0", + prevCardInfo: nil + ) +} + +extension DetailCardInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "cardId" + case likeCnt + case commentCnt = "commentCardCnt" + case cardImgName + case cardImgURL = "cardImgUrl" + case cardContent + case font + case distance + case createdAt + case storyExpirationTime + case isAdminCard + case isReported + case memberId + case nickname + case profileImgURL = "profileImgUrl" + case isLike + case isCommentWritten + case tags + case isOwnCard + case visitedCnt + case prevCardInfo + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.likeCnt = try container.decode(Int.self, forKey: .likeCnt) + self.commentCnt = try container.decode(Int.self, forKey: .commentCnt) + self.cardImgName = try container.decode(String.self, forKey: .cardImgName) + self.cardImgURL = try container.decode(String.self, forKey: .cardImgURL) + self.cardContent = try container.decode(String.self, forKey: .cardContent) + self.font = try container.decode(BaseCardInfo.Font.self, forKey: .font) + self.distance = try container.decodeIfPresent(String.self, forKey: .distance) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.storyExpirationTime = try container.decodeIfPresent(Date.self, forKey: .storyExpirationTime) + self.isAdminCard = try container.decode(Bool.self, forKey: .isAdminCard) + self.isReported = try container.decode(Bool.self, forKey: .isReported) + self.memberId = String(try container.decode(Int64.self, forKey: .memberId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImgURL = try container.decodeIfPresent(String.self, forKey: .profileImgURL) + self.isLike = try container.decode(Bool.self, forKey: .isLike) + self.isCommentWritten = try container.decode(Bool.self, forKey: .isCommentWritten) + self.tags = try container.decode([Tag].self, forKey: .tags) + self.isOwnCard = try container.decode(Bool.self, forKey: .isOwnCard) + self.visitedCnt = String(try container.decode(Int64.self, forKey: .visitedCnt)) + + let singleContainer = try decoder.singleValueContainer() + self.prevCardInfo = try? singleContainer.decode(PrevCardInfo.self) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/EntranceCardType.swift b/SOOUM/SOOUM/Domain/Models/EntranceCardType.swift new file mode 100644 index 00000000..f825c12d --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/EntranceCardType.swift @@ -0,0 +1,13 @@ +// +// EntranceCardType.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +enum EntranceCardType { + case feed + case comment +} diff --git a/SOOUM/SOOUM/Domain/Models/FavoriteTagInfo.swift b/SOOUM/SOOUM/Domain/Models/FavoriteTagInfo.swift new file mode 100644 index 00000000..74262482 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/FavoriteTagInfo.swift @@ -0,0 +1,33 @@ +// +// FavoriteTagInfo.swift +// SOOUM +// +// Created by 오현식 on 11/18/25. +// + +import Foundation + +struct FavoriteTagInfo: Hashable { + + let id: String + let title: String +} + +extension FavoriteTagInfo { + + static var defaultValue: FavoriteTagInfo = FavoriteTagInfo(id: "", title: "") +} + +extension FavoriteTagInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id + case title = "name" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.title = try container.decode(String.self, forKey: .title) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/FollowInfo.swift b/SOOUM/SOOUM/Domain/Models/FollowInfo.swift new file mode 100644 index 00000000..fd6430d8 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/FollowInfo.swift @@ -0,0 +1,52 @@ +// +// FollowInfo.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +struct FollowInfo: Hashable { + + let id: String + let memberId: String + let nickname: String + let profileImageUrl: String? + let isFollowing: Bool + let isRequester: Bool +} + +extension FollowInfo { + + static var defaultValue: FollowInfo = FollowInfo( + id: "", + memberId: "", + nickname: "", + profileImageUrl: nil, + isFollowing: false, + isRequester: false + ) +} + +extension FollowInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "followId" + case memberId + case nickname + case profileImageUrl + case isFollowing + case isRequester + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.memberId = String(try container.decode(Int64.self, forKey: .memberId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImageUrl = try container.decodeIfPresent(String.self, forKey: .profileImageUrl) + self.isFollowing = try container.decode(Bool.self, forKey: .isFollowing) + self.isRequester = try container.decode(Bool.self, forKey: .isRequester) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/ImageUrlInfo.swift b/SOOUM/SOOUM/Domain/Models/ImageUrlInfo.swift new file mode 100644 index 00000000..a1fd92cd --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/ImageUrlInfo.swift @@ -0,0 +1,47 @@ +// +// ImageUrlInfo.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +struct ImageUrlInfo: Hashable { + + let imgName: String + let imgUrl: String +} + +extension ImageUrlInfo { + + static var defaultValue: ImageUrlInfo = ImageUrlInfo(imgName: "", imgUrl: "") +} + +extension ImageUrlInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case imgName + case imgUrl + case url + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.imgName = try container.decode(String.self, forKey: .imgName) + + if let imgUrl = try? container.decode(String.self, forKey: .imgUrl) { + self.imgUrl = imgUrl + } else if let url = try? container.decode(String.self, forKey: .url) { + self.imgUrl = url + } else { + throw DecodingError.keyNotFound( + CodingKeys.imgUrl, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "imgUrl or url not found" + ) + ) + } + } +} diff --git a/SOOUM/SOOUM/Domain/Models/NoticeInfo.swift b/SOOUM/SOOUM/Domain/Models/NoticeInfo.swift new file mode 100644 index 00000000..91383295 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/NoticeInfo.swift @@ -0,0 +1,101 @@ +// +// NoticeInfo.swift +// SOOUM +// +// Created by 오현식 on 9/26/25. +// + +import UIKit + +struct NoticeInfo { + + let id: String + let noticeType: NoticeType + let message: String + let url: String? + let createdAt: Date +} + +extension NoticeInfo { + + enum NoticeType: String, Decodable { + case announcement = "ANNOUNCEMENT" + case news = "NEWS" + case maintenance = "MAINTENANCE" + + var title: String { + switch self { + case .announcement: + return "서비스 안내" + case .news: + return "숨 새소식" + case .maintenance: + return "서비스 점검" + } + } + + var image: UIImage? { + switch self { + case .announcement: + return .init(.icon(.v2(.filled(.headset)))) + case .news: + return .init(.icon(.v2(.filled(.mail)))) + case .maintenance: + return .init(.icon(.v2(.filled(.tool)))) + } + } + + var tintColor: UIColor { + switch self { + case .announcement: + return .som.v2.yMain + case .news: + return .som.v2.pMain + case .maintenance: + return .som.v2.gray400 + } + } + } +} + +extension NoticeInfo { + + static var defaultValue: NoticeInfo = NoticeInfo( + id: "", + noticeType: .announcement, + message: "", + url: nil, + createdAt: Date() + ) +} + +extension NoticeInfo: Hashable { + + static func == (lhs: NoticeInfo, rhs: NoticeInfo) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} + +extension NoticeInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id + case noticeType + case message = "title" + case url + case createdAt + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.noticeType = try container.decode(NoticeType.self, forKey: .noticeType) + self.message = try container.decode(String.self, forKey: .message) + self.url = try container.decodeIfPresent(String.self, forKey: .url) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/PostingPermission.swift b/SOOUM/SOOUM/Domain/Models/PostingPermission.swift new file mode 100644 index 00000000..6745591b --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/PostingPermission.swift @@ -0,0 +1,36 @@ +// +// PostingPermission.swift +// SOOUM +// +// Created by 오현식 on 10/30/25. +// + +import Foundation + +struct PostingPermission: Equatable { + + let isBaned: Bool + let expiredAt: Date? +} + +extension PostingPermission { + + static var defaultValue: PostingPermission = PostingPermission( + isBaned: false, + expiredAt: nil + ) +} + +extension PostingPermission: Decodable { + + enum CodingKeys: String, CodingKey { + case isBaned + case expiredAt + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.isBaned = try container.decode(Bool.self, forKey: .isBaned) + self.expiredAt = try container.decodeIfPresent(Date.self, forKey: .expiredAt) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/ProfileCardInfo.swift b/SOOUM/SOOUM/Domain/Models/ProfileCardInfo.swift new file mode 100644 index 00000000..710ebfe4 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/ProfileCardInfo.swift @@ -0,0 +1,48 @@ +// +// myCardInfo.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +struct ProfileCardInfo: Hashable { + + let id: String + let imgName: String + let imgURL: String + let content: String + let font: BaseCardInfo.Font +} + +extension ProfileCardInfo { + + static var defaultValue: ProfileCardInfo = ProfileCardInfo( + id: "", + imgName: "", + imgURL: "", + content: "", + font: .pretendard + ) +} + +extension ProfileCardInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "cardId" + case imgName = "cardImgName" + case imgURL = "cardImgUrl" + case content = "cardContent" + case font + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.imgName = try container.decode(String.self, forKey: .imgName) + self.imgURL = try container.decode(String.self, forKey: .imgURL) + self.content = try container.decode(String.self, forKey: .content) + self.font = try container.decode(BaseCardInfo.Font.self, forKey: .font) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/ProfileInfo.swift b/SOOUM/SOOUM/Domain/Models/ProfileInfo.swift new file mode 100644 index 00000000..20fe1784 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/ProfileInfo.swift @@ -0,0 +1,82 @@ +// +// ProfileInfo.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +struct ProfileInfo: Hashable { + + let userId: String + let nickname: String + let profileImgName: String? + let profileImageUrl: String? + let totalVisitCnt: String + let todayVisitCnt: String + let cardCnt: String + let followingCnt: String + let followerCnt: String + // 상대방 프로필 조회 + let isAlreadyFollowing: Bool? + let isBlocked: Bool? +} + +extension ProfileInfo { + + enum Content: String { + case card = "카드" + case follower = "팔로워" + case following = "팔로잉" + } +} + +extension ProfileInfo { + + static var defaultValue: ProfileInfo = ProfileInfo( + userId: "", + nickname: "", + profileImgName: nil, + profileImageUrl: nil, + totalVisitCnt: "", + todayVisitCnt: "", + cardCnt: "0", + followingCnt: "0", + followerCnt: "0", + isAlreadyFollowing: nil, + isBlocked: nil + ) +} + +extension ProfileInfo: Decodable { + + enum CodingKeys: CodingKey { + case userId + case nickname + case profileImgName + case profileImageUrl + case totalVisitCnt + case todayVisitCnt + case cardCnt + case followingCnt + case followerCnt + case isAlreadyFollowing + case isBlocked + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.userId = String(try container.decode(Int64.self, forKey: .userId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImgName = try container.decodeIfPresent(String.self, forKey: .profileImgName) + self.profileImageUrl = try container.decodeIfPresent(String.self, forKey: .profileImageUrl) + self.totalVisitCnt = String(try container.decode(Int64.self, forKey: .totalVisitCnt)) + self.todayVisitCnt = String(try container.decode(Int64.self, forKey: .todayVisitCnt)) + self.cardCnt = String(try container.decode(Int64.self, forKey: .cardCnt)) + self.followingCnt = String(try container.decode(Int64.self, forKey: .followingCnt)) + self.followerCnt = String(try container.decode(Int64.self, forKey: .followerCnt)) + self.isAlreadyFollowing = try container.decodeIfPresent(Bool.self, forKey: .isAlreadyFollowing) + self.isBlocked = try container.decodeIfPresent(Bool.self, forKey: .isBlocked) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/RejoinableDateInfo.swift b/SOOUM/SOOUM/Domain/Models/RejoinableDateInfo.swift new file mode 100644 index 00000000..34e47db7 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/RejoinableDateInfo.swift @@ -0,0 +1,36 @@ +// +// RejoinableDateInfo.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Foundation + +struct RejoinableDateInfo: Equatable { + + let rejoinableDate: Date + let isActivityRestricted: Bool +} + +extension RejoinableDateInfo { + + static var defaultValue: RejoinableDateInfo = RejoinableDateInfo( + rejoinableDate: Date(), + isActivityRestricted: false + ) +} + +extension RejoinableDateInfo: Decodable { + + enum CodingKeys: CodingKey { + case rejoinableDate + case isActivityRestricted + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.rejoinableDate = try container.decode(Date.self, forKey: .rejoinableDate) + self.isActivityRestricted = try container.decode(Bool.self, forKey: .isActivityRestricted) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/ReortType.swift b/SOOUM/SOOUM/Domain/Models/ReortType.swift new file mode 100644 index 00000000..d894ccc3 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/ReortType.swift @@ -0,0 +1,51 @@ +// +// ReortType.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import Foundation + +enum ReportType: String, CaseIterable { + case defamationAndAbuse = "DEFAMATION_AND_ABUSE" + case privacyViolation = "PRIVACY_VIOLATION" + case inappropriateAdvertising = "INAPPROPRIATE_ADVERTISING" + case pornography = "PORNOGRAPHY" + case impersonationAndFraud = "IMPERSONATION_AND_FRAUD" + case other = "OTHER" + + var identifier: Int { + switch self { + case .defamationAndAbuse: + return 0 + case .privacyViolation: + return 1 + case .inappropriateAdvertising: + return 2 + case .pornography: + return 3 + case .impersonationAndFraud: + return 4 + case .other: + return 5 + } + } + + var message: String { + switch self { + case .defamationAndAbuse: + return "폭언, 비속어, 혐오 발언" + case .privacyViolation: + return "개인정보 침해" + case .inappropriateAdvertising: + return "부적절한 홍보 및 바이럴" + case .pornography: + return "나체 이미지 또는 성적 행위" + case .impersonationAndFraud: + return "스팸, 사기 또는 스팸" + case .other: + return "기타" + } + } +} diff --git a/SOOUM/SOOUM/Domain/Models/TagInfo.swift b/SOOUM/SOOUM/Domain/Models/TagInfo.swift new file mode 100644 index 00000000..c6757301 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/TagInfo.swift @@ -0,0 +1,46 @@ +// +// TagInfo.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Foundation + +struct TagInfo { + let id: String + let name: String + let usageCnt: Int +} + +extension TagInfo { + + static var defaultValue: TagInfo = TagInfo(id: "", name: "", usageCnt: 0) +} + +extension TagInfo: Hashable { + + static func == (lhs: TagInfo, rhs: TagInfo) -> Bool { + return lhs.name == rhs.name + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.name) + } +} + +extension TagInfo: Decodable { + + enum CodingKeys: CodingKey { + case id + case name + case usageCnt + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.name = try container.decode(String.self, forKey: .name) + self.usageCnt = try container.decode(Int.self, forKey: .usageCnt) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/Token.swift b/SOOUM/SOOUM/Domain/Models/Token.swift new file mode 100644 index 00000000..81b7789c --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/Token.swift @@ -0,0 +1,25 @@ +// +// Token.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +struct Token: Equatable { + + var accessToken: String + var refreshToken: String +} + +extension Token { + + static var defaultValue: Token = Token(accessToken: "", refreshToken: "") + + var isEmpty: Bool { + return self.accessToken.isEmpty && self.refreshToken.isEmpty + } +} + +extension Token: Decodable { } diff --git a/SOOUM/SOOUM/Domain/Models/TransferCodeInfo.swift b/SOOUM/SOOUM/Domain/Models/TransferCodeInfo.swift new file mode 100644 index 00000000..44ebb801 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/TransferCodeInfo.swift @@ -0,0 +1,36 @@ +// +// TransferCodeInfo.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +struct TransferCodeInfo: Equatable { + + let code: String + let expiredAt: Date +} + +extension TransferCodeInfo { + + static var defaultValue: TransferCodeInfo = TransferCodeInfo( + code: "", + expiredAt: Date() + ) +} + +extension TransferCodeInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case code = "transferCode" + case expiredAt + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decode(String.self, forKey: .code) + self.expiredAt = try container.decode(Date.self, forKey: .expiredAt) + } +} diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Models/Version.swift b/SOOUM/SOOUM/Domain/Models/Version.swift similarity index 63% rename from SOOUM/SOOUM/Managers/NetworkManager/Models/Version.swift rename to SOOUM/SOOUM/Domain/Models/Version.swift index 909df9b8..c790a8c7 100644 --- a/SOOUM/SOOUM/Managers/NetworkManager/Models/Version.swift +++ b/SOOUM/SOOUM/Domain/Models/Version.swift @@ -7,13 +7,20 @@ import Foundation - -struct Version { +struct Version: Equatable { + let currentVersionStatus: Status + let latestVersion: String +} + +extension Version { - init(status: String) { + init(status: String, latest: String) { self.currentVersionStatus = Status(rawValue: status) ?? .NONE + self.latestVersion = latest } + + static var defaultValue: Version = Version(status: "NONE", latest: "1.0.0") } extension Version { @@ -43,3 +50,12 @@ extension Version { self.currentVersionStatus == .PENDING } } + +extension Version.Status: Decodable { } +extension Version: Decodable { + + enum CodingKeys: String, CodingKey { + case currentVersionStatus = "status" + case latestVersion + } +} diff --git a/SOOUM/SOOUM/Domain/Models/WithdrawType.swift b/SOOUM/SOOUM/Domain/Models/WithdrawType.swift new file mode 100644 index 00000000..67aa2b1b --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/WithdrawType.swift @@ -0,0 +1,51 @@ +// +// WithdrawType.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +enum WithdrawType: CaseIterable { + case donotUseOfften + case missingFeature + case frequentErrors + case notEasyToUse + case createNewAccount + case other + + var identifier: Int { + switch self { + case .donotUseOfften: + return 0 + case .missingFeature: + return 1 + case .frequentErrors: + return 2 + case .notEasyToUse: + return 3 + case .createNewAccount: + return 4 + case .other: + return 5 + } + } + + var message: String { + switch self { + case .donotUseOfften: + return "자주 사용하지 않아요" + case .missingFeature: + return "원하는 기능이 없어요" + case .frequentErrors: + return "오류가 잦아서 사용하기 어려워요" + case .notEasyToUse: + return "앱 사용법을 모르겠어요" + case .createNewAccount: + return "새로운 계정을 만들고 싶어요" + case .other: + return "기타" + } + } +} diff --git a/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift b/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift new file mode 100644 index 00000000..bb65ee0a --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift @@ -0,0 +1,15 @@ +// +// AppVersionRepository.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Foundation + +import RxSwift + +protocol AppVersionRepository { + + func version() -> Observable +} diff --git a/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift b/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift new file mode 100644 index 00000000..665e27e8 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift @@ -0,0 +1,22 @@ +// +// AuthRepository.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +protocol AuthRepository { + + func signUp(nickname: String, profileImageName: String?) -> Observable + func login() -> Observable + func withdraw(reaseon: String) -> Observable + + func initializeAuthInfo() + func hasToken() -> Bool + func tokens() -> Token + func encryptedDeviceId() -> Observable +} diff --git a/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift b/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift new file mode 100644 index 00000000..94d23569 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift @@ -0,0 +1,70 @@ +// +// CardRepository.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import Foundation + +import RxSwift + +protocol CardRepository { + + + // MARK: Home + + func latestCard(lastId: String?, latitude: String?, longitude: String?) -> Observable + func popularCard(latitude: String?, longitude: String?) -> Observable + func distanceCard(lastId: String?, latitude: String, longitude: String, distanceFilter: String) -> Observable + + + // MARK: Detail + + func detailCard(id: String, latitude: String?, longitude: String?) -> Observable + func isCardDeleted(id: String) -> Observable + func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable + func deleteCard(id: String) -> Observable + func updateLike(id: String, isLike: Bool) -> Observable + func reportCard(id: String, reportType: String) -> Observable + + + // MARK: Write + + func defaultImages() -> Observable + func presignedURL() -> Observable + func uploadImage(_ data: Data, with url: URL) -> Observable> + func writeCard( + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + isStory: Bool, + tags: [String] + ) -> Observable + func writeComment( + id: String, + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + tags: [String] + ) -> Observable + + + // MARK: Tag + + func tagCards(tagId: String, lastId: String?) -> Observable + + + // MARK: My + + func feedCards(userId: String, lastId: String?) -> Observable + func myCommentCards(lastId: String?) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift b/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift new file mode 100644 index 00000000..c14c6bc7 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift @@ -0,0 +1,18 @@ +// +// NotificationRepository.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +protocol NotificationRepository { + + func unreadNotifications(lastId: String?) -> Observable + func readNotifications(lastId: String?) -> Observable + func requestRead(notificationId: String) -> Observable + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/Repositories/SettingsRepository.swift b/SOOUM/SOOUM/Domain/Repositories/SettingsRepository.swift new file mode 100644 index 00000000..28e7d627 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/SettingsRepository.swift @@ -0,0 +1,28 @@ +// +// SettingsRepository.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +protocol SettingsRepository { + + func rejoinableDate() -> Observable + func issue() -> Observable + func enter(code: String, encryptedDeviceId: String) -> Observable + func update() -> Observable + func blockUsers(lastId: String?) -> Observable + func updateNotify(isAllowNotify: Bool) -> Observable + + func notificationStatus() -> Bool + func switchNotification(on: Bool) -> Observable + + func coordinate() -> Coordinate + func hasPermission() -> Bool + func requestLocationPermission() + func checkLocationAuthStatus() -> AuthStatus +} diff --git a/SOOUM/SOOUM/Domain/Repositories/TagRepository.swift b/SOOUM/SOOUM/Domain/Repositories/TagRepository.swift new file mode 100644 index 00000000..29563929 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/TagRepository.swift @@ -0,0 +1,16 @@ +// +// TagRepository.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import RxSwift + +protocol TagRepository { + + func related(keyword: String, size: Int) -> Observable + func favorites() -> Observable + func updateFavorite(tagId: String, isFavorite: Bool) -> Observable + func ranked() -> Observable +} diff --git a/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift b/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift new file mode 100644 index 00000000..f3c555c6 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift @@ -0,0 +1,28 @@ +// +// UserRepository.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import Foundation + +import RxSwift + +protocol UserRepository { + + func checkAvailable() -> Observable + func nickname() -> Observable + func validateNickname(nickname: String) -> Observable + func updateNickname(nickname: String) -> Observable + func presignedURL() -> Observable + func uploadImage(_ data: Data, with url: URL) -> Observable> + func updateImage(imageName: String) -> Observable + func postingPermission() -> Observable + func profile(userId: String?) -> Observable + func updateMyProfile(nickname: String?, imageName: String?) -> Observable + func followers(userId: String, lastId: String?) -> Observable + func followings(userId: String, lastId: String?) -> Observable + func updateFollowing(userId: String, isFollow: Bool) -> Observable + func updateBlocked(id: String, isBlocked: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift new file mode 100644 index 00000000..67c19fcd --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// AppVersionUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import RxSwift + +final class AppVersionUseCaseImpl: AppVersionUseCase { + + private let repository: AppVersionRepository + + init(repository: AppVersionRepository) { + self.repository = repository + } + + func version() -> Observable { + + return self.repository.version().map(\.version) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift new file mode 100644 index 00000000..fcbe3b9d --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift @@ -0,0 +1,52 @@ +// +// AuthUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import RxSwift + +final class AuthUseCaseImpl: AuthUseCase { + + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func signUp(nickname: String, profileImageName: String?) -> Observable { + + return self.repository.signUp(nickname: nickname, profileImageName: profileImageName) + } + + func login() -> Observable { + + return self.repository.login() + } + + func withdraw(reaseon: String) -> Observable { + + return self.repository.withdraw(reaseon: reaseon).map { $0 == 200 } + } + + func encryptedDeviceId() -> Observable { + + return self.repository.encryptedDeviceId() + } + + func initializeAuthInfo() { + + self.repository.initializeAuthInfo() + } + + func hasToken() -> Bool { + + return self.repository.hasToken() + } + + func tokens() -> Token { + + return self.repository.tokens() + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/BlockUserUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/BlockUserUseCaseImpl.swift new file mode 100644 index 00000000..3d29a7f2 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/BlockUserUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// BlockUserUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class BlockUserUseCaseImpl: BlockUserUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func updateBlocked(userId: String, isBlocked: Bool) -> Observable { + + return self.repository.updateBlocked(id: userId, isBlocked: isBlocked).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/CardImageUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/CardImageUseCaseImpl.swift new file mode 100644 index 00000000..a05fac2a --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/CardImageUseCaseImpl.swift @@ -0,0 +1,32 @@ +// +// CardImageUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class CardImageUseCaseImpl: CardImageUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + func defaultImages() -> Observable { + + return self.repository.defaultImages().map { $0.defaultImages } + } + + func presignedURL() -> Observable { + + return self.repository.presignedURL().map(\.imageUrlInfo) + } + + func uploadToS3(_ data: Data, with url: URL) -> Observable { + + return self.repository.uploadImage(data, with: url).map { (try? $0.get()) == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/DeleteCardUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/DeleteCardUseCaseImpl.swift new file mode 100644 index 00000000..c195f98e --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/DeleteCardUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// DeleteCardUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class DeleteCardUseCaseImpl: DeleteCardUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + func delete(cardId: String) -> Observable { + + return self.repository.deleteCard(id: cardId).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchBlockUserUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchBlockUserUseCaseImpl.swift new file mode 100644 index 00000000..565d789f --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchBlockUserUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// FetchBlockUserUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchBlockUserUseCaseImpl: FetchBlockUserUseCase { + + private let repository: SettingsRepository + + init(repository: SettingsRepository) { + self.repository = repository + } + + func blockUsers(lastId: String?) -> Observable<[BlockUserInfo]> { + + return self.repository.blockUsers(lastId: lastId).map(\.blockUsers) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchCardDetailUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchCardDetailUseCaseImpl.swift new file mode 100644 index 00000000..45562f87 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchCardDetailUseCaseImpl.swift @@ -0,0 +1,52 @@ +// +// FetchCardDetailUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchCardDetailUseCaseImpl: FetchCardDetailUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + func detailCard( + id: String, + latitude: String?, + longitude: String? + ) -> Observable { + + return self.repository.detailCard( + id: id, + latitude: latitude, + longitude: longitude + ) + .map(\.cardInfos) + } + + func commentCards( + id: String, + lastId: String?, + latitude: String?, + longitude: String? + ) -> Observable<[BaseCardInfo]> { + + return self.repository.commentCard( + id: id, + lastId: lastId, + latitude: latitude, + longitude: longitude + ) + .map(\.cardInfos) + } + + func isDeleted(cardId: String) -> Observable { + + return self.repository.isCardDeleted(id: cardId).map(\.isDeleted) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchCardUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchCardUseCaseImpl.swift new file mode 100644 index 00000000..41cac4af --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchCardUseCaseImpl.swift @@ -0,0 +1,84 @@ +// +// FetchCardUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchCardUseCaseImpl: FetchCardUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + /// 홈 피드 카드 조회 최신/인기/거리 + func latestCards( + lastId: String?, + latitude: String?, + longitude: String? + ) -> Observable<[BaseCardInfo]> { + + return self.repository.latestCard( + lastId: lastId, + latitude: latitude, + longitude: longitude + ) + .map(\.cardInfos) + } + + func popularCards( + latitude: String?, + longitude: String? + ) -> Observable<[BaseCardInfo]> { + + return self.repository.popularCard( + latitude: latitude, + longitude: longitude + ) + .map(\.cardInfos) + } + + func distanceCards( + lastId: String?, + latitude: String, + longitude: String, + distanceFilter: String + ) -> Observable<[BaseCardInfo]> { + + return self.repository.distanceCard( + lastId: lastId, + latitude: latitude, + longitude: longitude, + distanceFilter: distanceFilter + ) + .map(\.cardInfos) + } + + /// 마이 카드 조회 피드/댓글 + func writtenFeedCards(userId: String, lastId: String?) -> Observable<[ProfileCardInfo]> { + + return self.repository.feedCards(userId: userId, lastId: lastId).map(\.cardInfos) + } + + func writtenCommentCards(lastId: String?) -> Observable<[ProfileCardInfo]> { + + return self.repository.myCommentCards(lastId: lastId).map(\.cardInfos) + } + + /// 태그 태그가 포함된 카드 조회 + func cardsWithTag( + tagId: String, + lastId: String? + ) -> Observable<(cardInfos: [ProfileCardInfo], isFavorite: Bool)> { + + return self.repository.tagCards( + tagId: tagId, + lastId: lastId + ) + .map { ($0.cardInfos, $0.isFavorite) } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchFollowUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchFollowUseCaseImpl.swift new file mode 100644 index 00000000..0edc960a --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchFollowUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// FetchFollowUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchFollowUseCaseImpl: FetchFollowUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func followers(userId: String, lastId: String?) -> Observable<[FollowInfo]> { + + return self.repository.followers(userId: userId, lastId: lastId).map(\.followInfos) + } + + func followings(userId: String, lastId: String?) -> Observable<[FollowInfo]> { + + return self.repository.followings(userId: userId, lastId: lastId).map(\.followInfos) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchNoticeUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchNoticeUseCaseImpl.swift new file mode 100644 index 00000000..38061b05 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchNoticeUseCaseImpl.swift @@ -0,0 +1,31 @@ +// +// FetchNoticeUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchNoticeUseCaseImpl: FetchNoticeUseCase { + + private let repository: NotificationRepository + + init(repository: NotificationRepository) { + self.repository = repository + } + + func notices( + lastId: String?, + size: Int, + requestType: NotificationRequest.RequestType + ) -> Observable<[NoticeInfo]> { + + return self.repository.notices( + lastId: lastId, + size: size, + requestType: requestType + ) + .map(\.noticeInfos) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchTagUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchTagUseCaseImpl.swift new file mode 100644 index 00000000..6b721f6f --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchTagUseCaseImpl.swift @@ -0,0 +1,47 @@ +// +// FetchTagUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchTagUseCaseImpl: FetchTagUseCase { + + private let repository: TagRepository + + init(repository: TagRepository) { + self.repository = repository + } + + func related(keyword: String, size: Int) -> Observable<[TagInfo]> { + + return self.repository.related(keyword: keyword, size: size).map(\.tagInfos) + } + + /// 관심 태그는 최대 9개 + func favorites() -> Observable<[FavoriteTagInfo]> { + + return self.repository.favorites().map(\.tagInfos).map { Array($0.prefix(9)) } + } + + func isFavorites(with tagInfo: FavoriteTagInfo) -> Observable { + + return self.favorites().map { $0.contains(tagInfo) } + } + + // 인기 태그는 최소 1개 이상일 때 표시 + // 인기 태그는 최대 10개까지 표시 + func ranked() -> Observable<[TagInfo]> { + + return self.repository.ranked() + .map(\.tagInfos) + .map { $0.filter { $0.usageCnt > 0 } } + // 중복 제거 + // .map { Array(Set($0)) } + // 태그 갯수로 정렬 + // .map { $0.sorted(by: { $0.usageCnt > $1.usageCnt }) } + .map { Array($0.prefix(10)) } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/FetchUserInfoUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/FetchUserInfoUseCaseImpl.swift new file mode 100644 index 00000000..e87c02e5 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/FetchUserInfoUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// FetchUserInfoUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class FetchUserInfoUseCaseImpl: FetchUserInfoUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func userInfo(userId: String?) -> Observable { + + return self.repository.profile(userId: userId).map(\.profileInfo) + } + + func myNickname() -> Observable { + + return self.userInfo(userId: nil).map(\.nickname) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift new file mode 100644 index 00000000..11e48fb1 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift @@ -0,0 +1,13 @@ +// +// AppVersionUseCase.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import RxSwift + +protocol AppVersionUseCase: AnyObject { + + func version() -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift new file mode 100644 index 00000000..249900d5 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift @@ -0,0 +1,21 @@ +// +// AuthUseCase.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import RxSwift + +protocol AuthUseCase: AnyObject { + + func signUp(nickname: String, profileImageName: String?) -> Observable + func login() -> Observable + func withdraw(reaseon: String) -> Observable + + func encryptedDeviceId() -> Observable + + func initializeAuthInfo() + func hasToken() -> Bool + func tokens() -> Token +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/BlockUserUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/BlockUserUseCase.swift new file mode 100644 index 00000000..381e5a9f --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/BlockUserUseCase.swift @@ -0,0 +1,13 @@ +// +// BlockUserUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol BlockUserUseCase: AnyObject { + + func updateBlocked(userId: String, isBlocked: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardImageUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardImageUseCase.swift new file mode 100644 index 00000000..d9b24bc7 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardImageUseCase.swift @@ -0,0 +1,15 @@ +// +// CardImageUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol CardImageUseCase: AnyObject { + + func defaultImages() -> Observable + func presignedURL() -> Observable + func uploadToS3(_ data: Data, with url: URL) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/DeleteCardUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/DeleteCardUseCase.swift new file mode 100644 index 00000000..bfe55232 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/DeleteCardUseCase.swift @@ -0,0 +1,13 @@ +// +// DeleteCardUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol DeleteCardUseCase: AnyObject { + + func delete(cardId: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchBlockUserUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchBlockUserUseCase.swift new file mode 100644 index 00000000..1325039a --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchBlockUserUseCase.swift @@ -0,0 +1,13 @@ +// +// FetchBlockUserUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchBlockUserUseCase: AnyObject { + + func blockUsers(lastId: String?) -> Observable<[BlockUserInfo]> +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchCardDetailUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchCardDetailUseCase.swift new file mode 100644 index 00000000..653e488b --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchCardDetailUseCase.swift @@ -0,0 +1,26 @@ +// +// FetchCardDetailUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchCardDetailUseCase: AnyObject { + + func detailCard( + id: String, + latitude: String?, + longitude: String? + ) -> Observable + + func commentCards( + id: String, + lastId: String?, + latitude: String?, + longitude: String? + ) -> Observable<[BaseCardInfo]> + + func isDeleted(cardId: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchCardUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchCardUseCase.swift new file mode 100644 index 00000000..c9545dd9 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchCardUseCase.swift @@ -0,0 +1,38 @@ +// +// FetchCardUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchCardUseCase: AnyObject { + + /// 홈 피드 카드 조회 최신/인기/거리 + func latestCards( + lastId: String?, + latitude: String?, + longitude: String? + ) -> Observable<[BaseCardInfo]> + func popularCards( + latitude: String?, + longitude: String? + ) -> Observable<[BaseCardInfo]> + func distanceCards( + lastId: String?, + latitude: String, + longitude: String, + distanceFilter: String + ) -> Observable<[BaseCardInfo]> + + /// 마이 카드 조회 피드/댓글 + func writtenFeedCards(userId: String, lastId: String?) -> Observable<[ProfileCardInfo]> + func writtenCommentCards(lastId: String?) -> Observable<[ProfileCardInfo]> + + /// 태그 태그가 포함된 카드 조회 + func cardsWithTag( + tagId: String, + lastId: String? + ) -> Observable<(cardInfos: [ProfileCardInfo], isFavorite: Bool)> +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchFollowUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchFollowUseCase.swift new file mode 100644 index 00000000..ca497f0f --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchFollowUseCase.swift @@ -0,0 +1,14 @@ +// +// FetchFollowUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchFollowUseCase: AnyObject { + + func followers(userId: String, lastId: String?) -> Observable<[FollowInfo]> + func followings(userId: String, lastId: String?) -> Observable<[FollowInfo]> +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchNoticeUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchNoticeUseCase.swift new file mode 100644 index 00000000..ba21e10a --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchNoticeUseCase.swift @@ -0,0 +1,17 @@ +// +// FetchNoticeUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchNoticeUseCase: AnyObject { + + func notices( + lastId: String?, + size: Int, + requestType: NotificationRequest.RequestType + ) -> Observable<[NoticeInfo]> +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchTagUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchTagUseCase.swift new file mode 100644 index 00000000..5b44c07b --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchTagUseCase.swift @@ -0,0 +1,16 @@ +// +// FetchTagUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchTagUseCase: AnyObject { + + func related(keyword: String, size: Int) -> Observable<[TagInfo]> + func favorites() -> Observable<[FavoriteTagInfo]> + func isFavorites(with tagInfo: FavoriteTagInfo) -> Observable + func ranked() -> Observable<[TagInfo]> +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchUserInfoUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchUserInfoUseCase.swift new file mode 100644 index 00000000..8b89ef82 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/FetchUserInfoUseCase.swift @@ -0,0 +1,14 @@ +// +// FetchUserInfoUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol FetchUserInfoUseCase: AnyObject { + + func userInfo(userId: String?) -> Observable + func myNickname() -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/LocationUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/LocationUseCase.swift new file mode 100644 index 00000000..fbce5e0d --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/LocationUseCase.swift @@ -0,0 +1,16 @@ +// +// LocationUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol LocationUseCase: AnyObject { + + func coordinate() -> Coordinate + func hasPermission() -> Bool + func requestLocationPermission() + func checkLocationAuthStatus() -> AuthStatus +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift new file mode 100644 index 00000000..f0ca1575 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift @@ -0,0 +1,16 @@ +// +// NotificationUseCase.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import RxSwift + +protocol NotificationUseCase: AnyObject { + + func unreadNotifications(lastId: String?) -> Observable<[CompositeNotificationInfo]> + func readNotifications(lastId: String?) -> Observable<[CompositeNotificationInfo]> + func isUnreadNotiEmpty() -> Observable + func requestRead(notificationId: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/ReportCardUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/ReportCardUseCase.swift new file mode 100644 index 00000000..855d890a --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/ReportCardUseCase.swift @@ -0,0 +1,13 @@ +// +// ReportCardUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol ReportCardUseCase: AnyObject { + + func report(cardId: String, reportType: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/TransferAccountUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/TransferAccountUseCase.swift new file mode 100644 index 00000000..79afdad2 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/TransferAccountUseCase.swift @@ -0,0 +1,15 @@ +// +// TransferAccountUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol TransferAccountUseCase: AnyObject { + + func issue() -> Observable + func update() -> Observable + func enter(code: String, encryptedDeviceId: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateCardLikeUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateCardLikeUseCase.swift new file mode 100644 index 00000000..caaecc93 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateCardLikeUseCase.swift @@ -0,0 +1,13 @@ +// +// UpdateCardLikeUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol UpdateCardLikeUseCase: AnyObject { + + func updateLike(cardId: String, isLike: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateFollowUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateFollowUseCase.swift new file mode 100644 index 00000000..db4be51e --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateFollowUseCase.swift @@ -0,0 +1,13 @@ +// +// UpdateFollowUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol UpdateFollowUseCase: AnyObject { + + func updateFollowing(userId: String, isFollow: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateNotifyUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateNotifyUseCase.swift new file mode 100644 index 00000000..6b1fa516 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateNotifyUseCase.swift @@ -0,0 +1,16 @@ +// +// UpdateNotifyUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol UpdateNotifyUseCase: AnyObject { + + func notificationStatus() -> Bool + func switchNotification(on: Bool) -> Observable + + func updateNotify(isAllowNotify: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateTagFavoriteUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateTagFavoriteUseCase.swift new file mode 100644 index 00000000..04d35e66 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateTagFavoriteUseCase.swift @@ -0,0 +1,13 @@ +// +// UpdateTagFavoriteUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol UpdateTagFavoriteUseCase: AnyObject { + + func updateFavorite(tagId: String, isFavorite: Bool) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateUserInfoUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateUserInfoUseCase.swift new file mode 100644 index 00000000..1a3c0cee --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UpdateUserInfoUseCase.swift @@ -0,0 +1,13 @@ +// +// UpdateUserInfoUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol UpdateUserInfoUseCase: AnyObject { + + func updateUserInfo(nickname: String?, imageName: String?) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UploadUserImageUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UploadUserImageUseCase.swift new file mode 100644 index 00000000..7ebc5571 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UploadUserImageUseCase.swift @@ -0,0 +1,15 @@ +// +// UploadUserImageUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol UploadUserImageUseCase: AnyObject { + + func presignedURL() -> Observable + func uploadToS3(_ data: Data, with url: URL) -> Observable + func registerImageName(imageName: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/ValidateNicknameUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/ValidateNicknameUseCase.swift new file mode 100644 index 00000000..d34ea706 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/ValidateNicknameUseCase.swift @@ -0,0 +1,14 @@ +// +// ValidateNicknameUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol ValidateNicknameUseCase: AnyObject { + + func nickname() -> Observable + func checkValidation(nickname: String) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/ValidateUserUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/ValidateUserUseCase.swift new file mode 100644 index 00000000..586b5bd3 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/ValidateUserUseCase.swift @@ -0,0 +1,15 @@ +// +// ValidateUserUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol ValidateUserUseCase: AnyObject { + + func checkValidation() -> Observable + func iswithdrawn() -> Observable + func postingPermission() -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/WriteCardUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/WriteCardUseCase.swift new file mode 100644 index 00000000..84592bfd --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/WriteCardUseCase.swift @@ -0,0 +1,34 @@ +// +// WriteCardUseCase.swift +// SOOUM +// +// Created by 오현식 on 12/2/25. +// + +import RxSwift + +protocol WriteCardUseCase: AnyObject { + + func writeFeed( + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + isStory: Bool, + tags: [String] + ) -> Observable + func writeComment( + parentCardId: String, + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + tags: [String] + ) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/UseCases/LocationUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/LocationUseCaseImpl.swift new file mode 100644 index 00000000..9e99c8d8 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/LocationUseCaseImpl.swift @@ -0,0 +1,37 @@ +// +// LocationUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class LocationUseCaseImpl: LocationUseCase { + + private let repository: SettingsRepository + + init(repository: SettingsRepository) { + self.repository = repository + } + + func coordinate() -> Coordinate { + + return self.repository.coordinate() + } + + func hasPermission() -> Bool { + + return self.repository.hasPermission() + } + + func requestLocationPermission() { + + self.repository.requestLocationPermission() + } + + func checkLocationAuthStatus() -> AuthStatus { + + return self.repository.checkLocationAuthStatus() + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift new file mode 100644 index 00000000..7f432815 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift @@ -0,0 +1,37 @@ +// +// NotificationUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 9/17/25. +// + +import RxSwift + +class NotificationUseCaseImpl: NotificationUseCase { + + private let repository: NotificationRepository + + init(repository: NotificationRepository) { + self.repository = repository + } + + func unreadNotifications(lastId: String?) -> Observable<[CompositeNotificationInfo]> { + + return self.repository.unreadNotifications(lastId: lastId).map(\.notificationInfo) + } + + func readNotifications(lastId: String?) -> Observable<[CompositeNotificationInfo]> { + + return self.repository.readNotifications(lastId: lastId).map(\.notificationInfo) + } + + func isUnreadNotiEmpty() -> Observable { + + return self.unreadNotifications(lastId: nil).map(\.isEmpty) + } + + func requestRead(notificationId: String) -> Observable { + + return self.repository.requestRead(notificationId: notificationId).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/ReportCardUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/ReportCardUseCaseImpl.swift new file mode 100644 index 00000000..114edc89 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/ReportCardUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// ReportCardUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class ReportCardUseCaseImpl: ReportCardUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + func report(cardId: String, reportType: String) -> Observable { + + return self.repository.reportCard(id: cardId, reportType: reportType).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/TransferAccountUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/TransferAccountUseCaseImpl.swift new file mode 100644 index 00000000..843f4136 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/TransferAccountUseCaseImpl.swift @@ -0,0 +1,36 @@ +// +// TransferAccountUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class TransferAccountUseCaseImpl: TransferAccountUseCase { + + private let repository: SettingsRepository + + init(repository: SettingsRepository) { + self.repository = repository + } + + func issue() -> Observable { + + return self.repository.issue().map(\.transferInfo) + } + + func update() -> Observable { + + return self.repository.update().map(\.transferInfo) + } + + func enter(code: String, encryptedDeviceId: String) -> Observable { + + return self.repository.enter( + code: code, + encryptedDeviceId: encryptedDeviceId + ) + .map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UpdateCardLikeUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UpdateCardLikeUseCaseImpl.swift new file mode 100644 index 00000000..9dd8734e --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/UpdateCardLikeUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// UpdateCardLikeUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class UpdateCardLikeUseCaseImpl: UpdateCardLikeUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + func updateLike(cardId: String, isLike: Bool) -> Observable { + + return self.repository.updateLike(id: cardId, isLike: isLike).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UpdateFollowUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UpdateFollowUseCaseImpl.swift new file mode 100644 index 00000000..0b7f5c6a --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/UpdateFollowUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// UpdateFollowUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class UpdateFollowUseCaseImpl: UpdateFollowUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func updateFollowing(userId: String, isFollow: Bool) -> Observable { + + return self.repository.updateFollowing(userId: userId, isFollow: isFollow).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UpdateNotifyUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UpdateNotifyUseCaseImpl.swift new file mode 100644 index 00000000..ff497334 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/UpdateNotifyUseCaseImpl.swift @@ -0,0 +1,32 @@ +// +// UpdateNotifyUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class UpdateNotifyUseCaseImpl: UpdateNotifyUseCase { + + private let repository: SettingsRepository + + init(repository: SettingsRepository) { + self.repository = repository + } + + func notificationStatus() -> Bool { + + return self.repository.notificationStatus() + } + + func switchNotification(on: Bool) -> Observable { + + return self.repository.switchNotification(on: on).map { _ in } + } + + func updateNotify(isAllowNotify: Bool) -> Observable { + + return self.repository.updateNotify(isAllowNotify: isAllowNotify).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UpdateTagFavoriteUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UpdateTagFavoriteUseCaseImpl.swift new file mode 100644 index 00000000..6bc74cf4 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/UpdateTagFavoriteUseCaseImpl.swift @@ -0,0 +1,22 @@ +// +// UpdateTagFavoriteUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class UpdateTagFavoriteUseCaseImpl: UpdateTagFavoriteUseCase { + + private let repository: TagRepository + + init(repository: TagRepository) { + self.repository = repository + } + + func updateFavorite(tagId: String, isFavorite: Bool) -> Observable { + + return self.repository.updateFavorite(tagId: tagId, isFavorite: isFavorite).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UpdateUserInfoUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UpdateUserInfoUseCaseImpl.swift new file mode 100644 index 00000000..b658facb --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/UpdateUserInfoUseCaseImpl.swift @@ -0,0 +1,26 @@ +// +// UpdateUserInfoUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class UpdateUserInfoUseCaseImpl: UpdateUserInfoUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func updateUserInfo(nickname: String?, imageName: String?) -> Observable { + + return self.repository.updateMyProfile( + nickname: nickname, + imageName: imageName + ) + .map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UploadUserImageUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UploadUserImageUseCaseImpl.swift new file mode 100644 index 00000000..20fe2cf3 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/UploadUserImageUseCaseImpl.swift @@ -0,0 +1,32 @@ +// +// UploadUserImageUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class UploadUserImageUseCaseImpl: UploadUserImageUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func presignedURL() -> Observable { + + return self.repository.presignedURL().map(\.imageUrlInfo) + } + + func uploadToS3(_ data: Data, with url: URL) -> Observable { + + return self.repository.uploadImage(data, with: url).map { (try? $0.get()) == 200 } + } + + func registerImageName(imageName: String) -> Observable { + + return self.repository.updateImage(imageName: imageName).map { $0 == 200 } + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/ValidateNicknameUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/ValidateNicknameUseCaseImpl.swift new file mode 100644 index 00000000..884e889d --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/ValidateNicknameUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// ValidateNicknameUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class ValidateNicknameUseCaseImpl: ValidateNicknameUseCase { + + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func nickname() -> Observable { + + return self.repository.nickname().map(\.nickname) + } + + func checkValidation(nickname: String) -> Observable { + + return self.repository.validateNickname(nickname: nickname).map(\.isAvailable) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/ValidateUserUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/ValidateUserUseCaseImpl.swift new file mode 100644 index 00000000..605b52e8 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/ValidateUserUseCaseImpl.swift @@ -0,0 +1,34 @@ +// +// ValidateUserUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class ValidateUserUseCaseImpl: ValidateUserUseCase { + + private let userRepository: UserRepository + private let settingsRepository: SettingsRepository + + init(user: UserRepository, settings: SettingsRepository) { + self.userRepository = user + self.settingsRepository = settings + } + + func checkValidation() -> Observable { + + return self.userRepository.checkAvailable().map(\.checkAvailable) + } + + func iswithdrawn() -> Observable { + + return self.settingsRepository.rejoinableDate().map(\.rejoinableDate) + } + + func postingPermission() -> Observable { + + return self.userRepository.postingPermission().map(\.postingPermission) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/WriteCardUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/WriteCardUseCaseImpl.swift new file mode 100644 index 00000000..759b127f --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/WriteCardUseCaseImpl.swift @@ -0,0 +1,69 @@ +// +// WriteCardUseCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 12/3/25. +// + +import RxSwift + +final class WriteCardUseCaseImpl: WriteCardUseCase { + + private let repository: CardRepository + + init(repository: CardRepository) { + self.repository = repository + } + + func writeFeed( + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + isStory: Bool, + tags: [String] + ) -> Observable { + + return self.repository.writeCard( + isDistanceShared: isDistanceShared, + latitude: latitude, + longitude: longitude, + content: content, + font: font, + imgType: imgType, + imgName: imgName, + isStory: isStory, + tags: tags + ) + .map(\.cardId) + } + + func writeComment( + parentCardId: String, + isDistanceShared: Bool, + latitude: String?, + longitude: String?, + content: String, + font: String, + imgType: String, + imgName: String, + tags: [String] + ) -> Observable { + + return self.repository.writeComment( + id: parentCardId, + isDistanceShared: isDistanceShared, + latitude: latitude, + longitude: longitude, + content: content, + font: font, + imgType: imgType, + imgName: imgName, + tags: tags + ) + .map(\.cardId) + } +} diff --git a/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift b/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift index 33f3542c..d06f42a9 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift @@ -12,12 +12,12 @@ import Kingfisher extension KingfisherManager { - func download(strUrl: String?, completion: @escaping (UIImage?) -> Void) { + func download(strUrl: String?, with key: String? = nil, completion: @escaping (UIImage?) -> Void) { if let strUrl = strUrl, let url = URL(string: strUrl) { // 캐시 만료 기간 하루로 설정 - let resource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: url.absoluteString) - self.retrieveImage(with: resource, options: [.memoryCacheExpiration(.days(1))]) { result in + let resource = KF.ImageResource(downloadURL: url, cacheKey: key ?? strUrl) + self.retrieveImage(with: resource) { result in switch result { case let .success(result): completion(result.image) @@ -32,12 +32,12 @@ extension KingfisherManager { } } - func cancel(strUrl: String?) { + func cancel(strUrl: String?, with key: String? = nil) { if let strUrl = strUrl, let url = URL(string: strUrl) { self.downloader.cancel(url: url) - self.cache.removeImage(forKey: url.absoluteString) + self.cache.removeImage(forKey: key ?? strUrl) } } } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift b/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift index cdbf134b..fcedc5c9 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift @@ -14,4 +14,28 @@ extension Notification.Name { static let hidesBottomBarWhenPushedDidChange = Notification.Name("hidesBottomBarWhenPushedDidChange") /// Update location auth state static let changedLocationAuthorization = Notification.Name("changedLocationAuthorization") + /// Should scroll to top + static let scollingToTopWithAnimation = Notification.Name("scollingToTopWithAnimation") + /// Updated favorite + static let addedFavoriteWithCardId = Notification.Name("addedFavoriteWithCardId") + /// Added comment + static let addedCommentWithCardId = Notification.Name("addedCommentWithCardId") + /// Deleted feed card + static let deletedFeedCardWithId = Notification.Name("deletedFeedCardWithId") + /// Deleted comment card + static let deletedCommentCardWithId = Notification.Name("deletedCommentCardWithId") + /// Updated block user + static let updatedBlockUser = Notification.Name("updatedBlockUser") + /// Updated hasUnreads + static let updatedHasUnreadNotification = Notification.Name("updatedHasUnreadNotification") + /// Should reload home + static let reloadHomeData = Notification.Name("reloadHomeData") + /// Should reload detail + static let reloadDetailData = Notification.Name("reloadDetailData") + /// Updated report state + static let updatedReportState = Notification.Name("updatedReportState") + /// Should reload progile + static let reloadProfileData = Notification.Name("reloadProfileData") + /// Should reload favorite tag + static let reloadFavoriteTagData = Notification.Name("reloadFavoriteTagData") } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift b/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift index 7868ca1e..da19e1a2 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift @@ -7,11 +7,12 @@ import UIKit - extension UIApplication { var currentWindow: UIWindow? { - let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene + let scenes: Set = UIApplication.shared.connectedScenes + let activeScene: UIScene? = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first + let windowScene = activeScene as? UIWindowScene return windowScene?.windows.first { $0.isKeyWindow } } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UIImgeView.swift b/SOOUM/SOOUM/Extensions/Cocoa/UIImgeView.swift index 967fa8cc..a651ef5b 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UIImgeView.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UIImgeView.swift @@ -12,32 +12,17 @@ import Kingfisher import RxCocoa import RxSwift - extension UIImageView { - static let placeholder: UIImage? = UIColor.som.gray400.toImage + static let placeholder: UIImage? = UIColor.som.v2.pMain.toImage - func setImage(strUrl: String?) { + func setImage(strUrl: String?, with key: String? = nil) { if let strUrl: String = strUrl, let url = URL(string: strUrl) { - // 캐싱된 이미지가 있다면, 먼저 사용 - ImageCache.default.retrieveImage(forKey: url.absoluteString) { result in - switch result { - case let .success(value): - if let image = value.image { - self.image = image - } else { - self.kf.setImage( - with: url, - placeholder: Self.placeholder, - options: [.transition(.fade(0.25))] - ) - } - case let .failure(error): - Log.error("Error download image failed with kingfisher: \(error.localizedDescription)") - } - } - + /// ImageResource 객체를 생성하여 URL과 Cache Key를 연결 + let resource = KF.ImageResource(downloadURL: url, cacheKey: key ?? strUrl) + /// Kingfisher에 Resource를 전달하고 모든 캐시/다운로드 로직 위임 + self.kf.setImage(with: resource) self.backgroundColor = .clear } else { self.kf.cancelDownloadTask() diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UIRefreshControl.swift b/SOOUM/SOOUM/Extensions/Cocoa/UIRefreshControl.swift index a8fb41f4..8517be15 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UIRefreshControl.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UIRefreshControl.swift @@ -2,19 +2,15 @@ // UIRefreshControl.swift // SOOUM // -// Created by 오현식 on 9/27/24. +// Created by 오현식 on 11/2/25. // import UIKit extension UIRefreshControl { - - func beginRefreshingFromTop() { - if let scrollView: UIScrollView = superview as? UIScrollView { - let offset = CGPoint(x: 0, y: -self.frame.size.height) - scrollView.setContentOffset(offset, animated: true) - } + /// RefreshControl 에 offset 설정 + func beginRefreshingWithOffset(_ offset: CGFloat) { + self.bounds.origin.y = -offset self.beginRefreshing() - self.sendActions(for: .valueChanged) } } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift b/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift new file mode 100644 index 00000000..31c3b716 --- /dev/null +++ b/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift @@ -0,0 +1,78 @@ +// +// UITextField.swift +// SOOUM +// +// Created by 오현식 on 10/9/25. +// + +import UIKit + +extension UITextField { + + func shouldChangeCharactersIn(in range: NSRange, replacementString string: String, maxCharacters limit: Int) -> Bool { + guard let text = self.text else { + return true + } + if string.isEmpty { + return true + } + + let nsString: NSString? = text as NSString? + let newString: String = nsString?.replacingCharacters(in: range, with: string) ?? "" + + let isTyped: Bool = (range.length == 0) + let aleadyFull: Bool = (text.count >= limit) + let newFull: Bool = newString.count > limit + + if newFull { + // 최종 텍스트가 제한을 벗어남 + if aleadyFull { + // 텍스트 입력 전에 제한을 벗어남 + if isTyped { + // 영어 입력 시 더 이상 입력되지 않음 + guard string.isEnglish == false else { return false } + let lastCharacter = String(text[text.index(before: text.endIndex)]) + let separatedCharacters = lastCharacter.decomposedStringWithCanonicalMapping.unicodeScalars.map { String($0) } + let separatedCharactersCount = separatedCharacters.count + // 마지막 문자를 자음 + 모음으로 나누어 갯수에 따라 판단, + // 갯수가 1일 때, 모음이면 입력 가능 + if separatedCharactersCount == 1 && lastCharacter.isConsonant && string.isConsonant == false { return true } + // 갯수가 2일 때, 자음이면 입력 가능 + if separatedCharactersCount == 2 && string.isConsonant { return true } + // TODO: 겹받침일 때는 고려 X + + return false + } else { + // 텍스트 범위가 선택됨 + // 추가되는 문자열에서 선택된 범위의 길이만큼만 교체 + let to: Int = range.length + let validText: String = String(string.prefix(max(0, to))) + self.text = nsString?.replacingCharacters(in: range, with: validText) + + if let position = self.position(from: self.beginningOfDocument, offset: range.location + to) { + DispatchQueue.main.async { [weak self] in + self?.selectedTextRange = self?.textRange(from: position, to: position) + } + } + self.sendActions(for: .editingChanged) + } + } else { + // 텍스트 입력 후에 제한을 벗어남 + // 추가되는 문자열에서 제한을 넘지 않는 길이만큼만 추가 + let to: Int = limit - text.count + let validText: String = String(string.prefix(max(0, to))) + self.text = nsString?.replacingCharacters(in: range, with: validText) + + if let position = self.position(from: self.beginningOfDocument, offset: range.location + to) { + DispatchQueue.main.async { [weak self] in + self?.selectedTextRange = self?.textRange(from: position, to: position) + } + } + self.sendActions(for: .editingChanged) + } + return false + } else { + return true + } + } +} diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UITtextView.swift b/SOOUM/SOOUM/Extensions/Cocoa/UITtextView.swift new file mode 100644 index 00000000..10e3b789 --- /dev/null +++ b/SOOUM/SOOUM/Extensions/Cocoa/UITtextView.swift @@ -0,0 +1,62 @@ +// +// UITtextView.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import UIKit + +extension UITextView { + + func shouldChangeText(in range: NSRange, replacementText text: String, maxCharacters limit: Int) -> Bool { + if text.isEmpty { + return true + } + + let nsString = self.text as NSString? + let newString: String = nsString?.replacingCharacters(in: range, with: text) ?? "" + + let isTyped: Bool = (range.length == 0) + let aleadyFull: Bool = (self.text.count >= limit) + let newFull: Bool = newString.count > limit + + if newFull { + // 최종 텍스트가 제한을 벗어남 + if aleadyFull { + // 텍스트 입력 전에 제한을 벗어남 + if isTyped { + // 입력 시 더 이상 입력되지 않음 + return false + } else { + // 텍스트 범위가 선택됨 + // 추가되는 문자열에서 선택된 범위의 길이만큼만 교체 + let to: Int = range.length + let validText: String = String(text.prefix(max(0, to))) + self.text = nsString?.replacingCharacters(in: range, with: validText) + + if let position = self.position(from: self.beginningOfDocument, offset: range.location + to) { + DispatchQueue.main.async { [weak self] in + self?.selectedTextRange = self?.textRange(from: position, to: position) + } + } + } + } else { + // 텍스트 입력 후에 제한을 벗어남 + // 추가되는 문자열에서 제한을 넘지 않는 길이만큼만 추가 + let to: Int = limit - self.text.count + let validText: String = String(text.prefix(max(0, to))) + self.text = nsString?.replacingCharacters(in: range, with: validText) + + if let position = self.position(from: self.beginningOfDocument, offset: range.location + to) { + DispatchQueue.main.async { [weak self] in + self?.selectedTextRange = self?.textRange(from: position, to: position) + } + } + } + return false + } else { + return true + } + } +} diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift b/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift index d44cd27c..e5fc1fd2 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift @@ -29,7 +29,7 @@ extension UIViewController { func navigationPop( to: UIViewController.Type? = nil, animated: Bool = true, - bottomBarHidden: Bool = false, + bottomBarHidden: Bool = true, completion: (() -> Void)? = nil ) { CATransaction.begin() @@ -43,12 +43,24 @@ extension UIViewController { destination.hidesBottomBarWhenPushed = bottomBarHidden self.navigationController?.popToViewController(destination, animated: animated) } else { - self.navigationController? - .viewControllers.dropLast().last? - .hidesBottomBarWhenPushed = bottomBarHidden + self.hidesBottomBarWhenPushed = bottomBarHidden self.navigationController?.popViewController(animated: animated) } CATransaction.commit() } + + func navigationPopToRoot( + animated: Bool = true, + bottomBarHidden: Bool = true, + completion: (() -> Void)? = nil + ) { + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + + self.hidesBottomBarWhenPushed = bottomBarHidden + self.navigationController?.popToRootViewController(animated: animated) + + CATransaction.commit() + } } diff --git a/SOOUM/SOOUM/Extensions/Foundation/Array.swift b/SOOUM/SOOUM/Extensions/Foundation/Array.swift new file mode 100644 index 00000000..8b49c6b1 --- /dev/null +++ b/SOOUM/SOOUM/Extensions/Foundation/Array.swift @@ -0,0 +1,24 @@ +// +// Array.swift +// SOOUM +// +// Created by 오현식 on 10/14/25. +// + +extension Array where Element: Hashable { + + func removeOlderfromDuplicated() -> [Element] { + var seen = Set() + let reversed = self.reversed().filter { + seen.insert($0).inserted + } + return reversed.reversed() + } + + func sliceBySize(into size: Int) -> [[Element]] { + return stride(from: 0, to: self.count, by: size).map { + let end = Swift.min($0 + size, count) + return Array(self[$0.. 364 { - return "\(days / 365)년전".trimmingCharacters(in: .whitespaces) + if days > 368 { + return "\(days / 368)년전".trimmingCharacters(in: .whitespaces) } - if days > 0 && days < 365 { - return "\(days)일전".trimmingCharacters(in: .whitespaces) + if days > 29 && days < 369 { + return "\(days / 30)개월 전".trimmingCharacters(in: .whitespaces) + } + + if days > 6 && days < 30 { + return "\(days / 7)주 전".trimmingCharacters(in: .whitespaces) + } + + if days > 0 && days < 7 { + return "\(days)일 전".trimmingCharacters(in: .whitespaces) } if hours > 0 && hours < 24 { - return "\(hours)시간전".trimmingCharacters(in: .whitespaces) + return "\(hours)시간 전".trimmingCharacters(in: .whitespaces) } - if minutes > 9 && minutes < 60 { - return "\(minutes / 10)0분전".trimmingCharacters(in: .whitespaces) + if minutes > 10 && minutes < 60 { + return "\(minutes / 10)0분 전".trimmingCharacters(in: .whitespaces) } - if minutes > 4 && minutes < 10 { - return "10분전".trimmingCharacters(in: .whitespaces) + if minutes > 0 && minutes < 11 { + return "\(minutes)분 전".trimmingCharacters(in: .whitespaces) } - if minutes < 5 { - return "조금전".trimmingCharacters(in: .whitespaces) + if minutes < 1 { + return "방금 전".trimmingCharacters(in: .whitespaces) } return "" @@ -81,13 +89,41 @@ extension Date { let seconds: Int = .init(time % 60) if hours <= 0 && minutes <= 0 && seconds <= 0 { - return "00 : 00 : 00" + return "00:00:00" + } + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + + func infoReadableTimeTakenFromThisForPungToHoursAndMinutes(to: Date) -> String { + + let from: TimeInterval = self.timeIntervalSince1970 + let to: TimeInterval = to.timeIntervalSince1970 + let gap: TimeInterval = max(0, to - from) + + let time: Int = .init(gap) + let minutes: Int = .init(time % (60 * 60)) / 60 + let seconds: Int = .init(time % 60) + + if minutes <= 0 && seconds <= 0 { + return "00:00" } - return String(format: "%02d : %02d : %02d", hours, minutes, seconds) + return String(format: "%02d:%02d", minutes, seconds) + } + + func infoReadableTimeTakenFromThisForBanEndPosting(to: Date) -> String { + + let from: TimeInterval = self.timeIntervalSince1970 + let to: TimeInterval = to.timeIntervalSince1970 + let gap: TimeInterval = max(0, to - from) + + let time: Int = .init(gap) + let days: Int = time / (24 * 60 * 60) + + return "\(days)일간" } var banEndFormatted: String { - return self.toString("yyyy년 MM월 dd일") + return self.addingTimeInterval(24 * 60 * 60).toString("yyyy년 MM월 dd일") } var banEndDetailFormatted: String { @@ -95,7 +131,11 @@ extension Date { } var announcementFormatted: String { - return self.toString("yyyy. MM. dd") + return self.toString("yyyy.MM.dd") + } + + var noticeFormatted: String { + return self.toString("M월 d일") } } diff --git a/SOOUM/SOOUM/Extensions/Foundation/Log/Log+Extract.swift b/SOOUM/SOOUM/Extensions/Foundation/Log/Log+Extract.swift index c833822b..94900c77 100644 --- a/SOOUM/SOOUM/Extensions/Foundation/Log/Log+Extract.swift +++ b/SOOUM/SOOUM/Extensions/Foundation/Log/Log+Extract.swift @@ -21,21 +21,20 @@ extension Log { let filePaths: [String] = fileLogger.logFileManager.sortedLogFilePaths - if filePaths.isEmpty { - + if let latesFilePath = filePaths.last { + let fileUrls: [URL] = [URL(fileURLWithPath: latesFilePath)] + let viewController = UIActivityViewController( + activityItems: fileUrls, + applicationActivities: nil + ) + observer(.success(viewController)) + } else { let error = NSError( domain: "\(identifier):Log", code: -999, userInfo: [NSLocalizedDescriptionKey: "기록된 로그가 없습니다."] ) observer(.failure(error)) - } else { - let fileUrls: [URL] = filePaths.map { .init(fileURLWithPath: $0) } - let viewController = UIActivityViewController( - activityItems: fileUrls, - applicationActivities: nil - ) - observer(.success(viewController)) } } else { diff --git a/SOOUM/SOOUM/Extensions/Foundation/String.swift b/SOOUM/SOOUM/Extensions/Foundation/String.swift new file mode 100644 index 00000000..8c583fcb --- /dev/null +++ b/SOOUM/SOOUM/Extensions/Foundation/String.swift @@ -0,0 +1,28 @@ +// +// String.swift +// SOOUM +// +// Created by 오현식 on 11/16/25. +// + +import UIKit + +extension String { + /// 자음인지 여부 확인 + var isConsonant: Bool { + guard let scalar = UnicodeScalar(self)?.value else { + return false + } + + let consonantScalarRange: ClosedRange = 12593...12622 + return consonantScalarRange ~= scalar + } + /// 영어인지 여부 확인 + var isEnglish: Bool { + guard self.isEmpty == false else { return false } + + let pattern = "^[a-zA-Z]+$" + let predicate = NSPredicate(format: "SELF MATCHES %@", pattern) + return predicate.evaluate(with: self) + } +} diff --git a/SOOUM/SOOUM/Extensions/Foundation/UserDefaults.swift b/SOOUM/SOOUM/Extensions/Foundation/UserDefaults.swift index 196a6466..88f2ba50 100644 --- a/SOOUM/SOOUM/Extensions/Foundation/UserDefaults.swift +++ b/SOOUM/SOOUM/Extensions/Foundation/UserDefaults.swift @@ -10,17 +10,53 @@ import Foundation extension UserDefaults { + enum Keys { + static let hasBeenLaunchedBefore: String = "hasBeenLaunchedBefore" + static let hasBeenShowMessageGuide: String = "hasBeenShowMessageGuide" + static let hasBeenShowWriteCardGuide: String = "hasBeenShowWriteCardGuide" + static let userNickname: String = "userNickname" + } + // Keychain 삭제를 위한 flag static var isFirstLaunch: Bool { - let hasBeenLaunchedBefore = "hasBeenLaunchedBefore" - let isFirstLaunch = !UserDefaults.standard.bool(forKey: hasBeenLaunchedBefore) + let isFirstLaunch = !UserDefaults.standard.bool(forKey: Keys.hasBeenLaunchedBefore) + if isFirstLaunch { + UserDefaults.standard.set(true, forKey: Keys.hasBeenLaunchedBefore) + } + + return isFirstLaunch + } + + // 메인 홈 카드추가 가이드 메시지를 위한 flag + static var showGuideMessage: Bool { + let isFirstLaunch = !UserDefaults.standard.bool(forKey: Keys.hasBeenShowMessageGuide) if isFirstLaunch { - UserDefaults.standard.set(true, forKey: hasBeenLaunchedBefore) - UserDefaults.standard.synchronize() + UserDefaults.standard.set(true, forKey: Keys.hasBeenShowMessageGuide) } return isFirstLaunch } + + // 카드추가 시 가이드 뷰를 위한 flag + static var showGuideView: Bool { + + let isFirstLaunch = !UserDefaults.standard.bool(forKey: Keys.hasBeenShowWriteCardGuide) + if isFirstLaunch { + UserDefaults.standard.set(true, forKey: Keys.hasBeenShowWriteCardGuide) + } + + return isFirstLaunch + } + + // Nickname의 전역 사용을 위한 확장 + var nickname: String? { + get { + return UserDefaults.standard.string(forKey: Keys.userNickname) + } + set { + UserDefaults.standard.set(newValue, forKey: Keys.userNickname) + } + } } diff --git a/SOOUM/SOOUM/Extensions/RxCocoa/RxSwift/RxSwift+Unretained.swift b/SOOUM/SOOUM/Extensions/RxCocoa/RxSwift/RxSwift+Unretained.swift new file mode 100644 index 00000000..123d81b7 --- /dev/null +++ b/SOOUM/SOOUM/Extensions/RxCocoa/RxSwift/RxSwift+Unretained.swift @@ -0,0 +1,24 @@ +// +// RxSwift+Unretained.swift +// SOOUM +// +// Created by 오현식 on 11/23/25. +// + +import RxSwift + +extension ObservableType { + + // `do`와 `onNext`만을 사용할 경우 + func `do`( + with object: Object, + onNext: @escaping (((object: Object, element: Element)) throws -> Void) + ) -> Observable { + `do`( + onNext: { [weak object] in + guard let object = object else { return } + try onNext((object, $0)) + } + ) + } +} diff --git a/SOOUM/SOOUM/Managers/GAManager/AnalyticsEventProtocol.swift b/SOOUM/SOOUM/Managers/GAManager/AnalyticsEventProtocol.swift deleted file mode 100644 index 128547ef..00000000 --- a/SOOUM/SOOUM/Managers/GAManager/AnalyticsEventProtocol.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// AnalyticsEventProtocol.swift -// SOOUM -// -// Created by JDeoks on 3/11/25. -// - -protocol AnalyticsEventProtocol { - var eventName: String { get } - var parameters: [String: FirebaseLoggable]? { get } -} - -extension AnalyticsEventProtocol { - - var eventName: String { - let enumName = String(describing: type(of: self)) // "Home" - let caseName = "\(self)".components(separatedBy: "(").first ?? "" // "fetchDefectList" - return "\(enumName)_\(caseName)" // "Home_fetchDefectList" - } - - var parameters: [String: FirebaseLoggable]? { - // (1) 우선 "self"를 미러링 -> enum의 유일한 자식(child)이 "someEvent"라는 케이스 - let paramDict = Mirror(reflecting: self).children.reduce(into: [String: FirebaseLoggable]()) { dict, child in - guard let caseLabel = child.label else { - return - } - - let caseValue = child.value - let caseMirror = Mirror(reflecting: caseValue) - if caseMirror.displayStyle == .tuple { - // 🔑 "someEvent(num: 2, text: \"테스트\")" 이런 형태로 들어옴 - - // (3) 튜플 안에 있는 각 연관값( num: 2, text: "테스트" )을 순회 - for paramChild in caseMirror.children { - guard let paramLabel = paramChild.label else { continue } - let paramValue = paramChild.value - - // (4) FirebaseLoggable 등 타입 검사 - if paramValue is FirebaseLoggable { - dict[paramLabel] = paramValue as? any FirebaseLoggable - } - } - } - else if caseValue is FirebaseLoggable { - // (단일 파라미터인 경우) - dict[caseLabel] = caseValue as? any FirebaseLoggable - } - } - - return paramDict.isEmpty ? nil : paramDict - } -} diff --git a/SOOUM/SOOUM/Managers/GAManager/GAManager.swift b/SOOUM/SOOUM/Managers/GAManager/GAManager.swift deleted file mode 100644 index aaa98c28..00000000 --- a/SOOUM/SOOUM/Managers/GAManager/GAManager.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// GAManager.swift -// SOOUM -// -// Created by JDeoks on 3/11/25. -// - -import Foundation -import FirebaseAnalytics - -class GAManager { - - static let shared = GAManager() - - private init() { } - - func logEvent(event: AnalyticsEventProtocol) { - Analytics.logEvent(event.eventName, parameters: event.parameters) - } -} diff --git a/SOOUM/SOOUM/Managers/GAManager/SOMEvent.swift b/SOOUM/SOOUM/Managers/GAManager/SOMEvent.swift deleted file mode 100644 index 38ed1d43..00000000 --- a/SOOUM/SOOUM/Managers/GAManager/SOMEvent.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// SOMEvent.swift -// SOOUM -// -// Created by JDeoks on 3/11/25. -// - -enum SOMEvent { - enum WriteCard: AnalyticsEventProtocol { - /// 글쓰기 화면에서 태그를 추가하고 글을 작성하지 않음 - case dismiss_with_tag(tag_count: Int, tag_texts: [String]) - /// 글쓰기 화면에서 태그를 추가하고 글을 작성 - case add_tag(tag_count: Int, tag_texts: [String]) - } - - enum Tag: AnalyticsEventProtocol { - /// 태그를 클릭한 위치 - enum ClickPositionKey { - /// 카드 상세화면에서 태그 클릭 - static let post = "post" - /// 즐겨찾기 태그 목록에서 태그 클릭 - static let favorite = "favorite" - /// 즐겨찾기 태그 목록의 미리보기 카드 클릭 - static let favorite_preview = "favorite_preview" - /// 추천 태그 목록에서 태그 클릭 - static let recommendation = "recommendation" - /// 태그 검색 결과에서 태그 클릭 - static let search_result = "search_result" - } - /// 태그를 클릭 - case tag_click(tag_text: String, click_position: String) - } - - enum Comment: AnalyticsEventProtocol { - /// 사용자가 댓글을 작성 - /// - /// - Parameters: - /// - comment_length: 댓글 길이 - /// - parent_post_id: 부모 글 ID - /// - image_attached: 이미지 첨부 여부 - case add_comment(comment_length: Int, parent_post_id: String, image_attached: Bool) - } -} diff --git a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift b/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift deleted file mode 100644 index 8ef83398..00000000 --- a/SOOUM/SOOUM/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// ErrorInterceptor.swift -// SOOUM -// -// Created by 오현식 on 10/27/24. -// - -import Foundation - -import Alamofire - - -class ErrorInterceptor: RequestInterceptor { - - private let lock = NSLock() - - private let retryLimit: Int = 1 - - private let provider: ManagerTypeDelegate - - init(provider: ManagerTypeDelegate) { - self.provider = provider - } - - func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) { - self.lock.lock(); defer { self.lock.unlock() } - - guard let response = request.task?.response as? HTTPURLResponse, - response.statusCode == 401 - else { - completion(.doNotRetryWithError(error)) - return - } - - // 재인증 과정은 1번만 진행한다. - guard request.retryCount < retryLimit else { - completion(.doNotRetryWithError(error)) - return - } - - let accessToken = self.provider.authManager.authInfo.token.accessToken - self.provider.authManager.reAuthenticate(accessToken) { result in - - switch result { - case .success: - completion(.retry) - case let .failure(error): - Log.error("ReAuthenticate failed. \(error.localizedDescription)") - completion(.doNotRetry) - } - } - } -} diff --git a/SOOUM/SOOUM/Managers/PushManager/Models/NotificationInfo.swift b/SOOUM/SOOUM/Managers/PushManager/Models/NotificationInfo.swift deleted file mode 100644 index 416eb0ac..00000000 --- a/SOOUM/SOOUM/Managers/PushManager/Models/NotificationInfo.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// NotificationInfo.swift -// SOOUM -// -// Created by 오현식 on 12/27/24. -// - -import Foundation - - -class NotificationInfo { - - let notificationType: CommentHistoryInNoti.NotificationType? - let notificationId: String? - let targetCardId: String? - - var isTransfered: Bool { - return self.notificationType == .transfer - } - - init(_ info: [String: Any]) { - let notificationType = info["notificationType"] as? String ?? "" - self.notificationType = CommentHistoryInNoti.NotificationType(rawValue: notificationType) - self.notificationId = info["notificationId"] as? String - self.targetCardId = info["targetCardId"] as? String - } -} diff --git a/SOOUM/SOOUM/Models/Auth/BaseAuthResponse.swift b/SOOUM/SOOUM/Models/Auth/BaseAuthResponse.swift deleted file mode 100644 index 46471413..00000000 --- a/SOOUM/SOOUM/Models/Auth/BaseAuthResponse.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// CommonResponse.swift -// SOOUM -// -// Created by JDeoks on 10/1/24. -// - -import Foundation - - -// MARK: - Links -struct Links: Codable { - let login: URLString? - let home: URLString? -} - -// MARK: - Token -struct Token: Codable { - var accessToken: String - var refreshToken: String -} diff --git a/SOOUM/SOOUM/Models/Auth/RSAKeyResponse.swift b/SOOUM/SOOUM/Models/Auth/RSAKeyResponse.swift deleted file mode 100644 index 51c18f87..00000000 --- a/SOOUM/SOOUM/Models/Auth/RSAKeyResponse.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// RSAKeyResponse.swift -// SOOUM -// -// Created by JDeoks on 10/1/24. -// - -import Foundation - -import Alamofire - - -struct RSAKeyResponse: Codable { - let status: Status - let publicKey: String - let links: Links - - enum CodingKeys: String, CodingKey { - case status - case publicKey - case links = "_links" - } -} - -extension RSAKeyResponse { - - init() { - self.status = .init() - self.publicKey = "" - self.links = .init(login: nil, home: nil) - } -} - -extension RSAKeyResponse: EmptyResponse { - static func emptyValue() -> RSAKeyResponse { - RSAKeyResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Auth/ReAuthenticationResponse.swift b/SOOUM/SOOUM/Models/Auth/ReAuthenticationResponse.swift deleted file mode 100644 index 2fa7f388..00000000 --- a/SOOUM/SOOUM/Models/Auth/ReAuthenticationResponse.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// ReAuthenticationResponse.swift -// SOOUM -// -// Created by 오현식 on 10/27/24. -// - -import Foundation - -import Alamofire - - -struct ReAuthenticationResponse: Codable { - let status: Status - let accessToken: String - let links: Links - - enum CodingKeys: String, CodingKey { - case status - case accessToken - case links = "_links" - } -} - -extension ReAuthenticationResponse { - - init() { - self.status = .init() - self.accessToken = "" - self.links = .init(login: nil, home: nil) - } -} - -extension ReAuthenticationResponse: EmptyResponse { - static func emptyValue() -> ReAuthenticationResponse { - ReAuthenticationResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Auth/SignInResponse.swift b/SOOUM/SOOUM/Models/Auth/SignInResponse.swift deleted file mode 100644 index f7216b61..00000000 --- a/SOOUM/SOOUM/Models/Auth/SignInResponse.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SignInResponse.swift -// SOOUM -// -// Created by JDeoks on 10/1/24. -// - -import Foundation - -import Alamofire - - -struct SignInResponse: Codable { - let status: Status - let isRegistered: Bool - let token: Token? - let links: Links? - - enum CodingKeys: String, CodingKey { - case status - case isRegistered - case token - case links = "_links" - } -} - -extension SignInResponse { - - init() { - self.status = .init() - self.isRegistered = false - self.token = nil - self.links = nil - } -} - -extension SignInResponse: EmptyResponse { - static func emptyValue() -> SignInResponse { - SignInResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Auth/SignUpResponse.swift b/SOOUM/SOOUM/Models/Auth/SignUpResponse.swift deleted file mode 100644 index 526e6ba8..00000000 --- a/SOOUM/SOOUM/Models/Auth/SignUpResponse.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// SignUpResponse.swift -// SOOUM -// -// Created by JDeoks on 10/1/24. -// - -import Foundation - -import Alamofire - - -struct SignUpResponse: Codable { - let status: Status - let token: Token - let links: Links - - enum CodingKeys: String, CodingKey { - case status - case token - case links = "_links" - } -} - -extension SignUpResponse { - - init() { - self.status = .init() - self.token = .init(accessToken: "", refreshToken: "") - self.links = .init(login: nil, home: nil) - } -} - -extension SignUpResponse: EmptyResponse { - static func emptyValue() -> SignUpResponse { - SignUpResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/BaseEmptyAndHeader.swift b/SOOUM/SOOUM/Models/BaseEmptyAndHeader.swift deleted file mode 100644 index 7b8e55be..00000000 --- a/SOOUM/SOOUM/Models/BaseEmptyAndHeader.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// BaseEmptyAndHeader.swift -// SOOUM -// -// Created by 오현식 on 10/21/24. -// - -import Foundation - -import Alamofire - - -/// 서버 응답 status -struct Status: Codable { - let httpCode: Int - let httpStatus: String - let responseMessage: String -} - -extension Status { - init() { - self.httpCode = 0 - self.httpStatus = "" - self.responseMessage = "" - } -} - -extension Status: EmptyResponse { - static func emptyValue() -> Status { - Status.init() - } -} - -/// 실제 urlString -struct URLString: Equatable, Codable { - let url: String - - enum CodingKeys: String, CodingKey { - case url = "href" - } -} -extension URLString { - init() { - self.url = "" - } -} diff --git a/SOOUM/SOOUM/Models/Card/Card.swift b/SOOUM/SOOUM/Models/Card/Card.swift deleted file mode 100644 index ee62685d..00000000 --- a/SOOUM/SOOUM/Models/Card/Card.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// Card.swift -// SOOUM -// -// Created by 오현식 on 10/8/24. -// - -import Foundation - - -struct Card: CardProtocol { - let id: String - let content: String - - let distance: Double? - - let createdAt: Date - let storyExpirationTime: Date? - - let likeCnt: Int - let commentCnt: Int - - let backgroundImgURL: URLString - let links: Detail - - let font: Font - let fontSize: FontSize - - let isLiked: Bool - let isCommentWritten: Bool - - enum CodingKeys: String, CodingKey { - case id - case content - case distance - case createdAt - case storyExpirationTime - case likeCnt - case commentCnt - case backgroundImgURL = "backgroundImgUrl" - case links = "_links" - case font - case fontSize - case isLiked - case isCommentWritten - } -} - -extension Card { - // Card의 == 조건 id 및 좋아요 수, 코멘트 수 - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id && - lhs.isLiked == rhs.isLiked && - lhs.likeCnt == rhs.likeCnt && - lhs.isCommentWritten == rhs.isCommentWritten - } -} - -extension Card { - - init() { - self.id = "" - self.content = "" - self.distance = nil - self.createdAt = Date() - self.storyExpirationTime = nil - self.likeCnt = 0 - self.commentCnt = 0 - self.backgroundImgURL = .init() - self.links = .init() - self.font = .pretendard - self.fontSize = .big - self.isLiked = false - self.isCommentWritten = false - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.distance = try container.decodeIfPresent(Double.self, forKey: .distance) - self.createdAt = try container.decode(Date.self, forKey: .createdAt) - self.storyExpirationTime = try container.decodeIfPresent( - Date.self, - forKey: .storyExpirationTime - ) - self.content = try container.decode(String.self, forKey: .content) - self.likeCnt = try container.decode(Int.self, forKey: .likeCnt) - self.commentCnt = try container.decode(Int.self, forKey: .commentCnt) - self.backgroundImgURL = try container.decode(URLString.self, forKey: .backgroundImgURL) - self.links = try container.decode(Detail.self, forKey: .links) - self.font = try container.decode(Font.self, forKey: .font) - self.fontSize = try container.decode(FontSize.self, forKey: .fontSize) - self.isLiked = try container.decode(Bool.self, forKey: .isLiked) - self.isCommentWritten = try container.decode(Bool.self, forKey: .isCommentWritten) - } -} diff --git a/SOOUM/SOOUM/Models/Card/CardProtocol.swift b/SOOUM/SOOUM/Models/Card/CardProtocol.swift deleted file mode 100644 index 50b031ca..00000000 --- a/SOOUM/SOOUM/Models/Card/CardProtocol.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// CardProtocol.swift -// SOOUM -// -// Created by 오현식 on 9/27/24. -// - -import Foundation - - -protocol CardProtocol: Equatable, Codable { - var id: String { get } - var content: String { get } - - var distance: Double? { get } - - var createdAt: Date { get } - var storyExpirationTime: Date? { get } - - var likeCnt: Int { get } - var commentCnt: Int { get } - - var backgroundImgURL: URLString { get } - - var font: Font { get } - var fontSize: FontSize { get } - - var isLiked: Bool { get } - var isCommentWritten: Bool { get } -} - -/// 다음 카드 URL -struct Next: Codable { - let next: URLString -} -extension Next { - init() { - self.next = .init() - } -} -/// 상세보기 카드 URL -struct Detail: Codable { - let detail: URLString -} -extension Detail { - init() { - self.detail = .init() - } -} - -/// 사용하는 폰트 -enum Font: String, Codable { - case pretendard = "PRETENDARD" - case school = "SCHOOL_SAFE_CHALKBOARD_ERASER" -} -/// 사용하는 폰트 사이즈 -enum FontSize: String, Codable { - case big = "BIG" - case medium = "MEDIUM" - case small = "SMALL" - case none = "NONE" -} diff --git a/SOOUM/SOOUM/Models/Card/Detail/CardSummaryResponse.swift b/SOOUM/SOOUM/Models/Card/Detail/CardSummaryResponse.swift deleted file mode 100644 index 06ea11d7..00000000 --- a/SOOUM/SOOUM/Models/Card/Detail/CardSummaryResponse.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// SummaryResponse.swift -// SOOUM -// -// Created by JDeoks on 10/9/24. -// - -import Foundation - -import Alamofire - - -struct CardSummaryResponse: Codable { - let cardSummary: CardSummary - let status: Status - - enum CodingKeys: String, CodingKey { - case cardSummary - case status - } -} - -struct CardSummary: Equatable, Codable { - let commentCnt: Int - let cardLikeCnt: Int - let isLiked: Bool -} - -extension CardSummary { - - init() { - commentCnt = 0 - cardLikeCnt = 0 - isLiked = false - } -} - -extension CardSummaryResponse { - - init() { - self.cardSummary = .init() - self.status = .init() - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.status = try container.decode(Status.self, forKey: .status) - - let singleContainer = try decoder.singleValueContainer() - self.cardSummary = try singleContainer.decode(CardSummary.self) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(cardSummary, forKey: .cardSummary) - try container.encode(status, forKey: .status) - } -} - -extension CardSummaryResponse: EmptyResponse { - static func emptyValue() -> CardSummaryResponse { - CardSummaryResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Card/Detail/CommentCardResponse.swift b/SOOUM/SOOUM/Models/Card/Detail/CommentCardResponse.swift deleted file mode 100644 index 3638c118..00000000 --- a/SOOUM/SOOUM/Models/Card/Detail/CommentCardResponse.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// CommentCardResponse.swift -// SOOUM -// -// Created by JDeoks on 10/9/24. -// - -import Foundation - -import Alamofire - - -struct CommentCardResponse: Codable { - let embedded: CommentCardEmbedded - let links: Next - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -struct CommentCardEmbedded: Codable { - let commentCards: [Card] - - enum CodingKeys: String, CodingKey { - case commentCards = "commentCardsInfoList" - } -} - -extension CommentCardResponse { - - init() { - self.embedded = .init(commentCards: []) - self.links = .init() - self.status = .init() - } -} - -extension CommentCardResponse: EmptyResponse { - static func emptyValue() -> CommentCardResponse { - CommentCardResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Card/Detail/DetailCardResponse.swift b/SOOUM/SOOUM/Models/Card/Detail/DetailCardResponse.swift deleted file mode 100644 index ae3357b5..00000000 --- a/SOOUM/SOOUM/Models/Card/Detail/DetailCardResponse.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// DetailCardResponse.swift -// SOOUM -// -// Created by 오현식 on 10/8/24. -// - -import Foundation - -import Alamofire - - -struct DetailCardResponse: Codable { - let detailCard: DetailCard - let prevCard: PrevCard? - let status: Status - - enum CodingKeys: String, CodingKey { - case detailCard - case prevCard - case status - } -} - -extension DetailCardResponse { - - init() { - self.detailCard = .init() - self.prevCard = nil - self.status = .init() - } - - init(from decoder: any Decoder) throws { - let singleContainer = try decoder.singleValueContainer() - self.detailCard = try singleContainer.decode(DetailCard.self) - self.prevCard = try? singleContainer.decode(PrevCard.self) - - let container = try decoder.container(keyedBy: CodingKeys.self) - self.status = try container.decode(Status.self, forKey: .status) - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - - try container.encode(self.detailCard) - try container.encode(self.status) - } -} - -extension DetailCardResponse: EmptyResponse { - static func emptyValue() -> DetailCardResponse { - DetailCardResponse.init() - } -} - -struct DetailCard: CardProtocol { - - let id: String - let content: String - - let distance: Double? - - let createdAt: Date - let storyExpirationTime: Date? - - let likeCnt: Int - let commentCnt: Int - - let backgroundImgURL: URLString - - let font: Font - let fontSize: FontSize - - let isLiked: Bool - let isCommentWritten: Bool - - let isOwnCard: Bool - let isFeedCard: Bool? - let isPreviousCardDelete: Bool? - - let member: Member - let tags: [Tag] - - enum CodingKeys: String, CodingKey { - case id - case content - case distance - case createdAt - case storyExpirationTime - case likeCnt - case commentCnt - case backgroundImgURL = "backgroundImgUrl" - case font - case fontSize - case isLiked - case isCommentWritten - case isOwnCard - case isFeedCard - case isPreviousCardDelete - case member - case tags - } -} - -extension DetailCard { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } -} - -extension DetailCard { - - init() { - self.id = "" - self.content = "" - self.distance = nil - self.createdAt = Date() - self.storyExpirationTime = nil - self.likeCnt = 0 - self.commentCnt = 0 - self.backgroundImgURL = .init() - self.font = .pretendard - self.fontSize = .big - self.isLiked = false - self.isCommentWritten = false - self.isOwnCard = false - self.isFeedCard = nil - self.isPreviousCardDelete = nil - self.member = .init() - self.tags = [] - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.distance = try container.decodeIfPresent(Double.self, forKey: .distance) - self.createdAt = try container.decode(Date.self, forKey: .createdAt) - self.storyExpirationTime = try container.decodeIfPresent( - Date.self, - forKey: .storyExpirationTime - ) - self.content = try container.decode(String.self, forKey: .content) - self.likeCnt = try container.decode(Int.self, forKey: .likeCnt) - self.commentCnt = try container.decode(Int.self, forKey: .commentCnt) - self.backgroundImgURL = try container.decode(URLString.self, forKey: .backgroundImgURL) - self.font = try container.decode(Font.self, forKey: .font) - self.fontSize = try container.decode(FontSize.self, forKey: .fontSize) - self.isLiked = try container.decode(Bool.self, forKey: .isLiked) - self.isCommentWritten = try container.decode(Bool.self, forKey: .isCommentWritten) - - self.isOwnCard = try container.decode(Bool.self, forKey: .isOwnCard) - self.isFeedCard = try container.decodeIfPresent(Bool.self, forKey: .isFeedCard) - self.isPreviousCardDelete = try container.decodeIfPresent(Bool.self, forKey: .isPreviousCardDelete) - self.member = try container.decode(Member.self, forKey: .member) - self.tags = try container.decodeIfPresent([Tag].self, forKey: .tags) ?? [] - } -} - -struct Member: Codable { - let id: String - let nickname: String - let profileImgUrl: URLString? - - init() { - self.id = "" - self.nickname = "" - self.profileImgUrl = nil - } -} - -struct Tag: Codable { - let id: String - let content: String - let links: TagFeed - - enum CodingKeys: String, CodingKey { - case id - case content - case links = "_links" - } -} - -struct TagFeed: Codable { - let tagFeed: URLString - - enum CodingKeys: String, CodingKey { - case tagFeed = "tag-feed" - } -} - -struct PrevCard: Equatable, Codable { - let previousCardId: String - let previousCardImgLink: URLString? - let isFeedCardStory: Bool -} - -extension PrevCard { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.previousCardId == rhs.previousCardId - } -} - -extension PrevCard { - - init() { - self.previousCardId = "" - self.previousCardImgLink = nil - self.isFeedCardStory = false - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.previousCardId = try container.decode(String.self, forKey: .previousCardId) - self.previousCardImgLink = try? container.decode(URLString.self, forKey: .previousCardImgLink) - self.isFeedCardStory = try container.decode(Bool.self, forKey: .isFeedCardStory) - } -} diff --git a/SOOUM/SOOUM/Models/Card/MainHome/LatestCardResponse.swift b/SOOUM/SOOUM/Models/Card/MainHome/LatestCardResponse.swift deleted file mode 100644 index f3a7b7e2..00000000 --- a/SOOUM/SOOUM/Models/Card/MainHome/LatestCardResponse.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// LatestCardResponse.swift -// SOOUM -// -// Created by 오현식 on 9/26/24. -// - -import Foundation - -import Alamofire - - -struct LatestCardResponse: Codable { - let embedded: LatestCardEmbedded - let links: Next - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -struct LatestCardEmbedded: Codable { - let cards: [Card] - - enum CodingKeys: String, CodingKey { - case cards = "latestFeedCardDtoList" - } -} - -extension LatestCardResponse { - - init() { - self.embedded = .init(cards: []) - self.links = .init() - self.status = .init() - } -} - -extension LatestCardResponse: EmptyResponse { - static func emptyValue() -> LatestCardResponse { - LatestCardResponse.init() - } -} - - diff --git a/SOOUM/SOOUM/Models/Card/MainHome/PopularCardResponse.swift b/SOOUM/SOOUM/Models/Card/MainHome/PopularCardResponse.swift deleted file mode 100644 index 9b146dcc..00000000 --- a/SOOUM/SOOUM/Models/Card/MainHome/PopularCardResponse.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// PopularCardResponse.swift -// SOOUM -// -// Created by 오현식 on 9/27/24. -// - -import Foundation - -import Alamofire - - -struct PopularCardResponse: Codable { - let embedded: PopularCardEmbedded - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case status - } -} - -struct PopularCardEmbedded: Codable { - let cards: [Card] - - enum CodingKeys: String, CodingKey { - case cards = "popularCardRetrieveList" - } -} - -extension PopularCardResponse { - - init() { - self.embedded = .init(cards: []) - self.status = .init() - } -} - -extension PopularCardResponse: EmptyResponse { - static func emptyValue() -> PopularCardResponse { - PopularCardResponse.init() - } -} - - diff --git a/SOOUM/SOOUM/Models/Card/MainHome/distanceCardResponse.swift b/SOOUM/SOOUM/Models/Card/MainHome/distanceCardResponse.swift deleted file mode 100644 index bea4d12b..00000000 --- a/SOOUM/SOOUM/Models/Card/MainHome/distanceCardResponse.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// distanceCardResponse.swift -// SOOUM -// -// Created by 오현식 on 9/27/24. -// - -import Foundation - -import Alamofire - - -struct DistanceCardResponse: Codable { - let embedded: DistanceCardEmbedded - let links: Next - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -struct DistanceCardEmbedded: Codable { - let cards: [Card] - - enum CodingKeys: String, CodingKey { - case cards = "distanceCardDtoList" - } -} - -extension DistanceCardResponse { - - init() { - self.embedded = .init(cards: []) - self.links = .init() - self.status = .init() - } -} - -extension DistanceCardResponse: EmptyResponse { - static func emptyValue() -> DistanceCardResponse { - DistanceCardResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Card/WriteCard/RelatedTagResponse.swift b/SOOUM/SOOUM/Models/Card/WriteCard/RelatedTagResponse.swift deleted file mode 100644 index ff663dca..00000000 --- a/SOOUM/SOOUM/Models/Card/WriteCard/RelatedTagResponse.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// RelatedTagResponse.swift -// SOOUM -// -// Created by 오현식 on 10/22/24. -// - -import Foundation - -import Alamofire - - -struct RelatedTagResponse: Codable { - let embedded: RelatedTagEmbedded - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case status - } -} - -struct RelatedTagEmbedded: Equatable, Codable { - let relatedTags: [RelatedTag] - - enum CodingKeys: String, CodingKey { - case relatedTags = "relatedTagList" - } -} - -struct RelatedTag: Equatable, Codable { - let count: Int - let content: String -} - -extension RelatedTagResponse { - - init() { - self.embedded = .init(relatedTags: []) - self.status = .init() - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.embedded = try container.decodeIfPresent(RelatedTagEmbedded.self, forKey: .embedded) ?? .init(relatedTags: []) - self.status = try container.decode(Status.self, forKey: .status) - } -} - -extension RelatedTagResponse: EmptyResponse { - static func emptyValue() -> RelatedTagResponse { - RelatedTagResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/DefaultCardImageResponse.swift b/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/DefaultCardImageResponse.swift deleted file mode 100644 index fd530ed0..00000000 --- a/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/DefaultCardImageResponse.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// DefaultCardImageResponse.swift -// SOOUM -// -// Created by JDeoks on 10/24/24. -// - -import Foundation - -struct DefaultCardImageResponse: Codable { - let embedded: Embedded - let links: Links - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } - - // MARK: - Embedded - struct Embedded: Codable { - let imgURLInfoList: [ImgURLInfoList] - - enum CodingKeys: String, CodingKey { - case imgURLInfoList = "imgUrlInfoList" - } - } - - // MARK: - ImgURLInfoList - struct ImgURLInfoList: Codable { - let imgName: String - let url: Next - } - - // MARK: - Links - struct Links: Codable { - let next: Next - } - - // MARK: - Next - struct Next: Codable { - let href: String - } -} - - diff --git a/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/ImageURLWithName.swift b/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/ImageURLWithName.swift deleted file mode 100644 index c62669b7..00000000 --- a/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/ImageURLWithName.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// ImageURLWithName.swift -// SOOUM -// -// Created by JDeoks on 10/24/24. -// - -import Foundation - - diff --git a/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/PresignedStorageResponse.swift b/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/PresignedStorageResponse.swift deleted file mode 100644 index a12fd39e..00000000 --- a/SOOUM/SOOUM/Models/Card/WriteCard/UploadCard/PresignedStorageResponse.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// PresignedStorageResponse.swift -// SOOUM -// -// Created by JDeoks on 10/25/24. -// - -import Foundation - -import Alamofire - - -struct PresignedStorageResponse: Codable { - - let imgName: String - let url: URLString - let status: Status -} - -extension PresignedStorageResponse { - - init() { - self.imgName = "" - self.url = .init() - self.status = .init() - } -} - -extension PresignedStorageResponse: EmptyResponse { - - static func emptyValue() -> PresignedStorageResponse { - PresignedStorageResponse() - } -} diff --git a/SOOUM/SOOUM/Models/Join/NicknameValidationResponse.swift b/SOOUM/SOOUM/Models/Join/NicknameValidationResponse.swift deleted file mode 100644 index b0d81cbb..00000000 --- a/SOOUM/SOOUM/Models/Join/NicknameValidationResponse.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// NicknameValidationResponse.swift -// SOOUM -// -// Created by JDeoks on 11/7/24. -// - -import Foundation - -struct NicknameValidationResponse: Decodable { - let isAvailable: Bool - let status: Status - - struct Status: Decodable { - let httpCode: Int - let httpStatus: String - let responseMessage: String - } -} diff --git a/SOOUM/SOOUM/Models/Join/RegisterUserResponse.swift b/SOOUM/SOOUM/Models/Join/RegisterUserResponse.swift deleted file mode 100644 index d294f467..00000000 --- a/SOOUM/SOOUM/Models/Join/RegisterUserResponse.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// RegisterUserResponse.swift -// SOOUM -// -// Created by JDeoks on 11/13/24. -// - -import Foundation - -struct RegisterUserResponse: Codable { - - // MARK: - Status - struct Status: Codable { - let code: Int? - let message: String? - } - - let status: Status? -} - diff --git a/SOOUM/SOOUM/Models/Join/SuspensionResponse.swift b/SOOUM/SOOUM/Models/Join/SuspensionResponse.swift deleted file mode 100644 index 96403fa3..00000000 --- a/SOOUM/SOOUM/Models/Join/SuspensionResponse.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// SuspensionResponse.swift -// SOOUM -// -// Created by 오현식 on 1/17/25. -// - -import Foundation - -import Alamofire - - -struct SuspensionResponse: Codable { - - let suspension: Suspension? - let status: Status? -} - -extension SuspensionResponse { - - init() { - self.suspension = nil - self.status = nil - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.status = try container.decode(Status.self, forKey: .status) - - let singleContainer = try decoder.singleValueContainer() - self.suspension = try singleContainer.decode(Suspension.self) - } -} - -extension SuspensionResponse: EmptyResponse { - - static func emptyValue() -> SuspensionResponse { - SuspensionResponse() - } -} - -struct Suspension: Codable, Equatable { - - let untilBan: Date - let isBanUser: Bool -} - -extension Suspension { - - init() { - self.untilBan = Date() - self.isBanUser = false - } -} diff --git a/SOOUM/SOOUM/Models/Notification/CommentHistoryInNotiResponse.swift b/SOOUM/SOOUM/Models/Notification/CommentHistoryInNotiResponse.swift deleted file mode 100644 index 1338176e..00000000 --- a/SOOUM/SOOUM/Models/Notification/CommentHistoryInNotiResponse.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// CommentHistoryInNotiResponse.swift -// SOOUM -// -// Created by 오현식 on 12/20/24. -// - -import Foundation - -import Alamofire - - -struct CommentHistoryInNotiResponse: Codable { - let commentHistoryInNotis: [CommentHistoryInNoti] -} - -extension CommentHistoryInNotiResponse { - - init() { - self.commentHistoryInNotis = [] - } - - init(from decoder: any Decoder) throws { - let singleContainer = try decoder.singleValueContainer() - self.commentHistoryInNotis = try singleContainer.decode([CommentHistoryInNoti].self) - } -} - -extension CommentHistoryInNotiResponse: EmptyResponse { - - static func emptyValue() -> CommentHistoryInNotiResponse { - CommentHistoryInNotiResponse.init() - } -} - -struct CommentHistoryInNoti: Equatable, Codable { - let id: Int - let type: NotificationType - let createAt: Date - - /// 정지되었을 때 - let blockExpirationTime: Date? - - /// 공감/답카드 일 때 - let targetCardId: Int? - let feedCardImgURL: URLStringInNoti? - let content: String? - let font: Font? - let fontSize: FontSize? - let nickName: String? - - enum NotificationType: String, Codable { - case feedLike = "FEED_LIKE" - case commentLike = "COMMENT_LIKE" - case commentWrite = "COMMENT_WRITE" - case blocked = "BLOCKED" - case delete = "DELETED" - case transfer = "TRANSFER_SUCCESS" - - var description: String { - switch self { - case .feedLike, .commentLike: return "공감" - case .commentWrite: return "답카드" - default: return "" - } - } - } - - enum CodingKeys: String, CodingKey { - case id = "notificationId" - case type = "notificationType" - case createAt = "createTime" - case blockExpirationTime = "blockExpirationDateTime" - case targetCardId - case feedCardImgURL = "imgUrl" - case content - case font - case fontSize - case nickName - } -} - -extension CommentHistoryInNoti { - - init() { - self.id = 0 - self.type = .blocked - self.createAt = Date() - self.blockExpirationTime = nil - self.targetCardId = nil - self.feedCardImgURL = nil - self.content = nil - self.font = nil - self.fontSize = nil - self.nickName = nil - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(Int.self, forKey: .id) - self.type = try container.decode(NotificationType.self, forKey: .type) - self.createAt = try container.decode(Date.self, forKey: .createAt) - self.blockExpirationTime = try container.decodeIfPresent(Date.self, forKey: .blockExpirationTime) - self.targetCardId = try container.decodeIfPresent(Int.self, forKey: .targetCardId) - self.feedCardImgURL = try container.decodeIfPresent(URLStringInNoti.self, forKey: .feedCardImgURL) - self.content = try container.decodeIfPresent(String.self, forKey: .content) - self.font = try container.decodeIfPresent(Font.self, forKey: .font) - self.fontSize = try container.decodeIfPresent(FontSize.self, forKey: .fontSize) - self.nickName = try container.decodeIfPresent(String.self, forKey: .nickName) - } -} - -struct URLStringInNoti: Equatable, Codable { - let rel: String - let url: String - - enum CodingKeys: String, CodingKey { - case rel - case url = "href" - } -} - -extension URLStringInNoti { - - init() { - self.rel = "" - self.url = "" - } -} diff --git a/SOOUM/SOOUM/Models/Notification/WithoutReadNotisCountResponse.swift b/SOOUM/SOOUM/Models/Notification/WithoutReadNotisCountResponse.swift deleted file mode 100644 index 30c2dc2c..00000000 --- a/SOOUM/SOOUM/Models/Notification/WithoutReadNotisCountResponse.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// WithoutReadNotisCountResponse.swift -// SOOUM -// -// Created by 오현식 on 1/9/25. -// - -import Foundation - -import Alamofire - - -struct WithoutReadNotisCountResponse: Codable, Equatable { - - let unreadCnt: String -} diff --git a/SOOUM/SOOUM/Models/Profile/FollowerResponse.swift b/SOOUM/SOOUM/Models/Profile/FollowerResponse.swift deleted file mode 100644 index 0bac3cfd..00000000 --- a/SOOUM/SOOUM/Models/Profile/FollowerResponse.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// FollowerResponse.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import Foundation - -import Alamofire - - -struct FollowerResponse: Codable { - let embedded: FollowerEmbedded - let links: Next - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -extension FollowerResponse { - - init() { - self.embedded = .init() - self.links = .init() - self.status = .init() - } -} - -extension FollowerResponse: EmptyResponse { - static func emptyValue() -> FollowerResponse { - FollowerResponse.init() - } -} - -struct FollowerEmbedded: Codable { - let followers: [Follow] - - enum CodingKeys: String, CodingKey { - case followers = "followerInfoList" - } -} - -extension FollowerEmbedded { - - init() { - self.followers = [] - } -} diff --git a/SOOUM/SOOUM/Models/Profile/FollowingResponse.swift b/SOOUM/SOOUM/Models/Profile/FollowingResponse.swift deleted file mode 100644 index d75cfbfc..00000000 --- a/SOOUM/SOOUM/Models/Profile/FollowingResponse.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// FollowingResponse.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import Foundation - -import Alamofire - - -struct FollowingResponse: Codable { - let embedded: FollowingEmbedded - let links: Next - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -extension FollowingResponse { - - init() { - self.embedded = .init() - self.links = .init() - self.status = .init() - } -} - -extension FollowingResponse: EmptyResponse { - static func emptyValue() -> FollowingResponse { - FollowingResponse.init() - } -} - -struct FollowingEmbedded: Codable { - let followings: [Follow] - - enum CodingKeys: String, CodingKey { - case followings = "followingInfoList" - } -} - -extension FollowingEmbedded { - - init() { - self.followings = [] - } -} - -struct Follow: Equatable, Codable { - let id: String - let nickname: String - let backgroundImgURL: URLString? - let links: ProfileLinks - let isFollowing: Bool - let isRequester: Bool - - enum CodingKeys: String, CodingKey { - case id - case nickname - case backgroundImgURL = "backgroundImgUrl" - case links = "_links" - case isFollowing - case isRequester - } -} - -extension Follow { - - init() { - self.id = "" - self.nickname = "" - self.backgroundImgURL = nil - self.links = .init() - self.isFollowing = false - self.isRequester = false - } -} - -extension Follow { - static func == (lhs: Follow, rhs: Follow) -> Bool { - lhs.id == rhs.id && - lhs.nickname == rhs.nickname && - lhs.backgroundImgURL == rhs.backgroundImgURL && - lhs.isFollowing == rhs.isFollowing - } -} - -/// 상세보기 프로필 URL -struct ProfileLinks: Codable { - let profile: URLString -} -extension ProfileLinks { - init() { - self.profile = .init() - } -} diff --git a/SOOUM/SOOUM/Models/Profile/ProfileResponse.swift b/SOOUM/SOOUM/Models/Profile/ProfileResponse.swift deleted file mode 100644 index 2fa7f030..00000000 --- a/SOOUM/SOOUM/Models/Profile/ProfileResponse.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// ProfileResponse.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import Foundation - -import Alamofire - - -struct ProfileResponse: Codable { - let profile: Profile - let status: Status - - enum CodingKeys: String, CodingKey { - case profile - case status - } -} - -extension ProfileResponse { - - init() { - self.profile = .init() - self.status = .init() - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.status = try container.decode(Status.self, forKey: .status) - - let singleContainer = try decoder.singleValueContainer() - self.profile = try singleContainer.decode(Profile.self) - } -} - -extension ProfileResponse: EmptyResponse { - static func emptyValue() -> ProfileResponse { - ProfileResponse.init() - } -} - -struct Profile: Equatable, Codable { - let nickname: String - let currentDayVisitors: String - let totalVisitorCnt: String - let profileImg: URLString? - let cardCnt: String - let followingCnt: String - let followerCnt: String - let following: Bool? - let isFollowing: Bool? -} - -extension Profile { - - init() { - self.nickname = "" - self.currentDayVisitors = "0" - self.totalVisitorCnt = "0" - self.profileImg = nil - self.cardCnt = "0" - self.followingCnt = "0" - self.followerCnt = "0" - self.following = nil - self.isFollowing = nil - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.nickname = try container.decode(String.self, forKey: .nickname) - self.currentDayVisitors = try container.decode(String.self, forKey: .currentDayVisitors) - self.totalVisitorCnt = try container.decode(String.self, forKey: .totalVisitorCnt) - self.profileImg = try container.decodeIfPresent(URLString.self, forKey: .profileImg) - self.cardCnt = try container.decode(String.self, forKey: .cardCnt) - self.followingCnt = try container.decode(String.self, forKey: .followingCnt) - self.followerCnt = try container.decode(String.self, forKey: .followerCnt) - self.following = try container.decodeIfPresent(Bool.self, forKey: .following) - self.isFollowing = try container.decodeIfPresent(Bool.self, forKey: .isFollowing) - } -} diff --git a/SOOUM/SOOUM/Models/Profile/writtenCardResponse.swift b/SOOUM/SOOUM/Models/Profile/writtenCardResponse.swift deleted file mode 100644 index ec203e8e..00000000 --- a/SOOUM/SOOUM/Models/Profile/writtenCardResponse.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// writtenCardResponse.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import Foundation - -import Alamofire - - -struct WrittenCardResponse: Codable { - let embedded: WrittenCardEmbedded - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case status - } -} - -extension WrittenCardResponse { - - init() { - self.embedded = .init() - self.status = .init() - } -} - -extension WrittenCardResponse: EmptyResponse { - static func emptyValue() -> WrittenCardResponse { - WrittenCardResponse.init() - } -} - -struct WrittenCardEmbedded: Codable { - let writtenCards: [WrittenCard] - - enum CodingKeys: String, CodingKey { - case writtenCards = "myFeedCardDtoList" - } -} - -extension WrittenCardEmbedded { - - init() { - self.writtenCards = [] - } -} - -struct WrittenCard: Equatable, Codable { - let id: String - let content: String - let backgroundImgURL: URLString - let font: Font - let fontSize: FontSize - let links: Detail - - enum CodingKeys: String, CodingKey { - case id - case content - case backgroundImgURL = "backgroundImgUrl" - case font - case fontSize - case links = "_links" - } -} - -extension WrittenCard { - - init() { - self.id = "" - self.content = "" - self.backgroundImgURL = .init() - self.font = .pretendard - self.fontSize = .none - self.links = .init() - } -} - -extension WrittenCard { - static func == (lhs: WrittenCard, rhs: WrittenCard) -> Bool { - lhs.id == rhs.id - } -} diff --git a/SOOUM/SOOUM/Models/Settings/AnnouncementResponse.swift b/SOOUM/SOOUM/Models/Settings/AnnouncementResponse.swift deleted file mode 100644 index 3d945d65..00000000 --- a/SOOUM/SOOUM/Models/Settings/AnnouncementResponse.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// AnnouncementResponse.swift -// SOOUM -// -// Created by 오현식 on 12/6/24. -// - -import Foundation - -import Alamofire - - -struct AnnouncementResponse: Codable { - let embedded: AnnouncementEmbedded - let links: AnnouncementLinks - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -extension AnnouncementResponse { - - init() { - self.embedded = .init() - self.links = .init() - self.status = .init() - } -} - -extension AnnouncementResponse: EmptyResponse { - static func emptyValue() -> AnnouncementResponse { - AnnouncementResponse.init() - } -} - -struct AnnouncementLinks: Codable { - let `self`: URLString -} - -extension AnnouncementLinks { - - init() { - self.`self` = .init() - } -} - -struct AnnouncementEmbedded: Codable { - let announcements: [Announcement] - - enum CodingKeys: String, CodingKey { - case announcements = "noticeDtoList" - } -} - -extension AnnouncementEmbedded { - - init() { - self.announcements = [] - } -} - -struct Announcement: Equatable, Codable { - let id: Int - let noticeType: NoticeType - let noticeDate: String - let title: String - let link: String -} - -extension Announcement { - - init() { - self.id = 0 - self.noticeType = .announcement - self.noticeDate = "" - self.title = "" - self.link = "" - } -} - -enum NoticeType: String, Codable { - case maintenance = "MAINTENANCE" - case announcement = "ANNOUNCEMENT" -} diff --git a/SOOUM/SOOUM/Models/Settings/CommentHistoryResponse.swift b/SOOUM/SOOUM/Models/Settings/CommentHistoryResponse.swift deleted file mode 100644 index 8ed0fb8e..00000000 --- a/SOOUM/SOOUM/Models/Settings/CommentHistoryResponse.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// CommentHistoryResponse.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import Foundation - -import Alamofire - - -struct CommentHistoryResponse: Codable { - let embedded: CommentHistoryEmbedded - let links: Next - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} - -extension CommentHistoryResponse { - - init() { - self.embedded = .init() - self.links = .init() - self.status = .init() - } -} - -extension CommentHistoryResponse: EmptyResponse { - static func emptyValue() -> CommentHistoryResponse { - CommentHistoryResponse.init() - } -} - -struct CommentHistoryEmbedded: Codable { - let commentHistories: [CommentHistory] - - enum CodingKeys: String, CodingKey { - case commentHistories = "myCommentCardDtoList" - } -} - -extension CommentHistoryEmbedded { - - init() { - self.commentHistories = [] - } -} - -struct CommentHistory: Equatable, Codable { - let id: String - let content: String - let backgroundImgURL: URLString - let font: Font - let fontSize: FontSize - let links: Detail - - enum CodingKeys: String, CodingKey { - case id - case content - case backgroundImgURL = "backgroundImgUrl" - case font - case fontSize - case links = "_links" - } -} - -extension CommentHistory { - - init() { - self.id = "" - self.content = "" - self.backgroundImgURL = .init() - self.font = .pretendard - self.fontSize = .none - self.links = .init() - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(String.self, forKey: .id) - self.content = try container.decode(String.self, forKey: .content) - self.backgroundImgURL = try container.decode(URLString.self, forKey: .backgroundImgURL) - self.font = try container.decode(Font.self, forKey: .font) - self.fontSize = try container.decode(FontSize.self, forKey: .fontSize) - self.links = try container.decode(Detail.self, forKey: .links) - } -} - -extension CommentHistory { - static func == (lhs: CommentHistory, rhs: CommentHistory) -> Bool { - lhs.id == rhs.id - } -} diff --git a/SOOUM/SOOUM/Models/Settings/NotificationAllowResponse.swift b/SOOUM/SOOUM/Models/Settings/NotificationAllowResponse.swift deleted file mode 100644 index 91152a0f..00000000 --- a/SOOUM/SOOUM/Models/Settings/NotificationAllowResponse.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// NotificationAllowResponse.swift -// SOOUM -// -// Created by 오현식 on 12/30/24. -// - -import Foundation - -import Alamofire - - -struct NotificationAllowResponse: Codable { - - let isAllowNotify: Bool - let status: Status -} - -extension NotificationAllowResponse { - - init() { - self.isAllowNotify = false - self.status = .init() - } -} - -extension NotificationAllowResponse: EmptyResponse { - - static func emptyValue() -> NotificationAllowResponse { - NotificationAllowResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Settings/SettingsResponse.swift b/SOOUM/SOOUM/Models/Settings/SettingsResponse.swift deleted file mode 100644 index 6dfb2f86..00000000 --- a/SOOUM/SOOUM/Models/Settings/SettingsResponse.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// SettingsResponse.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import Foundation - -import Alamofire - - -struct SettingsResponse: Codable { - let banEndAt: Date? - let status: Status -} - -extension SettingsResponse { - - init() { - self.banEndAt = nil - self.status = .init() - } -} - -extension SettingsResponse: EmptyResponse { - static func emptyValue() -> SettingsResponse { - SettingsResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Settings/TransferCodeResponse.swift b/SOOUM/SOOUM/Models/Settings/TransferCodeResponse.swift deleted file mode 100644 index e2a0c644..00000000 --- a/SOOUM/SOOUM/Models/Settings/TransferCodeResponse.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// TransferCodeResponse.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import Foundation - -import Alamofire - - -struct TransferCodeResponse: Codable { - let transferCode: String - let status: Status -} - -extension TransferCodeResponse { - - init() { - self.transferCode = "" - self.status = .init() - } -} - -extension TransferCodeResponse: EmptyResponse { - static func emptyValue() -> TransferCodeResponse { - TransferCodeResponse.init() - } -} diff --git a/SOOUM/SOOUM/Models/Tags/AddFavoriteTagResponse.swift b/SOOUM/SOOUM/Models/Tags/AddFavoriteTagResponse.swift deleted file mode 100644 index 3df41d65..00000000 --- a/SOOUM/SOOUM/Models/Tags/AddFavoriteTagResponse.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// AddFavoriteTagResponse.swift -// SOOUM -// -// Created by JDeoks on 12/6/24. -// - -import Foundation - -// MARK: - AddFavoriteTagResponse -struct AddFavoriteTagResponse: Codable { - let httpCode: Int? - let httpStatus, responseMessage: String? -} diff --git a/SOOUM/SOOUM/Models/Tags/FavoriteTagsResponse.swift b/SOOUM/SOOUM/Models/Tags/FavoriteTagsResponse.swift deleted file mode 100644 index ccb44622..00000000 --- a/SOOUM/SOOUM/Models/Tags/FavoriteTagsResponse.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// FavoriteTagsResponse.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import Foundation - -import Alamofire - - -// MARK: - FavoriteTagsResponse - -struct FavoriteTagsResponse: Codable { - - // MARK: - Embedded - struct Embedded: Codable { - let favoriteTagList: [FavoriteTagList] - } - - // MARK: - FavoriteTagList - struct FavoriteTagList: Codable { - let id, tagContent, tagUsageCnt: String - let previewCards: [PreviewCard] - let links: FavoriteTagListLinks - - enum CodingKeys: String, CodingKey { - case id, tagContent, tagUsageCnt, previewCards - case links = "_links" - } - } - - // MARK: - FavoriteTagListLinks - struct FavoriteTagListLinks: Codable { - let tagFeed: TagFeed - - enum CodingKeys: String, CodingKey { - case tagFeed = "tag-feed" - } - } - - // MARK: - TagFeed - struct TagFeed: Codable { - let href: String - } - - // MARK: - PreviewCard - struct PreviewCard: Codable { - let id, content: String - let backgroundImgURL: TagFeed - let links: PreviewCardLinks - - enum CodingKeys: String, CodingKey { - case id, content - case backgroundImgURL = "backgroundImgUrl" - case links = "_links" - } - } - - // MARK: - PreviewCardLinks - struct PreviewCardLinks: Codable { - let detail: TagFeed - } - - // MARK: - Status - struct Status: Codable { - let httpCode: Int - let httpStatus, responseMessage: String - } - - let embedded: Embedded - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case status - } -} - -extension FavoriteTagsResponse: EmptyResponse { - static func emptyValue() -> FavoriteTagsResponse { - FavoriteTagsResponse.init( - embedded: .init(favoriteTagList: []), - status: .init(httpCode: 0, httpStatus: "", responseMessage: "") - ) - } -} diff --git a/SOOUM/SOOUM/Models/Tags/RecommendTagsResponse.swift b/SOOUM/SOOUM/Models/Tags/RecommendTagsResponse.swift deleted file mode 100644 index 0ccfb003..00000000 --- a/SOOUM/SOOUM/Models/Tags/RecommendTagsResponse.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// RecommendTagsResponse.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import Foundation - -struct RecommendTagsResponse: Codable { - - // MARK: - Embedded - struct Embedded: Codable { - let recommendTagList: [RecommendTag] - } - - // MARK: - RecommendTag - struct RecommendTag: Codable { - let tagID, tagContent, tagUsageCnt: String - let links: Links - - enum CodingKeys: String, CodingKey { - case tagID = "tagId" - case tagContent, tagUsageCnt - case links = "_links" - } - } - - // MARK: - Links - struct Links: Codable { - let tagFeed: TagFeed - - enum CodingKeys: String, CodingKey { - case tagFeed = "tag-feed" - } - } - - // MARK: - TagFeed - struct TagFeed: Codable { - let href: String - } - - // MARK: - Status - struct Status: Codable { - let httpCode: Int - let httpStatus, responseMessage: String - } - - let embedded: Embedded - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case status - } -} diff --git a/SOOUM/SOOUM/Models/Tags/SearchTagsResponse.swift b/SOOUM/SOOUM/Models/Tags/SearchTagsResponse.swift deleted file mode 100644 index ee930db8..00000000 --- a/SOOUM/SOOUM/Models/Tags/SearchTagsResponse.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// SearchTagsResponse.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import Foundation - -import Alamofire - - -// MARK: - SearchTagsResponse -struct SearchTagsResponse: Codable { - - // MARK: - Embedded - struct Embedded: Codable { - let relatedTagList: [RelatedTag] - } - - // MARK: - RelatedTagList - struct RelatedTag: Codable { - let tagId: String - let count: Int - let content: String - } - - // MARK: - Status - struct Status: Codable { - let httpCode: Int - let httpStatus, responseMessage: String - } - - let embedded: Embedded - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case status - } -} - -extension SearchTagsResponse { - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.embedded = try container.decodeIfPresent(Embedded.self, forKey: .embedded) ?? .init(relatedTagList: []) - self.status = try container.decode(Status.self, forKey: .status) - } -} - -extension SearchTagsResponse: EmptyResponse { - static func emptyValue() -> SearchTagsResponse { - SearchTagsResponse.init( - embedded: .init(relatedTagList: []), - status: .init(httpCode: 0, httpStatus: "", responseMessage: "") - ) - } -} diff --git a/SOOUM/SOOUM/Models/Tags/TagDetailCardResponse.swift b/SOOUM/SOOUM/Models/Tags/TagDetailCardResponse.swift deleted file mode 100644 index a81fa146..00000000 --- a/SOOUM/SOOUM/Models/Tags/TagDetailCardResponse.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// TagDetailCardResponse.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import Foundation - -// MARK: - TagDetailCardResponse -struct TagDetailCardResponse: Codable { - - // MARK: - Embedded - struct Embedded: Codable { - let tagFeedCardDtoList: [TagFeedCard] - } - - // MARK: - TagFeedCardDtoList - struct TagFeedCard: Codable { - let id: String - let content: String - let createdAt: Date - let likeCnt, commentCnt: Int - let backgroundImgURL: Next - let font: Font - let fontSize: FontSize - let distance: Double? - let links: TagFeedCardDtoListLinks - let isLiked, isCommentWritten: Bool - - enum CodingKeys: String, CodingKey { - case id, content, createdAt, likeCnt, commentCnt - case backgroundImgURL = "backgroundImgUrl" - case font, fontSize, distance - case links = "_links" - case isLiked, isCommentWritten - } - } - - // MARK: - Next - struct Next: Codable { - let href: String - } - - // MARK: - TagFeedCardDtoListLinks - struct TagFeedCardDtoListLinks: Codable { - let detail: Next - } - - // MARK: - TagDetailCardResponseLinks - struct TagDetailCardResponseLinks: Codable { - let next: Next - } - - // MARK: - Status - struct Status: Codable { - let httpCode: Int - let httpStatus, responseMessage: String - } - - let embedded: Embedded - let links: TagDetailCardResponseLinks - let status: Status - - enum CodingKeys: String, CodingKey { - case embedded = "_embedded" - case links = "_links" - case status - } -} diff --git a/SOOUM/SOOUM/Models/Tags/TagInfoResponse.swift b/SOOUM/SOOUM/Models/Tags/TagInfoResponse.swift deleted file mode 100644 index af03eec4..00000000 --- a/SOOUM/SOOUM/Models/Tags/TagInfoResponse.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// TagInfoResponse.swift -// SOOUM -// -// Created by JDeoks on 12/5/24. -// - -import Foundation - -// MARK: - TagInfoResponse -struct TagInfoResponse: Codable { - - // MARK: - Status - struct Status: Codable { - let httpCode: Int - let httpStatus, responseMessage: String - } - - let content: String - let cardCnt: Int - let isFavorite: Bool - let status: Status -} diff --git a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift index 13239661..112e0dc3 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift @@ -15,112 +15,100 @@ import SnapKit import Then -class LaunchScreenViewController: BaseViewController, View { +class LaunchScreenViewController: BaseNavigationViewController, View { enum Text { static let updateVerionTitle: String = "업데이트 안내" - static let updateVersionMessage: String = "안정적인 서비스 사용을 위해\n최신버전으로 업데이트해주세요" + static let updateVersionMessage: String = "새로운 버전이 출시되었습니다. 더 나은 사용을 위해, 서비스 업데이트 후 이용 바랍니다." static let testFlightStrUrl: String = "itms-beta://testflight.apple.com/v1/app" static let appStoreStrUrl: String = "itms-apps://itunes.apple.com/app/id" - static let exitActionTitle: String = "종료하기" - static let updateActionTitle: String = "업데이트" + static let updateActionTitle: String = "새로워진 숨 사용하기" } - let viewForAnimation = UIView().then { - $0.backgroundColor = UIColor(hex: "#A2E3FF") - } - let imageView = UIImageView(image: .init(.logo)).then { + // MARK: Views + + let imageView = UIImageView(image: .init(.logo(.v2(.logo_white)))).then { $0.contentMode = .scaleAspectFit - $0.tintColor = .som.white } + + // MARK: Override func + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } override func setupConstraints() { - self.view.backgroundColor = UIColor(hex: "#A2E3FF") + super.setupConstraints() + + self.isNavigationBarHidden = true + + self.view.backgroundColor = .som.v2.pMain self.view.addSubview(self.imageView) self.imageView.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.centerY.equalTo(self.view.safeAreaLayoutGuide.snp.centerY) - $0.width.equalTo(235) - $0.height.equalTo(45) - } - - self.view.addSubview(self.viewForAnimation) - self.viewForAnimation.snp.makeConstraints { - $0.edges.equalTo(self.imageView) + $0.center.equalToSuperview() + $0.width.equalTo(200) + $0.height.equalTo(33) } } + + // MARK: ReactorKit - bind + func bind(reactor: LaunchScreenViewReactor) { - // 애니메이션이 끝나면 launch action - self.rx.viewDidLayoutSubviews - .subscribe(with: self) { object, _ in - object.animate(to: 45) { _ in - reactor.action.onNext(.launch) - } - } + self.rx.viewDidLoad + .map { _ in Reactor.Action.launch } + .bind(to: reactor.action) .disposed(by: self.disposeBag) // 앱 버전 검사 reactor.state.map(\.mustUpdate) .distinctUntilChanged() .filter { $0 } - .subscribe(with: self) { object, _ in + .observe(on: MainScheduler.instance) + .subscribe(onNext: { _ in - let exitAction = SOMDialogAction( - title: Text.exitActionTitle, - style: .gray, - action: { - // 앱 종료 - // 자연스럽게 종료하기 위해 종료전, suspend 상태로 변경 후 종료 - UIApplication.shared.perform(#selector(NSXPCConnection.suspend)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exit(0) - } - } - ) let updateAction = SOMDialogAction( title: Text.updateActionTitle, style: .primary, action: { - #if DEVELOP - // 개발 버전일 때 testFlight로 전환 - let strUrl = "\(Text.testFlightStrUrl)/\(Info.appId)" - if let testFlightUrl = URL(string: strUrl) { - UIApplication.shared.open(testFlightUrl, options: [:], completionHandler: nil) - } - #elseif PRODUCTION - // 운영 버전일 때 app store로 전환 - let strUrl = "\(Text.appStoreStrUrl)\(Info.appId)" - if let appStoreUrl = URL(string: strUrl) { - UIApplication.shared.open(appStoreUrl, options: [:], completionHandler: nil) + SOMDialogViewController.dismiss { + #if DEVELOP + // 개발 버전일 때 testFlight로 전환 + let strUrl = "\(Text.testFlightStrUrl)/\(Info.appId)" + if let testFlightUrl = URL(string: strUrl) { + UIApplication.shared.open(testFlightUrl, options: [:], completionHandler: nil) + } + #elseif PRODUCTION + // 운영 버전일 때 app store로 전환 + let strUrl = "\(Text.appStoreStrUrl)\(Info.appId)" + if let appStoreUrl = URL(string: strUrl) { + UIApplication.shared.open(appStoreUrl, options: [:], completionHandler: nil) + } + #endif } - #endif - - UIApplication.topViewController?.dismiss(animated: true) } ) SOMDialogViewController.show( title: Text.updateVerionTitle, message: Text.updateVersionMessage, - actions: [exitAction, updateAction] + textAlignment: .left, + actions: [updateAction] ) - } + }) .disposed(by: self.disposeBag) // 로그인 성공 시 홈 화면으로 전환 let isRegistered = reactor.state.map(\.isRegistered).distinctUntilChanged().share() isRegistered .filter { $0 == true } + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in let viewController = MainTabBarController() viewController.reactor = reactor.reactorForMainTabBar() @@ -133,6 +121,7 @@ class LaunchScreenViewController: BaseViewController, View { // 로그인 실패 시 온보딩 화면으로 전환 isRegistered .filter { $0 == false } + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in let viewController = OnboardingViewController() viewController.reactor = reactor.reactorForOnboarding() @@ -144,19 +133,3 @@ class LaunchScreenViewController: BaseViewController, View { .disposed(by: self.disposeBag) } } - -extension LaunchScreenViewController { - - private func animate(to height: CGFloat, completion: @escaping ((Bool) -> Void)) { - - UIView.animate( - withDuration: 0.5, - delay: 0.2, - options: [.beginFromCurrentState, .curveEaseOut], - animations: { - self.viewForAnimation.transform = .init(translationX: 0, y: height) - }, - completion: completion - ) - } -} diff --git a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift index d1679f27..46ee10c4 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift @@ -25,7 +25,6 @@ import ReactorKit */ class LaunchScreenViewReactor: Reactor { - // MARK: - Action enum Action: Equatable { /// 앱이 시작되었을 때, 로그인 및 회원가입 처리 흐름을 시작 case launch @@ -34,14 +33,12 @@ class LaunchScreenViewReactor: Reactor { enum Mutation { case check(Bool) case updateIsRegistered(Bool) - case appFlag(Bool) } struct State { - var mustUpdate: Bool + fileprivate(set) var mustUpdate: Bool /// deviceId 서버 등록 여부, 로그인 성공 여부 - var isRegistered: Bool? - var appFlag: Bool? + fileprivate(set) var isRegistered: Bool? } var initialState: State = .init( @@ -49,11 +46,18 @@ class LaunchScreenViewReactor: Reactor { isRegistered: nil ) - let provider: ManagerProviderType - let pushInfo: NotificationInfo? + // TODO: 임시, 추후 Coordinator 패턴 적용 후 필요한 UseCase만 사용 + private let dependencies: AppDIContainerable + private let authUseCase: AuthUseCase + private let versionUseCase: AppVersionUseCase - init(provider: ManagerProviderType, pushInfo: NotificationInfo? = nil) { - self.provider = provider + private let pushInfo: PushNotificationInfo? + + init(dependencies: AppDIContainerable, pushInfo: PushNotificationInfo? = nil) { + self.dependencies = dependencies + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) + self.versionUseCase = dependencies.rootContainer.resolve(AppVersionUseCase.self) + self.pushInfo = pushInfo } @@ -63,11 +67,11 @@ class LaunchScreenViewReactor: Reactor { // 계정 이관에 성공했을 때, 온보딩 화면으로 전환 let isTransfered = self.pushInfo?.isTransfered ?? false if isTransfered { - self.provider.authManager.initializeAuthInfo() - return .just(.updateIsRegistered(false)) - } else { - return self.check() + // session token 삭제 + self.authUseCase.initializeAuthInfo() } + + return self.check() } } @@ -76,12 +80,8 @@ class LaunchScreenViewReactor: Reactor { switch mutation { case let .check(mustUpdate): newState.mustUpdate = mustUpdate - case let .updateIsRegistered(isRegistered): newState.isRegistered = isRegistered - - case let .appFlag(appFlag): - newState.appFlag = appFlag } return newState } @@ -93,23 +93,22 @@ class LaunchScreenViewReactor: Reactor { extension LaunchScreenViewReactor { private func login() -> Observable { - return self.provider.authManager.certification() + return self.authUseCase.login() .map { .updateIsRegistered($0) } + .catchAndReturn(.updateIsRegistered(false)) } private func check() -> Observable { - - return self.provider.networkManager.request(String.self, request: AuthRequest.updateCheck) + return self.versionUseCase.version() .withUnretained(self) - .flatMapLatest { object, currentVersionStatus -> Observable in - let version = Version(status: currentVersionStatus) + .flatMapLatest { object, version -> Observable in UserDefaults.standard.set(version.shouldHideTransfer, forKey: "AppFlag") if version.mustUpdate { return .just(.check(true)) } else { - return self.provider.authManager.hasToken ? .just(.updateIsRegistered(true)) : object.login() + return object.authUseCase.hasToken() ? .just(.updateIsRegistered(true)) : object.login() } } } @@ -118,10 +117,10 @@ extension LaunchScreenViewReactor { extension LaunchScreenViewReactor { func reactorForOnboarding() -> OnboardingViewReactor { - OnboardingViewReactor(provider: self.provider) + OnboardingViewReactor(dependencies: self.dependencies) } func reactorForMainTabBar() -> MainTabBarReactor { - MainTabBarReactor(provider: self.provider, pushInfo: self.pushInfo) + MainTabBarReactor(dependencies: self.dependencies, pushInfo: self.pushInfo) } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift new file mode 100644 index 00000000..abe3efe0 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift @@ -0,0 +1,109 @@ +// +// OnboardingCompletedViewController.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import ReactorKit + +import SnapKit +import Then + +class OnboardingCompletedViewController: BaseNavigationViewController, View { + + enum Text { + static let title: String = "가입 완료" + static let message: String = "숨에 오신 걸 환영해요" + + static let confirmButtonTitle: String = "확인" + } + + + // MARK: Views + + private let imageView = UIImageView().then { + $0.image = .init(.image(.v2(.onboarding_finish))) + $0.contentMode = .scaleAspectFit + } + + private let titleLabel = UILabel().then { + $0.text = Text.title + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.title2 + } + + private let messageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.black + $0.typography = .som.v2.head1 + } + + private let confirmButton = SOMButton().then { + $0.title = Text.confirmButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + } + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 + } + + + // MARK: Override func + + override func setupConstraints() { + super.setupConstraints() + + self.isNavigationBarHidden = true + + let container = UIStackView(arrangedSubviews: [ + self.imageView, + self.titleLabel, + self.messageLabel + ]).then { + $0.axis = .vertical + $0.alignment = .center + $0.setCustomSpacing(32, after: self.imageView) + $0.setCustomSpacing(4, after: self.titleLabel) + } + self.view.addSubview(container) + container.snp.makeConstraints { + $0.centerY.equalTo(self.view.safeAreaLayoutGuide.snp.centerY).offset(-56) + $0.centerX.equalToSuperview() + } + + self.view.addSubview(self.confirmButton) + self.confirmButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) + } + } + + + // MARK: ReactorKit - bind + + func bind(reactor: OnboardingCompletedViewReactor) { + + // Action + self.confirmButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + let viewController = MainTabBarController() + viewController.reactor = reactor.reactorForMainTabBar() + let navigationController = UINavigationController( + rootViewController: viewController + ) + object.view.window?.rootViewController = navigationController + } + .disposed(by: self.disposeBag) + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewReactor.swift new file mode 100644 index 00000000..5a855c06 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewReactor.swift @@ -0,0 +1,35 @@ +// +// OnboardingCompletedViewReactor.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import ReactorKit + +import Alamofire + +class OnboardingCompletedViewReactor: Reactor { + + typealias Action = NoAction + typealias Mutation = NoMutation + + struct State { } + var initialState: State { .init() } + + private let dependencies: AppDIContainerable + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + } +} + +extension OnboardingCompletedViewReactor { + + func reactorForNotification() -> NotificationViewReactor { + NotificationViewReactor(dependencies: self.dependencies) + } + + func reactorForMainTabBar() -> MainTabBarReactor { + MainTabBarReactor(dependencies: self.dependencies) + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift index e829bfa1..7d91eace 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift @@ -18,92 +18,66 @@ import RxSwift class OnboardingNicknameSettingViewController: BaseNavigationViewController, View { enum Text { - static let adjectives = [ - "공부하는", "생각하는", "사랑하는", "노래하는", - "요리하는", "운동하는", "여행하는", "대화하는", - "청소하는", "정리하는", "그리는", "사진하는", - "연구하는", "설계하는", "개발하는", "관리하는", - "발표하는", "수업하는", "교육하는", "상담하는", - "치료하는", "분석하는", "조사하는", "기록하는", - "편집하는", "제작하는", "수리하는", "판매하는", - "구매하는", "투자하는", "기획하는", "운영하는", - "지원하는", "협력하는", "참여하는", "소통하는", - "개선하는", "실천하는", "실험하는", "탐구하는", - "수집하는", "배달하는", "전달하는", "연결하는", - "조정하는", "선택하는", "결정하는", "준비하는", - "확인하는", "수업하는", "연습하는", "발표하는", - "기록하는", "정리하는", "대처하는", "해결하는", - "조율하는", "탐색하는", "분석하는", "실천하는" - ] - static let nouns = [ - "강아지", "고양이", "기린", "토끼", - "사자", "호랑이", "악어", "코끼리", - "판다", "부엉이", "까치", "앵무새", - "여우", "오리", "수달", "다람쥐", - "펭귄", "참새", "갈매기", "도마뱀", - "우산", "책상", "가방", "의자", - "시계", "안경", "컵", "접시", - "전화기", "자전거", "냉장고", "라디오", - "바나나", "케이크", "모자", "열쇠", - "지도", "구두", "텀블러", "바구니", - "공책", "거울", "청소기", "햄스터", - "낙타", "두더지", "돌고래", "문어", - "미어캣", "오소리", "다슬기", "해파리", - "원숭이", "홍학", "물개", "바다표", - "코뿔소", "물소", "개구리", "거북이" - ] + static let navigationTitle: String = "회원가입" - static let title: String = "반가워요!\n당신을 어떻게 부르면 될까요?" - static let message: String = "닉네임은 추후 변경이 가능해요" + static let title: String = "숨에서 사용할 닉네임을\n입력해주세요" + static let guideMessage: String = "최대 8자까지 입력할 수 있어요" - static let placeholder: String = "닉네임을 입력해주세요" - - static let confirmButtonTitle: String = "확인" + static let nextButtonTitle: String = "다음" } // MARK: Views - private let guideMessageView = OnboardingGuideMessageView(title: Text.title, message: Text.message) + private let guideMessageView = OnboardingGuideMessageView(title: Text.title, currentNumber: 2) - private let nicknameTextField = OnboardingNicknameTextFieldView().then { - $0.placeholder = Text.placeholder - } + private let nicknameTextField = SOMNicknameTextField() private let nextButton = SOMButton().then { - $0.title = Text.confirmButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.gray600 - - $0.backgroundColor = .som.gray300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true + $0.title = Text.nextButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + } + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 } // MARK: Override func + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + } + override func setupConstraints() { self.view.addSubview(self.guideMessageView) self.guideMessageView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(28) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } - self.view.addSubview(nicknameTextField) - nicknameTextField.snp.makeConstraints { - $0.top.equalTo(self.guideMessageView.snp.bottom).offset(24) + self.view.addSubview(self.nicknameTextField) + self.nicknameTextField.snp.makeConstraints { + $0.top.equalTo(self.guideMessageView.snp.bottom).offset(32) $0.leading.trailing.equalToSuperview() } self.view.addSubview(self.nextButton) self.nextButton.snp.makeConstraints { - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) - $0.height.equalTo(48) + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } @@ -111,7 +85,7 @@ class OnboardingNicknameSettingViewController: BaseNavigationViewController, Vie override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { super.updatedKeyboard(withoutBottomSafeInset: height) - let height = height + 12 + let height = height == 0 ? 0 : height + 12 self.nextButton.snp.updateConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide).offset(-height) } @@ -123,43 +97,52 @@ class OnboardingNicknameSettingViewController: BaseNavigationViewController, Vie func bind(reactor: OnboardingNicknameSettingViewReactor) { // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + let nickname = self.nicknameTextField.textField.rx.text.orEmpty.distinctUntilChanged().share() nickname - .debounce(.seconds(1), scheduler: MainScheduler.instance) + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) .map(Reactor.Action.checkValidate) .bind(to: reactor.action) - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) - self.rx.viewDidLoad - .map { _ in Text.adjectives.randomElement()! + " " + Text.nouns.randomElement()! } + self.nextButton.rx.throttleTap + .withLatestFrom(reactor.state.map(\.isProcessing)) + .filter { $0 == false } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + let profileImageSettingVC = OnboardingProfileImageSettingViewController() + profileImageSettingVC.reactor = reactor.reactorForProfileImage() + object.navigationPush(profileImageSettingVC, animated: true) + } + .disposed(by: self.disposeBag) + + // State + reactor.state.map(\.nickname) + .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, randomText in object.nicknameTextField.text = randomText object.nicknameTextField.textField.sendActions(for: .editingChanged) } .disposed(by: self.disposeBag) - self.nextButton.rx.tap - .withLatestFrom(nickname) - .subscribe(with: self) { object, nickname in - let profileImageVC = ProfileImageSettingViewController() - profileImageVC.reactor = reactor.reactorForProfileImage(nickname: nickname) - object.navigationPush(profileImageVC, animated: true) - } - .disposed(by: disposeBag) - - // State reactor.state.map(\.isValid) .distinctUntilChanged() - .subscribe(with: self, onNext: { object, isValid in - object.nextButton.foregroundColor = isValid ? .som.white : .som.gray600 - object.nextButton.backgroundColor = isValid ? .som.p300 : .som.gray300 - object.nextButton.isEnabled = isValid - }) - .disposed(by: disposeBag) + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.nextButton.rx.isEnabled) + .disposed(by: self.disposeBag) reactor.state.map(\.errorMessage) .distinctUntilChanged() - .bind(to: self.nicknameTextField.rx.errorMessage) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, errorMessage in + object.nicknameTextField.guideMessage = errorMessage == nil ? Text.guideMessage : errorMessage + object.nicknameTextField.hasError = errorMessage != nil + } .disposed(by: self.disposeBag) } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewReactor.swift index 7fd2922a..fc29d6ab 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewReactor.swift @@ -11,58 +11,113 @@ import RxSwift class OnboardingNicknameSettingViewReactor: Reactor { + enum Text { + static let adjectives = [ + "공부하는", "생각하는", "사랑하는", "노래하는", + "요리하는", "운동하는", "여행하는", "대화하는", + "청소하는", "정리하는", "그리는", "사진하는", + "연구하는", "설계하는", "개발하는", "관리하는", + "발표하는", "수업하는", "교육하는", "상담하는", + "치료하는", "분석하는", "조사하는", "기록하는", + "편집하는", "제작하는", "수리하는", "판매하는", + "구매하는", "투자하는", "기획하는", "운영하는", + "지원하는", "협력하는", "참여하는", "소통하는", + "개선하는", "실천하는", "실험하는", "탐구하는", + "수집하는", "배달하는", "전달하는", "연결하는", + "조정하는", "선택하는", "결정하는", "준비하는", + "확인하는", "수업하는", "연습하는", "발표하는", + "기록하는", "정리하는", "대처하는", "해결하는", + "조율하는", "탐색하는", "분석하는", "실천하는" + ] + static let nouns = [ + "강아지", "고양이", "기린", "토끼", + "사자", "호랑이", "악어", "코끼리", + "판다", "부엉이", "까치", "앵무새", + "여우", "오리", "수달", "다람쥐", + "펭귄", "참새", "갈매기", "도마뱀", + "우산", "책상", "가방", "의자", + "시계", "안경", "컵", "접시", + "전화기", "자전거", "냉장고", "라디오", + "바나나", "케이크", "모자", "열쇠", + "지도", "구두", "텀블러", "바구니", + "공책", "거울", "청소기", "햄스터", + "낙타", "두더지", "돌고래", "문어", + "미어캣", "오소리", "다슬기", "해파리", + "원숭이", "홍학", "물개", "바다표", + "코뿔소", "물소", "개구리", "거북이" + ] + } + enum ErrorMessages: String { case isEmpty = "한글자 이상 입력해주세요" case inValid = "부적절한 닉네임입니다. 다시 입력해주세요" } enum Action { + case landing case checkValidate(String) } enum Mutation { + case updateNickname(String) case updateIsValid(Bool) + case updateIsProcessing(Bool) case updateIsErrorMessage(String?) } struct State { - var isValid: Bool - var errorMessage: String? + fileprivate(set) var nickname: String + fileprivate(set) var isValid: Bool + fileprivate(set) var isProcessing: Bool + fileprivate(set) var errorMessage: String? } var initialState: State = .init( + nickname: "\(Text.adjectives.randomElement()!) \(Text.nouns.randomElement()!)", isValid: false, + isProcessing: false, errorMessage: nil ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let validateNicknameUseCase: ValidateNicknameUseCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.validateNicknameUseCase = dependencies.rootContainer.resolve(ValidateNicknameUseCase.self) } func mutate(action: Action) -> Observable { switch action { + case .landing: + + return self.validateNicknameUseCase.nickname() + .map(Mutation.updateNickname) case let .checkValidate(nickname): + if nickname.isEmpty { return .concat([ .just(.updateIsValid(false)), .just(.updateIsErrorMessage(ErrorMessages.isEmpty.rawValue)) ]) } - let request: JoinRequest = .validateNickname(nickname: nickname) return .concat([ + .just(.updateIsProcessing(true)), .just(.updateIsErrorMessage(nil)), - self.provider.networkManager.request(NicknameValidationResponse.self, request: request) - .flatMapLatest { response -> Observable in - let isAvailable = response.isAvailable - let errorMessage = isAvailable ? nil : ErrorMessages.inValid.rawValue + self.validateNicknameUseCase.checkValidation(nickname: nickname) + .withUnretained(self) + .flatMapLatest { object, isValid -> Observable in + + let errorMessage = isValid ? nil : ErrorMessages.inValid.rawValue + let nickname = isValid ? nickname : object.currentState.nickname return .concat([ - .just(.updateIsValid(isAvailable)), + .just(.updateIsValid(isValid)), + .just(.updateNickname(nickname)), .just(.updateIsErrorMessage(errorMessage)) ]) - } + }, + .just(.updateIsProcessing(false)) ]) } } @@ -70,8 +125,12 @@ class OnboardingNicknameSettingViewReactor: Reactor { func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { + case let .updateNickname(nickname): + newState.nickname = nickname case let .updateIsValid(isValid): newState.isValid = isValid + case let .updateIsProcessing(isProcessing): + newState.isProcessing = isProcessing case let .updateIsErrorMessage(errorMessage): newState.errorMessage = errorMessage } @@ -81,7 +140,10 @@ class OnboardingNicknameSettingViewReactor: Reactor { extension OnboardingNicknameSettingViewReactor { - func reactorForProfileImage(nickname: String) -> ProfileImageSettingViewReactor { - ProfileImageSettingViewReactor(provider: self.provider, nickname: nickname) + func reactorForProfileImage() -> OnboardingProfileImageSettingViewReactor { + OnboardingProfileImageSettingViewReactor( + dependencies: self.dependencies, + nickname: self.currentState.nickname + ) } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift index 960cc018..921a331b 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift @@ -18,12 +18,23 @@ import Then class OnboardingViewController: BaseNavigationViewController, View { enum Text { - static let guideText: String = "당신의 소중한 이야기를\n익명의 친구들에게 들려주세요" - static let startButtonText: String = "숨 시작하기" - static let oldUserButtonText: String = "기존 계정이 있으신가요?" + static let guideTitle: String = "숨겨진 진심이 모이는 공간" + static let guideSubTitle: String = "당신의 이야기를 편하게 남겨요" - static let banUserDialogTitle: String = "기존 정지된 계정으로\n가입이 불가능 합니다." - static let resignDialogTitle: String = "최근 탈퇴한 이력이 있습니다." + static let firstGuideMessage: String = "숨은 가입 시 어떤 개인정보도 요구하지 않아요" + static let secondGuideMessage: String = "자동으로 추천되는 닉네임으로 5초면 가입해요" + static let thirdGuideMessage: String = "익명으로 솔직한 이야기를 나눠요" + + static let startButtonTitle: String = "숨 시작하기" + static let oldUserButtontitle: String = "기존 계정이 있으신가요?" + + static let banUserDialogTitle: String = "가입할 수 없는 계정이에요" + static let banUserDialogLeadingMessage: String = "이 계정은 정지된 이력이 있습니다. 새 계정은 " + + static let resignDialogTitle: String = "최근 탈퇴한 계정이에요" + static let resignDialogLeadingMessage: String = "탈퇴일로부터 7일 후 새 계정을 만들 수 있습니다. 새 계정은 " + + static let dialogTrailingMessage: String = "부터 만들 수 있습니다." static let confirmActionTitle: String = "확인" } @@ -31,50 +42,69 @@ class OnboardingViewController: BaseNavigationViewController, View { // MARK: Views - private let backgroundImageView = UIImageView().then { - $0.image = .init(.image(.login)) - $0.contentMode = .scaleAspectFill + private let guideTitleLabel = UILabel().then { + $0.text = Text.guideTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.head1 } - private let guideLabel = UILabel().then { - $0.text = Text.guideText - $0.textColor = .som.p300 - $0.typography = .init( - fontContainer: BuiltInFont(size: 22, weight: .semibold), - lineHeight: 35, - letterSpacing: 0.05, - alignment: .left - ) - $0.numberOfLines = 0 + private let guideSubTitleLabel = UILabel().then { + $0.text = Text.guideSubTitle + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.title2 + } + + private let onboardingImageView = UIImageView().then { + $0.image = .init(.image(.v2(.onboarding))) + $0.contentMode = .scaleAspectFit + } + + private let guideMessageContainer = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 6 } private let startButton = SOMButton().then { - $0.title = Text.startButtonText - $0.typography = .init( - fontContainer: BuiltInFont(size: 16, weight: .heavy), - lineHeight: 20, - letterSpacing: 0.05 - ) - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true + $0.title = Text.startButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black } private let oldUserButton = SOMButton().then { - $0.title = Text.oldUserButtonText - $0.typography = .init( - fontContainer: BuiltInFont(size: 14, weight: .bold), - lineHeight: 20, - letterSpacing: 0.05 - ) - $0.foregroundColor = UIColor(hex: "#B4B4B4") - $0.hasUnderlined = true + $0.title = Text.oldUserButtontitle + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.gray500 + $0.backgroundColor = .som.v2.white + $0.inset = .init(top: 6, left: 0, bottom: 6, right: 0) } + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + old button height + start button height + padding + return 34 + 6 + 21 + 6 + 8 + 56 + 8 + } + + // MARK: Override func + override func viewDidLoad() { + super.viewDidLoad() + + // 제스처 뒤로가기를 위한 델리게이트 설정 + self.navigationController?.interactivePopGestureRecognizer?.delegate = self + + self.setupGuideMessage([ + Text.firstGuideMessage, + Text.secondGuideMessage, + Text.thirdGuideMessage + ]) + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -84,30 +114,46 @@ class OnboardingViewController: BaseNavigationViewController, View { override func setupConstraints() { super.setupConstraints() - self.view.addSubview(self.backgroundImageView) - self.backgroundImageView.snp.makeConstraints { - $0.edges.equalToSuperview() + self.view.addSubview(self.guideTitleLabel) + self.guideTitleLabel.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(60) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - self.view.addSubview(self.guideLabel) - self.guideLabel.snp.makeConstraints { - /// 실 기기 높이 * 0.6 - $0.top.equalToSuperview().offset(UIScreen.main.bounds.height * 0.6) - $0.leading.equalToSuperview().offset(20) + self.view.addSubview(self.guideSubTitleLabel) + self.guideSubTitleLabel.snp.makeConstraints { + $0.top.equalTo(self.guideTitleLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - self.view.addSubview(self.startButton) - self.startButton.snp.makeConstraints { - $0.bottom.equalToSuperview().offset(-128) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(56) + self.view.addSubview(self.onboardingImageView) + self.onboardingImageView.snp.makeConstraints { + $0.top.equalTo(self.guideSubTitleLabel.snp.bottom).offset(60) + $0.centerX.equalToSuperview() + } + + self.view.addSubview(self.guideMessageContainer) + self.guideMessageContainer.snp.makeConstraints { + $0.top.equalTo(self.onboardingImageView.snp.bottom).offset(60) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } self.view.addSubview(self.oldUserButton) self.oldUserButton.snp.makeConstraints { - $0.top.equalTo(self.startButton.snp.bottom).offset(21) - $0.centerX.equalToSuperview() + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(6) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + self.view.addSubview(self.startButton) + self.startButton.snp.makeConstraints { + $0.bottom.equalTo(self.oldUserButton.snp.top).offset(-8) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } @@ -116,24 +162,46 @@ class OnboardingViewController: BaseNavigationViewController, View { func bind(reactor: OnboardingViewReactor) { - // Action - self.rx.viewDidLoad - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - self.rx.viewWillAppear - .map { _ in Reactor.Action.reset } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) + let startButtonTapped = self.startButton.rx.throttleTap.share() + let checkAvailable = reactor.state.map(\.checkAvailable).filterNil().share() + // `숨 시작하기` 버튼을 탭한 이후 최신 상태 반영 + let latestAvailableDidTapped = startButtonTapped + .flatMapLatest { _ in checkAvailable.skip(1).take(1) } + // .observe(on: MainScheduler.instance) + .share() - // Navigation - self.startButton.rx.tap - .map { _ in Reactor.Action.check } - .bind(to: reactor.action) + // 차단 및 탈퇴한 계정이 아닐 경우 온보딩 화면 전환 + latestAvailableDidTapped + .filter { $0.banned == false && $0.withdrawn == false } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + let termsOfServiceViewController = OnboardingTermsOfServiceViewController() + termsOfServiceViewController.reactor = reactor.reactorForTermsOfService() + object.navigationPush(termsOfServiceViewController, animated: true) + } .disposed(by: disposeBag) - - self.oldUserButton.rx.tap + // 차단된 계정인 경우 팝업 표시 + latestAvailableDidTapped + .filter { $0.banned && $0.rejoinAvailableAt != nil } + .compactMap(\.rejoinAvailableAt) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, rejoinAvailableAt in + + object.showBannedUserDialog(at: rejoinAvailableAt) + } + .disposed(by: self.disposeBag) + // 탈퇴한 계정인 경우 팝업 표시 + latestAvailableDidTapped + .filter { $0.withdrawn && $0.rejoinAvailableAt != nil } + .compactMap(\.rejoinAvailableAt) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, rejoinAvailableAt in + + object.showResignUserDialog(at: rejoinAvailableAt) + } + .disposed(by: self.disposeBag) + // 계정 이관 화면 이동 + self.oldUserButton.rx.throttleTap .subscribe(with: self) { object, _ in let enterMemberTransferViewController = EnterMemberTransferViewController() enterMemberTransferViewController.reactor = reactor.reactorForEnterTransfer() @@ -141,44 +209,117 @@ class OnboardingViewController: BaseNavigationViewController, View { } .disposed(by: disposeBag) + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + startButtonTapped + .map { _ in Reactor.Action.check } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + // State - reactor.state.map(\.suspension) - .filterNil() - .subscribe(with: self) { object, suspension in - let dialogMessageView = DialogMessageView( - isBanUser: suspension.isBanUser, - banDateString: suspension.untilBan.banEndFormatted - ) - - let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) + checkAvailable + .take(1) + .filter { $0.banned && $0.rejoinAvailableAt != nil } + .compactMap(\.rejoinAvailableAt) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, rejoinAvailableAt in - SOMDialogViewController.show( - title: suspension.isBanUser ? Text.banUserDialogTitle : Text.resignDialogTitle, - messageView: dialogMessageView, - actions: [confirmAction] - ) + object.showBannedUserDialog(at: rejoinAvailableAt) } .disposed(by: self.disposeBag) - - reactor.state.map(\.shouldNavigate) - .filter { $0 } - .subscribe(with: self) { object, _ in - let termsOfServiceViewController = OnboardingTermsOfServiceViewController() - termsOfServiceViewController.reactor = reactor.reactorForTermsOfService() - object.navigationPush(termsOfServiceViewController, animated: true) + checkAvailable + .take(1) + .filter { $0.withdrawn && $0.rejoinAvailableAt != nil } + .compactMap(\.rejoinAvailableAt) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, rejoinAvailableAt in + + object.showResignUserDialog(at: rejoinAvailableAt) } .disposed(by: self.disposeBag) - + reactor.state.map(\.shouldHideTransfer) + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, shouldHide in object.oldUserButton.isHidden = shouldHide } .disposed(by: self.disposeBag) } } + +extension OnboardingViewController { + + func showBannedUserDialog(at rejoinAvailableAt: Date) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss() + } + ) + + let dialogMessage = Text.banUserDialogLeadingMessage + + rejoinAvailableAt.banEndFormatted + + Text.dialogTrailingMessage + SOMDialogViewController.show( + title: Text.banUserDialogTitle, + message: dialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } + + func showResignUserDialog(at rejoinAvailableAt: Date) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss() + } + ) + + let dialogMessage = Text.resignDialogLeadingMessage + + rejoinAvailableAt.banEndFormatted + + Text.dialogTrailingMessage + SOMDialogViewController.show( + title: Text.resignDialogTitle, + message: dialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } + + func setupGuideMessage(_ messages: [String]) { + + messages.forEach { message in + + let imageView = UIImageView().then { + $0.image = .init(.image(.v2(.check_square_light))) + $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + + let label = UILabel().then { + $0.text = message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + $0.textAlignment = .left + } + + let container = UIStackView(arrangedSubviews: [imageView, label]).then { + $0.axis = .horizontal + $0.spacing = 8 + } + container.snp.makeConstraints { + $0.height.equalTo(24) + } + + self.guideMessageContainer.addArrangedSubview(container) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift index 2b263d4d..d6039e5d 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift @@ -7,36 +7,36 @@ import ReactorKit - class OnboardingViewReactor: Reactor { enum Action: Equatable { case landing - case reset case check } enum Mutation { - case check(Suspension) - case shouldNavigate(Bool) + case check(CheckAvailable?) } struct State { - fileprivate(set) var suspension: Suspension? - fileprivate(set) var shouldNavigate: Bool = false + fileprivate(set) var checkAvailable: CheckAvailable? fileprivate(set) var shouldHideTransfer: Bool } var initialState: State = .init( - suspension: nil, + checkAvailable: nil, shouldHideTransfer: UserDefaults.standard.bool(forKey: "AppFlag") ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let validateUserUseCase: ValidateUserUseCase + private let updateNotifyUseCase: UpdateNotifyUseCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.validateUserUseCase = dependencies.rootContainer.resolve(ValidateUserUseCase.self) + self.updateNotifyUseCase = dependencies.rootContainer.resolve(UpdateNotifyUseCase.self) } func mutate(action: Action) -> Observable { @@ -44,67 +44,35 @@ class OnboardingViewReactor: Reactor { case .landing: return .concat([ - self.check() - .compactMap(\.value) + self.validateUserUseCase.checkValidation() .map(Mutation.check), - self.provider.pushManager.switchNotification(on: true) + self.updateNotifyUseCase.switchNotification(on: true) .flatMapLatest { _ -> Observable in .empty() } ]) - case .reset: - - return .just(.shouldNavigate(false)) case .check: - return self.check() - .map { $0 == nil } - .map(Mutation.shouldNavigate) + return self.validateUserUseCase.checkValidation() + .map(Mutation.check) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState = state switch mutation { - case let .check(suspension): - state.suspension = suspension - case let .shouldNavigate(shouldNavigate): - state.shouldNavigate = shouldNavigate - } - return state - } -} - -extension OnboardingViewReactor { - - private func check() -> Observable { - - return self.provider.networkManager.request( - RSAKeyResponse.self, - request: AuthRequest.getPublicKey - ) - .map(\.publicKey) - .withUnretained(self) - .flatMapLatest { object, publicKey -> Observable in - - if let secKey = object.provider.authManager.convertPEMToSecKey(pemString: publicKey), - let encryptedDeviceId = object.provider.authManager.encryptUUIDWithPublicKey(publicKey: secKey) { - - let request: JoinRequest = .suspension(encryptedDeviceId: encryptedDeviceId) - return object.provider.networkManager.request(SuspensionResponse.self, request: request) - .map(\.suspension) - } else { - return .empty() - } + case let .check(checkAvailable): + newState.checkAvailable = checkAvailable } + return newState } } extension OnboardingViewReactor { func reactorForTermsOfService() -> OnboardingTermsOfServiceViewReactor { - OnboardingTermsOfServiceViewReactor(provider: self.provider) + OnboardingTermsOfServiceViewReactor(dependencies: self.dependencies) } func reactorForEnterTransfer() -> EnterMemberTransferViewReactor { - EnterMemberTransferViewReactor(provider: self.provider, entranceType: .onboarding) + EnterMemberTransferViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/Views/DialogMessageView.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/Views/DialogMessageView.swift deleted file mode 100644 index a11149c9..00000000 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/Views/DialogMessageView.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// DialogMessageView.swift -// SOOUM -// -// Created by 오현식 on 1/18/25. -// - -import UIKit - -import SnapKit -import Then - - -class DialogMessageView: UIView { - - static var messageTypo: Typography = .som.body2WithBold - - enum Text { - static let dot: String = "•" - - static let banUserDialogFirstMessage: String = "해당 계정은 정지된 이력이 있는 탈퇴 계정 입니다." - static let resignDialogFirstMessage: String = "탈퇴 시점으로 부터 7일 경과 후 새로운 계정 생성이 가능합니다." - - static let dialogSecondLeftMessage: String = "새로운 계정 생성은 " - static let dialogSecondRightMessage: String = " 이후 가능합니다." - } - - - // MARK: Views - - private let firstDotLabel = UILabel().then { - $0.text = Text.dot - $0.textColor = .som.gray600 - $0.typography = .som.body2WithBold - - $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) - } - private let firstMessageLabel = UILabel().then { - $0.textColor = .som.gray600 - $0.typography = .som.body2WithBold.withAlignment(.left) - $0.lineBreakMode = .byWordWrapping - $0.lineBreakStrategy = .hangulWordPriority - $0.numberOfLines = 0 - } - - private let secondDotLabel = UILabel().then { - $0.text = Text.dot - $0.textColor = .som.gray600 - $0.typography = .som.body2WithBold - - $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) - } - private let secondMessageLabel = UILabel().then { - $0.textColor = .som.gray600 - $0.typography = .som.body2WithBold.withAlignment(.left) - $0.lineBreakMode = .byWordWrapping - $0.lineBreakStrategy = .hangulWordPriority - $0.numberOfLines = 0 - } - - - // MARK: Variables - - var firstMessage: String? { - set { - self.firstMessageLabel.text = newValue - self.firstMessageLabel.typography = Self.messageTypo.withAlignment(.left) - } - get { - return self.firstMessageLabel.text - } - } - - var secondMessage: String? { - set { - self.secondMessageLabel.text = newValue - self.secondMessageLabel.typography = Self.messageTypo.withAlignment(.left) - } - get { - return self.secondMessageLabel.text - } - } - - - // MARK: Initalization - - init(isBanUser: Bool, banDateString: String) { - super.init(frame: .zero) - - self.setupConstraints() - - self.firstMessage = isBanUser ? Text.banUserDialogFirstMessage : Text.resignDialogFirstMessage - self.secondMessage = Text.dialogSecondLeftMessage + banDateString + Text.dialogSecondRightMessage - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - // MARK: Private Func - - private func setupConstraints() { - - self.addSubview(self.firstDotLabel) - self.firstDotLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview() - } - - self.addSubview(self.firstMessageLabel) - self.firstMessageLabel.snp.makeConstraints { - $0.top.trailing.equalToSuperview() - $0.leading.equalTo(self.firstDotLabel.snp.trailing).offset(4) - } - - self.addSubview(self.secondDotLabel) - self.secondDotLabel.snp.makeConstraints { - $0.top.equalTo(self.firstMessageLabel.snp.bottom) - $0.leading.equalToSuperview() - } - - self.addSubview(self.secondMessageLabel) - self.secondMessageLabel.snp.makeConstraints { - $0.top.equalTo(self.firstMessageLabel.snp.bottom) - $0.bottom.trailing.equalToSuperview() - $0.leading.equalTo(self.secondDotLabel.snp.trailing).offset(4) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift new file mode 100644 index 00000000..5af7bb4d --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift @@ -0,0 +1,368 @@ +// +// OnboardingProfileImageSettingViewController.swift +// SOOUM +// +// Created by JDeoks on 11/6/24. +// + +import UIKit + +import SnapKit +import Then + +import Photos +import SwiftEntryKit +import YPImagePicker + +import Clarity + +import ReactorKit +import RxCocoa +import RxGesture +import RxSwift + +class OnboardingProfileImageSettingViewController: BaseNavigationViewController, View { + + enum Text { + static let navigationTitle: String = "회원가입" + + static let title: String = "숨에서 사용할 프로필 사진을\n등록해주세요" + + static let cancelActionTitle: String = "취소" + static let settingActionTitle: String = "설정" + static let completeButtonTitle: String = "완료" + static let passButtonTitle: String = "건너뛰기" + + static let libraryDialogTitle: String = "앱 접근 권한 안내" + static let libraryDialogMessage: String = "사진첨부를 위해 접근 권한이 필요해요. [설정 > 앱 > 숨 > 사진]에서 사진 보관함 접근 권한을 허용해 주세요." + + static let inappositeDialogTitle: String = "부적절한 사진으로 보여져요" + static let inappositeDialogMessage: String = "다른 사진으로 변경하거나 기본 이미지를 사용해 주세요." + static let inappositeDialogConfirmButtonTitle: String = "확인" + + static let selectProfileEntryName: String = "SOMBottomFloatView" + + static let selectProfileFirstButtonTitle: String = "앨범에서 사진 선택" + static let selectProfileSecondButtonTitle: String = "사진 찍기" + static let selectProfileThirdButtonTitle: String = "기본 이미지 적용" + + static let selectPhotoFullScreenNextTitle: String = "다음" + static let selectPhotoFullScreenCancelTitle: String = "취소" + static let selectPhotoFullScreenSaveTitle: String = "저장" + static let selectPhotoFullScreenAlbumsTitle: String = "앨범" + static let selectPhotoFullScreenCameraTitle: String = "카메라" + static let selectPhotoFullScreenLibraryTitle: String = "갤러리" + static let selectPhotoFullScreenCropTitle: String = "자르기" + } + + + // MARK: Views + + private let guideMessageView = OnboardingGuideMessageView(title: Text.title, currentNumber: 3) + + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_large))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 120 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + private let cameraButton = SOMButton().then { + $0.image = .init(.icon(.v2(.filled(.camera)))) + $0.foregroundColor = .som.v2.gray400 + + $0.backgroundColor = .som.v2.white + $0.layer.borderColor = UIColor.som.v2.gray200.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 32 * 0.5 + } + + private let completeButton = SOMButton().then { + $0.title = Text.completeButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + } + + private let passButton = SOMButton().then { + $0.title = Text.passButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 + } + + + // MARK: Variables + + private var actions: [SOMBottomFloatView.FloatAction] = [] + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 + } + + + // MARK: Override func + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.guideMessageView) + self.guideMessageView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + self.view.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.top.equalTo(self.guideMessageView.snp.bottom).offset(32) + $0.centerX.equalToSuperview() + $0.size.equalTo(120) + } + self.view.addSubview(self.cameraButton) + self.cameraButton.snp.makeConstraints { + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.trailing.equalTo(self.profileImageView.snp.trailing) + $0.size.equalTo(32) + } + + let container = UIStackView(arrangedSubviews: [self.passButton, self.completeButton]).then { + $0.axis = .horizontal + $0.spacing = 10 + } + self.view.addSubview(container) + container.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-6) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in } + } + + + // MARK: ReactorKit - bind + + func bind(reactor: OnboardingProfileImageSettingViewReactor) { + + // Action + Observable.merge( + self.profileImageView.rx.tapGesture().when(.ended).map { _ in }, + self.cameraButton.rx.throttleTap.asObservable() + ) + .subscribe(with: self) { object, _ in + + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if status == .authorized || status == .limited { + + let selectProfileBottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = selectProfileBottomFloatView.sek + wrapper.entryName = Text.selectProfileEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } else { + + object.showLibraryPermissionDialog() + } + } + .disposed(by: self.disposeBag) + + self.completeButton.rx.throttleTap(.seconds(3)) + .map { _ in Reactor.Action.signUp } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.passButton.rx.throttleTap(.seconds(3)) + .map { _ in Reactor.Action.signUp } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.state.map(\.isSignUp) + .distinctUntilChanged() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + let viewController = OnboardingCompletedViewController() + viewController.reactor = reactor.reactorForCompleted() + object.navigationPush(viewController, animated: true) + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.isLoading) + .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.loadingIndicatorView) { loadingIndicatorView, isLoading in + if isLoading { + loadingIndicatorView.startAnimating() + } else { + loadingIndicatorView.stopAnimating() + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) + .distinctUntilChanged() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + object.showInappositeDialog(reactor) + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.profileImage) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, profileImage in + object.profileImageView.image = profileImage ?? .init(.image(.v2(.profile_large))) + + var actions: [SOMBottomFloatView.FloatAction] = [ + .init( + title: Text.selectProfileFirstButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .library) + } + } + ), + .init( + title: Text.selectProfileSecondButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .photo) + } + } + ) + ] + + if profileImage != nil { + actions.append(.init( + title: Text.selectProfileThirdButtonTitle, + action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + reactor.action.onNext(.setDefaultImage) + } + } + )) + } + + object.actions = actions + } + .disposed(by: self.disposeBag) + } + } + + +// MARK: Show dialog + +extension OnboardingProfileImageSettingViewController { + + func showLibraryPermissionDialog() { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let settingAction = SOMDialogAction( + title: Text.settingActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } + } + } + ) + + SOMDialogViewController.show( + title: Text.libraryDialogTitle, + message: Text.libraryDialogMessage, + actions: [cancelAction, settingAction] + ) + } + + func showInappositeDialog(_ reactor: OnboardingProfileImageSettingViewReactor) { + + let actions: [SOMDialogAction] = [ + .init( + title: Text.inappositeDialogConfirmButtonTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.setDefaultImage) + } + } + ) + ] + + SOMDialogViewController.show( + title: Text.inappositeDialogTitle, + message: Text.inappositeDialogMessage, + textAlignment: .left, + actions: actions + ) + } + + func showPicker(for screen: YPPickerScreen) { + + var config = YPImagePickerConfiguration() + + config.library.options = nil + config.library.minWidthForItem = nil + config.showsCrop = .rectangle(ratio: 1.0) + config.showsPhotoFilters = false + config.library.preselectedItems = nil + config.screens = [screen] + config.startOnScreen = screen + config.shouldSaveNewPicturesToAlbum = false + + config.wordings.next = Text.selectPhotoFullScreenNextTitle + config.wordings.cancel = Text.selectPhotoFullScreenCancelTitle + config.wordings.save = Text.selectPhotoFullScreenSaveTitle + config.wordings.albumsTitle = Text.selectPhotoFullScreenAlbumsTitle + config.wordings.cameraTitle = Text.selectPhotoFullScreenCameraTitle + config.wordings.libraryTitle = Text.selectPhotoFullScreenLibraryTitle + config.wordings.crop = Text.selectPhotoFullScreenCropTitle + + let picker = YPImagePicker(configuration: config) + picker.didFinishPicking { [weak self, weak picker] items, cancelled in + + if cancelled { + Log.debug("Picker was canceled") + picker?.dismiss(animated: true, completion: nil) + return + } + + if let image = items.singlePhoto?.image, let reactor = self?.reactor { + reactor.action.onNext(.uploadImage(image)) + } else { + Log.error("Error occured while picking an image") + } + picker?.dismiss(animated: true) { ClaritySDK.resume() } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.present(picker, animated: true) { ClaritySDK.pause() } + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift new file mode 100644 index 00000000..4bd0d3d1 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift @@ -0,0 +1,158 @@ +// +// OnboardingProfileImageSettingViewReactor.swift +// SOOUM +// +// Created by JDeoks on 11/12/24. +// + +import ReactorKit + +import Alamofire + + +class OnboardingProfileImageSettingViewReactor: Reactor { + + enum Action { + case uploadImage(UIImage) + case setDefaultImage + case signUp + } + + enum Mutation { + case updateImageInfo(UIImage?, String?) + case updateIsSignUp(Bool) + case updateIsLoading(Bool) + case updateErrors(Bool) + } + + struct State { + var profileImage: UIImage? + var profileImageName: String? + var isSignUp: Bool + var isLoading: Bool + var hasErrors: Bool + } + + var initialState: State = .init( + profileImage: nil, + profileImageName: nil, + isSignUp: false, + isLoading: false, + hasErrors: false + ) + + private let dependencies: AppDIContainerable + private let authUseCase: AuthUseCase + private let uploadUserImageUseCase: UploadUserImageUseCase + + private let nickname: String + + init(dependencies: AppDIContainerable, nickname: String) { + self.dependencies = dependencies + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) + self.uploadUserImageUseCase = dependencies.rootContainer.resolve(UploadUserImageUseCase.self) + self.nickname = nickname + } + + func mutate(action: Action) -> Observable { + switch action { + case let .uploadImage(image): + + return .concat([ + .just(.updateIsLoading(true)), + .just(.updateErrors(false)), + self.uploadImage(image) + .catch(self.catchClosure), + .just(.updateIsLoading(false)) + ]) + case .setDefaultImage: + + return .just(.updateImageInfo(nil, nil)) + case .signUp: + + return .concat([ + .just(.updateErrors(false)), + self.signUp() + .catch(self.catchClosure) + ]) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .updateImageInfo(profileImage, profileImageName): + newState.profileImage = profileImage + newState.profileImageName = profileImageName + case let .updateIsSignUp(isSignUp): + newState.isSignUp = isSignUp + case let .updateIsLoading(isLoading): + newState.isLoading = isLoading + case let .updateErrors(hasErrors): + newState.hasErrors = hasErrors + } + return newState + } +} + +extension OnboardingProfileImageSettingViewReactor { + + private func signUp() -> Observable { + + return self.authUseCase.signUp( + nickname: self.nickname, + profileImageName: self.currentState.profileImageName + ) + .map(Mutation.updateIsSignUp) + } + + private func uploadImage(_ image: UIImage) -> Observable { + + return self.presignedURL() + .withUnretained(self) + .flatMapLatest { object, presignedInfo -> Observable in + if let imageData = image.jpegData(compressionQuality: 0.5), + let url = URL(string: presignedInfo.imgUrl) { + + return object.uploadUserImageUseCase.uploadToS3(imageData, with: url) + .flatMapLatest { isSuccess -> Observable in + + let image = isSuccess ? image : nil + let imageName = isSuccess ? presignedInfo.imgName : nil + + return .just(.updateImageInfo(image, imageName)) + } + } else { + return .empty() + } + } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + } + + private func presignedURL() -> Observable { + + return self.uploadUserImageUseCase.presignedURL() + } + + private var catchClosure: ((Error) throws -> Observable ) { + return { error in + + let nsError = error as NSError + let endProcessing = Observable.concat([ + // TODO: 부적절한 사진일 때, `확인` 버튼 탭 시 이미지 변경 + .just(.updateIsSignUp(false)), + .just(.updateIsLoading(false)), + .just(.updateErrors(nsError.code == 422)) + ]) + + return endProcessing + } + } +} + +extension OnboardingProfileImageSettingViewReactor { + + func reactorForCompleted() -> OnboardingCompletedViewReactor { + OnboardingCompletedViewReactor(dependencies: self.dependencies) + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/ProfileImageSettingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/ProfileImageSettingViewController.swift deleted file mode 100644 index 3c950576..00000000 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/ProfileImageSettingViewController.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// ProfileImageSettingViewController.swift -// SOOUM -// -// Created by JDeoks on 11/6/24. -// - -import UIKit - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then -import YPImagePicker - -class ProfileImageSettingViewController: BaseNavigationViewController, View { - - enum Text { - static let title: String = "당신을 표현하는 사진을\n프로필로 등록해볼까요?" - static let message: String = "프로필 사진은 추후 변경이 가능해요" - - static let confirmButtonTitle: String = "확인" - static let passButtonTitle: String = "다음에 변경하기" - } - - - // MARK: Views - - private let guideMessageView = OnboardingGuideMessageView(title: Text.title, message: Text.message) - - private let profileImageView = UIImageView().then { - $0.image = .init(.image(.sooumLogo)) - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - private let cameraButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.camera))) - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.gray400 - $0.layer.cornerRadius = 32 * 0.5 - $0.clipsToBounds = true - } - - private let confirmButton = SOMButton().then { - $0.title = Text.confirmButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.gray600 - - $0.backgroundColor = .som.gray300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - private let passButton = SOMButton().then { - $0.title = Text.passButtonTitle - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.p300 - $0.hasUnderlined = true - } - - - // MARK: Override func - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.guideMessageView) - self.guideMessageView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(28) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - self.view.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalTo(self.guideMessageView.snp.bottom).offset(94) - $0.centerX.equalToSuperview() - $0.size.equalTo(128) - } - self.view.addSubview(self.cameraButton) - self.cameraButton.snp.makeConstraints { - $0.bottom.equalTo(self.profileImageView.snp.bottom).offset(-4) - $0.trailing.equalTo(self.profileImageView.snp.trailing).offset(-4) - $0.size.equalTo(32) - } - - self.view.addSubviews(self.passButton) - self.passButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.centerX.equalToSuperview() - } - - self.view.addSubview(self.confirmButton) - self.confirmButton.snp.makeConstraints { - $0.bottom.equalTo(passButton.snp.top).offset(-20) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) - } - } - - - // MARK: ReactorKit - bind - - func bind(reactor: ProfileImageSettingViewReactor) { - - // Action - self.cameraButton.rx.tap - .subscribe(with: self) { object, _ in - object.showPicker() - } - .disposed(by: self.disposeBag) - - self.confirmButton.rx.tap - .map { _ in Reactor.Action.updateProfile } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - self.passButton.rx.tap - .map { _ in Reactor.Action.updateProfile } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - // State - reactor.state.map(\.isSuccess) - .distinctUntilChanged() - .filter { $0 } - .subscribe(with: self) { object, _ in - let viewController = MainTabBarController() - viewController.reactor = reactor.reactorForMainTabBar() - let navigationController = UINavigationController( - rootViewController: viewController - ) - object.view.window?.rootViewController = navigationController - } - .disposed(by: disposeBag) - - reactor.state.map(\.profileImage) - .map { $0 != nil } - .distinctUntilChanged() - .subscribe(with: self) { object, isUpdated in - object.confirmButton.isEnabled = isUpdated - object.confirmButton.foregroundColor = isUpdated ? .som.white : .som.gray600 - object.confirmButton.backgroundColor = isUpdated ? .som.p300 : .som.gray300 - } - .disposed(by: disposeBag) - } - } - -extension ProfileImageSettingViewController { - func showPicker() { - var config = YPImagePickerConfiguration() - - config.library.options = nil - config.library.onlySquare = false - config.library.isSquareByDefault = true - config.library.minWidthForItem = nil - config.library.mediaType = YPlibraryMediaType.photo - config.library.defaultMultipleSelection = false - config.library.maxNumberOfItems = 1 - config.library.minNumberOfItems = 1 - config.library.numberOfItemsInRow = 4 - config.library.spacingBetweenItems = 1.0 - config.showsCrop = .rectangle(ratio: 1) - config.showsPhotoFilters = false - config.library.skipSelectionsGallery = false - config.library.preselectedItems = nil - config.library.preSelectItemOnMultipleSelection = true - config.startOnScreen = .library - config.shouldSaveNewPicturesToAlbum = false - - config.wordings.next = "다음" - config.wordings.cancel = "취소" - config.wordings.save = "저장" - config.wordings.albumsTitle = "앨범" - config.wordings.cameraTitle = "카메라" - config.wordings.libraryTitle = "갤러리" - config.wordings.crop = "자르기" - - let picker = YPImagePicker(configuration: config) - picker.didFinishPicking { [weak self] items, cancelled in - - guard let self = self, let reactor = self.reactor else { return } - - if cancelled { - Log.error("Picker was canceled") - picker.dismiss(animated: true, completion: nil) - return - } - - if let image = items.singlePhoto?.image { - self.profileImageView.image = image - reactor.action.onNext(.updateImage(image)) - } - picker.dismiss(animated: true, completion: nil) - } - self.present(picker, animated: true, completion: nil) - } -} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/ProfileImageSettingViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/ProfileImageSettingViewReactor.swift deleted file mode 100644 index 27a48d0f..00000000 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/ProfileImageSettingViewReactor.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// ProfileImageSettingViewReactor.swift -// SOOUM -// -// Created by JDeoks on 11/12/24. -// - -import ReactorKit - -import Alamofire - - -class ProfileImageSettingViewReactor: Reactor { - - enum Action { - case updateImage(UIImage) - case updateProfile - } - - enum Mutation { - case updateImage(UIImage) - case updateIsSuccess(Bool) - } - - struct State { - var profileImage: UIImage? - var isSuccess: Bool - } - - var nickname: String - var imageName: String? - - var initialState: State = .init( - profileImage: nil, - isSuccess: false - ) - - let provider: ManagerProviderType - - init(provider: ManagerProviderType, nickname: String) { - self.provider = provider - self.nickname = nickname - } - - func mutate(action: Action) -> Observable { - switch action { - case let .updateImage(image): - return self.updateImage(image) - case .updateProfile: - let trimedNickname = self.nickname.trimmingCharacters(in: .whitespacesAndNewlines) - let request: JoinRequest = .registerUser(userName: trimedNickname, imageName: self.imageName) - - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsSuccess(true)) - } - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var newState = state - switch mutation { - case let .updateImage(profileImage): - newState.profileImage = profileImage - case let .updateIsSuccess(isSuccess): - newState.isSuccess = isSuccess - } - return newState - } -} - -extension ProfileImageSettingViewReactor { - - private func updateImage(_ image: UIImage) -> Observable { - return self.presignedURL() - .withUnretained(self) - .flatMapLatest { object, presignedResponse -> Observable in - if let imageData = image.jpegData(compressionQuality: 0.5), - let url = URL(string: presignedResponse.strUrl) { - return object.provider.networkManager.upload(imageData, to: url) - .flatMapLatest { _ -> Observable in - return .just(.updateImage(image)) - } - } else { - return .empty() - } - } - } - - private func presignedURL() -> Observable<(strUrl: String, imageName: String)> { - let request: JoinRequest = .profileImagePresignedURL - - return self.provider.networkManager.request(PresignedStorageResponse.self, request: request) - .withUnretained(self) - .flatMapLatest { object, response -> Observable<(strUrl: String, imageName: String)> in - object.imageName = response.imgName - let result = (response.url.url, response.imgName) - return .just(result) - } - } -} - -extension ProfileImageSettingViewReactor { - - func reactorForMainTabBar() -> MainTabBarReactor { - MainTabBarReactor(provider: self.provider) - } -} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift index 6513e206..5c2f6b52 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift @@ -19,15 +19,53 @@ import RxSwift class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View { enum Text { - static let guideMessageTitle: String = "숨을 시작하기 위해서는\n약관 동의가 필요해요" + static let navigationTitle: String = "회원가입" - static let confirmButtonTitle: String = "확인" + static let guideMessageTitle: String = "숨 서비스 이용을 위해\n동의해주세요" + + static let termsOfSeviceUrlString: String = "https://adjoining-guanaco-d0a.notion.site/26b2142ccaa38076b491df099cd7b559" + static let locationServiceUrlString: String = "https://adjoining-guanaco-d0a.notion.site/26b2142ccaa380f1bfafe99f5f8a10f1?pvs=74" + static let privacyPolicyUrlString: String = "https://adjoining-guanaco-d0a.notion.site/26b2142ccaa38059a1dbf3e6b6b6b4e6?pvs=74" + + static let nextButtonTitle: String = "다음" + } + + enum TermsOfService: CaseIterable { + + case termsOfService + case locationService + case privacyPolicy + + var text: String { + switch self { + case .termsOfService: + "[필수] 서비스 이용 약관" + case .locationService: + "[필수] 위치정보 이용 약관" + case .privacyPolicy: + "[필수] 개인정보 처리 방침" + } + } + + var url: URL { + switch self { + case .termsOfService: + return URL(string: Text.termsOfSeviceUrlString)! + case .locationService: + return URL(string: Text.locationServiceUrlString)! + case .privacyPolicy: + return URL(string: Text.privacyPolicyUrlString)! + } + } } // MARK: Views - private let guideMessageView = OnboardingGuideMessageView(title: Text.guideMessageTitle) + private let guideMessageView = OnboardingGuideMessageView( + title: Text.guideMessageTitle, + currentNumber: 1 + ) private let agreeAllButtonView = TermsOfServiceAgreeButtonView() @@ -36,60 +74,74 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View private let privacyPolicyCellView = TermsOfServiceCellView(title: TermsOfService.privacyPolicy.text) private let nextButton = SOMButton().then { - $0.title = Text.confirmButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.gray600 - - $0.backgroundColor = .som.gray300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true + $0.title = Text.nextButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + $0.isEnabled = false + } + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 } // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + } override func setupConstraints() { super.setupConstraints() self.view.addSubview(self.guideMessageView) self.guideMessageView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(28) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } self.view.addSubview(self.agreeAllButtonView) self.agreeAllButtonView.snp.makeConstraints { - $0.top.equalTo(self.guideMessageView.snp.bottom).offset(44) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(60) + $0.top.equalTo(self.guideMessageView.snp.bottom).offset(32) + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() } self.view.addSubview(self.termsOfServiceCellView) self.termsOfServiceCellView.snp.makeConstraints { - $0.top.equalTo(self.agreeAllButtonView.snp.bottom).offset(36) - $0.leading.trailing.equalToSuperview() + $0.top.equalTo(self.agreeAllButtonView.snp.bottom).offset(8) + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() } self.view.addSubview(self.locationServiceCellView) self.locationServiceCellView.snp.makeConstraints { $0.top.equalTo(self.termsOfServiceCellView.snp.bottom) - $0.leading.trailing.equalToSuperview() + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() } self.view.addSubview(self.privacyPolicyCellView) self.privacyPolicyCellView.snp.makeConstraints { $0.top.equalTo(self.locationServiceCellView.snp.bottom) - $0.leading.trailing.equalToSuperview() + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview() } self.view.addSubview(self.nextButton) self.nextButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } @@ -99,8 +151,7 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View func bind(reactor: OnboardingTermsOfServiceViewReactor) { // Action - self.agreeAllButtonView.rx.tapGesture() - .when(.recognized) + self.agreeAllButtonView.rx.didSelect .map { _ in Reactor.Action.allAgree } .bind(to: reactor.action) .disposed(by: disposeBag) @@ -110,7 +161,8 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.termsOfServiceCellView.rx.nextSelect + self.termsOfServiceCellView.rx.moveSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(onNext: { _ in if UIApplication.shared.canOpenURL(TermsOfService.termsOfService.url) { UIApplication.shared.open( @@ -127,7 +179,8 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.locationServiceCellView.rx.nextSelect + self.locationServiceCellView.rx.moveSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(onNext: { _ in if UIApplication.shared.canOpenURL(TermsOfService.locationService.url) { UIApplication.shared.open( @@ -144,7 +197,8 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.privacyPolicyCellView.rx.nextSelect + self.privacyPolicyCellView.rx.moveSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(onNext: { _ in if UIApplication.shared.canOpenURL(TermsOfService.privacyPolicy.url) { UIApplication.shared.open( @@ -156,14 +210,7 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View }) .disposed(by: self.disposeBag) - self.nextButton.rx.tap - .map { _ in Reactor.Action.signUp } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - // State - reactor.state.map(\.shouldNavigate) - .filter { $0 } + self.nextButton.rx.throttleTap(.seconds(3)) .subscribe(with: self) { object, _ in let nicknameSettingVC = OnboardingNicknameSettingViewController() nicknameSettingVC.reactor = reactor.reactorForNickname() @@ -174,33 +221,34 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View // State reactor.state.map(\.isAllAgreed) .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, isAllAgreed in - object.agreeAllButtonView.updateState(isAllAgreed) - - object.nextButton.foregroundColor = isAllAgreed ? .som.white : .som.gray600 - object.nextButton.backgroundColor = isAllAgreed ? .som.p300 : .som.gray300 + object.agreeAllButtonView.updateState(isAllAgreed, animated: false) object.nextButton.isEnabled = isAllAgreed } .disposed(by: self.disposeBag) reactor.state.map(\.isTermsOfServiceAgreed) .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, isTermsOfServiceAgreed in - object.termsOfServiceCellView.updateState(isTermsOfServiceAgreed) + object.termsOfServiceCellView.updateState(isTermsOfServiceAgreed, animated: false) } .disposed(by: self.disposeBag) reactor.state.map(\.isLocationAgreed) .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, isLocationAgreed in - object.locationServiceCellView.updateState(isLocationAgreed) + object.locationServiceCellView.updateState(isLocationAgreed, animated: false) } .disposed(by: self.disposeBag) reactor.state.map(\.isPrivacyPolicyAgreed) .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, isPrivacyPolicyAgreed in - object.privacyPolicyCellView.updateState(isPrivacyPolicyAgreed) + object.privacyPolicyCellView.updateState(isPrivacyPolicyAgreed, animated: false) } .disposed(by: self.disposeBag) } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewReactor.swift index 7e4af8ff..27c92972 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewReactor.swift @@ -10,47 +10,9 @@ import UIKit import ReactorKit import RxSwift - -enum TermsOfService: CaseIterable { - - enum Text { - static let termsOfSeviceUrlString: String = "https://mewing-space-6d3.notion.site/3f92380d536a4b569921d2809ed147ef?pvs=4" - static let locationServiceUrlString: String = "https://mewing-space-6d3.notion.site/45d151f68ba74b23b24483ad8b2662b4?pvs=4" - static let privacyPolicyUrlString: String = "https://mewing-space-6d3.notion.site/44e378c9d11d45159859492434b6b128?pvs=4" - } - - case termsOfService - case locationService - case privacyPolicy - - var text: String { - switch self { - case .termsOfService: - "[필수] 서비스 이용 약관" - case .locationService: - "[필수] 위치정보 이용 약관" - case .privacyPolicy: - "[필수] 개인정보 처리 방침" - } - } - - var url: URL { - switch self { - case .termsOfService: - return URL(string: Text.termsOfSeviceUrlString)! - case .locationService: - return URL(string: Text.locationServiceUrlString)! - case .privacyPolicy: - return URL(string: Text.privacyPolicyUrlString)! - } - } -} - class OnboardingTermsOfServiceViewReactor: Reactor { enum Action { - /// 약관동의 전 회원가입 - case signUp /// 모두 동의 버튼 클릭 case allAgree /// 이용약관 버튼 클릭 @@ -62,25 +24,21 @@ class OnboardingTermsOfServiceViewReactor: Reactor { } enum Mutation { - /// 약관동의 전 가입 api 결과 - case signUpResult(Bool) /// 이용약관 설정 - case setIsTermsOfServiceAgreed(Bool) + case updateIsTermsOfServiceAgreed(Bool) /// 위치 동의 설정 - case setIsLocationAgreed(Bool) + case updateIsLocationAgreed(Bool) /// 개인정보 동의 설정 - case setIsPrivacyPolicyAgreed(Bool) + case updateIsPrivacyPolicyAgreed(Bool) } struct State { - /// 다음 화면으로 넘어가기 필요 여부 - fileprivate(set) var shouldNavigate: Bool = false /// 이용약관 동의 여부 - fileprivate(set) var isTermsOfServiceAgreed = false + fileprivate(set) var isTermsOfServiceAgreed: Bool /// 위치 동의 여부 - fileprivate(set) var isLocationAgreed = false + fileprivate(set) var isLocationAgreed: Bool /// 개인정보 처리 동의 여부 - fileprivate(set) var isPrivacyPolicyAgreed = false + fileprivate(set) var isPrivacyPolicyAgreed: Bool /// 전체동의 여부 var isAllAgreed: Bool { @@ -89,35 +47,38 @@ class OnboardingTermsOfServiceViewReactor: Reactor { && self.isPrivacyPolicyAgreed } } - var initialState = State() + var initialState = State( + isTermsOfServiceAgreed: false, + isLocationAgreed: false, + isPrivacyPolicyAgreed: false + ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let authUseCase: AuthUseCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) } func mutate(action: Action) -> Observable { switch action { - case .signUp: - return self.provider.authManager.join() - .map(Mutation.signUpResult) - case .termsOfServiceAgree: - return .just(.setIsTermsOfServiceAgreed(!self.currentState.isTermsOfServiceAgreed)) + return .just(.updateIsTermsOfServiceAgreed(!self.currentState.isTermsOfServiceAgreed)) case .locationAgree: - return .just(.setIsLocationAgreed(!self.currentState.isLocationAgreed)) + return .just(.updateIsLocationAgreed(!self.currentState.isLocationAgreed)) case .privacyPolicyAgree: - return .just(.setIsPrivacyPolicyAgreed(!self.currentState.isPrivacyPolicyAgreed)) + return .just(.updateIsPrivacyPolicyAgreed(!self.currentState.isPrivacyPolicyAgreed)) case .allAgree: + let isAgreed: Bool = !self.currentState.isAllAgreed return .concat([ - .just(.setIsTermsOfServiceAgreed(isAgreed)), - .just(.setIsLocationAgreed(isAgreed)), - .just(.setIsPrivacyPolicyAgreed(isAgreed)) + .just(.updateIsTermsOfServiceAgreed(isAgreed)), + .just(.updateIsLocationAgreed(isAgreed)), + .just(.updateIsPrivacyPolicyAgreed(isAgreed)) ]) } } @@ -125,16 +86,11 @@ class OnboardingTermsOfServiceViewReactor: Reactor { func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { - case let .signUpResult(shouldNavigate): - newState.shouldNavigate = shouldNavigate - - case let .setIsTermsOfServiceAgreed(isAgreed): + case let .updateIsTermsOfServiceAgreed(isAgreed): newState.isTermsOfServiceAgreed = isAgreed - - case let .setIsLocationAgreed(isAgreed): + case let .updateIsLocationAgreed(isAgreed): newState.isLocationAgreed = isAgreed - - case let .setIsPrivacyPolicyAgreed(isAgreed): + case let .updateIsPrivacyPolicyAgreed(isAgreed): newState.isPrivacyPolicyAgreed = isAgreed } return newState @@ -144,6 +100,6 @@ class OnboardingTermsOfServiceViewReactor: Reactor { extension OnboardingTermsOfServiceViewReactor { func reactorForNickname() -> OnboardingNicknameSettingViewReactor { - OnboardingNicknameSettingViewReactor(provider: self.provider) + OnboardingNicknameSettingViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView+Rx.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView+Rx.swift new file mode 100644 index 00000000..de386b69 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView+Rx.swift @@ -0,0 +1,17 @@ +// +// TermsOfServiceAgreeButtonView+Rx.swift +// SOOUM +// +// Created by 오현식 on 9/11/25. +// + +import RxCocoa +import RxSwift + + +extension Reactive where Base: TermsOfServiceAgreeButtonView { + + var didSelect: ControlEvent { + self.base.backgroundButton.rx.tap + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView.swift index 2b2a0c57..13e9790f 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceAgreeButtonView.swift @@ -14,21 +14,25 @@ import Then class TermsOfServiceAgreeButtonView: UIView { enum Text { - static let title: String = "약관 전체 동의" + static let title: String = "전체 동의하기" } // MARK: Views private let checkImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.check))) - $0.tintColor = .som.gray600 + $0.image = .init(.icon(.v2(.outlined(.check)))) + $0.tintColor = .som.v2.gray400 } private let titleLabel = UILabel().then { $0.text = Text.title - $0.textColor = .som.gray600 - $0.typography = .som.head2WithRegular + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.title1 + } + + let backgroundButton = SOMButton().then { + $0.backgroundColor = .som.v2.gray100 } @@ -49,21 +53,31 @@ class TermsOfServiceAgreeButtonView: UIView { private func setupConstraints() { - self.layer.borderColor = UIColor.som.gray300.cgColor - self.layer.borderWidth = 1 - self.layer.cornerRadius = 12 + let container = UIView() + self.addSubview(container) + container.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) + } - self.addSubview(self.checkImageView) + container.addSubview(self.backgroundButton) + self.backgroundButton.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + container.addSubview(self.checkImageView) self.checkImageView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(24) $0.size.equalTo(24) } - self.addSubview(self.titleLabel) + container.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalTo(self.checkImageView.snp.trailing).offset(6) + $0.leading.equalTo(self.checkImageView.snp.trailing).offset(8) $0.trailing.lessThanOrEqualToSuperview() } } @@ -75,10 +89,8 @@ class TermsOfServiceAgreeButtonView: UIView { let animationDuration: TimeInterval = animated ? 0.25 : 0 - UIView.animate(withDuration: animationDuration) { - self.layer.borderColor = state ? UIColor.som.p300.cgColor : UIColor.som.gray300.cgColor - self.checkImageView.tintColor = state ? .som.p300 : .som.gray600 - self.titleLabel.textColor = state ? .som.p300 : .som.gray600 + UIView.animate(withDuration: animationDuration) { [weak self] in + self?.checkImageView.tintColor = state ? .som.v2.pDark : .som.v2.gray400 } } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView+Rx.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView+Rx.swift index 585cee2b..87ad549c 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView+Rx.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView+Rx.swift @@ -15,7 +15,7 @@ extension Reactive where Base: TermsOfServiceCellView { self.base.backgroundButton.rx.tap } - var nextSelect: ControlEvent { - self.base.nextButton.rx.tap + var moveSelect: ControlEvent { + self.base.moveButton.rx.tap } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView.swift index b8bcc958..6bf867cc 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/Views/TermsOfServiceCellView.swift @@ -17,21 +17,23 @@ class TermsOfServiceCellView: UIView { // MARK: Views private let checkBoxImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.checkBox))) - $0.tintColor = .som.gray500 + $0.image = .init(.icon(.v2(.outlined(.check)))) + $0.tintColor = .som.v2.gray200 } private let titleLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body1WithRegular + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.subtitle1 } - let nextButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.next))) - $0.foregroundColor = .som.gray800 + let moveButton = SOMButton().then { + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.foregroundColor = .som.v2.gray500 } - let backgroundButton = UIButton() + let backgroundButton = SOMButton().then { + $0.backgroundColor = .som.v2.white + } // MARK: Initalization @@ -57,37 +59,38 @@ class TermsOfServiceCellView: UIView { private func setupConstraints() { - self.snp.makeConstraints { - $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(44) + let container = UIView() + self.addSubview(container) + container.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + + container.addSubview(self.backgroundButton) + self.backgroundButton.snp.makeConstraints { + $0.edges.equalToSuperview() } - self.addSubview(self.checkBoxImageView) + container.addSubview(self.checkBoxImageView) self.checkBoxImageView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(36) + $0.leading.equalToSuperview().offset(24) $0.size.equalTo(24) } - self.addSubview(self.titleLabel) + container.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalTo(self.checkBoxImageView.snp.trailing).offset(6) + $0.leading.equalTo(self.checkBoxImageView.snp.trailing).offset(8) } - self.addSubview(self.backgroundButton) - self.backgroundButton.snp.makeConstraints { - $0.top.equalTo(self.checkBoxImageView.snp.top) - $0.bottom.equalTo(self.checkBoxImageView.snp.bottom) - $0.leading.equalTo(self.checkBoxImageView.snp.leading) - $0.trailing.equalTo(self.titleLabel.snp.trailing) - } - - self.addSubview(self.nextButton) - self.nextButton.snp.makeConstraints { - $0.centerY.trailing.equalToSuperview() - $0.leading.equalTo(self.titleLabel.snp.trailing).offset(6) - $0.trailing.equalToSuperview().offset(-30) + container.addSubview(self.moveButton) + self.moveButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.titleLabel.snp.trailing).offset(12) + $0.trailing.equalToSuperview().offset(-20) $0.size.equalTo(32) } } @@ -99,13 +102,8 @@ class TermsOfServiceCellView: UIView { let animationDuration: TimeInterval = animated ? 0.25 : 0 - UIView.animate(withDuration: animationDuration) { - self.checkBoxImageView.image = state ? .init(.icon(.filled(.checkBox))) : .init(.icon(.outlined(.checkBox))) - self.checkBoxImageView.tintColor = state ? .som.p300 : .som.gray600 - - self.titleLabel.textColor = state ? .som.p300 : .som.gray600 - - self.nextButton.foregroundColor = state ? .som.p300 : .som.gray600 + UIView.animate(withDuration: animationDuration) { [weak self] in + self?.checkBoxImageView.tintColor = state ? .som.v2.pDark : .som.v2.gray200 } } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingGuideMessageView.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingGuideMessageView.swift index 9367ccdf..5cbdc191 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingGuideMessageView.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingGuideMessageView.swift @@ -21,49 +21,46 @@ class OnboardingGuideMessageView: UIView { // MARK: Views + private let numberingView = OnboardingNumberingView(numbers: [1, 2, 3]) + private let titleLabel = UILabel().then { - $0.textColor = .som.black - $0.typography = .som.head1WithRegular.withAlignment(.left) + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2.withAlignment(.left) $0.lineBreakMode = .byWordWrapping $0.lineBreakStrategy = .hangulWordPriority $0.numberOfLines = 0 } - private let messageLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body2WithRegular - } - // MARK: Variables var title: String? { set { - self.titleLabel.text = newValue + let attributes = Typography.som.v2.head2.withAlignment(.left).attributes + self.titleLabel.attributedText = .init(string: newValue ?? "", attributes: attributes) } get { return self.titleLabel.text } } - var message: String? { + var currentNumber: Int { set { - self.messageLabel.text = newValue - self.messageLabel.isHidden = newValue == nil + self.numberingView.currentNumber = newValue } get { - return self.messageLabel.text + self.numberingView.currentNumber ?? 0 } } // MARK: Initalization - convenience init(title: String, message: String? = nil) { + convenience init(title: String, currentNumber: Int) { self.init(frame: .zero) self.title = title - self.message = message + self.currentNumber = currentNumber } override init(frame: CGRect) { @@ -81,15 +78,15 @@ class OnboardingGuideMessageView: UIView { private func setupConstraints() { - self.addSubview(self.titleLabel) - self.titleLabel.snp.makeConstraints { + self.addSubview(self.numberingView) + self.numberingView.snp.makeConstraints { $0.top.leading.equalToSuperview() $0.trailing.lessThanOrEqualToSuperview() } - self.addSubview(self.messageLabel) - self.messageLabel.snp.makeConstraints { - $0.top.equalTo(self.titleLabel.snp.bottom).offset(16) + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalTo(self.numberingView.snp.bottom).offset(16) $0.bottom.leading.equalToSuperview() $0.trailing.lessThanOrEqualToSuperview() } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingNumberingView.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingNumberingView.swift new file mode 100644 index 00000000..430ee381 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Views/OnboardingNumberingView.swift @@ -0,0 +1,104 @@ +// +// OnboardingNumberingView.swift +// SOOUM +// +// Created by 오현식 on 9/11/25. +// + +import UIKit + +import SnapKit +import Then + +class OnboardingNumberingView: UIView { + + enum Color { + static let selectedBackgroundColor: UIColor = .som.v2.pMain + static let selectedBorderColor: UIColor = .som.v2.pLight2 + static let defaultBackgroundColor: UIColor = .som.v2.gray300 + static let defaultBorderColor: UIColor = .som.v2.gray200 + } + + + // MARK: Views + + private let container = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + } + + + // MARK: Variables + + var currentNumber: Int? { + willSet { + self.container.subviews.forEach { view in + if view.tag <= newValue ?? 1 { + view.backgroundColor = Color.selectedBackgroundColor + view.layer.borderColor = Color.selectedBorderColor.cgColor + } + } + } + } + + + // MARK: Initalization + + convenience init(numbers: [Int]) { + self.init(frame: .zero) + + self.setupNumberView(numbers: numbers) + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension OnboardingNumberingView { + + func setupConstraints() { + + self.addSubview(self.container) + self.container.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func setupNumberView(numbers: [Int]) { + + numbers.forEach { number in + + let backgroundView = UIView().then { + $0.backgroundColor = Color.defaultBackgroundColor + $0.layer.borderColor = Color.defaultBorderColor.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 32 * 0.5 + $0.tag = number + } + + let label = UILabel().then { + $0.text = "\(number)" + $0.textColor = .som.v2.white + $0.typography = .som.v2.subtitle3 + } + + backgroundView.addSubview(label) + label.snp.makeConstraints { + $0.center.equalToSuperview() + } + + backgroundView.snp.makeConstraints { + $0.size.equalTo(32) + } + + self.container.addArrangedSubview(backgroundView) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift new file mode 100644 index 00000000..5e3f7a9e --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift @@ -0,0 +1,71 @@ +// +// HomePlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 12/30/24. +// + +import UIKit + +import SnapKit +import Then + +class HomePlaceholderViewCell: UITableViewCell { + + enum Text { + static let message: String = "아직 작성된 글이 없어요\n하고 싶은 이야기를 카드로 남겨보세요" + } + + static let cellIdentifier = String(reflecting: HomePlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.image(.v2(.placeholder_home))) + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.selectionStyle = .none + self.backgroundColor = .clear + self.isUserInteractionEnabled = false + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + let offset = UIScreen.main.bounds.height * 0.1 + $0.top.equalToSuperview().offset(offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(113) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Cells/MainHomeViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomeViewCell.swift similarity index 58% rename from SOOUM/SOOUM/Presentations/Main/Home/Cells/MainHomeViewCell.swift rename to SOOUM/SOOUM/Presentations/Main/Home/Cells/HomeViewCell.swift index ed949940..f2715dba 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Cells/MainHomeViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomeViewCell.swift @@ -1,5 +1,5 @@ // -// MainHomeViewCell.swift +// HomeViewCell.swift // SOOUM // // Created by 오현식 on 10/3/24. @@ -11,10 +11,18 @@ import SnapKit import Then -class MainHomeViewCell: UITableViewCell { +class HomeViewCell: UITableViewCell { + + static let cellIdentifier = String(reflecting: HomeViewCell.self) + + + // MARK: Views let cardView = SOMCard() + + // MARK: Initialize + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -28,33 +36,33 @@ class MainHomeViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + + // MARK: Override func + override func prepareForReuse() { super.prepareForReuse() self.cardView.prepareForReuse() } + + // MARK: Private func + private func setupConstraints() { self.contentView.addSubview(self.cardView) self.cardView.snp.makeConstraints { - $0.top.equalToSuperview().offset(10) - $0.bottom.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + $0.top.equalToSuperview() + $0.bottom.equalToSuperview().offset(-10) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } } - func setModel(_ model: SOMCardModel) { - self.cardView.setModel(model: model) - } - func setData(tagCard: TagDetailCardResponse.TagFeedCard) { - self.cardView.setData(tagCard: tagCard) - } + // MARK: Public info - /// 컨텐츠 모드에 따라 정보 스택뷰 순서 변경 - func changeOrderInCardContentStack(_ selectedIndex: Int) { - self.cardView.changeOrderInCardContentStack(selectedIndex) + func bind(_ model: BaseCardInfo) { + self.cardView.setModel(model: model) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Cells/PlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Cells/PlaceholderViewCell.swift deleted file mode 100644 index 1cddda8f..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Cells/PlaceholderViewCell.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// PlaceholderViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/30/24. -// - -import UIKit - -import SnapKit -import Then - - -class PlaceholderViewCell: UITableViewCell { - - enum Text { - static let title: String = "아직 등록된 카드가 없어요" - static let firstSubTitle: String = "사소하지만 말 못 한 이야기를" - static let secondSubTitle: String = "카드로 만들어 볼까요?" - } - - - // MARK: Views - - private let placeholderTitleLabel = UILabel().then { - $0.text = Text.title - $0.textColor = .som.black - $0.textAlignment = .center - $0.typography = .som.body1WithBold - } - - private let placeholderFirstSubTitleLabel = UILabel().then { - $0.text = Text.firstSubTitle - $0.textColor = .som.gray500 - $0.textAlignment = .center - $0.typography = .som.body2WithBold - } - - private let placeholderSecondSubTitleLabel = UILabel().then { - $0.text = Text.secondSubTitle - $0.textColor = .som.gray500 - $0.textAlignment = .center - $0.typography = .som.body2WithBold - } - - - // MARK: Initalization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.selectionStyle = .none - self.backgroundColor = .clear - self.isUserInteractionEnabled = false - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - // MARK: Private func - - private func setupConstraints() { - - self.contentView.addSubview(self.placeholderTitleLabel) - self.placeholderTitleLabel.snp.makeConstraints { - let offset = UIScreen.main.bounds.height * 0.2 - $0.top.equalToSuperview().offset(offset) - $0.centerX.equalToSuperview() - } - - self.contentView.addSubview(self.placeholderFirstSubTitleLabel) - self.placeholderFirstSubTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.placeholderTitleLabel.snp.bottom).offset(14) - $0.centerX.equalToSuperview() - } - - self.contentView.addSubview(self.placeholderSecondSubTitleLabel) - self.placeholderSecondSubTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.placeholderFirstSubTitleLabel.snp.bottom) - $0.centerX.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift index 6101394a..99d304c2 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift @@ -7,131 +7,115 @@ import UIKit -import RxSwift - import SnapKit import Then +import RxCocoa +import RxSwift class DetailViewCell: UICollectionViewCell { enum Text { - static let prevCardTitle: String = "전글" - static let pungedPrevCardTitle: String = "삭제됨" - static let deletedCardInDetailText: String = "이 글은 삭제되었어요" + static let deletedCardInDetailText: String = "삭제된 카드예요" } - private let cardView = SOMCard(hasScrollEnabled: true) - let prevCardBackgroundButton = UIButton().then { - $0.isHidden = true - } + // MARK: Views + + let memberInfoView = MemberInfoView() + /// 상세보기, 전글 배경 private let prevCardBackgroundImageView = UIImageView().then { - $0.backgroundColor = .clear - $0.layer.borderColor = UIColor.som.white.cgColor - $0.layer.borderWidth = 2 - $0.layer.cornerRadius = 14 + $0.contentMode = .scaleAspectFill + $0.layer.cornerRadius = 8 $0.layer.masksToBounds = true $0.isHidden = true } - /// 상세보기, 전글 라벨 - private let prevCardTextLabel = UILabel().then { - $0.text = Text.prevCardTitle - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .som.body2WithBold + /// 상세보기, 전글 dim + private let prevCardBackgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.black.withAlphaComponent(0.3) + } + /// 상세보기, 전글 아이콘 + private let prevCardbuttonImageView = UIImageView().then { + $0.image = .init(.image(.v2(.prev_card_button))) + $0.tintColor = .som.v2.white + } + let prevCardBackgroundButton = UIButton().then { + $0.isHidden = true } - /// 상세보기, 상단 오른쪽 (더보기/삭제) 버튼, 기본 = 더보기 - let rightTopSettingButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.more))) - $0.foregroundColor = .som.white + /// 상세보기, 배경 이미지 + private let backgroundImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.layer.borderColor = UIColor.som.v2.gray100.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 16 + $0.contentMode = .scaleAspectFill + $0.layer.masksToBounds = true + $0.isUserInteractionEnabled = true + } + /// 상세보기, 본문 dim + private let contentBackgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.dim + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + /// 상세보기, 본문 + private let contentScrollView = UIScrollView().then { + $0.isScrollEnabled = false + $0.showsVerticalScrollIndicator = true + $0.showsHorizontalScrollIndicator = false + $0.indicatorStyle = .white + $0.scrollIndicatorInsets = .zero + } + private let contentLabelView = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.body1 + $0.textAlignment = .center + $0.numberOfLines = 0 + $0.lineBreakMode = .byWordWrapping + $0.lineBreakStrategy = .hangulWordPriority } /// 상세보기, 카드 삭제 됐을 때 배경 private let deletedCardInDetailBackgroundView = UIView().then { - $0.backgroundColor = .init(hex: "#F8F8F8") - $0.layer.cornerRadius = 40 + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 16 $0.layer.masksToBounds = true $0.isHidden = true } /// 상세보기, 카드 삭제 됐을 때 이미지 private let deletedCardInDetailImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.trash))) - $0.tintColor = .som.gray300 + $0.image = .init(.image(.v2(.detail_delete_card))) } /// 상세보기, 카드 삭제 됐을 때 라벨 private let deletedCardInDetailLabel = UILabel().then { $0.text = Text.deletedCardInDetailText - $0.textColor = .som.gray500 - $0.textAlignment = .center - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 } - let memberBackgroundButton = UIButton() - private let memberImageView = UIImageView().then { - $0.backgroundColor = .clear - $0.layer.cornerRadius = 32 * 0.5 - $0.clipsToBounds = true - } - private let memberLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .som.body1WithBold - } + let tags = WrittenTags() - lazy var tags = SOMTags(configure: .horizontalWithoutRemove) + let likeAndCommentView = LikeAndCommentView() - var isOwnCard: Bool = false { - didSet { - let image = UIImage(.icon(.outlined(self.isOwnCard ? .trash : .more))) - self.rightTopSettingButton.configuration?.image = image - } - } - var prevCard: PrevCard? { - didSet { - if let prevCard = self.prevCard { - self.isPrevCardExist = true - self.prevCardBackgroundImageView.setImage(strUrl: prevCard.previousCardImgLink?.url ?? "") - } else { - self.isPrevCardExist = false - } - } - } + // MARK: Variables - var isPrevCardDelete: Bool? { - didSet { - if self.isPrevCardDelete ?? false { - self.isPrevCardDeleted() - } - } - } + private(set) var model: DetailCardInfo? - var member: Member = .init() { - didSet { - if let strUrl = self.member.profileImgUrl?.url { - self.memberImageView.setImage(strUrl: strUrl) - } else { - self.memberImageView.image = .init(.image(.sooumLogo)) - } - self.memberLabel.text = self.member.nickname - } - } + var disposeBag = DisposeBag() - private var isPrevCardExist: Bool = false { - didSet { - self.prevCardBackgroundImageView.isHidden = !self.isPrevCardExist - self.prevCardBackgroundButton.isHidden = !self.isPrevCardExist - } - } - var disposeBag = DisposeBag() + // MARK: Constraint + + private var textViewBackgroundHeightConstraint: Constraint? + + + // MARK: Initialize override init(frame: CGRect) { super.init(frame: frame) - self.setupConstraints() } @@ -139,120 +123,190 @@ class DetailViewCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + // MARK: Override func + override func prepareForReuse() { super.prepareForReuse() - self.cardView.prepareForReuse() + self.disposeBag = DisposeBag() } + + // MARK: Private func + private func setupConstraints() { - self.contentView.addSubview(self.cardView) - self.cardView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - let width: CGFloat = UIScreen.main.bounds.width - 20 * 2 - $0.height.equalTo(width) + self.backgroundColor = .som.v2.white + + self.contentView.addSubview(self.memberInfoView) + self.memberInfoView.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() } - self.contentView.addSubview(self.prevCardBackgroundImageView) + self.contentView.addSubview(self.backgroundImageView) + self.backgroundImageView.snp.makeConstraints { + $0.top.equalTo(self.memberInfoView.snp.bottom) + $0.centerX.equalToSuperview() + let size: CGFloat = UIScreen.main.bounds.width - 16 * 2 + $0.size.equalTo(size) + } + + self.backgroundImageView.addSubview(self.prevCardBackgroundImageView) self.prevCardBackgroundImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.leading.equalToSuperview().offset(36) - $0.size.equalTo(44) + $0.top.leading.equalToSuperview().offset(16) + $0.size.equalTo(40) + } + self.prevCardBackgroundImageView.addSubview(self.prevCardBackgroundDimView) + self.prevCardBackgroundDimView.snp.makeConstraints { + $0.edges.equalToSuperview() } - self.prevCardBackgroundImageView.addSubviews(self.prevCardTextLabel) - self.prevCardTextLabel.snp.makeConstraints { + self.prevCardBackgroundDimView.addSubview(self.prevCardbuttonImageView) + self.prevCardbuttonImageView.snp.makeConstraints { $0.center.equalToSuperview() + $0.size.equalTo(24) } - - self.contentView.addSubview(self.prevCardBackgroundButton) + self.backgroundImageView.addSubview(self.prevCardBackgroundButton) self.prevCardBackgroundButton.snp.makeConstraints { - $0.edges.equalTo(self.prevCardBackgroundImageView.snp.edges) + $0.top.leading.equalToSuperview().offset(16) + $0.size.equalTo(40) } - self.contentView.addSubview(self.rightTopSettingButton) - self.rightTopSettingButton.snp.makeConstraints { - $0.top.equalTo(self.cardView.snp.top).offset(26) - $0.trailing.equalTo(self.cardView.snp.trailing).offset(-26) - $0.size.equalTo(24) + self.backgroundImageView.addSubview(self.contentBackgroundDimView) + self.contentBackgroundDimView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(32) + $0.trailing.equalToSuperview().offset(-32) + self.textViewBackgroundHeightConstraint = $0.height.equalTo(Typography.som.v2.body1.lineHeight + 20 * 2).constraint } - self.contentView.addSubview(self.memberImageView) - self.memberImageView.snp.makeConstraints { - $0.bottom.equalTo(self.cardView.snp.bottom).offset(-16) - $0.leading.equalTo(self.cardView.snp.leading).offset(16) - $0.size.equalTo(32) + self.contentBackgroundDimView.addSubview(self.contentScrollView) + self.contentScrollView.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.bottom.equalToSuperview().offset(-20) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + } + self.contentScrollView.addSubview(self.contentLabelView) + self.contentLabelView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalTo(self.contentScrollView.snp.width) } - self.contentView.addSubview(self.memberLabel) - self.memberLabel.snp.makeConstraints { - $0.bottom.equalTo(self.cardView.snp.bottom).offset(-22) - $0.leading.equalTo(self.memberImageView.snp.trailing).offset(8) + self.contentView.addSubview(self.tags) + self.tags.snp.makeConstraints { + $0.bottom.equalTo(self.backgroundImageView.snp.bottom).offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(28) } - self.contentView.addSubview(self.memberBackgroundButton) - self.memberBackgroundButton.snp.makeConstraints { - $0.top.equalTo(self.memberImageView.snp.top) - $0.bottom.equalTo(self.memberImageView.snp.bottom) - $0.leading.equalTo(self.memberImageView.snp.leading) - $0.trailing.equalTo(self.memberLabel.snp.trailing) + self.contentView.addSubview(self.likeAndCommentView) + self.likeAndCommentView.snp.makeConstraints { + $0.top.equalTo(self.backgroundImageView.snp.bottom) + $0.bottom.horizontalEdges.equalToSuperview() } self.contentView.addSubview(self.deletedCardInDetailBackgroundView) self.deletedCardInDetailBackgroundView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - let width: CGFloat = UIScreen.main.bounds.width - 20 * 2 - $0.height.equalTo(width) - } - - let container = UIStackView(arrangedSubviews: [ - self.deletedCardInDetailImageView, - self.deletedCardInDetailLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - } - self.deletedCardInDetailBackgroundView.addSubview(container) - container.snp.makeConstraints { - $0.center.equalToSuperview() + $0.top.equalTo(self.memberInfoView.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.bottom.trailing.equalToSuperview().offset(-16) } + self.deletedCardInDetailBackgroundView.addSubview(self.deletedCardInDetailImageView) self.deletedCardInDetailImageView.snp.makeConstraints { - $0.size.equalTo(60) + $0.centerY.equalToSuperview().offset(-20) + $0.centerX.equalToSuperview() } - - self.contentView.addSubview(self.tags) - self.tags.snp.makeConstraints { - $0.top.equalTo(self.cardView.snp.bottom) - $0.bottom.leading.trailing.equalToSuperview() - let isEmpty = self.tags.models.isEmpty - $0.height.equalTo(isEmpty ? 40 : 59) + self.deletedCardInDetailBackgroundView.addSubview(self.deletedCardInDetailLabel) + self.deletedCardInDetailLabel.snp.makeConstraints { + $0.top.equalTo(self.deletedCardInDetailImageView.snp.bottom).offset(20) + $0.centerX.equalToSuperview() } } - func setDatas(_ model: SOMCardModel, tags: [SOMTagModel]) { - self.cardView.setModel(model: model) - self.cardView.removeLikeAndCommentInStack() - self.tags.setModels(tags) - self.tags.snp.updateConstraints { - $0.height.equalTo(tags.isEmpty ? 40 : 59) + private func updateTextContainerInsetAndHeight(_ content: String, typography: Typography) { + + var attributes = typography.attributes + attributes[.font] = typography.font + let attributedText = NSAttributedString( + string: content, + attributes: attributes + ) + + let size: CGSize = .init(width: self.contentScrollView.bounds.width, height: .greatestFiniteMagnitude) + let boundingHeight = attributedText.boundingRect( + with: size, + options: [.usesLineFragmentOrigin], + context: nil + ).height + + let lines: CGFloat = boundingHeight / typography.lineHeight + let isScrollEnabled: Bool = lines > 8 + let newHeight: CGFloat = isScrollEnabled ? typography.lineHeight * 8 : boundingHeight + let updatedHeight: CGFloat = max(newHeight, typography.lineHeight) + self.textViewBackgroundHeightConstraint?.update(offset: updatedHeight + 20 * 2) + self.contentScrollView.isScrollEnabled = isScrollEnabled + } + + + // MARK: Public func + + func setModels(_ model: DetailCardInfo) { + + self.model = model + + self.memberInfoView.member = (model.nickname, model.profileImgURL) + self.memberInfoView.distance = model.distance + self.memberInfoView.createAt = model.createdAt + + if let prevCardInfo = model.prevCardInfo { + + self.prevCardBackgroundImageView.setImage(strUrl: prevCardInfo.prevCardImgURL) + + self.prevCardBackgroundImageView.isHidden = false + self.prevCardBackgroundButton.isHidden = false + } else { + + self.prevCardBackgroundImageView.isHidden = true + self.prevCardBackgroundButton.isHidden = true + } + + if let isPrevCardDeleted = model.prevCardInfo?.isPrevCardDeleted, isPrevCardDeleted { + + self.prevCardBackgroundImageView.image = nil } + + self.backgroundImageView.setImage(strUrl: model.cardImgURL, with: model.cardImgName) + + let typography: Typography + switch model.font { + case .pretendard: typography = .som.v2.body1 + case .ridi: typography = .som.v2.ridiCard + case .yoonwoo: typography = .som.v2.yoonwooCard + case .kkookkkook: typography = .som.v2.kkookkkookCard + } + self.contentLabelView.text = model.cardContent + self.contentLabelView.typography = typography + self.updateTextContainerInsetAndHeight(model.cardContent, typography: typography) + + let tagModels: [WrittenTagModel] = model.tags.map { tag in + WrittenTagModel(tag.id, originalText: tag.title, typography: typography) + } + self.tags.setModels(tagModels) + + self.likeAndCommentView.isLikeSelected = model.isLike + self.likeAndCommentView.likeCount = model.likeCnt + self.likeAndCommentView.commentCount = model.commentCnt + self.likeAndCommentView.visitedCount = model.visitedCnt } func isDeleted() { - self.cardView.removeFromSuperview() - self.rightTopSettingButton.removeFromSuperview() - self.memberImageView.removeFromSuperview() - self.memberLabel.removeFromSuperview() + + self.memberInfoView.updateViewsWhenDeleted() + self.likeAndCommentView.updateViewsWhenDeleted() + self.backgroundImageView.removeFromSuperview() + self.tags.removeFromSuperview() + self.deletedCardInDetailBackgroundView.isHidden = false } - - func isPrevCardDeleted() { - self.prevCardBackgroundImageView.backgroundColor = .init(hex: "#7D7D7D") - self.prevCardBackgroundImageView.image = nil - self.prevCardTextLabel.text = Text.pungedPrevCardTitle - } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift index 54950736..7ba5aba6 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift @@ -17,12 +17,12 @@ import Then class DetailViewFooter: UICollectionReusableView { enum Text { - static let noContentText: String = "답카드가 아직 없어요" + static let noContentText: String = "첫 댓글을 남겨보세요" } - let likeAndCommentView = LikeAndCommentView() - let noContentBackgroundView = UIImageView() + // MARK: Views + let noContentLabel = UILabel().then { $0.text = Text.noContentText $0.textColor = .som.gray400 @@ -30,38 +30,39 @@ class DetailViewFooter: UICollectionReusableView { $0.typography = .som.body1WithBold } + /** + TODO: 임시, + cell을 제외한 top padding 54 * 0.5, bottom inset = 10 맞추기 위해 32만큼 올림 + 현재 bottom padding == 27, 디자인 bottom padding == 10 + */ private let flowLayout = UICollectionViewFlowLayout().then { - $0.minimumLineSpacing = 8 + $0.minimumLineSpacing = 10 + $0.minimumInteritemSpacing = 0 $0.scrollDirection = .horizontal + $0.sectionInset = .init(top: 0, left: 16, bottom: 32, right: 16) } - lazy var collectionView = UICollectionView( + private lazy var collectionView = UICollectionView( frame: .zero, collectionViewLayout: self.flowLayout ).then { - $0.alwaysBounceHorizontal = true $0.backgroundColor = .clear $0.indicatorStyle = .black - $0.decelerationRate = .fast - + $0.alwaysBounceHorizontal = true $0.showsVerticalScrollIndicator = false $0.showsHorizontalScrollIndicator = false - $0.register(DetailViewFooterCell.self, forCellWithReuseIdentifier: "cell") + $0.register(DetailViewFooterCell.self, forCellWithReuseIdentifier: DetailViewFooterCell.cellIdentifier) + $0.dataSource = self $0.delegate = self } - var commentCards = [Card]() - var isDeletedCard: Bool = false { - didSet { - if isDeletedCard { - self.likeAndCommentView.updateViewsWhenDeleted() - } - } - } + // MARK: Variables + + private(set) var commentCards: [BaseCardInfo] = [] private var currentOffset: CGFloat = 0 private var isLoadingMore: Bool = false @@ -71,6 +72,9 @@ class DetailViewFooter: UICollectionReusableView { var disposeBag = DisposeBag() + + // MARK: Initialize + override init(frame: CGRect) { super.init(frame: frame) self.setupConstraints() @@ -80,60 +84,50 @@ class DetailViewFooter: UICollectionReusableView { fatalError("init(coder:) has not been implemented") } + + // MARK: Override func + override func prepareForReuse() { super.prepareForReuse() + self.disposeBag = DisposeBag() } private func setupConstraints() { + self.backgroundColor = .som.v2.gray100 + let topSeperatorView = UIView().then { $0.backgroundColor = .som.gray200 } self.addSubview(topSeperatorView) topSeperatorView.snp.makeConstraints { - $0.top.leading.trailing.equalToSuperview() - $0.height.equalTo(0.4) + $0.top.horizontalEdges.equalToSuperview() + $0.height.equalTo(1) } - self.addSubview(self.likeAndCommentView) - self.likeAndCommentView.snp.makeConstraints { - $0.top.equalTo(topSeperatorView.snp.bottom) - $0.leading.trailing.equalToSuperview() - $0.height.equalTo(48) - } - - self.addSubviews(self.collectionView) + self.addSubview(self.collectionView) self.collectionView.snp.makeConstraints { - $0.top.equalTo(self.likeAndCommentView.snp.bottom) - $0.bottom.leading.trailing.equalToSuperview() + $0.edges.equalToSuperview() } - self.addSubviews(self.noContentBackgroundView) - self.noContentBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.likeAndCommentView.snp.bottom) - $0.bottom.leading.trailing.equalToSuperview() - } - self.noContentBackgroundView.addSubview(self.noContentLabel) + self.addSubview(self.noContentLabel) self.noContentLabel.snp.makeConstraints { $0.center.equalToSuperview() } } - func setDatas(_ datas: [Card], cardSummary: CardSummary) { + func setModels(_ models: [BaseCardInfo]) { self.isLoadingMore = false - self.commentCards = datas - self.likeAndCommentView.likeCount = cardSummary.cardLikeCnt - self.likeAndCommentView.commentCount = cardSummary.commentCnt - self.likeAndCommentView.isLikeSelected = cardSummary.isLiked + self.commentCards = models - self.collectionView.isHidden = datas.isEmpty - self.noContentBackgroundView.isHidden = !datas.isEmpty + self.collectionView.isHidden = models.isEmpty + self.noContentLabel.isHidden = models.isEmpty == false - if !datas.isEmpty { + UIView.performWithoutAnimation { self.collectionView.reloadData() } } @@ -145,6 +139,7 @@ extension DetailViewFooter: UICollectionViewDataSource { _ collectionView: UICollectionView, numberOfItemsInSection section: Int ) -> Int { + return self.commentCards.count } @@ -152,13 +147,14 @@ extension DetailViewFooter: UICollectionViewDataSource { _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { - let cell: DetailViewFooterCell = collectionView - .dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) - as! DetailViewFooterCell + + let cell: DetailViewFooterCell = collectionView.dequeueReusableCell( + withReuseIdentifier: DetailViewFooterCell.cellIdentifier, + for: indexPath + ) as! DetailViewFooterCell let commentCard = self.commentCards[indexPath.row] - let model: SOMCardModel = .init(data: commentCard) - cell.setModel(model) + cell.bind(commentCard) return cell } @@ -176,26 +172,27 @@ extension DetailViewFooter: UICollectionViewDelegateFlowLayout { willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath ) { - if self.commentCards.count > 1 { - let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout - layout.sectionInset.left = 19 - layout.sectionInset.right = 19 - } else { - let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout - let cellWidthWithSpacing = cell.bounds.width + layout.minimumLineSpacing - layout.sectionInset.left = (collectionView.bounds.width - cellWidthWithSpacing) * 0.5 - layout.sectionInset.right = (collectionView.bounds.width - cellWidthWithSpacing) * 0.5 - } - + // TODO: 답카드가 1개일 때, 중앙 정렬 + // if self.commentCards.count > 1 { + // let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout + // layout.sectionInset.left = 19 + // layout.sectionInset.right = 19 + // } else { + // let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout + // let cellWidthWithSpacing = cell.bounds.width + layout.minimumLineSpacing + // layout.sectionInset.left = (collectionView.bounds.width - cellWidthWithSpacing) * 0.5 + // layout.sectionInset.right = (collectionView.bounds.width - cellWidthWithSpacing) * 0.5 + // } + guard self.commentCards.isEmpty == false else { return } - + let lastSectionIndex = collectionView.numberOfSections - 1 let lastRowIndex = collectionView.numberOfItems(inSection: lastSectionIndex) - 1 - + if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.item == lastRowIndex { - + self.isLoadingMore = false - + let lastId = self.commentCards[indexPath.item].id self.moreDisplay.accept(lastId) } @@ -206,7 +203,7 @@ extension DetailViewFooter: UICollectionViewDelegateFlowLayout { layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { - let height: CGFloat = collectionView.bounds.height + let height: CGFloat = collectionView.bounds.height - 10 * 2 - 34 return CGSize(width: height, height: height) } @@ -214,7 +211,6 @@ extension DetailViewFooter: UICollectionViewDelegateFlowLayout { let offset = scrollView.contentOffset.x - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 self.isLoadingMore = offset > self.currentOffset self.currentOffset = offset } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooterCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooterCell.swift index 1844c93b..cb916734 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooterCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooterCell.swift @@ -10,10 +10,17 @@ import UIKit import SnapKit import Then - class DetailViewFooterCell: UICollectionViewCell { - let cardView = SOMCard() + static let cellIdentifier = String(reflecting: DetailViewFooterCell.self) + + + // MARK: Views + + private let cardView = SOMCard(type: .comment) + + + // MARK: Initialize override init(frame: CGRect) { super.init(frame: frame) @@ -24,11 +31,18 @@ class DetailViewFooterCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + + // MARK: Override func + override func prepareForReuse() { super.prepareForReuse() + self.cardView.prepareForReuse() } + + // MARK: Private func + private func setupConstraints() { self.contentView.addSubview(self.cardView) @@ -37,7 +51,10 @@ class DetailViewFooterCell: UICollectionViewCell { } } - func setModel(_ model: SOMCardModel) { + + // MARK: Public func + + func bind(_ model: BaseCardInfo) { self.cardView.setModel(model: model) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift index b1c1d598..48b56b9f 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift @@ -10,48 +10,79 @@ import UIKit import SnapKit import Then +import SwiftEntryKit + import ReactorKit import RxCocoa import RxSwift - class DetailViewController: BaseNavigationViewController, View { enum Text { - static let moreBottomSheetEntryName: String = "moreButtonBottomSheetViewController" - static let deleteDialogTitle: String = "카드를 삭제할까요?" - static let deleteDialogMessage: String = "삭제한 카드는 복구할 수 없어요" + static let feedDetailNavigationTitle: String = "카드" + static let commentDetailNavigationTitle: String = "댓글카드" + static let deletedNavigationTitle: String = "삭제된 카드" + + static let deleteDialogTitle: String = "카드를 삭제하시겠어요?" + static let deleteDialogMessage: String = "삭제한 카드는 영구적으로 삭제되며, 복구할 수 없습니다." + static let deletePungDialogTitle: String = "시간 제한 카드를 삭제할까요?" static let deletePungDialogMessage: String = "카드를 삭제하면,\n답카드가 자동으로 삭제되지 않아요" - static let blockDialogTitle: String = "해당 사용자를 차단할까요?" - static let blockDialogMessage: String = "해당 사용자의 모든 카드를 모두 볼 수 없어요" + static let deletedCardDialogTitle: String = "삭제된 카드예요" + + static let bottomFloatEntryName: String = "bottomFloatEntryName" + static let bottomToastEntryName: String = "bottomToastEntryName" + + static let blockButtonFloatActionTitle: String = "차단하기" + static let unblockButtonFloatActionTitle: String = "차단해제" + static let reportButtonFloatActionTitle: String = "신고하기" + static let deleteButtonFloatActionTitle: String = "삭제" + + static let blockToastLeadingTitle: String = "앞으로 " + static let blockToastTrailingTitle: String = "의 카드가 목록에서 보이지 않습니다" + + static let blockDialogTitle: String = "차단하시겠어요?" + static let blockDialogMessage: String = "의 모든 카드를 볼 수 없어요." + static let confirmActionTitle: String = "확인" static let cancelActionTitle: String = "취소" - static let deleteActionTitle: String = "삭제하기" - static let blockActionTitle: String = "차단하기" + + static let eventCardTitle: String = "event" } + + + // MARK: Views - let rightHomeButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.home))) + private let leftHomeButton = SOMButton().then { + $0.image = .init(.icon(.v2(.outlined(.home)))) $0.foregroundColor = .som.black } + + private let rightMoreButton = SOMButton().then { + $0.image = .init(.icon(.v2(.outlined(.more)))) + $0.foregroundColor = .som.black + } + + private let pungView = PungView().then { + $0.isHidden = true + } private let flowLayout = UICollectionViewFlowLayout().then { $0.scrollDirection = .vertical } - lazy var collectionView = UICollectionView( + private lazy var collectionView = UICollectionView( frame: .zero, collectionViewLayout: self.flowLayout ).then { - $0.backgroundColor = .som.white - $0.alwaysBounceVertical = true $0.showsVerticalScrollIndicator = false $0.showsHorizontalScrollIndicator = false + $0.contentInsetAdjustmentBehavior = .never + $0.refreshControl = SOMRefreshControl() $0.register(DetailViewCell.self, forCellWithReuseIdentifier: "cell") @@ -64,218 +95,485 @@ class DetailViewController: BaseNavigationViewController, View { $0.dataSource = self $0.delegate = self } + + private let floatingButton = FloatingButton() + + + // MARK: Variables - let moreButtonBottomSheetViewController = MoreBottomSheetViewController() - - override var navigationBarHeight: CGFloat { - 58 - } - - var detailCard = DetailCard() - var prevCard: PrevCard? - - var commentCards = [Card]() - var cardSummary = CardSummary() - - var isDeleted = false - var isRefreshEnabled = false + private var detailCard: DetailCardInfo = .defaultValue + private var commentCards: [BaseCardInfo] = [] + private var isDeleted = false + + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + private var actions: [SOMBottomFloatView.FloatAction] = [] + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + floating button height + padding + return 34 + 56 + 8 + } + // MARK: Override func - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadDetaildata(_:)), + name: .reloadDetailData, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.deletedCommentCardWithId(_:)), + name: .deletedCommentCardWithId, + object: nil + ) - self.hidesBottomBarWhenPushed = true + NotificationCenter.default.addObserver( + self, + selector: #selector(self.updatedReportState(_:)), + name: .updatedReportState, + object: nil + ) } override func setupNaviBar() { super.setupNaviBar() - self.navigationBar.setRightButtons([self.rightHomeButton]) - } - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.collectionView) - self.collectionView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.bottom.equalToSuperview().offset(-22) - $0.leading.trailing.equalToSuperview() + if self.reactor?.currentState.isDeleted == false { + self.navigationBar.setRightButtons([self.rightMoreButton]) } } - override func bind() { - super.bind() - - // Navigation pop to root - self.rightHomeButton.rx.tap - .subscribe(with: self) { object, _ in - if let navigationController = object.navigationController { - navigationController.popToRootViewController(animated: false) - } else { - object.navigationPop() - } - } - .disposed(by: self.disposeBag) - - /// 신고하기 화면으로 전환 - self.moreButtonBottomSheetViewController.reportLabelButton.rx.tap - .subscribe(with: self) { object, _ in - if let reactor = object.reactor { - - object.dismissBottomSheet() - let viewController = ReportViewController() - viewController.reactor = reactor.reactorForReport() - object.navigationPush(viewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: self.disposeBag) + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.horizontalEdges.equalToSuperview() + } + + self.view.addSubview(self.pungView) + self.pungView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.centerX.equalToSuperview() + } + + self.view.addSubview(self.floatingButton) + self.floatingButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.trailing.equalToSuperview().offset(-16) + } } // MARK: - Bind - func bind(reactor: DetailViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // 차단하기 - self.moreButtonBottomSheetViewController.blockLabelButton.rx.tap - .subscribe(with: self) { object, _ in - - let cancelAction = SOMDialogAction( - title: Text.cancelActionTitle, - style: .gray, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - let blockAction = SOMDialogAction( - title: Text.blockActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) { - reactor.action.onNext(.block) - object.dismissBottomSheet() + func bind(reactor: DetailViewReactor) { + + // 댓글카드 작성 전환 + self.floatingButton.backgoundButton.rx.throttleTap(.seconds(3)) + .map { _ in Reactor.Action.willPushToWrite(.floating) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let detailCard = reactor.state.map(\.detailCard).distinctUntilChanged().filterNil().share() + let commentCards = reactor.state.map(\.commentCards).distinctUntilChanged().filterNil().share() + let isFeed = reactor.state.map(\.isFeed).share() + let isBlocked = reactor.state.map(\.isBlocked).distinctUntilChanged().share() + let isReported = reactor.state.map(\.isReported).distinctUntilChanged().share() + + let rightMoreButtonDidTap = self.rightMoreButton.rx.throttleTap.share() + // 더보기 버튼 액션 + rightMoreButtonDidTap + .withLatestFrom(detailCard) + .filter { $0.isOwnCard } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + object.actions = [ + .init( + title: Text.deleteButtonFloatActionTitle, + image: .init(.icon(.v2(.outlined(.trash)))), + foregroundColor: .som.v2.rMain, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomFloatEntryName)) { + + object?.showDeleteCardDialog() + } + } + ) + ] + + let bottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomFloatEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } + .disposed(by: self.disposeBag) + + rightMoreButtonDidTap + .withLatestFrom(Observable.combineLatest(detailCard, isBlocked, isReported)) + .filter { $0.0.isOwnCard == false } + .map { ($0.0.nickname, $0.1, $0.2) } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, combined in + + let (nickname, isBlocked, isReported) = combined + + object.actions = [ + .init( + title: isBlocked ? Text.blockButtonFloatActionTitle : Text.unblockButtonFloatActionTitle, + image: .init(.icon(.v2(.outlined(isBlocked ? .hide : .eye)))), + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomFloatEntryName)) { + if isBlocked { + object?.showBlockedUserDialog(nickname: nickname) { + reactor.action.onNext(.block(isBlocked: true)) + } + } else { + reactor.action.onNext(.block(isBlocked: false)) + } + } + } + ), + .init( + title: Text.reportButtonFloatActionTitle, + image: .init(.icon(.v2(.outlined(.flag)))), + foregroundColor: .som.v2.rMain, + isEnabled: isReported == false, + action: { [weak object] in + + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomFloatEntryName)) { + + let reportViewController = ReportViewController() + reportViewController.reactor = reactor.reactorForReport() + object?.navigationPush(reportViewController, animated: true) + } + } + ) + ] + + let bottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomFloatEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } + .disposed(by: self.disposeBag) + + // 댓글카드 홈 버튼 액션 + self.leftHomeButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + object.navigationPopToRoot(animated: false) + } + .disposed(by: self.disposeBag) + + // 카드 정보 업데이트 시 전역으로 알림 + self.rx.viewDidDisappear + .subscribe(with: self) { object, _ in + /// 좋아요 업데이트 후 뒤로갔을 때, 좋아요 업데이트 알림 + if reactor.currentState.isLiked { + NotificationCenter.default.post( + name: .addedFavoriteWithCardId, + object: nil, + userInfo: [ + "cardId": object.detailCard.id, + "addedFavorite": object.detailCard.isLike + ] + ) + } + /// 사용자 차단 후 뒤로 갔을 때, 차단된 사용자 카드 숨김 알림 + if reactor.initialState.isBlocked != reactor.currentState.isBlocked { + NotificationCenter.default.post( + name: .updatedBlockUser, + object: nil, + userInfo: ["isBlocked": !reactor.currentState.isBlocked] + ) + } + } + .disposed(by: self.disposeBag) + + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .observe(on: MainScheduler.asyncInstance) + .filter { $0 == false } + .subscribe(with: self.collectionView) { collectionView, _ in + collectionView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + isFeed + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFeed in + object.navigationBar.title = isFeed ? + Text.feedDetailNavigationTitle : + Text.commentDetailNavigationTitle + + if isFeed == false { + object.navigationBar.setLeftButtons([object.leftHomeButton]) + } + } + .disposed(by: self.disposeBag) + + detailCard + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, detailCard in + object.detailCard = detailCard + + object.pungView.subscribePungTime(detailCard.storyExpirationTime) + object.pungView.isHidden = detailCard.storyExpirationTime == nil + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + } + .disposed(by: self.disposeBag) + + commentCards + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, commentCards in + object.commentCards = commentCards + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.willPushToDetailEnabled) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, willPushToDetailEnabled in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForPush( + willPushToDetailEnabled.prevCardId, + hasDeleted: willPushToDetailEnabled.isDeleted + ) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .detail + ) + ) + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.willPushToWriteEnabled) + .distinctUntilChanged(reactor.canPushToWrite) + .filterNil() + .filter { $0.isDeleted } + .map(\.enterTo) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, enterTo in + let writeCardViewController = WriteCardViewController() + writeCardViewController.reactor = reactor.reactorForWriteCard() + object.navigationPush( + writeCardViewController, + animated: true + ) { _ in + reactor.action.onNext(.cleanup) + + if enterTo == .icon { + GAHelper.shared.logEvent( + event: GAEvent.DetailView.moveToCreateCommentCardView_icon_btn_click + ) + } else { + GAHelper.shared.logEvent( + event: GAEvent.DetailView.moveToCreateCommentCardView_floating_btn_click + ) + if reactor.currentState.detailCard? + .cardImgName + .contains(Text.eventCardTitle) == true { + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.moveToCreateCommentCardView_withEventImg_floating_btn_click + ) } } - ) - - SOMDialogViewController.show( - title: Text.blockDialogTitle, - message: Text.blockDialogMessage, - actions: [cancelAction, blockAction] - ) - } - .disposed(by: self.disposeBag) - - // State - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .subscribe(with: self.collectionView) { collectionView, isLoading in - if isLoading { - collectionView.refreshControl?.beginRefreshingFromTop() - } else { - collectionView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - Observable.combineLatest( - reactor.state.map(\.detailCard).distinctUntilChanged(), - reactor.state.map(\.prevCard).distinctUntilChanged() - ) - .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, pair in - object.detailCard = pair.0 - object.prevCard = pair.1 - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.commentCards) - .distinctUntilChanged() - .subscribe(with: self) { object, commentCards in - object.commentCards = commentCards - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - } - .disposed(by: disposeBag) - - reactor.state.map(\.cardSummary) - .distinctUntilChanged() - .subscribe(with: self) { object, cardSummary in - object.cardSummary = cardSummary - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - } - .disposed(by: disposeBag) - - reactor.state.map(\.isDeleted) - .filterNil() - .distinctUntilChanged() - .subscribe(with: self) { object, _ in - UIApplication.topViewController?.dismiss(animated: true) { - - object.navigationPop() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isBlocked) - .distinctUntilChanged() - .subscribe(with: self) { object, _ in - object.navigationPop() - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isErrorOccur) - .distinctUntilChanged() - .filterNil() - .filter { $0 } - .subscribe(with: self) { object, _ in - - switch reactor.entranceType { - case .navi: - object.isDeleted = true - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - case .push: - let notificationTabBarController = NotificationTabBarController() - notificationTabBarController.reactor = reactor.reactorForNoti() - - object.navigationPush(notificationTabBarController, animated: false) - object.navigationController?.viewControllers.removeAll(where: { $0.isKind(of: DetailViewController.self) }) - } - } - .disposed(by: self.disposeBag) - } + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.isLiked) + .distinctUntilChanged() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let updated: DetailCardInfo + if object.detailCard.isLike { + + let updatedLikeCnt = object.detailCard.likeCnt - 1 + updated = object.detailCard.updateLikeCnt(updatedLikeCnt, with: false) + } else { + + let updatedLikeCnt = object.detailCard.likeCnt + 1 + updated = object.detailCard.updateLikeCnt(updatedLikeCnt, with: true) + } + + object.detailCard = updated + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + } + .disposed(by: self.disposeBag) + + isBlocked + .filter { $0 == false } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let title = Text.blockToastLeadingTitle + object.detailCard.nickname + Text.blockToastTrailingTitle + let actions = [ + SOMBottomToastView.ToastAction(title: Text.cancelActionTitle, action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomToastEntryName)) { + reactor.action.onNext(.block(isBlocked: false)) + } + }) + ] + let bottomToastView = SOMBottomToastView(title: title, actions: actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 56 + 8) + } + .disposed(by: self.disposeBag) + + Observable.combineLatest( + reactor.state.map(\.isDeleted).distinctUntilChanged().filter { $0 }, + commentCards.map(\.isEmpty), + isFeed, + reactor.state.map(\.hasErrors) + ) + .map { ($0.1, $0.2, $0.3) } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, combined in + + object.navigationBar.title = Text.deletedNavigationTitle + object.navigationBar.setRightButtons([]) + + object.floatingButton.removeFromSuperview() + + object.isDeleted = true + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + + let (isCommentEmpty, isFeed, errors) = combined + + guard let isFeed = isFeed else { return } + + if isFeed { + NotificationCenter.default.post( + name: .deletedFeedCardWithId, + object: nil, + userInfo: ["cardId": reactor.selectedCardId, "isDeleted": true] + ) + } else { + NotificationCenter.default.post( + name: .addedCommentWithCardId, + object: nil, + userInfo: ["cardId": reactor.selectedCardId, "addedComment": false] + ) + + NotificationCenter.default.post( + name: .deletedCommentCardWithId, + object: nil, + userInfo: ["cardId": reactor.selectedCardId, "isDeleted": true] + ) + } + + if case 410 = errors { + object.showDeletedCardDialog { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.navigationPopToRoot() + } + } + return + } + + guard isCommentEmpty else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.navigationPop() + } + } + .disposed(by: self.disposeBag) + } + + + // MARK: Objc func + + @objc + private func reloadDetaildata(_ notification: Notification) { + + self.reactor?.action.onNext(.landing) + } + + @objc + private func deletedCommentCardWithId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + notification.userInfo?["isDeleted"] as? Bool == true + else { return } + + guard self.reactor?.currentState.commentCards?.contains(where: { $0.id == cardId }) == true else { return } + + if let detailCard = self.reactor?.currentState.detailCard { + let commentCnt = detailCard.commentCnt > 0 ? detailCard.commentCnt - 1 : 0 + self.reactor?.action.onNext(.updateDetail(detailCard.updateCommentCnt(commentCnt))) + } + + var commentCards = self.reactor?.currentState.commentCards ?? [] + commentCards.removeAll(where: { $0.id == cardId }) + + self.reactor?.action.onNext(.updateComments(commentCards)) + } + + @objc + private func updatedReportState(_ notification: Notification) { + + self.reactor?.action.onNext(.updateReport(true)) + } } extension DetailViewController: UICollectionViewDataSource { @@ -291,115 +589,102 @@ extension DetailViewController: UICollectionViewDataSource { _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { + let cell: DetailViewCell = collectionView .dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! DetailViewCell guard self.isDeleted == false else { cell.isDeleted() + self.pungView.isDeleted() return cell } - let card = Card( - id: self.detailCard.id, - content: self.detailCard.content, - distance: self.detailCard.distance, - createdAt: self.detailCard.createdAt, - storyExpirationTime: self.detailCard.storyExpirationTime, - likeCnt: self.detailCard.likeCnt, - commentCnt: self.detailCard.commentCnt, - backgroundImgURL: self.detailCard.backgroundImgURL, - links: .init(), - font: self.detailCard.font, - fontSize: self.detailCard.fontSize, - isLiked: self.detailCard.isLiked, - isCommentWritten: self.detailCard.isCommentWritten - ) - let model: SOMCardModel = .init(data: card) + cell.setModels(self.detailCard) - let tags: [SOMTagModel] = self.detailCard.tags.map { - SOMTagModel(id: $0.id, originalText: $0.content, isSelectable: true, isRemovable: false) - } - cell.setDatas(model, tags: tags) - cell.tags.delegate = self - cell.isOwnCard = self.detailCard.isOwnCard - cell.isPrevCardDelete = self.detailCard.isPreviousCardDelete - cell.prevCard = self.prevCard - cell.member = self.detailCard.member - - cell.prevCardBackgroundButton.rx.tap + guard let reactor = self.reactor else { return cell } + + cell.memberInfoView.memberBackgroundButton.rx.throttleTap(.seconds(3)) .subscribe(with: self) { object, _ in - /// 현재 쌓인 viewControllers 중 바로 이전 viewController가 전환해야 할 전글이라면 naviPop, 아니면 naviPush - if let naviStackCount = object.navigationController?.viewControllers.count, - let prevViewController = object.navigationController?.viewControllers[naviStackCount - 2] as? DetailViewController, - prevViewController.reactor?.selectedCardId == object.prevCard?.previousCardId { + /// 내 프로필일 경우 탭 이동 + if object.detailCard.isOwnCard { + guard let navigationController = object.navigationController, + let tabBarController = navigationController.parent as? SOMTabBarController + else { return } - object.navigationPop() + tabBarController.didSelectedIndex(3) + navigationController.viewControllers.removeAll(where: { $0.isKind(of: HomeViewController.self) == false }) } else { - - let detailViewController = DetailViewController() - detailViewController.reactor = object.reactor?.reactorForPush( - object.prevCard?.previousCardId ?? "" + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile( + type: .other, + object.detailCard.memberId ) - object.navigationPush(detailViewController, animated: true, bottomBarHidden: true) + object.navigationPush(profileViewController, animated: true) } } .disposed(by: cell.disposeBag) - cell.rightTopSettingButton.rx.tap - .subscribe(with: self) { object, _ in - - if object.detailCard.isOwnCard { - /// 자신의 카드일 때 카드 삭제하기 - let isFeedAndPungCard = object.detailCard.isFeedCard == true && object.detailCard.storyExpirationTime != nil - - let cancelAction = SOMDialogAction( - title: Text.cancelActionTitle, - style: .gray, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - let deleteAction = SOMDialogAction( - title: Text.deleteActionTitle, - style: .primary, - action: { - object.reactor?.action.onNext(.delete) - } - ) - - SOMDialogViewController.show( - title: isFeedAndPungCard ? Text.deletePungDialogTitle : Text.deleteDialogTitle, - message: isFeedAndPungCard ? Text.deletePungDialogMessage : Text.deleteDialogMessage, - actions: [cancelAction, deleteAction] - ) - } else { - /// 자신의 카드가 아닐 때 차단/신고하기 - object.showBottomSheet( - presented: object.moreButtonBottomSheetViewController, - dismissWhenScreenDidTap: true, - isHandleBar: true, - neverDismiss: false, - initalHeight: 178 + cell.tags.tagDidTap + .throttle(.seconds(3), scheduler: MainScheduler.instance) + .subscribe(with: self) { object, tagInfo in + let tagCollectViewController = TagCollectViewController() + tagCollectViewController.reactor = reactor.reactorForTagCollect( + with: tagInfo.id, + title: tagInfo.text + ) + object.navigationPush(tagCollectViewController, animated: true) { _ in + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailTag_btn_click(tag_name: tagInfo.text) ) } } .disposed(by: cell.disposeBag) - cell.memberBackgroundButton.rx.tap + cell.likeAndCommentView.likeBackgroundButton.rx.throttleTap + .withLatestFrom(reactor.state.compactMap(\.detailCard).map(\.isLike)) + .subscribe(onNext: { isLike in + reactor.action.onNext(.updateLike(!isLike)) + }) + .disposed(by: cell.disposeBag) + + cell.likeAndCommentView.commentBackgroundButton.rx.throttleTap(.seconds(3)) + .map { _ in Reactor.Action.willPushToWrite(.icon) } + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + + cell.prevCardBackgroundButton.rx.throttleTap(.seconds(3)) .subscribe(with: self) { object, _ in - if object.detailCard.isOwnCard { + guard let prevCardInfo = reactor.currentState.detailCard?.prevCardInfo else { + object.navigationPop() + return + } + /// 현재 쌓인 viewControllers 중 바로 이전 viewController가 전환해야 할 전글이라면 naviPop + if let naviStackCount = object.navigationController?.viewControllers.count, + let prevViewController = object.navigationController?.viewControllers[naviStackCount - 2] as? Self, + prevViewController.reactor?.selectedCardId == prevCardInfo.prevCardId { - let memberId = object.detailCard.member.id - let profileViewController = ProfileViewController() - profileViewController.reactor = object.reactor?.reactorForProfile(type: .myWithNavi, memberId) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + object.navigationPop() } else { - let memberId = object.detailCard.member.id - let profileViewController = ProfileViewController() - profileViewController.reactor = object.reactor?.reactorForProfile(type: .other, memberId) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + if prevCardInfo.isPrevCardDeleted { + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForPush( + prevCardInfo.prevCardId, + hasDeleted: true + ) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .detail + ) + ) + } + } else { + reactor.action.onNext(.willPushToDetail(prevCardInfo.prevCardId)) + } } } .disposed(by: cell.disposeBag) @@ -421,8 +706,7 @@ extension DetailViewController: UICollectionViewDataSource { for: indexPath ) as! DetailViewFooter - footer.setDatas(self.commentCards, cardSummary: self.cardSummary) - footer.isDeletedCard = self.isDeleted + footer.setModels(self.commentCards) guard let reactor = self.reactor else { return footer } @@ -430,26 +714,7 @@ extension DetailViewController: UICollectionViewDataSource { .subscribe(with: self) { object, selectedId in let viewController = DetailViewController() viewController.reactor = reactor.reactorForPush(selectedId) - object.navigationPush(viewController, animated: true, bottomBarHidden: true) - } - .disposed(by: footer.disposeBag) - - footer.likeAndCommentView.likeBackgroundButton.rx.throttleTap(.seconds(1)) - .withLatestFrom(reactor.state.map(\.cardSummary.isLiked)) - .subscribe(with: self) { object, isLike in - guard object.isDeleted == false else { return } - - reactor.action.onNext(.updateLike(!isLike)) - } - .disposed(by: footer.disposeBag) - - footer.likeAndCommentView.commentBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - guard object.isDeleted == false else { return } - - let writeCardViewController = WriteCardViewController() - writeCardViewController.reactor = reactor.reactorForWriteCard(object.detailCard.storyExpirationTime) - object.navigationPush(writeCardViewController, animated: true, bottomBarHidden: true) + object.navigationPush(viewController, animated: true) } .disposed(by: footer.disposeBag) @@ -474,8 +739,7 @@ extension DetailViewController: UICollectionViewDelegateFlowLayout { sizeForItemAt indexPath: IndexPath ) -> CGSize { let width: CGFloat = UIScreen.main.bounds.width - let tagHeight: CGFloat = self.detailCard.tags.isEmpty ? 40 : 59 - let height: CGFloat = (width - 20 * 2) + tagHeight /// 카드 높이 + 태그 높이 + let height: CGFloat = 52 + (width - 16 * 2) + 44 return CGSize(width: width, height: height) } @@ -485,8 +749,7 @@ extension DetailViewController: UICollectionViewDelegateFlowLayout { referenceSizeForFooterInSection section: Int ) -> CGSize { let width: CGFloat = UIScreen.main.bounds.width - let tagHeight: CGFloat = self.detailCard.tags.isEmpty ? 40 : 59 - let cellHeight: CGFloat = (width - 20 * 2) + tagHeight + let cellHeight: CGFloat = 52 + (width - 16 * 2) + 44 let height: CGFloat = collectionView.bounds.height - cellHeight return CGSize(width: width, height: height) } @@ -495,37 +758,130 @@ extension DetailViewController: UICollectionViewDelegateFlowLayout { let offset = scrollView.contentOffset.y - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.collectionView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { + // 아래 -> 위 스크롤 막음 + guard offset <= self.initialOffset else { + scrollView.contentOffset.y = 0 + return + } + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.collectionView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) - refreshControl.beginRefreshingFromTop() + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.collectionView.refreshControl?.beginRefreshingWithOffset( + self.detailCard.storyExpirationTime == nil ? 0 : 23 + ) } } } -extension DetailViewController: SOMTagsDelegate { +private extension DetailViewController { + + func showBlockedUserDialog(nickname: String, completion: (() -> Void)? = nil) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let blockAction = SOMDialogAction( + title: Text.blockButtonFloatActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + completion?() + } + } + ) + + SOMDialogViewController.show( + title: Text.blockDialogTitle, + message: nickname + Text.blockDialogMessage, + textAlignment: .left, + actions: [cancelAction, blockAction] + ) + } - func tags(_ tags: SOMTags, didTouch model: SOMTagModel) { + func showDeleteCardDialog() { guard let reactor = self.reactor else { return } - GAManager.shared.logEvent( - event: SOMEvent.Tag.tag_click( - tag_text: model.originalText, - click_position: SOMEvent.Tag.ClickPositionKey.post - ) + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let deleteAction = SOMDialogAction( + title: Text.deleteButtonFloatActionTitle, + style: .red, + action: { + SOMDialogViewController.dismiss { + + reactor.action.onNext(.delete) + } + } + ) + + SOMDialogViewController.show( + title: Text.deleteDialogTitle, + message: Text.deleteDialogMessage, + textAlignment: .left, + actions: [cancelAction, deleteAction] + ) + } + + func showDeletedCardDialog(completion: (() -> Void)? = nil) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + completion?() + } + } + ) + + SOMDialogViewController.show( + title: Text.deletedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] ) - let tagDetailVC = TagDetailViewController() - tagDetailVC.reactor = reactor.reactorForTagDetail(model.id) - self.navigationPush(tagDetailVC, animated: true) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift index a4397b41..1ab6c3e9 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift @@ -10,278 +10,338 @@ import ReactorKit class DetailViewReactor: Reactor { - enum EntranceType { - case push - case navi - } - enum Action: Equatable { case landing case refresh case moreFindForComment(lastId: String) + case updateDetail(DetailCardInfo) + case updateComments([BaseCardInfo]) case delete - case block + case block(isBlocked: Bool) case updateLike(Bool) + case updateReport(Bool) + case willPushToDetail(String) + case willPushToWrite(GAEvent.DetailView.EnterTo) + case cleanup } enum Mutation { - case detailCard(DetailCard, PrevCard?) - case commentCards([Card]) - case moreComment([Card]) - case cardSummary(CardSummary) + case cardType(Bool) + case detailCard(DetailCardInfo?) + case commentCards([BaseCardInfo]) + case moreComment([BaseCardInfo]) + case updateIsRefreshing(Bool) + case updateIsLiked(Bool) case updateIsDeleted(Bool) + case updateReported(Bool) case updateIsBlocked(Bool) - case updateIsLoading(Bool) - case updateIsProcessing(Bool) - case updateError(Bool?) + case updateErrors(Int?) + case willPushToDetail((String, Bool)?) + case willPushToWrite((Bool, GAEvent.DetailView.EnterTo)?) } struct State { - var detailCard: DetailCard - var prevCard: PrevCard? - var commentCards: [Card] - var cardSummary: CardSummary - var isDeleted: Bool? - var isBlocked: Bool - var isLoading: Bool - var isProcessing: Bool - var isErrorOccur: Bool? + fileprivate(set) var isFeed: Bool? + fileprivate(set) var detailCard: DetailCardInfo? + fileprivate(set) var commentCards: [BaseCardInfo]? + fileprivate(set) var isRefreshing: Bool + fileprivate(set) var isLiked: Bool + fileprivate(set) var isDeleted: Bool + fileprivate(set) var isReported: Bool + fileprivate(set) var isBlocked: Bool + fileprivate(set) var hasErrors: Int? + fileprivate(set) var willPushToDetailEnabled: (prevCardId: String, isDeleted: Bool)? + fileprivate(set) var willPushToWriteEnabled: (isDeleted: Bool, enterTo: GAEvent.DetailView.EnterTo)? } - var initialState: State = .init( - detailCard: .init(), - prevCard: nil, - commentCards: [], - cardSummary: .init(), - isDeleted: nil, - isBlocked: false, - isLoading: false, - isProcessing: false, - isErrorOccur: nil - ) + var initialState: State - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let fetchCardDetailUseCase: FetchCardDetailUseCase + private let deleteCardUseCase: DeleteCardUseCase + private let updateCardLikeUseCase: UpdateCardLikeUseCase + private let blockUserUseCase: BlockUserUseCase + private let locationUseCase: LocationUseCase - let entranceType: EntranceType let selectedCardId: String init( - provider: ManagerProviderType, - type entranceType: EntranceType = .navi, - _ selectedCardId: String + dependencies: AppDIContainerable, + with selectedCardId: String, + hasDeleted: Bool = false ) { - self.provider = provider - self.entranceType = entranceType + self.dependencies = dependencies + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) + self.deleteCardUseCase = dependencies.rootContainer.resolve(DeleteCardUseCase.self) + self.updateCardLikeUseCase = dependencies.rootContainer.resolve(UpdateCardLikeUseCase.self) + self.blockUserUseCase = dependencies.rootContainer.resolve(BlockUserUseCase.self) + self.locationUseCase = dependencies.rootContainer.resolve(LocationUseCase.self) + self.selectedCardId = selectedCardId + + self.initialState = .init( + isFeed: nil, + detailCard: nil, + commentCards: nil, + isRefreshing: false, + isLiked: false, + isDeleted: hasDeleted, + isReported: false, + isBlocked: true, + hasErrors: nil, + willPushToDetailEnabled: nil, + willPushToWriteEnabled: nil + ) } func mutate(action: Action) -> Observable { switch action { case .landing: - let combined = Observable.concat([ - self.fetchDetailCard(), - self.fetchCommentCards(), - self.fetchCardSummary() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) + // 삭제된 카드의 경우, 댓글 카드만 조회 + guard self.initialState.isDeleted == false else { return self.commentCards()} + + let coordinate = self.locationUseCase.coordinate() + let latitude = coordinate.latitude + let longitude = coordinate.longitude return .concat([ - .just(.updateIsProcessing(true)), - combined, - .just(.updateIsProcessing(false)) + .just(.updateErrors(nil)), + self.fetchCardDetailUseCase.detailCard( + id: self.selectedCardId, + latitude: latitude, + longitude: longitude + ) + .flatMapLatest { detailCardInfo -> Observable in + return .concat([ + .just(.cardType(detailCardInfo.prevCardInfo == nil)), + .just(.updateReported(detailCardInfo.isReported)), + .just(.detailCard(detailCardInfo)) + ]) + } + .catch(self.catchClosure), + self.commentCards() ]) case .refresh: - let combined = Observable.concat([ - self.fetchDetailCard(), - self.fetchCommentCards(), - self.fetchCardSummary() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - return .concat([ - .just(.updateIsLoading(true)), - combined, - .just(.updateIsLoading(false)) + .just(.updateIsRefreshing(true)), + .just(.updateErrors(nil)), + self.detailCard() + .catch(self.catchClosure), + self.commentCards(), + .just(.updateIsRefreshing(false)) ]) case let .moreFindForComment(lastId): + + return self.fetchMoreCommentCards(lastId) + case let .updateDetail(detailCard): + + return .just(.detailCard(detailCard)) + case let .updateComments(commentCards): + + return .just(.commentCards(commentCards)) + case .delete: + + return self.deleteCardUseCase.delete(cardId: self.selectedCardId) + .map(Mutation.updateIsDeleted) + case let .block(isBlocked): + + guard let memberId = self.currentState.detailCard?.memberId else { return .empty() } + return .concat([ - .just(.updateIsProcessing(true)), - self.fetchMoreCommentCards(lastId) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) + .just(.updateErrors(nil)), + self.blockUserUseCase.updateBlocked(userId: memberId, isBlocked: isBlocked) + .flatMapLatest { isBlockedSuccess -> Observable in + /// isBlocked == true 일 때, 차단 요청 + return isBlockedSuccess ? .just(.updateIsBlocked(isBlocked == false)) : .empty() + } + .catch(self.catchClosure) ]) - case .delete: - let request: CardRequest = .deleteCard(id: self.selectedCardId) - return self.provider.networkManager.request(Status.self, request: request) - .map { _ in .updateIsDeleted(true) } - case .block: - let request: ReportRequest = .blockMember(id: self.currentState.detailCard.member.id) - return self.provider.networkManager.request(Status.self, request: request) - .map { .updateIsBlocked($0.httpCode == 201) } case let .updateLike(isLike): - let request: CardRequest = .updateLike(id: self.selectedCardId, isLike: isLike) + return .concat([ - self.provider.networkManager.request(Status.self, request: request) - .filter { $0.httpCode != 400 } + .just(.updateErrors(nil)), + .just(.updateIsLiked(false)), + self.updateCardLikeUseCase.updateLike(cardId: self.selectedCardId, isLike: isLike) + .filter { $0 } .withUnretained(self) - .flatMapLatest { object, _ in object.fetchCardSummary() } + .flatMapLatest { object, _ -> Observable in + return .just(.updateIsLiked(true)) + } + .catch(self.catchClosure) + ]) + case let .updateReport(isReported): + + return .just(.updateReported(isReported)) + case let .willPushToDetail(prevCardId): + + return self.fetchCardDetailUseCase.isDeleted(cardId: prevCardId) + .map { (prevCardId, $0) } + .map(Mutation.willPushToDetail) + case let .willPushToWrite(enterTo): + + return self.fetchCardDetailUseCase.isDeleted(cardId: self.selectedCardId) + .flatMapLatest { isDeleted -> Observable in + return .concat([ + .just(.willPushToWrite((!isDeleted, enterTo))), + .just(.updateIsDeleted(isDeleted)) + ]) + } + case .cleanup: + + return .concat([ + .just(.willPushToDetail(nil)), + .just(.willPushToWrite(nil)) ]) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState = state switch mutation { - case let .detailCard(detailCard, prevCard): - state.detailCard = detailCard - state.prevCard = prevCard + case let .cardType(isFeed): + newState.isFeed = isFeed + case let .detailCard(detailCard): + newState.detailCard = detailCard case let .commentCards(commentCards): - state.commentCards = commentCards + newState.commentCards = commentCards case let .moreComment(commentCards): - state.commentCards += commentCards - case let .cardSummary(cardSummary): - state.cardSummary = cardSummary + newState.commentCards? += commentCards + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + case let .updateIsLiked(isLiked): + newState.isLiked = isLiked case let .updateIsDeleted(isDeleted): - state.isDeleted = isDeleted + newState.isDeleted = isDeleted + case let .updateReported(isReported): + newState.isReported = isReported case let .updateIsBlocked(isBlocked): - state.isBlocked = isBlocked - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateError(isErrorOccur): - state.isErrorOccur = isErrorOccur + newState.isBlocked = isBlocked + case let .updateErrors(hasErrors): + newState.hasErrors = hasErrors + case let .willPushToDetail(willPushToDetailEnabled): + newState.willPushToDetailEnabled = willPushToDetailEnabled + case let .willPushToWrite(willPushToWriteEnabled): + newState.willPushToWriteEnabled = willPushToWriteEnabled } - return state + return newState } - func fetchDetailCard() -> Observable { - guard (self.currentState.isErrorOccur ?? false) == false else { return .empty() } + func detailCard() -> Observable { - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude + let coordinate = self.locationUseCase.coordinate() + let latitude = coordinate.latitude + let longitude = coordinate.longitude - let requset: CardRequest = .detailCard( + return self.fetchCardDetailUseCase.detailCard( id: self.selectedCardId, latitude: latitude, longitude: longitude ) - - return self.provider.networkManager.request(DetailCardResponse.self, request: requset) - .flatMapLatest { response -> Observable in - let detailCard = response.detailCard - let prevCard = response.prevCard - - return .just(.detailCard(detailCard, prevCard)) - } - .catch(self.catchClosure) + .map(Mutation.detailCard) } - func fetchCommentCards() -> Observable { - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude + func commentCards() -> Observable { - let requset: CardRequest = .commentCard( + let coordinate = self.locationUseCase.coordinate() + let latitude = coordinate.latitude + let longitude = coordinate.longitude + + return self.fetchCardDetailUseCase.commentCards( id: self.selectedCardId, lastId: nil, latitude: latitude, longitude: longitude ) - return self.provider.networkManager.request(CommentCardResponse.self, request: requset) - .map(\.embedded.commentCards) - .map(Mutation.commentCards) - .catch(self.catchClosure) + .map(Mutation.commentCards) } func fetchMoreCommentCards(_ lastId: String) -> Observable { - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude - let request: CardRequest = .commentCard( + let coordinate = self.locationUseCase.coordinate() + let latitude = coordinate.latitude + let longitude = coordinate.longitude + + return self.fetchCardDetailUseCase.commentCards( id: self.selectedCardId, lastId: lastId, latitude: latitude, longitude: longitude ) - return self.provider.networkManager.request(CommentCardResponse.self, request: request) - .map(\.embedded.commentCards) - .map(Mutation.moreComment) - .catch(self.catchClosure) - } - - func fetchCardSummary() -> Observable { - let requset: CardRequest = .cardSummary(id: self.selectedCardId) - return self.provider.networkManager.request(CardSummaryResponse.self, request: requset) - .map(\.cardSummary) - .map(Mutation.cardSummary) - .catch(self.catchClosure) + .map(Mutation.moreComment) } } extension DetailViewReactor { - func reactorForMainTabBar() -> MainTabBarReactor { - MainTabBarReactor(provider: self.provider) - } - - func reactorForMainHome() -> MainHomeTabBarReactor { - MainHomeTabBarReactor(provider: self.provider) - } - - func reactorForPush(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, selectedId) + func reactorForPush(_ selectedId: String, hasDeleted: Bool = false) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: selectedId, hasDeleted: hasDeleted) } func reactorForReport() -> ReportViewReactor { - ReportViewReactor(provider: self.provider, self.selectedCardId) + ReportViewReactor(dependencies: self.dependencies, with: self.selectedCardId) } - func reactorForWriteCard(_ hasPungTime: Date? = nil) -> WriteCardViewReactor { + func reactorForWriteCard() -> WriteCardViewReactor { WriteCardViewReactor( - provider: self.provider, + dependencies: self.dependencies, type: .comment, - parentCardId: self.selectedCardId, - parentPungTime: hasPungTime + parentCardId: self.currentState.detailCard?.id ) } - func reactorForTagDetail(_ tagID: String) -> TagDetailViewrReactor { - TagDetailViewrReactor(provider: self.provider, tagID: tagID) + func reactorForTagCollect(with id: String, title: String) -> TagCollectViewReactor { + TagCollectViewReactor(dependencies: self.dependencies, with: id, title: title, isFavorite: false) } func reactorForProfile( type: ProfileViewReactor.EntranceType, - _ memberId: String + _ userId: String ) -> ProfileViewReactor { - ProfileViewReactor(provider: self.provider, type: type, memberId: memberId) - } - - func reactorForNoti() -> NotificationTabBarReactor { - NotificationTabBarReactor(provider: self.provider) + ProfileViewReactor(dependencies: self.dependencies, type: type, with: userId) } } extension DetailViewReactor { - var catchClosure: ((Error) throws -> Observable ) { + var catchClosure: ((Error) throws -> Observable) { return { error in let nsError = error as NSError - let endProcessing = Observable.concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - - if nsError.code == 400 { + // errorCode == 409 일 때, 해당 사용자 중복 차단 + if case 409 = nsError.code { + return .concat([ + .just(.updateIsRefreshing(false)), + .just(.updateIsBlocked(false)) + ]) + } + // errorCode == 410 일 때, 이미 삭제된 카드 + if case 410 = nsError.code { return .concat([ - .just(.updateError(true)), - endProcessing + .just(.updateIsRefreshing(false)), + .just(.updateErrors(410)), + .just(.updateIsDeleted(true)) ]) - } else { - return endProcessing } + + return .just(.updateIsRefreshing(false)) } } + + func canPushToDetail( + prev prevCardIsDeleted: (prevCardId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (prevCardId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.prevCardId == currCardIsDeleted?.prevCardId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } + + func canPushToWrite( + prev prevCardIsDelete: (isDeleted: Bool, enterTo: GAEvent.DetailView.EnterTo)?, + curr currCardIsDelete: (isDeleted: Bool, enterTo: GAEvent.DetailView.EnterTo)? + ) -> Bool { + return prevCardIsDelete?.isDeleted == currCardIsDelete?.isDeleted && + prevCardIsDelete?.enterTo == currCardIsDelete?.enterTo + } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/Cells/ReportTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/Cells/ReportTableViewCell.swift deleted file mode 100644 index f665b74f..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/Cells/ReportTableViewCell.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// ReportTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/14/24. -// - -import UIKit - -import RxSwift -import SnapKit -import Then - -class ReportTableViewCell: UITableViewCell { - - let reasonView = ReportReasonView() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func setSelected(_ selected: Bool, animated: Bool) { - updateIsSelected(isSelected: selected, animated: true) - } - - // MARK: - setData - func setData(reason: ReportViewReactor.ReportType, isSelected: Bool) { - reasonView.titleLabel.text = reason.title - reasonView.descLabel.text = reason.description - updateIsSelected(isSelected: isSelected, animated: false) - } - - func updateIsSelected(isSelected: Bool, animated: Bool) { - let color: UIColor = isSelected ? .som.p300 : .som.gray300 - let toggleImage: UIImage? = isSelected ? .init(.icon(.filled(.radio))) : .init(.icon(.outlined(.radio))) - let durationTime: TimeInterval = 0.3 - if animated { - UIView.transition( - with: reasonView.toggleView, - duration: durationTime, - options: .transitionCrossDissolve, - animations: { [weak self] in - self?.reasonView.toggleView.image = toggleImage - self?.reasonView.toggleView.tintColor = color - }, - completion: nil - ) - - UIView.animate(withDuration: durationTime) { [weak self] in - self?.reasonView.rootContainerView.layer.borderColor = color.cgColor - } - } else { - reasonView.toggleView.image = toggleImage - reasonView.toggleView.tintColor = color - reasonView.rootContainerView.layer.borderColor = color.cgColor - } - } - - // MARK: - initUI - private func initUI() { - self.selectionStyle = .none - self.backgroundColor = .clear - self.contentView.clipsToBounds = true - setupConstraints() - } - - // MARK: - setupConstraints - private func setupConstraints() { - self.contentView.addSubview(reasonView) - reasonView.snp.makeConstraints { - $0.top.equalToSuperview().offset(6) - $0.bottom.equalToSuperview().offset(-6) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift index 2919c2a1..99694a1b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift @@ -18,145 +18,196 @@ import Then class ReportViewController: BaseNavigationViewController, View { enum Text { - static let title: String = "신고하기" - static let tableViewTitle: String = "신고 사유 선택" + static let navigationTitle: String = "신고하기" + static let guideMessage: String = "신고하는 이유를 선택해주세요" static let successDialogTitle: String = "신고가 접수 되었어요" - static let successDialogMessage: String = "신고 내용을 확인한 후 조치할 예정이에요" + static let successDialogMessage: String = "신고 내용을 확인한 후 조치하도록 하겠습니다. 감사합니다." static let failedDialogTitle: String = "이미 신고를 한 카드에요" static let failedDialogMessage: String = "이전 신고가 접수되어 처리 중이에요" - static let confirmActionTitle: String = "확인" + static let confirmButtonTitle: String = "확인" + static let completeButtonTitle: String = "완료" } - let tableViewTitleLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.textColor = .som.gray800 - $0.text = Text.tableViewTitle + + // MARK: Views + + private let guideMessageLabel = UILabel().then { + $0.text = Text.guideMessage + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2 } - - lazy var reportTableView = UITableView().then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - - $0.register(ReportTableViewCell.self, forCellReuseIdentifier: String(describing: ReportTableViewCell.self)) - - $0.dataSource = self - $0.delegate = self + + private let container = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 10 } - let uploadReportButtonLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.text = Text.title - $0.textColor = .som.white - $0.isUserInteractionEnabled = false - $0.textAlignment = .center - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 10 - $0.clipsToBounds = true + private let completeButton = SOMButton().then { + $0.title = Text.completeButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + + $0.isEnabled = false } - var selectedReason: ReportViewReactor.ReportType? - override var navigationBarHeight: CGFloat { - 53 + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + floating button height + padding + return 34 + 56 + 8 } + + + // MARK: Override func + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + } + override func setupConstraints() { - self.view.addSubview(tableViewTitleLabel) - tableViewTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(28) - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(22) + super.setupConstraints() + + self.view.addSubview(self.guideMessageLabel) + self.guideMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - self.view.addSubview(reportTableView) - reportTableView.snp.makeConstraints { - $0.top.equalTo(tableViewTitleLabel.snp.bottom).offset(22) - $0.leading.trailing.equalToSuperview() + self.view.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalTo(self.guideMessageLabel.snp.bottom).offset(32) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } - self.view.addSubview(uploadReportButtonLabel) - uploadReportButtonLabel.snp.makeConstraints { - $0.top.equalTo(reportTableView.snp.bottom) - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-20) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(50) + self.view.addSubview(self.completeButton) + self.completeButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } - } - - override func setupNaviBar() { - super.setupNaviBar() - self.navigationBar.title = Text.title - self.hidesNavigationBarBottomSeperator = false + self.setupReportButtons() } + + // MARK: ReactorKit - bind + func bind(reactor: ReportViewReactor) { - bindAction(reactor: reactor) - bindState(reactor: reactor) + self.bindAction(reactor: reactor) + self.bindState(reactor: reactor) } private func bindAction(reactor: ReportViewReactor) { - uploadReportButtonLabel.rx - .tapGesture() - .when(.recognized) - .withUnretained(self) - .compactMap { object, _ in object.selectedReason } - .map(Reactor.Action.report) + + self.completeButton.rx.throttleTap + .map { _ in Reactor.Action.report } .bind(to: reactor.action) .disposed(by: self.disposeBag) } private func bindState(reactor: ReportViewReactor) { - reactor.state.map(\.isDialogPresented) + reactor.state.map(\.reportReason) + .distinctUntilChanged() .filterNil() - .subscribe(with: self) { object, isDialogPresented in + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, reportReason in - let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) { - object.navigationPop() - } - } - ) + let items = object.container.arrangedSubviews + .compactMap { $0 as? SOMButton } - SOMDialogViewController.show( - title: isDialogPresented ? Text.successDialogTitle : Text.failedDialogTitle, - message: isDialogPresented ? Text.successDialogMessage : Text.failedDialogMessage, - actions: [confirmAction] - ) + items.forEach { item in + item.isSelected = reportReason.identifier == item.tag + } + + object.completeButton.isEnabled = true + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.isReported) + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + object.showSuccessReportedDialog() + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + NotificationCenter.default.post(name: .updatedReportState, object: nil, userInfo: nil) + object.navigationPop() } .disposed(by: self.disposeBag) } } -// MARK: - UITableView -extension ReportViewController: UITableViewDataSource, UITableViewDelegate { + +// MARK: setup buttons and show dialog + +private extension ReportViewController { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return ReportViewReactor.ReportType.allCases.count + func setupReportButtons() { + + guard let reactor = self.reactor else { return } + + ReportType.allCases.forEach { reportType in + + let item = SOMButton().then { + + $0.title = reportType.message + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 + + $0.inset = .init(top: 0, left: 16, bottom: 0, right: 0) + $0.contentHorizontalAlignment = .left + + $0.tag = reportType.identifier + } + item.snp.makeConstraints { + $0.height.equalTo(48) + } + item.rx.throttleTap(.seconds(2)) + .map { _ in Reactor.Action.updateReportReason(reportType) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.container.addArrangedSubview(item) + } } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = reportTableView.dequeueReusableCell( - withIdentifier: String(describing: ReportTableViewCell.self), - for: indexPath - ) as! ReportTableViewCell - cell.setData( - reason: ReportViewReactor.ReportType.allCases[indexPath.item], - isSelected: selectedReason == ReportViewReactor.ReportType.allCases[indexPath.item] + func showSuccessReportedDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmButtonTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + NotificationCenter.default.post(name: .updatedReportState, object: nil, userInfo: nil) + self.navigationPop() + } + } + ) + + SOMDialogViewController.show( + title: Text.successDialogTitle, + message: Text.successDialogMessage, + textAlignment: .left, + actions: [confirmAction] ) - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - self.selectedReason = ReportViewReactor.ReportType.allCases[indexPath.item] } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewReactor.swift index e92a1a8b..0c6d719b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewReactor.swift @@ -5,100 +5,82 @@ // Created by JDeoks on 10/13/24. // -import Foundation - import ReactorKit class ReportViewReactor: Reactor { - enum ReportType: String, CaseIterable { - case profanity = "DEFAMATION_AND_ABUSE" - case privacyViolation = "PRIVACY_VIOLATION" - case inappropriatePromotion = "INAPPROPRIATE_ADVERTISING" - case obsceneContent = "PORNOGRAPHY" - case fraud = "IMPERSONATION_AND_FRAUD" - case etc = "OTHER" - - var title: String { - switch self { - case .profanity: - "비방 및 욕설" - case .privacyViolation: - "개인정보 침해" - case .inappropriatePromotion: - "부적절한 홍보 및 바이럴" - case .obsceneContent: - "음란물" - case .fraud: - "사칭 및 사기" - case .etc: - "기타" - } - } - - var description: String { - switch self { - case .profanity: - "욕설을 사용하여 타인에게 모욕감을 주는 경우" - case .privacyViolation: - "법적으로 중요한 타인의 개인정보를 게재" - case .inappropriatePromotion: - "부적절한 스팸 홍보 행위" - case .obsceneContent: - "음란한 행위와 관련된 부적절한 행동" - case .fraud: - "사칭으로 타인의 권리를 침해하는 경우" - case .etc: - "해당하는 신고항목이 없는 경우" - } - } - } - enum Action: Equatable { - case report(ReportType) + case updateReportReason(ReportType) + case report } enum Mutation { + case updateReportReason(ReportType?) /// 업로드 완료 여부 변경 - case updateDialogPresent(Bool) + case updateisReported(Bool) + case updateHasErrors(Bool) } struct State { - var isDialogPresented: Bool? + fileprivate(set) var reportReason: ReportType? + fileprivate(set) var isReported: Bool + fileprivate(set) var hasErrors: Bool } - var initialState: State = .init(isDialogPresented: nil) + var initialState: State = .init(reportReason: nil, isReported: false, hasErrors: false) + private let dependencies: AppDIContainerable + private let reportCardUseCase: ReportCardUseCase /// 신고할 카드 id private let id: String - let provider: ManagerProviderType - - init(provider: ManagerProviderType, _ id: String) { - self.provider = provider + init(dependencies: AppDIContainerable, with id: String) { + self.dependencies = dependencies + self.reportCardUseCase = dependencies.rootContainer.resolve(ReportCardUseCase.self) + self.id = id } func mutate(action: Action) -> Observable { switch action { - case .report(let reportType): - return summitReport(reportType: reportType) + case let .updateReportReason(reportReason): + + return .just(.updateReportReason(reportReason)) + case .report: + + guard let reportReason = self.currentState.reportReason else { return .empty() } + + return self.reportCardUseCase.report(cardId: self.id, reportType: reportReason.rawValue) + .map(Mutation.updateisReported) + .catchAndReturn(.updateHasErrors(true)) } } func reduce(state: State, mutation: Mutation) -> State { - var state: State = state + var newState = state switch mutation { - case let .updateDialogPresent(isPresent): - state.isDialogPresented = isPresent + case let .updateReportReason(reportReason): + newState.reportReason = reportReason + case let .updateisReported(isReported): + newState.isReported = isReported + case let .updateHasErrors(hasErrors): + newState.hasErrors = hasErrors } - return state + return newState } +} + +extension ReportViewReactor { - func summitReport(reportType: ReportType) -> Observable { - - let request = ReportRequest.reportCard(id: id, reportType: reportType) - return self.provider.networkManager.request(Status.self, request: request) - .map { .updateDialogPresent($0.httpCode == 201) } - } + // TODO: 임시, 에러 발생 시 뒤로가기 + // var catchClosure: ((Error) throws -> Observable ) { + // return { error in + // + // let nsError = error as NSError + // switch nsError.code { + // case 409, 410: return .just(.updateHasErrors(true)) + // default: return .empty() + // } + // } + // } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/Views/ReportReasonView.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/Views/ReportReasonView.swift deleted file mode 100644 index 5fb6d77d..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/Views/ReportReasonView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ReportReasonView.swift -// SOOUM -// -// Created by JDeoks on 10/14/24. -// - -import UIKit - -import SnapKit -import Then - -class ReportReasonView: UIView { - - let rootContainerView = UIView().then { - $0.backgroundColor = .som.white - $0.layer.borderColor = UIColor.som.gray200.cgColor - $0.layer.borderWidth = 1 - $0.layer.cornerRadius = 12 - } - - let toggleView = UIImageView().then { - $0.image = .init(.icon(.outlined(.radio))) - $0.tintColor = .som.gray300 - } - - let titleLabel = UILabel().then { - $0.typography = .som.body2WithBold - $0.textColor = .som.gray800 - } - - let descLabel = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.gray600 - } - - // MARK: - init - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - initUI() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - initUI - private func initUI() { - addSubviews() - initConstraint() - } - - // MARK: - addSubviews - private func addSubviews() { - self.addSubviews(rootContainerView) - rootContainerView.addSubviews(toggleView, titleLabel, descLabel) - } - - // MARK: - initConstraint - private func initConstraint() { - rootContainerView.snp.makeConstraints { - $0.leading.top.trailing.bottom.equalToSuperview() - } - - toggleView.snp.makeConstraints { - $0.size.equalTo(22) - $0.leading.equalToSuperview().offset(16) - $0.top.equalToSuperview().offset(10) - } - - titleLabel.snp.makeConstraints { - $0.height.equalTo(22) - $0.leading.equalTo(toggleView.snp.trailing).offset(10) - $0.top.equalToSuperview().offset(10) - } - - descLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(4) - $0.leading.equalTo(titleLabel.snp.leading) - $0.bottom.equalToSuperview().offset(-12) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/FloatingButton.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/FloatingButton.swift new file mode 100644 index 00000000..8884e8b9 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/FloatingButton.swift @@ -0,0 +1,80 @@ +// +// FloatingButton.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import UIKit + +import SnapKit +import Then + +class FloatingButton: UIView { + + + // MARK: Views + + let backgoundButton = UIButton() + + private let shadowbackgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray600 + $0.layer.cornerRadius = 56 * 0.5 + } + + private let imageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.plus)))) + $0.tintColor = .som.v2.white + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func layoutSubviews() { + super.layoutSubviews() + + self.shadowbackgroundView.setShadow( + radius: 0, + color: UIColor(hex: "#64686C33").withAlphaComponent(0.2), + blur: 12, + offset: .init(width: 0, height: 8) + ) + } + + + // MARK: Private func + + private func setupConstraints() { + + self.snp.makeConstraints { + $0.size.equalTo(56) + } + + self.addSubview(self.shadowbackgroundView) + self.shadowbackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.center.equalToSuperview() + } + + self.addSubview(self.backgoundButton) + self.backgoundButton.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/LikeAndCommentView.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/LikeAndCommentView.swift index a375d2d5..b6d18655 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/LikeAndCommentView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/LikeAndCommentView.swift @@ -10,65 +10,73 @@ import UIKit import SnapKit import Then - class LikeAndCommentView: UIView { - let likeBackgroundButton = UIButton() - private let likeContainer = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 + enum Text { + static let visitedPrefix: String = "조회 " } + + + // MARK: Views + + let likeBackgroundButton = UIButton() + private let likeContainer = UIView() private let likeImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.heart))) - $0.tintColor = .som.gray800 + $0.image = .init(.icon(.v2(.outlined(.heart)))) + $0.tintColor = .som.v2.gray500 } private let likeCountLabel = UILabel().then { - $0.textColor = .som.gray800 - $0.textAlignment = .center - $0.typography = .som.body2WithRegular + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption1.withAlignment(.left) } let commentBackgroundButton = UIButton() - private let commentContainer = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } + private let commentContainer = UIView() private let commentImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.commentAdd))) - $0.tintColor = .som.gray800 + $0.image = .init(.icon(.v2(.outlined(.message_circle)))) + $0.tintColor = .som.v2.gray500 } private let commentCountLabel = UILabel().then { - $0.textColor = .som.gray800 - $0.textAlignment = .center - $0.typography = .som.body2WithRegular + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption1.withAlignment(.left) + } + + private let visitedLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption1 + } + + + // MARK: Variables + + var likeCount: Int = 0 { + didSet { + self.likeCountLabel.text = self.likeCount.description + self.likeCountLabel.typography = .som.v2.caption1 + } } - private var inputLikeCount: Int = 0 - var likeCount: Int { - set { - self.inputLikeCount = newValue - self.likeCountLabel.text = newValue > 99 ? "99+" : newValue.description + var commentCount: Int = 0 { + didSet { + self.commentCountLabel.text = self.commentCount.description + self.commentCountLabel.typography = .som.v2.caption1 } - get { return inputLikeCount } } - private var inputCommentCount: Int = 0 - var commentCount: Int { - set { - self.inputCommentCount = newValue - self.commentCountLabel.text = newValue > 99 ? "99+" : newValue.description + var visitedCount: String = "0" { + didSet { + self.visitedLabel.text = Text.visitedPrefix + self.visitedCount + self.visitedLabel.typography = .som.v2.caption1 } - get { return inputCommentCount } } var isLikeSelected: Bool = false { didSet { self.updateLikeContainerColor(self.isLikeSelected) } } + + // MARK: Initialize + override init(frame: CGRect) { super.init(frame: frame) self.setupConstraints() @@ -78,46 +86,79 @@ class LikeAndCommentView: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { - let container = UIStackView(arrangedSubviews: [ - self.likeContainer, - self.commentContainer - ]).then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 6 + self.backgroundColor = .som.v2.white + + self.snp.makeConstraints { + $0.height.equalTo(44) + } + + self.addSubview(self.likeContainer) + self.likeContainer.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.width.equalTo(60) + } + self.likeContainer.addSubview(self.likeImageView) + self.likeImageView.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(20) } - self.addSubview(container) - container.snp.makeConstraints { + self.likeContainer.addSubview(self.likeCountLabel) + self.likeCountLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) + $0.leading.equalTo(self.likeImageView.snp.trailing).offset(4) } - self.likeContainer.addArrangedSubviews(self.likeImageView, self.likeCountLabel) self.addSubview(self.likeBackgroundButton) self.likeBackgroundButton.snp.makeConstraints { - $0.edges.equalTo(likeContainer) + $0.edges.equalTo(self.likeImageView) + } + + self.addSubview(self.commentContainer) + self.commentContainer.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.likeContainer.snp.trailing) + $0.width.equalTo(60) + } + self.commentContainer.addSubview(self.commentImageView) + self.commentImageView.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(20) + } + self.commentContainer.addSubview(self.commentCountLabel) + self.commentCountLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.commentImageView.snp.trailing).offset(4) } - self.commentContainer.addArrangedSubviews(self.commentImageView, self.commentCountLabel) self.addSubview(self.commentBackgroundButton) self.commentBackgroundButton.snp.makeConstraints { - $0.edges.equalTo(self.commentContainer) + $0.edges.equalTo(self.commentImageView) + } + + self.addSubview(self.visitedLabel) + self.visitedLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.commentContainer.snp.trailing).offset(20) + $0.trailing.equalToSuperview().offset(-20) } } func updateLikeContainerColor(_ isSelected: Bool) { - - self.likeImageView.image = .init(.icon(isSelected ? .filled(.heart) : .outlined(.heart))) - self.likeImageView.tintColor = isSelected ? .som.p300 : .som.gray800 - self.likeCountLabel.textColor = isSelected ? .som.p300 : .som.gray800 + self.likeImageView.image = .init(.icon(.v2(isSelected ? .filled(.heart) : .outlined(.heart)))) + self.likeImageView.tintColor = isSelected ? .som.v2.rMain : .som.v2.gray500 } func updateViewsWhenDeleted() { self.likeContainer.removeFromSuperview() + self.likeBackgroundButton.removeFromSuperview() + self.commentContainer.removeFromSuperview() self.commentBackgroundButton.removeFromSuperview() + self.visitedLabel.removeFromSuperview() } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift new file mode 100644 index 00000000..b0a593e7 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift @@ -0,0 +1,180 @@ +// +// MemberInfoView.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import UIKit + +import SnapKit +import Then + +class MemberInfoView: UIView { + + enum Text { + static let visitedPrefix: String = "조회 " + static let deletedUserNickname: String = "알 수 없는 사용자" + } + + + // MARK: Views + + /// 상세보기, 멤버 이미지 + let memberBackgroundButton = UIButton() + private let memberImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 36 * 0.5 + $0.clipsToBounds = true + } + /// 상세보기, 멤버 닉네임 + private let memberLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.subtitle2 + } + + /// 상세보기, 거리 뷰 + private let distanceBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.pLight1 + $0.layer.cornerRadius = 21 * 0.5 + $0.clipsToBounds = true + } + /// 상세보기, 거리 아이콘 + private let distanceImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.location)))) + $0.tintColor = .som.v2.pMain + } + /// 상세보기, 거리 라벨 + private let distanceLabel = UILabel().then { + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.caption3 + } + + /// 상세보기, 타임 갭 라벨 + private let timeGapLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + + // MARK: Variables + + var member: (nickname: String, imgURL: String?)? { + didSet { + guard let member = self.member else { return } + + if let strUrl = member.imgURL { + self.memberImageView.setImage(strUrl: strUrl) + } else { + self.memberImageView.image = .init(.image(.v2(.profile_small))) + } + self.memberLabel.text = member.nickname + } + } + + var distance: String? { + didSet { + guard let distance = self.distance else { + self.distanceBackgroundView.isHidden = true + return + } + + self.distanceLabel.text = distance + self.distanceLabel.typography = .som.v2.caption3 + self.distanceBackgroundView.isHidden = false + } + } + + var createAt: Date? { + didSet { + guard let createAt = self.createAt else { return } + + self.timeGapLabel.text = createAt.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + self.timeGapLabel.typography = .som.v2.caption2 + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.white + + self.snp.makeConstraints { + $0.height.equalTo(52) + } + + let container = UIStackView(arrangedSubviews: [ + self.memberImageView, + self.memberLabel, + self.distanceBackgroundView + ]).then { + $0.axis = .horizontal + $0.alignment = .center + $0.distribution = .equalSpacing + $0.spacing = 10 + } + self.addSubview(container) + container.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + } + + self.memberImageView.snp.makeConstraints { + $0.size.equalTo(36) + } + + self.distanceBackgroundView.snp.makeConstraints { + $0.height.equalTo(21) + } + self.distanceBackgroundView.addSubview(self.distanceImageView) + self.distanceImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(4) + $0.size.equalTo(12) + } + self.distanceBackgroundView.addSubview(self.distanceLabel) + self.distanceLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.distanceImageView.snp.trailing).offset(2) + $0.trailing.equalToSuperview().offset(-4) + } + + self.addSubview(self.timeGapLabel) + self.timeGapLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(container.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-20) + } + + self.addSubview(self.memberBackgroundButton) + self.memberBackgroundButton.snp.makeConstraints { + $0.verticalEdges.equalTo(container.snp.verticalEdges) + $0.leading.equalTo(container.snp.leading) + $0.trailing.equalTo(self.memberLabel.snp.trailing) + } + } + + func updateViewsWhenDeleted() { + self.memberImageView.image = .init(.image(.v2(.profile_small))) + self.memberLabel.text = Text.deletedUserNickname + self.distanceBackgroundView.removeFromSuperview() + self.timeGapLabel.removeFromSuperview() + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MoreBottomSheet/MoreBottomSheetViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MoreBottomSheet/MoreBottomSheetViewController.swift deleted file mode 100644 index 56e4efb4..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MoreBottomSheet/MoreBottomSheetViewController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// MoreBottomSheetViewController.swift -// SOOUM -// -// Created by 오현식 on 10/2/24. -// - -import UIKit - -import SnapKit -import Then - - -class MoreBottomSheetViewController: BaseViewController { - - enum Text { - static let blockButtonTitle: String = "차단하기" - static let reportButtonTitle: String = "신고하기" - } - - let blockLabelButton = SOMButton().then { - $0.title = Text.blockButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.red - } - - let reportLabelButton = SOMButton().then { - $0.title = Text.reportButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.red - } - - override func setupConstraints() { - - self.view.backgroundColor = .som.white - - let handle = UIView().then { - $0.backgroundColor = UIColor(hex: "#B4B4B4") - $0.layer.cornerRadius = 8 - } - self.view.addSubview(handle) - handle.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) - $0.centerX.equalToSuperview() - $0.width.equalTo(68) - $0.height.equalTo(2) - } - - let blockBackgroundView = UIView() - self.view.addSubview(blockBackgroundView) - blockBackgroundView.snp.makeConstraints { - $0.top.equalTo(handle.snp.bottom).offset(15) - $0.leading.trailing.equalToSuperview() - $0.height.equalTo(66) - } - blockBackgroundView.addSubview(self.blockLabelButton) - self.blockLabelButton.snp.makeConstraints { - $0.center.equalToSuperview() - } - - let reportBackgroundView = UIView() - self.view.addSubview(reportBackgroundView) - reportBackgroundView.snp.makeConstraints { - $0.top.equalTo(blockBackgroundView.snp.bottom) - $0.leading.trailing.equalToSuperview() - $0.height.equalTo(66) - } - reportBackgroundView.addSubview(self.reportLabelButton) - self.reportLabelButton.snp.makeConstraints { - $0.center.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift new file mode 100644 index 00000000..d2fd4279 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift @@ -0,0 +1,100 @@ +// +// PungView.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class PungView: UIView { + + + // MARK: Views + + private let pungTimeLabel = UILabel().then { + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.caption3 + } + + + // MARK: Variables + + /// 펑 이벤트 처리 위해 추가 + private var serialTimer: Disposable? + private var disposeBag = DisposeBag() + + var isPunged = PublishRelay() + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.white + self.layer.borderColor = UIColor.som.v2.gray200.cgColor + self.layer.borderWidth = 1 + self.layer.cornerRadius = 23 * 0.5 + + self.snp.makeConstraints { + $0.height.equalTo(23) + } + + self.addSubview(self.pungTimeLabel) + self.pungTimeLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(6) + $0.trailing.equalToSuperview().offset(-6) + } + } + + /// 펑 이벤트 구독 + func subscribePungTime(_ pungTime: Date?) { + self.serialTimer?.dispose() + self.serialTimer = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) + .withUnretained(self) + .startWith((self, 0)) + .map { object, _ in + guard let pungTime = pungTime else { + object.serialTimer?.dispose() + return "00:00:00" + } + + let currentDate = Date() + let remainingTime = currentDate.infoReadableTimeTakenFromThisForPung(to: pungTime) + if remainingTime == "00:00:00" { + object.serialTimer?.dispose() + object.isPunged.accept(()) + } + + return remainingTime + } + .bind(to: self.pungTimeLabel.rx.text) + } + + func isDeleted() { + + self.serialTimer?.dispose() + self.serialTimer = nil + + self.pungTimeLabel.removeFromSuperview() + self.removeFromSuperview() + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTag.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTag.swift new file mode 100644 index 00000000..3db2a9f3 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTag.swift @@ -0,0 +1,82 @@ +// +// WrittenTag.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import UIKit + +import SnapKit +import Then + +class WrittenTag: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: WrittenTag.self) + + + // MARK: Views + + private let imageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.hash)))) + $0.tintColor = .som.v2.gray300 + } + + private let label = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.caption2 + } + + + // MARK: Variables + + private(set) var model: WrittenTagModel? + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.backgroundColor = .som.v2.dim + self.contentView.layer.cornerRadius = 6 + self.contentView.clipsToBounds = true + + self.contentView.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(8) + $0.size.equalTo(14) + } + + self.contentView.addSubview(self.label) + self.label.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.imageView.snp.trailing).offset(2) + $0.trailing.equalToSuperview().offset(-8) + } + } + + + // MARK: Public func + + func setModel(_ model: WrittenTagModel) { + + self.model = model + + self.label.text = model.originalText + self.label.typography = model.typography + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTagModel.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTagModel.swift new file mode 100644 index 00000000..145e53a8 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTagModel.swift @@ -0,0 +1,45 @@ +// +// WrittenTagModel.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import Foundation + +class WrittenTagModel { + + + // MARK: Variables + + let id: String + let originalText: String + let typography: Typography + + var identifier: AnyHashable { + self.originalText + } + + + // MARK: Initialize + + init(_ id: String, originalText: String, typography: Typography) { + self.id = id + self.originalText = originalText + self.typography = typography + } +} + + +// MARK: Hashable + +extension WrittenTagModel: Hashable { + + static func == (lhs: WrittenTagModel, rhs: WrittenTagModel) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.identifier) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTags.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTags.swift new file mode 100644 index 00000000..82690d60 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/WrittenTags/WrittenTags.swift @@ -0,0 +1,173 @@ +// +// WrittenTags.swift +// SOOUM +// +// Created by 오현식 on 11/2/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa + +class WrittenTags: UIView { + + enum Section: Int { + case main + } + + enum Item: Hashable { + case tag(WrittenTagModel) + } + + + // MARK: Views + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .horizontal + $0.minimumInteritemSpacing = 6 + $0.minimumLineSpacing = 6 + } + ).then { + $0.backgroundColor = .clear + + $0.alwaysBounceHorizontal = true + $0.isScrollEnabled = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .init(top: 0, left: 16, bottom: 0, right: 16) + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(WrittenTag.self, forCellWithReuseIdentifier: WrittenTag.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return nil } + + switch item { + case let .tag(model): + + let cell: WrittenTag = collectionView.dequeueReusableCell( + withReuseIdentifier: WrittenTag.cellIdentifier, + for: indexPath + ) as! WrittenTag + cell.setModel(model) + + return cell + } + } + + private(set) var models = [WrittenTagModel]() + + let tagDidTap = PublishRelay<(id: String, text: String)>() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + + // MARK: Public func + + func setModels(_ models: [WrittenTagModel]) { + + guard models.isEmpty == false else { + self.models = [] + var snapshot = Snapshot() + snapshot.appendSections([.main]) + self.dataSource.apply(snapshot, animatingDifferences: false) + return + } + + let current = self.models + var new = models + /// 변경사항이 없다면 종료 + guard current != new else { return } + + /// 새로운 태그가 유효한지 확인 (중복 여부 확인) + if new.count != Set(new).count { + Log.warning("중복된 태그가 존재합니다. 태그의 순서를 유지하고 중복된 태그를 제거합니다.") + new = new.removeOlderfromDuplicated() + } + + self.models = new + + var snapshot = Snapshot() + snapshot.appendSections([.main]) + + let items = new.map { Item.tag($0) } + snapshot.appendItems(items, toSection: .main) + self.dataSource.apply(snapshot, animatingDifferences: false) + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension WrittenTags: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + if case let .tag(model) = item { + self.tagDidTap.accept((model.id, model.originalText)) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return .zero } + + switch item { + case let .tag(model): + + var size: CGSize { + let textWidth: CGFloat = (model.originalText as NSString).size( + withAttributes: [.font: model.typography.font] + ).width + /// leading offset + hash image width + spacing + text width + spacing + trailing offset + let tagWidth: CGFloat = 8 + 14 + 2 + ceil(textWidth) + 8 + return CGSize(width: tagWidth, height: 28) + } + + return size + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Distance/MainHomeDistanceViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Distance/MainHomeDistanceViewController.swift deleted file mode 100644 index bf93886b..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Distance/MainHomeDistanceViewController.swift +++ /dev/null @@ -1,314 +0,0 @@ -// -// MainHomeDistanceViewController.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import UIKit - -import Kingfisher -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class MainHomeDistanceViewController: BaseViewController, View { - - - // MARK: Views - - private lazy var tableView = UITableView(frame: .zero, style: .plain).then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - - $0.contentInset.top = SOMSwipeTabBar.Height.mainHome + SOMLocationFilter.height - - $0.isHidden = true - - $0.register(MainHomeViewCell.self, forCellReuseIdentifier: "cell") - $0.register(PlaceholderViewCell.self, forCellReuseIdentifier: "placeholder") - - $0.refreshControl = SOMRefreshControl() - - $0.dataSource = self - $0.prefetchDataSource = self - - $0.delegate = self - } - - private let moveTopButton = MoveTopButtonView().then { - $0.isHidden = true - } - - - // MARK: Variables - - // tableView 정보 - private var currentOffset: CGFloat = 0 - private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = false - - private let cellHeight: CGFloat = { - let width: CGFloat = (UIScreen.main.bounds.width - 20 * 2) * 0.9 - return width + 10 /// 가로 + top inset - }() - - - // MARK: Variables + Rx - - let hidesHeaderContainer = PublishRelay() - let willPushCardId = PublishRelay() - - - // MARK: Override func - - override func setupConstraints() { - super.setupConstraints() - self.view.addSubview(self.tableView) - self.tableView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.view.addSubview(self.moveTopButton) - self.view.bringSubviewToFront(self.moveTopButton) - self.moveTopButton.snp.makeConstraints { - let bottomOffset: CGFloat = 24 + 60 + 4 + 20 - $0.bottom.equalTo(self.tableView.snp.bottom).offset(-bottomOffset) - $0.centerX.equalToSuperview() - $0.height.equalTo(MoveTopButtonView.height) - } - } - - override func bind() { - super.bind() - - // tableView 상단 이동 - self.moveTopButton.backgroundButton.rx.throttleTap(.seconds(3)) - .subscribe(with: self) { object, _ in - let indexPath = IndexPath(row: 0, section: 0) - object.tableView.scrollToRow(at: indexPath, at: .top, animated: true) - } - .disposed(by: self.disposeBag) - } - - - // MARK: ReactorKit - bind - - func bind(reactor: MainHomeDistanceViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() - // isLoading == true && isRefreshing == false 일 때, 이벤트 무시 - self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - isLoading - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.displayedCardsWithUpdate) - .filterNil() - .distinctUntilChanged(reactor.canUpdateCells) - .subscribe(with: self) { object, displayedCardsWithUpdate in - let displayedCards = displayedCardsWithUpdate.cards - let hasMoreUpdate = displayedCardsWithUpdate.hasMoreUpdate - - object.tableView.isHidden = false - - // hasMoreUpdate == true일 때, 추가된 데이터만 로드 - if hasMoreUpdate { - - let lastSectionIndex = object.tableView.numberOfSections - 1 - let lastRowIndex = object.tableView.numberOfRows(inSection: lastSectionIndex) - 1 - let loadedDisplayedCards = displayedCards[0...lastRowIndex] - let indexPathForInsert = displayedCards.enumerated() - .filter { loadedDisplayedCards.contains($0.element) == false } - .map { IndexPath(row: $0.offset, section: 0) } - - object.tableView.performBatchUpdates { - object.tableView.insertRows(at: indexPathForInsert, with: .fade) - } - } else { - - object.tableView.reloadData() - } - } - .disposed(by: self.disposeBag) - } -} - -extension MainHomeDistanceViewController { - - private func cellForPlaceholder(_ tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell { - - let placeholder = tableView.dequeueReusableCell( - withIdentifier: "placeholder", - for: indexPath - ) as! PlaceholderViewCell - - return placeholder - } - - private func cellForMainHome( - _ tableView: UITableView, - for indexPath: IndexPath, - with reactor: MainHomeDistanceViewReactor - ) -> UITableViewCell { - - let displayedCards = reactor.currentState.displayedCards - let model = SOMCardModel(data: displayedCards[indexPath.row]) - let cell: MainHomeViewCell = tableView.dequeueReusableCell( - withIdentifier: "cell", - for: indexPath - ) as! MainHomeViewCell - cell.setModel(model) - // 카드 하단 contents 스택 순서 변경 (최신순) - cell.changeOrderInCardContentStack(2) - - return cell - } -} - - -// MARK: MainHomeViewController DataSource and Delegate - -extension MainHomeDistanceViewController: UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.reactor?.currentState.displayedCardsCount ?? 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - guard let reactor = self.reactor else { return .init(frame: .zero) } - - if reactor.currentState.isDisplayedCardsEmpty { - return self.cellForPlaceholder(tableView, for: indexPath) - } else { - return self.cellForMainHome(tableView, for: indexPath, with: reactor) - } - } -} - -extension MainHomeDistanceViewController: UITableViewDataSourcePrefetching { - - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - guard let reactor = self.reactor else { return } - - indexPaths.forEach { indexPath in - // 데이터 로드 전, 이미지 캐싱 - let strUrl = reactor.currentState.displayedCards[indexPath.row].backgroundImgURL.url - KingfisherManager.shared.download(strUrl: strUrl) { _ in } - } - } -} - -extension MainHomeDistanceViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let reactor = self.reactor else { return } - - let selectedId = reactor.currentState.displayedCards[indexPath.row].id - self.willPushCardId.accept(selectedId) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return (self.reactor?.currentState.isDisplayedCardsEmpty ?? true) ? tableView.bounds.height : self.cellHeight - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard self.reactor?.currentState.isDisplayedCardsEmpty == false else { return } - - let lastSectionIndex = tableView.numberOfSections - 1 - let lastRowIndex = tableView.numberOfRows(inSection: lastSectionIndex) - 1 - - if self.isLoadingMore, - indexPath.section == lastSectionIndex, - indexPath.row == lastRowIndex, - let reactor = self.reactor { - - let lastId = reactor.currentState.displayedCards[indexPath.row].id - reactor.action.onNext(.moreFind(lastId)) - } - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (self.currentOffset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { - - self.hidesHeaderContainer.accept(false) - self.currentOffset = offset - self.moveTopButton.isHidden = true - - return - } - - guard offset <= (scrollView.contentSize.height - scrollView.frame.height) else { return } - - // offset이 currentOffset보다 크면 아래로 스크롤, 반대일 경우 위로 스크롤 - // 위로 스크롤 중일 때 헤더뷰 표시, 아래로 스크롤 중일 때 헤더뷰 숨김 - self.hidesHeaderContainer.accept(offset > self.currentOffset) - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - - self.currentOffset = offset - - // 최상단일 때만 moveToButton 숨김 - self.moveTopButton.isHidden = self.currentOffset <= 0 - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Distance/MainHomeDistanceViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Distance/MainHomeDistanceViewReactor.swift deleted file mode 100644 index 8fd5777e..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Distance/MainHomeDistanceViewReactor.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// MainHomeDistanceViewReactor.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import ReactorKit - - -class MainHomeDistanceViewReactor: Reactor { - - // hasMoreUpdate == true 일 때, more - typealias CardsWithUpdate = (cards: [Card], hasMoreUpdate: Bool) - - enum Action: Equatable { - case landing - case refresh - case moreFind(String) - case distanceFilter(String) - } - - enum Mutation { - case cards(CardsWithUpdate) - case more(CardsWithUpdate) - case updateDistanceFilter(String) - case updateIsLoading(Bool) - case updateIsProcessing(Bool) - } - - struct State { - fileprivate(set) var displayedCardsWithUpdate: CardsWithUpdate? - fileprivate(set) var distanceFilter: String - fileprivate(set) var isLoading: Bool - fileprivate(set) var isProcessing: Bool - - var displayedCards: [Card] { - return self.displayedCardsWithUpdate?.cards ?? [] - } - var isDisplayedCardsEmpty: Bool { - return self.displayedCards.isEmpty - } - var displayedCardsCount: Int { - return self.isDisplayedCardsEmpty ? 1 : self.displayedCards.count - } - } - - var initialState: State = .init( - displayedCardsWithUpdate: nil, - distanceFilter: "UNDER_1", - isLoading: false, - isProcessing: false - ) - - let provider: ManagerProviderType - - // TODO: 페이징 - // private let countPerLoading: Int = 10 - - init(provider: ManagerProviderType) { - self.provider = provider - } - - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - - return .concat([ - .just(.updateIsProcessing(true)), - self.refresh(self.currentState.distanceFilter) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) - case .refresh: - - return .concat([ - .just(.updateIsLoading(true)), - self.refresh(self.currentState.distanceFilter) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsLoading(false)) - ]) - case let .moreFind(lastId): - - return .concat([ - .just(.updateIsProcessing(true)), - self.moreFind(lastId), - .just(.updateIsProcessing(false)) - ]) - case let .distanceFilter(distanceFilter): - - return .concat([ - .just(.updateIsProcessing(true)), - .just(.updateDistanceFilter(distanceFilter)), - self.refresh(distanceFilter) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state: State = state - switch mutation { - case let .cards(displayedCardsWithUpdate): - state.displayedCardsWithUpdate = displayedCardsWithUpdate - case let .more(displayedCardsWithUpdate): - state.displayedCardsWithUpdate?.cards += displayedCardsWithUpdate.cards - state.displayedCardsWithUpdate?.hasMoreUpdate = displayedCardsWithUpdate.hasMoreUpdate - case let .updateDistanceFilter(distanceFilter): - state.distanceFilter = distanceFilter - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - } - return state - } -} - -extension MainHomeDistanceViewReactor { - - func refresh(_ distanceFilter: String) -> Observable { - - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude - - let request: CardRequest = .distancCard( - lastId: nil, - latitude: latitude, - longitude: longitude, - distanceFilter: distanceFilter - ) - return self.provider.networkManager.request(DistanceCardResponse.self, request: request) - .map(\.embedded.cards) - .map { Mutation.cards((cards: $0, hasMoreUpdate: false)) } - .catch(self.catchClosure) - } - - func moreFind(_ lastId: String) -> Observable { - - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude - - let distanceFilter = self.currentState.distanceFilter - - let request: CardRequest = .distancCard( - lastId: lastId, - latitude: latitude, - longitude: longitude, - distanceFilter: distanceFilter - ) - return self.provider.networkManager.request(DistanceCardResponse.self, request: request) - .map(\.embedded.cards) - .map { Mutation.more((cards: $0, hasMoreUpdate: true)) } - .catch(self.catchClosure) - } -} - -extension MainHomeDistanceViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.cards((cards: [], hasMoreUpdate: false))), - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } - } - - // TODO: 페이징 - // func separate(displayed displayedCards: [Card], current cards: [Card]) -> [Card] { - // let count = displayedCards.count - // let displayedCards = Array(cards[count.. Bool { - return prevCardsWithUpdate.cards == currCardsWithUpdate.cards && - prevCardsWithUpdate.hasMoreUpdate == currCardsWithUpdate.hasMoreUpdate - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift new file mode 100644 index 00000000..b66f6ea9 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift @@ -0,0 +1,966 @@ +// +// HomeViewController.swift +// SOOUM +// +// Created by 오현식 on 9/22/25. +// + +import UIKit + +import SnapKit +import Then + +import ReactorKit +import RxCocoa +import RxSwift + +class HomeViewController: BaseNavigationViewController, View { + + enum Text { + static let tabLatestTitle: String = "최신카드" + static let tabPopularityTitle: String = "인기카드" + static let tabDistanceTitle: String = "주변카드" + + static let distanceFilternder1km: String = "1km" + static let distanceFilternder5km: String = "5km" + static let distanceFilternder10km: String = "10km" + static let distanceFilternder20km: String = "20km" + static let distanceFilternder50km: String = "50km" + + static let dialogTitle: String = "위치 정보 사용 설정" + static let dialogMessage: String = "내 위치 확인을 위해 ‘설정 > 앱 > 숨 > 위치’에서 위치 정보 사용을 허용해 주세요." + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + + static let cancelActionTitle: String = "취소" + static let settingActionTitle: String = "설정" + static let confirmActionTitle: String = "확인" + + static let eventCardTitle: String = "event" + } + + enum Section: Int, CaseIterable { + case latest + case popular + case distance + case empty + } + + enum Item: Hashable { + case latest(BaseCardInfo) + case popular(BaseCardInfo) + case distance(BaseCardInfo) + case empty + } + + + // MARK: Views + + private let logo = UIImageView().then { + $0.image = .init(.logo(.v2(.logo_black))) + $0.contentMode = .scaleAspectFit + } + + private let rightAlamButton = UIButton() + private let rightAlamImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.bell)))) + $0.tintColor = .som.v2.black + } + + private let dotWithoutReadView = UIView().then { + $0.backgroundColor = .som.v2.rMain + $0.layer.cornerRadius = 5 * 0.5 + $0.isHidden = true + } + + private let headerContainer = UIStackView().then { + $0.backgroundColor = .som.v2.white + $0.axis = .vertical + } + + private lazy var stickyTabBar = SOMStickyTabBar().then { + $0.items = [Text.tabLatestTitle, Text.tabPopularityTitle, Text.tabDistanceTitle] + $0.spacing = 24 + $0.delegate = self + } + + private lazy var distanceFilterView = SOMSwipableTabBar().then { + $0.items = [ + Text.distanceFilternder1km, + Text.distanceFilternder5km, + Text.distanceFilternder10km, + Text.distanceFilternder20km, + Text.distanceFilternder50km + ] + $0.isHidden = true + $0.delegate = self + } + + private lazy var topNoticeView = SOMPageViews().then { + $0.delegate = self + } + + private lazy var tableView = UITableView().then { + $0.backgroundColor = .som.v2.gray100 + $0.indicatorStyle = .black + $0.separatorStyle = .none + + $0.contentInset.top = self.headerViewHeight + 16 + $0.contentInset.bottom = 54 + 16 + + $0.verticalScrollIndicatorInsets.bottom = 54 + + $0.isHidden = true + + $0.refreshControl = SOMRefreshControl() + + $0.register(HomeViewCell.self, forCellReuseIdentifier: HomeViewCell.cellIdentifier) + $0.register(HomePlaceholderViewCell.self, forCellReuseIdentifier: HomePlaceholderViewCell.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(tableView: self.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + + guard let self = self else { return nil } + + switch item { + case let .latest(cardInfo): + + let cell: HomeViewCell = self.cellForCard(tableView, with: indexPath) + cell.bind(cardInfo) + + return cell + case let .popular(cardInfo): + + let cell: HomeViewCell = self.cellForCard(tableView, with: indexPath) + cell.bind(cardInfo) + + return cell + case let .distance(cardInfo): + + let cell: HomeViewCell = self.cellForCard(tableView, with: indexPath) + cell.bind(cardInfo) + + return cell + case .empty: + + return self.cellForPlaceholder(tableView, with: indexPath) + } + } + + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + private var isLoadingMore: Bool = false + + private var hidesHeaderView: Bool = false + private var headerViewHeight: CGFloat = SOMStickyTabBar.Constants.height + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + + private var cellHeight: CGFloat { + let width: CGFloat = (UIScreen.main.bounds.width - 16 * 2) * 0.5 + /// (가로 : 세로 = 2 : 1) + bottom contents container height + bottom inset + return width + 34 + 10 + } + + + // MARK: Constraints + + private var headerViewContainerTopConstraint: Constraint? + + + // MARK: Variables + Rx + + private let willPushCardId = PublishRelay() + + + // MARK: Override func + + override func viewDidLoad() { + super.viewDidLoad() + + // 제스처 뒤로가기를 위한 델리게이트 설정 + self.parent?.navigationController?.interactivePopGestureRecognizer?.delegate = self + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadHomeData(_:)), + name: .reloadHomeData, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.addedFavoriteWithCardId(_:)), + name: .addedFavoriteWithCardId, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.addedCommentWithCardId(_:)), + name: .addedCommentWithCardId, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.deletedFeedCardWithId(_:)), + name: .deletedFeedCardWithId, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.updatedBlockUser(_:)), + name: .updatedBlockUser, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.updatedHasUnreadNotification(_:)), + name: .updatedHasUnreadNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.scollingToTopWithAnimation(_:)), + name: .scollingToTopWithAnimation, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.changedLocationAuthorization(_:)), + name: .changedLocationAuthorization, + object: nil + ) + } + + override func bind() { + super.bind() + #if DEVELOP + self.setupDebugging() + #endif + } + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.titleView = self.logo + self.navigationBar.titlePosition = .left + + self.navigationBar.hidesBackButton = true + + self.rightAlamButton.addSubview(self.rightAlamImageView) + self.rightAlamButton.addSubview(self.dotWithoutReadView) + self.rightAlamImageView.snp.makeConstraints { + $0.size.equalTo(24) + } + self.dotWithoutReadView.snp.makeConstraints { + $0.top.trailing.equalToSuperview() + $0.size.equalTo(5) + } + self.rightAlamButton.snp.makeConstraints { + $0.edges.equalTo(self.rightAlamImageView.snp.edges) + } + + self.navigationBar.setRightButtons([self.rightAlamButton]) + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.tableView) + self.tableView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.horizontalEdges.equalToSuperview() + } + + self.view.addSubview(self.headerContainer) + self.headerContainer.snp.makeConstraints { + self.headerViewContainerTopConstraint = $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).priority(.high).constraint + $0.horizontalEdges.equalToSuperview() + } + self.headerContainer.addArrangedSubview(self.stickyTabBar) + self.headerContainer.addArrangedSubview(self.distanceFilterView) + } + + + // MARK: ReactorKit - bind + + func bind(reactor: HomeViewReactor) { + + // navigation + self.rightAlamButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + let viewController = NotificationViewController() + viewController.reactor = reactor.reactorForNotification() + object.parent?.navigationPush(viewController, animated: true) + } + .disposed(by: self.disposeBag) + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.tableView.refreshControl?.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .observe(on: MainScheduler.asyncInstance) + .filter { $0 == false } + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasUnreadNotifications) + .distinctUntilChanged() + .map { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.dotWithoutReadView.rx.isHidden) + .disposed(by: self.disposeBag) + + reactor.state.map(\.noticeInfos) + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, noticeInfos in + let models: [SOMPageModel] = noticeInfos.map { SOMPageModel(data: $0) } + object.topNoticeView.frame = CGRect( + origin: .zero, + size: .init(width: UIScreen.main.bounds.width - 16 * 2, height: 81) + ) + object.topNoticeView.setModels(models) + object.tableView.tableHeaderView = noticeInfos.isEmpty ? nil : object.topNoticeView + } + .disposed(by: self.disposeBag) + + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + object.showPungedCardDialog(reactor, with: selectedId) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(with: selectedId) + object.parent?.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent(event: GAEvent.HomeView.feedToCardDetailView_card_click) + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click(previous_path: .home) + ) + } + } + .disposed(by: self.disposeBag) + + reactor.state.map { + HomeViewReactor.DisplayStates( + displayType: $0.displayType, + latests: $0.latestCards, + populars: $0.popularCards, + distances: $0.distanceCards + ) + } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, displayStats in + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + switch displayStats.displayType { + case .latest: + + guard let latests = displayStats.latests else { return } + + guard latests.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = latests.map { Item.latest($0) } + snapshot.appendItems(new, toSection: .latest) + case .popular: + + guard let populars = displayStats.populars else { return } + + guard populars.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = populars.map { Item.popular($0) } + snapshot.appendItems(new, toSection: .popular) + case .distance: + + guard let distances = displayStats.distances else { return } + + guard distances.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = distances.map { Item.distance($0) } + snapshot.appendItems(new, toSection: .distance) + } + + object.dataSource.apply(snapshot, animatingDifferences: false) + + object.tableView.isHidden = false + } + .disposed(by: self.disposeBag) + } + + + // MARK: Objc func + + @objc + private func reloadHomeData(_ notification: Notification) { + + self.reactor?.action.onNext(.landing) + } + + /// 피드카드 좋아요 업데이트 시, 최신/인기/거리 해당 카드만 업데이트 + @objc + private func addedFavoriteWithCardId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + let addedFavorite = notification.userInfo?["addedFavorite"] as? Bool + else { return } + + var latests = self.reactor?.currentState.latestCards ?? [] + var populars = self.reactor?.currentState.popularCards ?? [] + var distances = self.reactor?.currentState.distanceCards ?? [] + + if let index = latests.firstIndex(where: { $0.id == cardId }) { + let curr = latests[index].likeCnt + let new = addedFavorite ? curr + 1 : curr - 1 + latests[index] = latests[index].updateLikeCnt(new) + } + + if let index = populars.firstIndex(where: { $0.id == cardId }) { + let curr = populars[index].likeCnt + let new = addedFavorite ? curr + 1 : curr - 1 + populars[index] = populars[index].updateLikeCnt(new) + } + + if let index = distances.firstIndex(where: { $0.id == cardId }) { + let curr = distances[index].likeCnt + let new = addedFavorite ? curr + 1 : curr - 1 + distances[index] = distances[index].updateLikeCnt(new) + } + + self.reactor?.action.onNext( + .updateCards( + latests: latests, + populars: populars, + distances: distances + ) + ) + } + /// 피드카드 댓글카드 작성 및 삭제 시, 최신/인기/거리 해당 카드만 업데이트 + @objc + private func addedCommentWithCardId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + let addedComment = notification.userInfo?["addedComment"] as? Bool + else { return } + + var latests = self.reactor?.currentState.latestCards ?? [] + var populars = self.reactor?.currentState.popularCards ?? [] + var distances = self.reactor?.currentState.distanceCards ?? [] + + if let index = latests.firstIndex(where: { $0.id == cardId }) { + let curr = latests[index].commentCnt + let new = addedComment ? curr + 1 : curr - 1 + latests[index] = latests[index].updateCommentCnt(new) + } + + if let index = populars.firstIndex(where: { $0.id == cardId }) { + let curr = populars[index].commentCnt + let new = addedComment ? curr + 1 : curr - 1 + populars[index] = populars[index].updateCommentCnt(new) + } + + if let index = distances.firstIndex(where: { $0.id == cardId }) { + let curr = distances[index].commentCnt + let new = addedComment ? curr + 1 : curr - 1 + distances[index] = distances[index].updateCommentCnt(new) + } + + self.reactor?.action.onNext( + .updateCards( + latests: latests, + populars: populars, + distances: distances + ) + ) + } + /// 피드카드 삭제 시, 최신/인기/거리 해당 카드만 업데이트 + @objc + private func deletedFeedCardWithId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + notification.userInfo?["isDeleted"] as? Bool == true + else { return } + + var latests = self.reactor?.currentState.latestCards ?? [] + var populars = self.reactor?.currentState.popularCards ?? [] + var distances = self.reactor?.currentState.distanceCards ?? [] + + latests.removeAll(where: { $0.id == cardId }) + populars.removeAll(where: { $0.id == cardId }) + distances.removeAll(where: { $0.id == cardId }) + + self.reactor?.action.onNext( + .updateCards( + latests: latests, + populars: populars, + distances: distances + ) + ) + } + /// 특정 사용자 차단 시, 최신/인기/거리 특정 사용자 카드 숨김 처리 + @objc + private func updatedBlockUser(_ notification: Notification) { + + guard notification.userInfo?["isBlocked"] as? Bool != nil else { return } + + self.reactor?.action.onNext(.landing) + } + + @objc + private func updatedHasUnreadNotification(_ notification: Notification) { + + self.reactor?.action.onNext(.updateHasUnReadNotifications(false)) + } + + @objc + private func scollingToTopWithAnimation(_ notification: Notification) { + + guard let displayType = self.reactor?.currentState.displayType else { return } + + var section: Int { + switch displayType { + case .latest: return Section.latest.rawValue + case .popular: return Section.popular.rawValue + case .distance: return Section.distance.rawValue + } + } + + let toTop = CGPoint(x: 0, y: -(self.tableView.contentInset.top)) + self.tableView.setContentOffset(toTop, animated: true) + + GAHelper.shared.logEvent(event: GAEvent.HomeView.feedMoveToTop_home_btn_click) + } + + @objc + private func changedLocationAuthorization(_ notification: Notification) { + + self.reactor?.action.onNext(.updateLocationPermission) + } +} + + +// MARK: Cells + +private extension HomeViewController { + + func cellForPlaceholder( + _ tableView: UITableView, + with indexPath: IndexPath + ) -> HomePlaceholderViewCell { + + return tableView.dequeueReusableCell( + withIdentifier: HomePlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! HomePlaceholderViewCell + } + + func cellForCard( + _ tableView: UITableView, + with indexPath: IndexPath + ) -> HomeViewCell { + + return tableView.dequeueReusableCell( + withIdentifier: HomeViewCell.cellIdentifier, + for: indexPath + ) as! HomeViewCell + } +} + + +// MARK: show Dialog + +private extension HomeViewController { + + func showLocationPermissionDialog() { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss { + let prevIdx = self.stickyTabBar.previousIndex + let currInx = self.stickyTabBar.selectedIndex + + self.stickyTabBar.didSelectTabBarItem(prevIdx == currInx ? 0 : prevIdx) + } + } + ) + let settingAction = SOMDialogAction( + title: Text.settingActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } + } + } + ) + + SOMDialogViewController.show( + title: Text.dialogTitle, + message: Text.dialogMessage, + textAlignment: .left, + actions: [cancelAction, settingAction] + ) + } + + func showPungedCardDialog(_ reactor: HomeViewReactor, with selectedId: String) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext( + .updateCards( + latests: (reactor.currentState.latestCards ?? []).filter { $0.id != selectedId }, + populars: (reactor.currentState.popularCards ?? []).filter { $0.id != selectedId }, + distances: (reactor.currentState.distanceCards ?? []).filter { $0.id != selectedId } + ) + ) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} + + +// MARK: SOMStickyTabBarDelegate + +extension HomeViewController: SOMStickyTabBarDelegate { + + func tabBar(_ tabBar: SOMStickyTabBar, shouldSelectTabAt index: Int) -> Bool { + // TODO: 주변카드 선택 시 위치권한이 없어도 탭 전환 허용 + return true + } + + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) { + + let hidesDistanceFilter = index != 2 + self.distanceFilterView.isHidden = hidesDistanceFilter + self.headerViewHeight = hidesDistanceFilter ? + SOMStickyTabBar.Constants.height : + SOMStickyTabBar.Constants.height + SOMSwipableTabBar.Constants.height + self.tableView.contentInset.top = self.headerViewHeight + 16 + + let toTop = CGPoint(x: 0, y: -(self.headerViewHeight + 16)) + self.tableView.setContentOffset(toTop, animated: false) + + var displayType: HomeViewReactor.DisplayType { + switch index { + case 1: return .popular + case 2: return .distance + default: return .latest + } + } + self.reactor?.action.onNext(.updateDisplayType(displayType)) + + if index == 2, self.reactor?.currentState.hasPermission == false { + self.showLocationPermissionDialog() + } + } +} + + +// MARK: SOMSwipeTabBarDelegate + +extension HomeViewController: SOMSwipableTabBarDelegate { + + func tabBar(_ tabBar: SOMSwipableTabBar, didSelectTabAt index: Int) { + + let distanceFilter = tabBar.items[index] + self.reactor?.action.onNext(.updateDistanceFilter(distanceFilter)) + } +} + + +extension HomeViewController: SOMPageViewsDelegate { + + func pages(_ tags: SOMPageViews, didTouch model: SOMPageModel) { + + guard let reactorForNotification = self.reactor?.reactorForNotification(with: .notice) else { return } + + let viewController = NotificationViewController() + viewController.reactor = reactorForNotification + self.parent?.navigationPush(viewController, animated: true) + } +} + + +// MARK: UITableViewDelegate + +extension HomeViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath), + let reactor = self.reactor + else { return } + + var isPunged: Bool { + switch item { + case let .latest(selectedCard): + guard let expireAt = selectedCard.storyExpirationTime else { return false } + return expireAt < Date() + case let .popular(selectedCard): + guard let expireAt = selectedCard.storyExpirationTime else { return false } + return expireAt < Date() + case let .distance(selectedCard): + guard let expireAt = selectedCard.storyExpirationTime else { return false } + return expireAt < Date() + case .empty: + return false + } + } + + var selectedId: String? { + switch item { + case let .latest(selectedCard): + return selectedCard.id + case let .popular(selectedCard): + return selectedCard.id + case let .distance(selectedCard): + return selectedCard.id + case .empty: + return nil + } + } + + guard let selectedId = selectedId else { return } + + guard isPunged == false else { + self.showPungedCardDialog(reactor, with: selectedId) + return + } + + var isEventCard: Bool { + switch item { + case let .latest(selectedCard): + return selectedCard.cardImgName.contains(Text.eventCardTitle) + case let .popular(selectedCard): + return selectedCard.cardImgName.contains(Text.eventCardTitle) + case let .distance(selectedCard): + return selectedCard.cardImgName.contains(Text.eventCardTitle) + case .empty: + return false + } + } + + reactor.action.onNext(.hasDetailCard(selectedId, isEventCard)) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return tableView.bounds.height + } + + switch item { + case .empty: + return (UIScreen.main.bounds.height * 0.2) + 113 + 20 + 42 + default: + return self.cellHeight + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + + guard let reactor = self.reactor, reactor.currentState.isRefreshing == false else { return } + + switch reactor.currentState.displayType { + case .latest: + + let lastRowIndexPath = tableView.numberOfRows(inSection: Section.latest.rawValue) - 1 + if self.isLoadingMore, + reactor.currentState.latestCards?.isEmpty == false, + indexPath.section == Section.latest.rawValue, + indexPath.row == lastRowIndexPath { + + let lastId = reactor.currentState.latestCards?.last?.id ?? "" + reactor.action.onNext(.moreFind(lastId)) + } + case .distance: + + let lastRowIndexPath = tableView.numberOfRows(inSection: Section.distance.rawValue) - 1 + if self.isLoadingMore, + reactor.currentState.distanceCards?.isEmpty == false, + indexPath.section == Section.distance.rawValue, + indexPath.row == lastRowIndexPath { + + let lastId = reactor.currentState.distanceCards?.last?.id ?? "" + reactor.action.onNext(.moreFind(lastId)) + } + default: + return + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + let delta = offset - self.currentOffset + + let isScrollingDown = delta > 0 + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.tableView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) + + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } + + // 당겨서 새로고침 시 무시 + guard offset > 0, + // 스크롤이 맨 아래에 도달했을 때, 헤더뷰 숨김 로직을 무시 + offset <= (scrollView.contentSize.height - scrollView.frame.height) + else { + self.currentOffset = offset + return + } + + if isScrollingDown { + // 현재 constraint를 직접 비교 + let currentTopConstraint = self.headerViewContainerTopConstraint?.layoutConstraints.first?.constant ?? 0 + // 헤더 뷰 높이의 70% 만큼 스크롤될 때, 숨김 + let targetOffset = self.headerViewHeight * 0.7 <= delta - currentTopConstraint ? + -self.headerViewHeight : + (currentTopConstraint - delta) + self.headerViewContainerTopConstraint?.update(offset: targetOffset).update(priority: .high) + } + + if isScrollingDown == false { + + self.headerViewContainerTopConstraint?.update(offset: 0).update(priority: .high) + } + + UIView.animate(withDuration: 0.25) { [weak self] in + self?.view.layoutIfNeeded() + } + + // 아래로 스크롤 중일 때, 데이터 추가로드 가능 + self.isLoadingMore = isScrollingDown + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() + } + } +} + + +// MARK: Download log history for debugging + +private extension HomeViewController { + + func setupDebugging() { + + self.logo.rx.longPressGesture() + .when(.began) + .flatMapLatest { _ in Log.extract() } + .subscribe( + with: self, + onNext: { object, viewController in + object.navigationController?.present(viewController, animated: true) + }, + onError: { _, error in + Log.error(error.localizedDescription) + } + ) + .disposed(by: self.disposeBag) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift new file mode 100644 index 00000000..f8869781 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift @@ -0,0 +1,424 @@ +// +// HomeViewReactor.swift +// SOOUM +// +// Created by 오현식 on 9/28/25. +// + +import ReactorKit + +import Alamofire + +class HomeViewReactor: Reactor { + + struct DisplayStates { + let displayType: DisplayType + let latests: [BaseCardInfo]? + let populars: [BaseCardInfo]? + let distances: [BaseCardInfo]? + } + + enum DisplayType: Equatable { + case latest + case popular + case distance + } + + enum Action: Equatable { + case updateLocationPermission + case landing + case refresh + case moreFind(String) + case updateDisplayType(DisplayType) + case updateDistanceFilter(String) + case hasDetailCard(String, Bool) + case updateCards(latests: [BaseCardInfo], populars: [BaseCardInfo], distances: [BaseCardInfo]) + case updateHasUnReadNotifications(Bool) + case cleanup + } + + enum Mutation { + case updateLocationPermission(Bool) + case cards(latests: [BaseCardInfo], populars: [BaseCardInfo], distances: [BaseCardInfo]) + case more(latests: [BaseCardInfo], distances: [BaseCardInfo]) + case updateHasUnreadNotifications(Bool) + case notices([NoticeInfo]) + case cardIsDeleted((String, Bool)?) + case updateDisplayType(DisplayType) + case updateDistanceFilter(String) + case updateIsRefreshing(Bool) + } + + struct State { + fileprivate(set) var hasPermission: Bool + fileprivate(set) var displayType: DisplayType + fileprivate(set) var noticeInfos: [NoticeInfo]? + fileprivate(set) var latestCards: [BaseCardInfo]? + fileprivate(set) var popularCards: [BaseCardInfo]? + fileprivate(set) var distanceCards: [BaseCardInfo]? + fileprivate(set) var hasUnreadNotifications: Bool + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? + fileprivate(set) var distanceFilter: String + fileprivate(set) var isRefreshing: Bool + } + + var initialState: State + + private let dependencies: AppDIContainerable + private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase + private let fetchNoticeUseCase: FetchNoticeUseCase + private let notificationUseCase: NotificationUseCase + private let locationUseCase: LocationUseCase + + init(dependencies: AppDIContainerable, displayType: DisplayType = .latest) { + self.dependencies = dependencies + self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) + self.fetchNoticeUseCase = dependencies.rootContainer.resolve(FetchNoticeUseCase.self) + self.notificationUseCase = dependencies.rootContainer.resolve(NotificationUseCase.self) + self.locationUseCase = dependencies.rootContainer.resolve(LocationUseCase.self) + + self.initialState = State( + hasPermission: self.locationUseCase.hasPermission(), + displayType: displayType, + noticeInfos: nil, + latestCards: nil, + popularCards: nil, + distanceCards: nil, + hasUnreadNotifications: false, + cardIsDeleted: nil, + distanceFilter: "1km", + isRefreshing: false + ) + } + + func mutate(action: Action) -> Observable { + switch action { + case .updateLocationPermission: + + return .just(.updateLocationPermission(self.locationUseCase.hasPermission())) + case .landing: + + let displayType = self.currentState.displayType + let distanceFilter = self.currentState.distanceFilter + return .concat([ + self.refresh(displayType, distanceFilter) + .catch(self.catchClosureForCards), + self.unreadNotifications() + .catch(self.catchClosureForNotis) + ]) + case .refresh: + + let displayType = self.currentState.displayType + let distanceFilter = self.currentState.distanceFilter + return .concat([ + .just(.updateIsRefreshing(true)), + self.refresh(displayType, distanceFilter) + .catch(self.catchClosureForCards), + self.unreadNotifications() + .catch(self.catchClosureForNotis), + .just(.updateIsRefreshing(false)) + ]) + case let .moreFind(lastId): + + return self.moreFind(lastId) + .catch(self.catchClosureForMore) + case let .updateDisplayType(displayType): + + let distanceFilter = self.currentState.distanceFilter + var emitObservable: Observable { + switch displayType { + case .latest: + if self.currentState.latestCards?.isEmpty == true { + return self.refresh(.latest, distanceFilter) + } + return .empty() + case .popular: + if self.currentState.popularCards?.isEmpty == true { + return self.refresh(.popular, distanceFilter) + } + return .empty() + case .distance: + if self.currentState.distanceCards?.isEmpty == true { + return self.refresh(.distance, distanceFilter) + } + return .empty() + } + } + + return .concat([ + emitObservable + .catch(self.catchClosureForCards), + .just(.updateDisplayType(displayType)) + ]) + case let .updateDistanceFilter(distanceFilter): + + let displayType = self.currentState.displayType + return .concat([ + .just(.updateDistanceFilter(distanceFilter)), + self.refresh(displayType, distanceFilter) + .catch(self.catchClosureForCards) + ]) + case let .hasDetailCard(selectedId, isEventCard): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .do(onNext: { + if isEventCard, $0 == false { + GAHelper.shared.logEvent( + event: GAEvent.HomeView.feedToCardDetailView_cardWithEventImg_click + ) + } + }) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) + ]) + case let .updateCards(latest, populars, distances): + + return .just(.cards(latests: latest, populars: populars, distances: distances)) + case let .updateHasUnReadNotifications(hasUnReads): + + return .just(.updateHasUnreadNotifications(hasUnReads)) + case .cleanup: + + return .just(.cardIsDeleted(nil)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .updateLocationPermission(hasPermission): + newState.hasPermission = hasPermission + case let .cards(latest, popular, distance): + newState.latestCards = latest + newState.popularCards = popular + newState.distanceCards = distance + case let .more(latest, distance): + newState.latestCards? += latest + newState.distanceCards? += distance + case let .notices(noticeInfos): + newState.noticeInfos = noticeInfos + case let .updateHasUnreadNotifications(hasUnreadNotifications): + newState.hasUnreadNotifications = hasUnreadNotifications + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted + case let .updateDisplayType(displayType): + newState.displayType = displayType + case let .updateDistanceFilter(distanceFilter): + newState.distanceFilter = distanceFilter + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + } + return newState + } +} + +private extension HomeViewReactor { + + func refresh(_ displayType: DisplayType, _ distanceFilter: String) -> Observable { + + let coordinate = self.locationUseCase.coordinate() + let latitude = coordinate.latitude + let longitude = coordinate.longitude + + switch displayType { + case .latest: + return self.fetchCardUseCase.latestCards( + lastId: nil, + latitude: latitude, + longitude: longitude + ) + .map { + return .cards( + latests: $0, + populars: self.currentState.popularCards ?? [], + distances: self.currentState.distanceCards ?? [] + ) + } + case .popular: + return self.fetchCardUseCase.popularCards(latitude: latitude, longitude: longitude) + .map { + return .cards( + latests: self.currentState.latestCards ?? [], + populars: $0, + distances: self.currentState.distanceCards ?? [] + ) + } + case .distance: + let distanceFilter = distanceFilter.replacingOccurrences(of: "km", with: "") + return self.fetchCardUseCase.distanceCards( + lastId: nil, + latitude: latitude, + longitude: longitude, + distanceFilter: distanceFilter + ) + .map { + return .cards( + latests: self.currentState.latestCards ?? [], + populars: self.currentState.popularCards ?? [], + distances: $0 + ) + } + } + } + + func moreFind(_ lastId: String) -> Observable { + + let coordinate = self.locationUseCase.coordinate() + let latitude = coordinate.latitude + let longitude = coordinate.longitude + + switch self.currentState.displayType { + case .latest: + return self.fetchCardUseCase.latestCards( + lastId: lastId, + latitude: latitude, + longitude: longitude + ) + .map { + return .more( + latests: $0, + distances: self.currentState.distanceCards ?? [] + ) + } + case .distance: + let distanceFilter = self.currentState.distanceFilter.replacingOccurrences(of: "km", with: "") + return self.fetchCardUseCase.distanceCards( + lastId: lastId, + latitude: latitude, + longitude: longitude, + distanceFilter: distanceFilter + ) + .map { + return .more( + latests: self.currentState.latestCards ?? [], + distances: $0 + ) + } + default: + return .empty() + } + } + + func unreadNotifications() -> Observable { + + return self.fetchNoticeUseCase.notices(lastId: nil, size: 3, requestType: .notification) + .flatMapLatest { noticeInfos -> Observable in + + return .concat([ + self.notificationUseCase.isUnreadNotiEmpty() + .map { !$0 } + .map(Mutation.updateHasUnreadNotifications), + .just(.notices(noticeInfos)) + ]) + } + } +} + +extension HomeViewReactor { + + var catchClosureForCards: ((Error) throws -> Observable ) { + return { _ in + + let displayType = self.currentState.displayType + var emitObservable: Observable { + switch displayType { + case .latest: + return .just(.cards( + latests: [], + populars: self.currentState.popularCards ?? [], + distances: self.currentState.distanceCards ?? [] + )) + case .popular: + return .just(.cards( + latests: self.currentState.latestCards ?? [], + populars: [], + distances: self.currentState.distanceCards ?? [] + )) + case .distance: + return .just(.cards( + latests: self.currentState.latestCards ?? [], + populars: self.currentState.popularCards ?? [], + distances: [] + )) + } + } + + return .concat([ + emitObservable, + .just(.updateIsRefreshing(false)) + ]) + } + } + + var catchClosureForMore: ((Error) throws -> Observable ) { + return { _ in + + let displayType = self.currentState.displayType + var emitObservable: Observable { + switch displayType { + case .latest: + return .just(.more( + latests: [], + distances: self.currentState.distanceCards ?? [] + )) + case .distance: + return .just(.more( + latests: self.currentState.latestCards ?? [], + distances: [] + )) + default: + return .empty() + } + } + + return .concat([ + emitObservable, + .just(.updateIsRefreshing(false)) + ]) + } + } + + var catchClosureForNotis: ((Error) throws -> Observable ) { + return { _ in + .concat([ + .just(.notices([])), + .just(.updateHasUnreadNotifications(false)), + .just(.updateIsRefreshing(false)) + ]) + } + } + + func canUpdateCells( + prev prevDisplayState: DisplayStates, + curr currDisplayState: DisplayStates + ) -> Bool { + return prevDisplayState.displayType == currDisplayState.displayType && + prevDisplayState.latests == currDisplayState.latests && + prevDisplayState.populars == currDisplayState.populars && + prevDisplayState.distances == currDisplayState.distances + } + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } +} + + +extension HomeViewReactor { + + func reactorForNotification(with displayType: NotificationViewReactor.DisplayType = .activity(.unread)) -> NotificationViewReactor { + NotificationViewReactor(dependencies: self.dependencies, displayType: displayType) + } + + func reactorForDetail(with id: String) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: id) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Latest/MainHomeLatestViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Latest/MainHomeLatestViewController.swift deleted file mode 100644 index 2cc40f46..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Latest/MainHomeLatestViewController.swift +++ /dev/null @@ -1,323 +0,0 @@ -// -// MainHomeLatestViewController.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import UIKit - -import Kingfisher -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class MainHomeLatestViewController: BaseViewController, View { - - - // MARK: Views - - private lazy var tableView = UITableView(frame: .zero, style: .plain).then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - - $0.contentInset.top = SOMSwipeTabBar.Height.mainHome - - $0.isHidden = true - - $0.register(MainHomeViewCell.self, forCellReuseIdentifier: "cell") - $0.register(PlaceholderViewCell.self, forCellReuseIdentifier: "placeholder") - - $0.refreshControl = SOMRefreshControl() - - $0.dataSource = self - $0.prefetchDataSource = self - - $0.delegate = self - } - - private let moveTopButton = MoveTopButtonView().then { - $0.isHidden = true - } - - - // MARK: Variables - - // tableView 정보 - private var currentOffset: CGFloat = 0 - private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = false - - private let cellHeight: CGFloat = { - let width: CGFloat = (UIScreen.main.bounds.width - 20 * 2) * 0.9 - return width + 10 /// 가로 + top inset - }() - - - // MARK: Variables + Rx - - let hidesHeaderContainer = PublishRelay() - let willPushCardId = PublishRelay() - - - // MARK: Override func - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.tableView) - self.tableView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.view.addSubview(self.moveTopButton) - self.view.bringSubviewToFront(self.moveTopButton) - self.moveTopButton.snp.makeConstraints { - let bottomOffset: CGFloat = 24 + 60 + 4 + 20 - $0.bottom.equalTo(self.tableView.snp.bottom).offset(-bottomOffset) - $0.centerX.equalToSuperview() - $0.height.equalTo(MoveTopButtonView.height) - } - } - - override func bind() { - super.bind() - - // tableView 상단 이동 - self.moveTopButton.backgroundButton.rx.throttleTap(.seconds(3)) - .subscribe(with: self) { object, _ in - let indexPath = IndexPath(row: 0, section: 0) - object.tableView.scrollToRow(at: indexPath, at: .top, animated: true) - } - .disposed(by: self.disposeBag) - } - - - // MARK: ReactorKit - bind - - func bind(reactor: MainHomeLatestViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() - // isLoading == true && isRefreshing == false 일 때, 이벤트 무시 - self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - isLoading - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.displayedCardsWithUpdate) - .filterNil() - .distinctUntilChanged(reactor.canUpdateCells) - .subscribe(with: self) { object, displayedCardsWithUpdate in - let displayedCards = displayedCardsWithUpdate.cards - let hasMoreUpdate = displayedCardsWithUpdate.hasMoreUpdate - - object.tableView.isHidden = false - - // hasMoreUpdate == true일 때, 추가된 데이터만 로드 - if hasMoreUpdate { - - let lastSectionIndex = object.tableView.numberOfSections - 1 - let lastRowIndex = object.tableView.numberOfRows(inSection: lastSectionIndex) - 1 - let loadedDisplayedCards = displayedCards[0...lastRowIndex] - let indexPathForInsert = displayedCards.enumerated() - .filter { loadedDisplayedCards.contains($0.element) == false } - .map { IndexPath(row: $0.offset, section: 0) } - - object.tableView.performBatchUpdates { - object.tableView.insertRows(at: indexPathForInsert, with: .fade) - } - } else { - - object.tableView.reloadData() - } - } - .disposed(by: self.disposeBag) - } -} - - -// MARK: Set UITableView cell - -extension MainHomeLatestViewController { - - private func cellForPlaceholder(_ tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell { - - let placeholder = tableView.dequeueReusableCell( - withIdentifier: "placeholder", - for: indexPath - ) as! PlaceholderViewCell - - return placeholder - } - - private func cellForMainHome( - _ tableView: UITableView, - for indexPath: IndexPath, - with reactor: MainHomeLatestViewReactor - ) -> UITableViewCell { - - let displayedCards = reactor.currentState.displayedCards - let model = SOMCardModel(data: displayedCards[indexPath.row]) - let cell: MainHomeViewCell = tableView.dequeueReusableCell( - withIdentifier: "cell", - for: indexPath - ) as! MainHomeViewCell - cell.setModel(model) - // 카드 하단 contents 스택 순서 변경 (최신순) - cell.changeOrderInCardContentStack(0) - - return cell - } -} - - -// MARK: MainHomeViewController DataSource and Delegate - -extension MainHomeLatestViewController: UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.reactor?.currentState.displayedCardsCount ?? 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let reactor = self.reactor else { return .init(frame: .zero) } - - if reactor.currentState.isDisplayedCardsEmpty { - - return self.cellForPlaceholder(tableView, for: indexPath) - } else { - - return self.cellForMainHome(tableView, for: indexPath, with: reactor) - } - } -} - -extension MainHomeLatestViewController: UITableViewDataSourcePrefetching { - - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - guard let reactor = self.reactor else { return } - - indexPaths.forEach { indexPath in - // 데이터 로드 전, 이미지 캐싱 - let strUrl = reactor.currentState.displayedCards[indexPath.row].backgroundImgURL.url - KingfisherManager.shared.download(strUrl: strUrl) { _ in } - } - } -} - -extension MainHomeLatestViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let reactor = self.reactor else { return } - - let selectedId = reactor.currentState.displayedCards[indexPath.row].id - self.willPushCardId.accept(selectedId) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return (self.reactor?.currentState.isDisplayedCardsEmpty ?? true) ? tableView.bounds.height : self.cellHeight - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard self.reactor?.currentState.isDisplayedCardsEmpty == false else { return } - - let lastSectionIndex = tableView.numberOfSections - 1 - let lastRowIndex = tableView.numberOfRows(inSection: lastSectionIndex) - 1 - - if self.isLoadingMore, - indexPath.section == lastSectionIndex, - indexPath.row == lastRowIndex, - let reactor = self.reactor { - - let lastId = reactor.currentState.displayedCards[indexPath.row].id - reactor.action.onNext(.moreFind(lastId)) - } - } - - - // MARK: UIScrollView Delegate - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (self.currentOffset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { - - self.hidesHeaderContainer.accept(false) - self.currentOffset = offset - self.moveTopButton.isHidden = true - - return - } - - // 정상적인 스크롤 상황일 때, 헤더뷰 숨김 - guard offset <= (scrollView.contentSize.height - scrollView.frame.height) else { return } - - // offset이 currentOffset보다 크면 아래로 스크롤, 반대일 경우 위로 스크롤 - // 위로 스크롤 중일 때 헤더뷰 표시, 아래로 스크롤 중일 때 헤더뷰 숨김 - self.hidesHeaderContainer.accept(offset > self.currentOffset) - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - - self.currentOffset = offset - - // 최상단일 때만 moveToButton 숨김 - self.moveTopButton.isHidden = self.currentOffset <= 0 - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Latest/MainHomeLatestViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Latest/MainHomeLatestViewReactor.swift deleted file mode 100644 index 3a829f2a..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Latest/MainHomeLatestViewReactor.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// MainHomeLatestViewReactor.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import ReactorKit - - -class MainHomeLatestViewReactor: Reactor { - - // hasMoreUpdate == true 일 때, moreFind - typealias CardsWithUpdate = (cards: [Card], hasMoreUpdate: Bool) - - enum Action: Equatable { - case landing - case refresh - case moreFind(String) - } - - enum Mutation { - case cards(CardsWithUpdate) - case more(CardsWithUpdate) - case updateIsLoading(Bool) - case updateIsProcessing(Bool) - } - - struct State { - fileprivate(set) var displayedCardsWithUpdate: CardsWithUpdate? - fileprivate(set) var isLoading: Bool - fileprivate(set) var isProcessing: Bool - - var displayedCards: [Card] { - return self.displayedCardsWithUpdate?.cards ?? [] - } - var isDisplayedCardsEmpty: Bool { - return self.displayedCards.isEmpty - } - var displayedCardsCount: Int { - return self.isDisplayedCardsEmpty ? 1 : self.displayedCards.count - } - } - - var initialState: State = .init( - displayedCardsWithUpdate: nil, - isLoading: false, - isProcessing: false - ) - - let provider: ManagerProviderType - - // TODO: 페이징 - // private let countPerLoading: Int = 10 - - init(provider: ManagerProviderType) { - self.provider = provider - } - - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - - return .concat([ - .just(.updateIsProcessing(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) - case .refresh: - - return .concat([ - .just(.updateIsLoading(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsLoading(false)) - ]) - case let .moreFind(lastId): - - return .concat([ - .just(.updateIsProcessing(true)), - self.moreFind(lastId) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state: State = state - switch mutation { - case let .cards(displayedCardsWithUpdate): - state.displayedCardsWithUpdate = displayedCardsWithUpdate - case let .more(displayedCardsWithUpdate): - state.displayedCardsWithUpdate?.cards += displayedCardsWithUpdate.cards - state.displayedCardsWithUpdate?.hasMoreUpdate = displayedCardsWithUpdate.hasMoreUpdate - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - } - return state - } -} - -extension MainHomeLatestViewReactor { - - func refresh() -> Observable { - - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude - - let request: CardRequest = .latestCard(lastId: nil, latitude: latitude, longitude: longitude) - return self.provider.networkManager.request(LatestCardResponse.self, request: request) - .map(\.embedded.cards) - .map { Mutation.cards((cards: $0, hasMoreUpdate: false)) } - .catch(self.catchClosure) - } - - func moreFind(_ lastId: String) -> Observable { - - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude - - let request: CardRequest = .latestCard(lastId: lastId, latitude: latitude, longitude: longitude) - return self.provider.networkManager.request(LatestCardResponse.self, request: request) - .map(\.embedded.cards) - .map { Mutation.more((cards: $0, hasMoreUpdate: true)) } - .catch(self.catchClosure) - } -} - -extension MainHomeLatestViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.cards((cards: [], hasMoreUpdate: false))), - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } - } - - // TODO: 페이징 - // func separate(displayed displayedCards: [Card], current cards: [Card]) -> [Card] { - // let count = displayedCards.count - // let displayedCards = Array(cards[count.. Bool { - return prevCardsWithUpdate.cards == currCardsWithUpdate.cards && - prevCardsWithUpdate.hasMoreUpdate == currCardsWithUpdate.hasMoreUpdate - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/MainHomeTabBarController.swift b/SOOUM/SOOUM/Presentations/Main/Home/MainHomeTabBarController.swift deleted file mode 100644 index 43292642..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/MainHomeTabBarController.swift +++ /dev/null @@ -1,432 +0,0 @@ -// -// MainHomeTabBarController.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class MainHomeTabBarController: BaseNavigationViewController, View { - - enum Text { - static let tabLatestTitle: String = "최신순" - static let tabPopularityTitle: String = "인기순" - static let tabDistanceTitle: String = "거리순" - - static let dialogTitle: String = "위치 정보 사용 설정" - static let dialogMessage: String = "위치 확인을 위해 권한 설정이 필요해요" - - static let cancelActionTitle: String = "취소" - static let settingActionTitle: String = "설정" - } - - - // MARK: Set navigationBar Items - - private let logo = UIImageView().then { - $0.image = .init(.logo) - $0.tintColor = .som.p300 - $0.contentMode = .scaleAspectFit - } - - private let rightAlamButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.alarm))) - $0.foregroundColor = .som.gray700 - } - - private let dotWithoutReadView = UIView().then { - $0.backgroundColor = .som.red - $0.layer.cornerRadius = 6 * 0.5 - $0.clipsToBounds = true - $0.isHidden = true - } - - - // MARK: Views - - private let headerContainer = UIStackView().then { - $0.backgroundColor = .som.white - $0.axis = .vertical - } - - private lazy var headerTapBar = SOMSwipeTabBar(alignment: .left).then { - $0.items = [Text.tabLatestTitle, Text.tabPopularityTitle, Text.tabDistanceTitle] - - $0.delegate = self - } - - private lazy var headerLocationFilter = SOMLocationFilter().then { - $0.delegate = self - } - - private lazy var pageViewController = UIPageViewController( - transitionStyle: .scroll, - navigationOrientation: .horizontal - ).then { - $0.dataSource = self - $0.delegate = self - } - - - // MARK: Variables - - private var pages = [UIViewController]() - private var currentPage: Int = 0 - - private var animator: UIViewPropertyAnimator? - - private var locationFilterHeight: CGFloat = 0 - - - // MARK: Constraints - - private var headerTapBarHeightConstraint: Constraint? - private var headerLocationFilterHeightConstraint: Constraint? - - - // MARK: Override func - - override func setupNaviBar() { - super.setupNaviBar() - - self.navigationBar.titleView = self.logo - self.navigationBar.titlePosition = .left - - self.navigationBar.hidesBackButton = true - - self.rightAlamButton.snp.makeConstraints { - $0.size.equalTo(24) - } - (self.rightAlamButton.imageView ?? self.rightAlamButton).addSubview(self.dotWithoutReadView) - self.dotWithoutReadView.snp.makeConstraints { - $0.top.equalToSuperview().offset(2) - $0.trailing.equalToSuperview().offset(-3) - $0.size.equalTo(6) - } - self.navigationBar.setRightButtons([self.rightAlamButton]) - } - - override func bind() { - super.bind() - - // 탭바 표시 - self.rx.viewWillAppear - .subscribe(with: self) { object, _ in - object.hidesBottomBarWhenPushed = false - } - .disposed(by: self.disposeBag) - - // 알림 화면으로 전환 - self.rightAlamButton.rx.tap - .subscribe(with: self) { object, _ in - let notificatinoTabBarController = NotificationTabBarController() - notificatinoTabBarController.reactor = object.reactor?.reactorForNoti() - object.navigationPush(notificatinoTabBarController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - - // 유저 정보 모두 제거 후 온보딩 화면으로 전환 - #if DEVELOP - logo.rx.longPressGesture() - .when(.began) - .subscribe(with: self) { object, _ in - AuthKeyChain.shared.delete(.deviceId) - AuthKeyChain.shared.delete(.refreshToken) - AuthKeyChain.shared.delete(.accessToken) - - DispatchQueue.main.async { - if let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) { - - let viewController = OnboardingViewController() - viewController.reactor = OnboardingViewReactor(provider: object.reactor!.provider) - window.rootViewController = UINavigationController(rootViewController: viewController) - } - } - } - .disposed(by: self.disposeBag) - #endif - } - - override func setupConstraints() { - super.setupConstraints() - - self.addChild(self.pageViewController) - self.view.addSubview(self.pageViewController.view) - self.pageViewController.view.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.bottom.leading.trailing.equalToSuperview() - } - - self.view.addSubview(self.headerContainer) - self.headerContainer.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - } - self.headerContainer.addArrangedSubview(self.headerTapBar) - self.headerTapBar.snp.makeConstraints { - self.headerTapBarHeightConstraint = $0.height.equalTo(SOMSwipeTabBar.Height.mainHome).priority(.high).constraint - } - self.headerContainer.addArrangedSubview(self.headerLocationFilter) - self.headerLocationFilter.snp.makeConstraints { - self.headerLocationFilterHeightConstraint = $0.height.equalTo(0).priority(.high).constraint - } - } - - - // MARK: ReactorKit - bind - - func bind(reactor: MainHomeTabBarReactor) { - - let mainHomeLatestViewController = MainHomeLatestViewController() - mainHomeLatestViewController.reactor = reactor.reactorForLatest() - - self.pages.append(mainHomeLatestViewController) - - let mainHomePopularViewController = MainHomePopularViewController() - mainHomePopularViewController.reactor = reactor.reactorForPopular() - - self.pages.append(mainHomePopularViewController) - - let mainHomeDistanceViewController = MainHomeDistanceViewController() - mainHomeDistanceViewController.reactor = reactor.reactorForDistance() - - self.pages.append(mainHomeDistanceViewController) - - self.currentPage = 0 - self.pageViewController.setViewControllers( - [self.pages[0]], - direction: .forward, - animated: false, - completion: nil - ) - - // 각 뷰컨트롤러의 hidesHeaderContainer 구독 - Observable.merge( - mainHomeLatestViewController.hidesHeaderContainer.distinctUntilChanged().asObservable(), - mainHomePopularViewController.hidesHeaderContainer.distinctUntilChanged().asObservable(), - mainHomeDistanceViewController.hidesHeaderContainer.distinctUntilChanged().asObservable() - ) - .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, hidesHeaderContainer in - - // 애니메이터가 이미 실행 중이라면 취소하고 새 애니메이션 시작 - if object.animator?.state == .active { - - object.animator?.stopAnimation(false) - object.animator?.finishAnimation(at: .end) - } - // 헤더 뷰 높이 조절 - object.headerTapBarHeightConstraint?.deactivate() - object.headerLocationFilterHeightConstraint?.deactivate() - object.headerTapBar.snp.makeConstraints { - let height = hidesHeaderContainer ? 0 : SOMSwipeTabBar.Height.mainHome - object.headerTapBarHeightConstraint = $0.height.equalTo(height).priority(.high).constraint - } - object.headerLocationFilter.snp.makeConstraints { - let height = hidesHeaderContainer ? 0 : object.locationFilterHeight - object.headerLocationFilterHeightConstraint = $0.height.equalTo(height).priority(.high).constraint - } - - // 애니메이션 추가 - object.animator = UIViewPropertyAnimator(duration: 0.25, curve: .easeInOut) - object.animator?.addAnimations { - - object.view.layoutIfNeeded() - } - // 새 애니메이션 시작 - object.animator?.startAnimation() - object.animator?.addCompletion { position in - // 애니메이션이 끝난 후 animator 초기화 - if position == .end { object.animator = nil } - // Update headerContainer hidden - object.headerContainer.isHidden = hidesHeaderContainer - } - } - .disposed(by: self.disposeBag) - - // 각 뷰컨트롤러의 willPushCardId 구독 - Observable.merge( - mainHomeLatestViewController.willPushCardId.asObservable(), - mainHomePopularViewController.willPushCardId.asObservable(), - mainHomeDistanceViewController.willPushCardId.asObservable() - ) - .subscribe(with: self) { object, willPushCardId in - - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(willPushCardId) - object.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.notisWithoutRead } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - reactor.state.map(\.noNotisWithoutRead) - .bind(to: self.dotWithoutReadView.rx.isHidden) - .disposed(by: self.disposeBag) - } -} - - -// MARK: Private func - -extension MainHomeTabBarController { - - private func showLocationPermissionDialog() { - - let cancelAction = SOMDialogAction( - title: Text.cancelActionTitle, - style: .gray, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - let settingAction = SOMDialogAction( - title: Text.settingActionTitle, - style: .primary, - action: { - let application = UIApplication.shared - let openSettingsURLString: String = UIApplication.openSettingsURLString - if let settingsURL = URL(string: openSettingsURLString), - application.canOpenURL(settingsURL) { - application.open(settingsURL) - } - - UIApplication.topViewController?.dismiss(animated: true) - } - ) - - SOMDialogViewController.show( - title: Text.dialogTitle, - message: Text.dialogMessage, - actions: [cancelAction, settingAction] - ) - } -} - - -// MARK: SOMSwipeTabBarDelegate - -extension MainHomeTabBarController: SOMSwipeTabBarDelegate { - - func tabBar(_ tabBar: SOMSwipeTabBar, shouldSelectTabAt index: Int) -> Bool { - - if index == 2, self.reactor?.provider.locationManager.checkLocationAuthStatus() == .denied { - - self.showLocationPermissionDialog() - return false - } - - return true - } - - func tabBar(_ tabBar: SOMSwipeTabBar, didSelectTabAt index: Int) { - - let hidesLocationFilter = index != 2 - - self.locationFilterHeight = hidesLocationFilter ? 0 : SOMLocationFilter.height - - self.headerLocationFilterHeightConstraint?.deactivate() - self.headerLocationFilter.snp.makeConstraints { - self.headerLocationFilterHeightConstraint = $0.height.equalTo(self.locationFilterHeight).priority(.high).constraint - } - - UIView.performWithoutAnimation { - self.view.layoutIfNeeded() - } - - if self.currentPage != index { - - self.currentPage = index - self.pageViewController.setViewControllers( - [self.pages[index]], - direction: tabBar.previousIndex <= index ? .forward : .reverse, - animated: true, - completion: nil - ) - } - } -} - - -// MARK: SOMLocationFilterDelegate - -extension MainHomeTabBarController: SOMLocationFilterDelegate { - - func filter(_ filter: SOMLocationFilter, didSelectDistanceAt distance: SOMLocationFilter.Distance) { - guard filter.prevDistance != distance, - let mainHomeDistanceViewController = self.pages[self.currentPage] as? MainHomeDistanceViewController - else { return } - - mainHomeDistanceViewController.reactor?.action.onNext(.distanceFilter(distance.rawValue)) - } -} - - -// MARK: UIPageViewController dataSource and delegate - -extension MainHomeTabBarController: UIPageViewControllerDataSource { - - func pageViewController( - _ pageViewController: UIPageViewController, - viewControllerBefore viewController: UIViewController - ) -> UIViewController? { - guard let currentIndex = self.pages.firstIndex(of: viewController), - currentIndex > 0 - else { return nil } - - return self.pages[currentIndex - 1] - } - - func pageViewController( - _ pageViewController: UIPageViewController, - viewControllerAfter viewController: UIViewController - ) -> UIViewController? { - guard let currentIndex = self.pages.firstIndex(of: viewController), - currentIndex < self.pages.count - 1 - else { return nil } - - // TODO: 임시, 위치 권한 허용 X일 때, 거리순 탭으로 진입 시 스와이프 제스처 막음 - if currentIndex == 1, - self.reactor?.provider.locationManager.checkLocationAuthStatus() == .denied { - return nil - } else { - return self.pages[currentIndex + 1] - } - } -} - -extension MainHomeTabBarController: UIPageViewControllerDelegate { - - func pageViewController( - _ pageViewController: UIPageViewController, - willTransitionTo pendingViewControllers: [UIViewController] - ) { - self.currentPage = self.pages.firstIndex(of: pendingViewControllers[0]) ?? 0 - } - - func pageViewController( - _ pageViewController: UIPageViewController, - didFinishAnimating finished: Bool, - previousViewControllers: [UIViewController], - transitionCompleted completed: Bool - ) { - if completed { - self.headerTapBar.didSelectTabBarItem(self.currentPage) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/MainHomeTabBarReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/MainHomeTabBarReactor.swift deleted file mode 100644 index b33e3343..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/MainHomeTabBarReactor.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// MainHomeTabBarReactor.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import ReactorKit - -import Alamofire - - -class MainHomeTabBarReactor: Reactor { - - enum Action: Equatable { - case notisWithoutRead - case requestRead(String) - } - - - enum Mutation { - case notisWithoutRead(Bool) - case updateIsReadCompleted(Bool) - } - - struct State { - var noNotisWithoutRead: Bool - var isReadCompleted: Bool - } - - var initialState: State = .init( - noNotisWithoutRead: true, - isReadCompleted: false - ) - - let provider: ManagerProviderType - - init(provider: ManagerProviderType) { - self.provider = provider - } - - func mutate(action: Action) -> Observable { - switch action { - case .notisWithoutRead: - let request: NotificationRequest = .totalWithoutReadCount - - return self.provider.networkManager.request(WithoutReadNotisCountResponse.self, request: request) - .map { $0.unreadCnt == "0" } - .map(Mutation.notisWithoutRead) - case let .requestRead(selectedId): - let request: NotificationRequest = .requestRead(notificationId: selectedId) - return self.provider.networkManager.request(Empty.self, request: request) - .map { _ in .updateIsReadCompleted(true) } - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state = state - switch mutation { - case let .notisWithoutRead(noNotisWithoutRead): - state.noNotisWithoutRead = noNotisWithoutRead - case let .updateIsReadCompleted(isReadCompleted): - state.isReadCompleted = isReadCompleted - } - return state - } -} - -extension MainHomeTabBarReactor { - - func reactorForLatest() -> MainHomeLatestViewReactor { - MainHomeLatestViewReactor(provider: self.provider) - } - - func reactorForPopular() -> MainHomePopularViewReactor { - MainHomePopularViewReactor(provider: self.provider) - } - - func reactorForDistance() -> MainHomeDistanceViewReactor { - MainHomeDistanceViewReactor(provider: self.provider) - } - - func reactorForDetail(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor.init(provider: self.provider, selectedId) - } - - func reactorForNoti() -> NotificationTabBarReactor { - NotificationTabBarReactor(provider: self.provider) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationTabBarController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationTabBarController.swift deleted file mode 100644 index 7bcfd2d8..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationTabBarController.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// NotificationTabBarController.swift -// SOOUM -// -// Created by 오현식 on 12/20/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class NotificationTabBarController: BaseNavigationViewController, View { - - enum Text { - static let navigationTitle: String = "알림" - - static let tabTotalTitle: String = "전체" - static let tabCommentTitle: String = "답카드" - static let tabLikeTitle: String = "공감" - } - - - // MARK: Views - - private lazy var headerTabBar = SOMSwipeTabBar(alignment: .fill).then { - $0.inset = .zero - $0.spacing = 0 - $0.seperatorHeight = 1.4 - $0.seperatorColor = .som.gray300 - $0.items = [Text.tabTotalTitle, Text.tabCommentTitle, Text.tabLikeTitle] - - $0.delegate = self - } - - private lazy var pageViewController = UIPageViewController( - transitionStyle: .scroll, - navigationOrientation: .horizontal - ).then { - $0.dataSource = self - $0.delegate = self - } - - - // MARK: Variables - - private var pages = [UIViewController]() - private var currentPage: Int = 0 - - - // MARK: Override variables - - override var navigationBarHeight: CGFloat { - 46 - } - - - // MARK: Override func - - override func setupNaviBar() { - super.setupNaviBar() - - self.navigationBar.title = Text.navigationTitle - } - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.headerTabBar) - self.headerTabBar.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() - $0.height.equalTo(SOMSwipeTabBar.Height.notification) - } - - self.addChild(self.pageViewController) - self.view.addSubview(self.pageViewController.view) - self.pageViewController.view.snp.makeConstraints { - $0.top.equalTo(self.headerTabBar.snp.bottom) - $0.bottom.leading.trailing.equalToSuperview() - } - } - - - // MARK: ReactorKit - bind - - func bind(reactor: NotificationTabBarReactor) { - - let notificationTotalViewController = NotificationViewController() - notificationTotalViewController.reactor = reactor.reactorForTotal() - - self.pages.append(notificationTotalViewController) - - let notificationCommentViewController = NotificationViewController() - notificationCommentViewController.reactor = reactor.reactorForComment() - - self.pages.append(notificationCommentViewController) - - let notificationLikeViewController = NotificationViewController() - notificationLikeViewController.reactor = reactor.reactorForLike() - - self.pages.append(notificationLikeViewController) - - self.currentPage = 0 - self.pageViewController.setViewControllers( - [self.pages[0]], - direction: .forward, - animated: true, - completion: nil - ) - - // 각 뷰컨트롤러의 willPushCardId 구독 - Observable.merge( - notificationTotalViewController.willPushCardId.asObservable(), - notificationCommentViewController.willPushCardId.asObservable(), - notificationLikeViewController.willPushCardId.asObservable() - ) - .subscribe(with: self) { object, willPushCardId in - - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(willPushCardId) - object.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - } -} - -extension NotificationTabBarController: SOMSwipeTabBarDelegate { - - func tabBar(_ tabBar: SOMSwipeTabBar, shouldSelectTabAt index: Int) -> Bool { - return true - } - - func tabBar(_ tabBar: SOMSwipeTabBar, didSelectTabAt index: Int) { - - if self.currentPage != index { - - self.currentPage = index - self.pageViewController.setViewControllers( - [self.pages[index]], - direction: tabBar.previousIndex <= index ? .forward : .reverse, - animated: true, - completion: nil - ) - } - } -} - -extension NotificationTabBarController: UIPageViewControllerDataSource { - - func pageViewController( - _ pageViewController: UIPageViewController, - viewControllerBefore viewController: UIViewController - ) -> UIViewController? { - guard let currentIndex = self.pages.firstIndex(of: viewController), - currentIndex > 0 - else { return nil } - - return self.pages[currentIndex - 1] - } - - func pageViewController( - _ pageViewController: UIPageViewController, - viewControllerAfter viewController: UIViewController - ) -> UIViewController? { - guard let currentIndex = self.pages.firstIndex(of: viewController), - currentIndex < self.pages.count - 1 - else { return nil } - - return self.pages[currentIndex + 1] - } -} - -extension NotificationTabBarController: UIPageViewControllerDelegate { - - func pageViewController( - _ pageViewController: UIPageViewController, - willTransitionTo pendingViewControllers: [UIViewController] - ) { - self.currentPage = self.pages.firstIndex(of: pendingViewControllers[0]) ?? 0 - } - - func pageViewController( - _ pageViewController: UIPageViewController, - didFinishAnimating finished: Bool, - previousViewControllers: [UIViewController], - transitionCompleted completed: Bool - ) { - if completed { - self.headerTabBar.didSelectTabBarItem(self.currentPage) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationTabBarReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationTabBarReactor.swift deleted file mode 100644 index a9484931..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationTabBarReactor.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// NotificationTabBarReactor.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import ReactorKit - - -class NotificationTabBarReactor: Reactor { - - typealias Action = NoAction - typealias Mutation = NoMutation - - struct State { } - - var initialState: State { .init() } - - let provider: ManagerProviderType - - init(provider: ManagerProviderType) { - self.provider = provider - } -} - -extension NotificationTabBarReactor { - - func reactorForTotal() -> NotificationViewReactor { - NotificationViewReactor(provider: self.provider, .total) - } - - func reactorForComment() -> NotificationViewReactor { - NotificationViewReactor(provider: self.provider, .comment) - } - - func reactorForLike() -> NotificationViewReactor { - NotificationViewReactor(provider: self.provider, .like) - } - - func reactorForDetail(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, selectedId) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift new file mode 100644 index 00000000..2f176e68 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift @@ -0,0 +1,626 @@ +// +// NotificationViewController.swift +// SOOUM +// +// Created by 오현식 on 12/23/24. +// + +import UIKit + +import SnapKit +import Then + +import ReactorKit +import RxCocoa +import RxSwift + +class NotificationViewController: BaseNavigationViewController, View { + + enum Text { + static let navigationTitle: String = "알림" + + static let activityTitle: String = "활동" + static let noticeTitle: String = "공지사항" + + static let headerTextForRead: String = "지난 알림" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let confirmActionTitle: String = "확인" + } + + enum Section: Int, CaseIterable { + case unread + case read + case notice + case empty + } + + enum Item: Hashable { + case unread(CompositeNotificationInfo) + case read(CompositeNotificationInfo) + case notice(NoticeInfo) + case empty + } + + + // MARK: Views + + private lazy var headerView = SOMSwipableTabBar().then { + $0.items = [Text.activityTitle, Text.noticeTitle] + $0.delegate = self + } + + private lazy var tableView = UITableView(frame: .zero, style: .grouped).then { + $0.backgroundColor = .som.v2.white + $0.indicatorStyle = .black + $0.separatorStyle = .none + + $0.isHidden = true + + $0.contentInsetAdjustmentBehavior = .never + $0.sectionHeaderTopPadding = .zero + + $0.refreshControl = SOMRefreshControl() + + $0.register( + NotificationViewCell.self, + forCellReuseIdentifier: NotificationViewCell.cellIdentifier + ) + $0.register( + NoticeViewCell.self, + forCellReuseIdentifier: NoticeViewCell.cellIdentifier + ) + $0.register( + NotificationPlaceholderViewCell.self, + forCellReuseIdentifier: NotificationPlaceholderViewCell.cellIdentifier + ) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(tableView: self.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + + guard let self = self else { return nil } + + switch item { + case let .unread(notification): + + let cell: NotificationViewCell = self.cellForNotification(tableView, with: indexPath) + cell.bind(notification, isReaded: false) + + return cell + case let .read(notification): + + let cell: NotificationViewCell = self.cellForNotification(tableView, with: indexPath) + cell.bind(notification, isReaded: true) + + return cell + case let .notice(notice): + + let cell: NoticeViewCell = self.cellForNotice(tableView, with: indexPath) + cell.bind(notice) + + return cell + case .empty: + + return self.cellForPlaceholder(tableView, with: indexPath) + } + } + + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } + + + // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.headerView) + self.headerView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.leading.trailing.equalToSuperview() + } + + self.view.addSubview(self.tableView) + self.tableView.snp.makeConstraints { + $0.top.equalTo(self.headerView.snp.bottom) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + + // MARK: ReactorKit - bind + + func bind(reactor: NotificationViewReactor) { + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.tableView.refreshControl?.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .observe(on: MainScheduler.asyncInstance) + .filter { $0 == false } + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.displayType) + .filter { $0 == .notice } + .take(1) + .observe(on: MainScheduler.instance) + .subscribe(with: self.headerView) { headerView, _ in + headerView.didSelectTabBarItem(1, onlyUpdateApperance: true) + } + .disposed(by: self.disposeBag) + + reactor.state.map { + NotificationViewReactor.DisplayStates( + displayType: $0.displayType, + unreads: $0.notificationsForUnread, + reads: $0.notifications, + notices: $0.notices + ) + } + /// 읽지 않은 알림이 없을 때, 홈에 알림 + .do(onNext: { + if $0.unreads?.isEmpty == true { + NotificationCenter.default.post(name: .updatedHasUnreadNotification, object: nil, userInfo: nil) + } + }) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, displayStats in + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + switch displayStats.displayType { + case .activity: + + guard let unreads = displayStats.unreads, let reads = displayStats.reads else { return } + + if unreads.isEmpty && reads.isEmpty { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let newUnreads = unreads.map { Item.unread($0) } + snapshot.appendItems(newUnreads, toSection: .unread) + + let newReads = reads.map { Item.read($0) } + snapshot.appendItems(newReads, toSection: .read) + case .notice: + + guard let notices = displayStats.notices else { return } + + guard notices.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = notices.map { Item.notice($0) } + snapshot.appendItems(new, toSection: .notice) + } + + object.dataSource.apply(snapshot, animatingDifferences: false) + + object.tableView.isHidden = false + } + .disposed(by: self.disposeBag) + + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + object.showPungedCardDialog(reactor) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { ($0.selectedId, $0.selectedNotiId) } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, combined in + + let (selectedId, selectedNotiId) = combined + if let selectedNotiId = selectedNotiId { + reactor.action.onNext(.requestRead(selectedNotiId)) + } + + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(with: selectedId) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .notification + ) + ) + } + } + .disposed(by: self.disposeBag) + } +} + + +// MARK: Cells + +private extension NotificationViewController { + + func cellForPlaceholder( + _ tableView: UITableView, + with indexPath: IndexPath + ) -> NotificationPlaceholderViewCell { + + return tableView.dequeueReusableCell( + withIdentifier: NotificationPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! NotificationPlaceholderViewCell + } + + func cellForNotification( + _ tableView: UITableView, + with indexPath: IndexPath + ) -> NotificationViewCell { + + return tableView.dequeueReusableCell( + withIdentifier: NotificationViewCell.cellIdentifier, + for: indexPath + ) as! NotificationViewCell + } + + func cellForNotice( + _ tableView: UITableView, + with indexPath: IndexPath + ) -> NoticeViewCell { + + return tableView.dequeueReusableCell( + withIdentifier: NoticeViewCell.cellIdentifier, + for: indexPath + ) as! NoticeViewCell + } +} + +private extension NotificationViewController { + + func showPungedCardDialog(_ reactor: NotificationViewReactor) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext(.updateNotifications) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} + + +// MARK: UITableViewDelegate + +extension NotificationViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath), + let reactor = self.reactor + else { return } + + switch item { + case let .notice(notice): + + if let urlString = notice.url, let url = URL(string: urlString), + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + case let .unread(notification): + + switch notification { + case let .default(notification): + + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: notification.notificationInfo.notificationId + ) + ) + case let .tag(notification): + + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: notification.notificationInfo.notificationId + ) + ) + case let .follow(notification): + + reactor.action.onNext(.requestRead(notification.notificationInfo.notificationId)) + + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(with: notification.userId) + self.navigationPush(profileViewController, animated: true) + default: + return + } + case let .read(notification): + + switch notification { + case let .default(notification): + + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: nil + ) + ) + case let .tag(notification): + + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: nil + ) + ) + case let .follow(notification): + + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(with: notification.userId) + self.navigationPush(profileViewController, animated: true) + default: + return + } + default: + return + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + + let sections = self.dataSource.snapshot().sectionIdentifiers + guard sections.isEmpty == false, self.headerView.selectedIndex != 1 else { return nil } + + switch sections[section] { + case .read: + + let backgroundView = UIView().then { + $0.backgroundColor = .som.v2.white + } + + let label = UILabel().then { + $0.text = Text.headerTextForRead + $0.textColor = .som.v2.black + + let typography = Typography.som.v2.subtitle3.withAlignment(.left) + $0.typography = typography + + let frame = CGRect( + x: 25, + y: 32, + width: UIScreen.main.bounds.width, + height: typography.lineHeight + ) + $0.frame = frame + } + backgroundView.addSubview(label) + + return backgroundView + default: + + return nil + } + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + + let sections = self.dataSource.snapshot().sectionIdentifiers + guard sections.isEmpty == false, self.headerView.selectedIndex != 1 else { return 0 } + + switch sections[section] { + case .read: + + return (self.reactor?.currentState.notifications?.isEmpty ?? true) ? 0 : 53 + default: + + return 0 + } + } + + // group style이기 때문에 footer 제거 + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return nil + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0 + } + + func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + + guard let reactor = self.reactor, reactor.currentState.isRefreshing == false else { return } + + switch reactor.currentState.displayType { + case let .activity(activityType): + + switch activityType { + case .unread: + + let lastRowIndexPath = tableView.numberOfRows(inSection: Section.unread.rawValue) - 1 + if reactor.currentState.notificationsForUnread?.isEmpty == false, + indexPath.section == Section.unread.rawValue, + indexPath.row == lastRowIndexPath { + + var lastId: String? { + switch reactor.currentState.notificationsForUnread?.last { + case let .default(notification): + return notification.notificationInfo.notificationId + case let .follow(notification): + return notification.notificationInfo.notificationId + case let .deleted(notification): + return notification.notificationInfo.notificationId + case let .blocked(notification): + return notification.notificationInfo.notificationId + case let .tag(notification): + return notification.notificationInfo.notificationId + default: + return nil + } + } + + guard let lastId = lastId else { return } + + reactor.action.onNext(.moreFind(lastId: lastId, displayType: .activity(.unread))) + } + case .read: + + let lastRowIndexPath = tableView.numberOfRows(inSection: Section.read.rawValue) - 1 + if reactor.currentState.notifications?.isEmpty == false, + indexPath.section == Section.read.rawValue, + indexPath.row == lastRowIndexPath { + + var lastId: String? { + switch reactor.currentState.notificationsForUnread?.last { + case let .default(notification): + return notification.notificationInfo.notificationId + case let .follow(notification): + return notification.notificationInfo.notificationId + case let .deleted(notification): + return notification.notificationInfo.notificationId + case let .blocked(notification): + return notification.notificationInfo.notificationId + case let .tag(notification): + return notification.notificationInfo.notificationId + default: + return nil + } + } + + guard let lastId = lastId else { return } + + reactor.action.onNext(.moreFind(lastId: lastId, displayType: .activity(.read))) + } + } + case .notice: + + let lastRowIndexPath = tableView.numberOfRows(inSection: Section.notice.rawValue) - 1 + if reactor.currentState.notices?.isEmpty == false, + indexPath.section == Section.notice.rawValue, + indexPath.row == lastRowIndexPath { + + let lastId = reactor.currentState.notices?.last?.id ?? "" + reactor.action.onNext(.moreFind(lastId: lastId, displayType: .notice)) + } + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.tableView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) + + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() + } + } +} + + +// MARK: SOMSwipableTabBarDelegate + +extension NotificationViewController: SOMSwipableTabBarDelegate { + + func tabBar(_ tabBar: SOMSwipableTabBar, didSelectTabAt index: Int) { + + self.tableView.reloadData() + + self.reactor?.action.onNext(.updateDisplayType(index == 1 ? .notice : .activity(.unread))) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift new file mode 100644 index 00000000..14c0e4ab --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift @@ -0,0 +1,313 @@ +// +// NotificationViewReactor.swift +// SOOUM +// +// Created by 오현식 on 12/23/24. +// + +import ReactorKit + +import Alamofire + +class NotificationViewReactor: Reactor { + + enum Action: Equatable { + case landing + case refresh + case updateDisplayType(DisplayType) + case moreFind(lastId: String, displayType: DisplayType) + case hasDetailCard(selectedId: String, selectedNotiId: String?) + case updateNotifications + case requestRead(String) + case cleanup + } + + enum Mutation { + case notifications(unreads: [CompositeNotificationInfo], reads: [CompositeNotificationInfo]) + case more(unreads: [CompositeNotificationInfo], reads: [CompositeNotificationInfo]) + case notices([NoticeInfo]) + case moreNotices([NoticeInfo]) + case updateDisplayType(DisplayType) + case cardIsDeleted((String, String?, Bool)?) + case updateIsRefreshing(Bool) + case updateIsReadSuccess(Bool) + } + + struct State { + fileprivate(set) var displayType: DisplayType + fileprivate(set) var notificationsForUnread: [CompositeNotificationInfo]? + fileprivate(set) var notifications: [CompositeNotificationInfo]? + fileprivate(set) var notices: [NoticeInfo]? + fileprivate(set) var cardIsDeleted: (selectedId: String, selectedNotiId: String?, isDeleted: Bool)? + fileprivate(set) var isRefreshing: Bool + fileprivate(set) var isReadSuccess: Bool + } + + var initialState: State + + private let dependencies: AppDIContainerable + private let notificationUseCase: NotificationUseCase + private let fetchNoticeUseCase: FetchNoticeUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase + + init(dependencies: AppDIContainerable, displayType: DisplayType = .activity(.unread)) { + self.dependencies = dependencies + self.notificationUseCase = dependencies.rootContainer.resolve(NotificationUseCase.self) + self.fetchNoticeUseCase = dependencies.rootContainer.resolve(FetchNoticeUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) + + self.initialState = State( + displayType: displayType, + notificationsForUnread: nil, + notifications: nil, + notices: nil, + cardIsDeleted: nil, + isRefreshing: false, + isReadSuccess: false + ) + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return .concat([ + Observable.zip( + self.notificationUseCase.unreadNotifications(lastId: nil), + self.notificationUseCase.readNotifications(lastId: nil) + ) + .map(Mutation.notifications) + .catch(self.catchClosureNotis), + self.fetchNoticeUseCase.notices(lastId: nil, size: 10, requestType: .notification) + .map(Mutation.notices) + .catch(self.catchClosureNotices) + ]) + case .refresh: + + switch self.currentState.displayType { + case .activity: + return .concat([ + .just(.updateIsRefreshing(true)), + Observable.zip( + self.notificationUseCase.unreadNotifications(lastId: nil), + self.notificationUseCase.readNotifications(lastId: nil) + ) + .map(Mutation.notifications) + .catch(self.catchClosureNotis), + .just(.updateIsRefreshing(false)) + ]) + + case .notice: + return .concat([ + .just(.updateIsRefreshing(true)), + self.fetchNoticeUseCase.notices(lastId: nil, size: 10, requestType: .notification) + .map(Mutation.notices) + .catch(self.catchClosureNotices), + .just(.updateIsRefreshing(false)) + ]) + } + case let .updateDisplayType(displayType): + return .just(.updateDisplayType(displayType)) + + case let .moreFind(lastId, displayType): + + switch displayType { + case let .activity(activityType): + return .concat([ + self.moreNotification(activityType, with: lastId) + .catch(self.catchClosureNotisMore) + ]) + case .notice: + return .concat([ + self.fetchNoticeUseCase.notices(lastId: lastId, size: 10, requestType: .notification) + .map(Mutation.moreNotices) + .catch(self.catchClosureNoticesMore) + ]) + } + case let .hasDetailCard(selectedId, selectedNotiId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, selectedNotiId, $0) } + .map(Mutation.cardIsDeleted) + ]) + case .updateNotifications: + + return Observable.zip( + self.notificationUseCase.unreadNotifications(lastId: nil), + self.notificationUseCase.readNotifications(lastId: nil) + ) + .map(Mutation.notifications) + .catch(self.catchClosureNotis) + case let .requestRead(selectedId): + + return self.notificationUseCase.requestRead(notificationId: selectedId) + .flatMapLatest { isReadSuccess -> Observable in + if isReadSuccess { + return .concat([ + Observable.zip( + self.notificationUseCase.unreadNotifications(lastId: nil), + self.notificationUseCase.readNotifications(lastId: nil) + ) + .map(Mutation.notifications) + .catch(self.catchClosureNotis), + .just(.updateIsReadSuccess(true)) + ]) + } else { + return .just(.updateIsReadSuccess(false)) + } + } + case .cleanup: + + return .just(.cardIsDeleted(nil)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .notifications(unreads, reads): + newState.notificationsForUnread = unreads + newState.notifications = reads + case let .more(unreads, reads): + newState.notificationsForUnread? += unreads + newState.notifications? += reads + case let .notices(notices): + newState.notices = notices + case let .moreNotices(notices): + newState.notices? += notices + case let .updateDisplayType(displayType): + newState.displayType = displayType + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + case let .updateIsReadSuccess(isReadSuccess): + newState.isReadSuccess = isReadSuccess + } + return newState + } +} + +private extension NotificationViewReactor { + + func moreNotification( + _ activityType: DisplayType.ActivityType, + with lastId: String + ) -> Observable { + + switch activityType { + case .unread: + return self.notificationUseCase.unreadNotifications(lastId: lastId) + .map { .more(unreads: $0, reads: []) } + case .read: + return self.notificationUseCase.readNotifications(lastId: lastId) + .map { .more(unreads: [], reads: $0) } + } + } +} + +extension NotificationViewReactor { + + struct DisplayStates { + let displayType: DisplayType + let unreads: [CompositeNotificationInfo]? + let reads: [CompositeNotificationInfo]? + let notices: [NoticeInfo]? + } + + enum DisplayType: Equatable { + enum ActivityType: Equatable { + case unread + case read + } + + case activity(ActivityType) + case notice + } + + struct PushOrRequestReadInfo: Equatable { + let entranceType: EntranceCardType + let notificationId: String + let targetCardId: String? + let shouldRead: Bool + } +} + +extension NotificationViewReactor { + + var catchClosureNotis: ((Error) throws -> Observable ) { + return { _ in + .concat([ + .just(.notifications(unreads: [], reads: [])), + .just(.updateIsRefreshing(false)) + ]) + } + } + + var catchClosureNotisMore: ((Error) throws -> Observable ) { + return { _ in + .concat([ + .just(.more(unreads: [], reads: [])), + .just(.updateIsRefreshing(false)) + ]) + } + } + + var catchClosureNotices: ((Error) throws -> Observable ) { + return { _ in + .concat([ + .just(.notices([])), + .just(.updateIsRefreshing(false)) + ]) + } + } + + var catchClosureNoticesMore: ((Error) throws -> Observable ) { + return { _ in + .concat([ + .just(.moreNotices([])), + .just(.updateIsRefreshing(false)) + ]) + } + } + + func canUpdatePushInfos( + prev prevPushInfo: (entranceType: EntranceCardType, id: String), + curr currPushInfo: (entranceType: EntranceCardType, id: String) + ) -> Bool { + return prevPushInfo.entranceType == currPushInfo.entranceType && + prevPushInfo.id == currPushInfo.id + } + + func canUpdateCells( + prev prevStates: DisplayStates, + curr currStates: DisplayStates + ) -> Bool { + return prevStates.displayType == currStates.displayType && + prevStates.unreads == currStates.unreads && + prevStates.reads == currStates.reads && + prevStates.notices == currStates.notices + } + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, selectedNotiId: String?, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, selectedNotiId: String?, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.selectedNotiId == currCardIsDeleted?.selectedNotiId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } +} + +extension NotificationViewReactor { + + func reactorForDetail(with id: String) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: id) + } + + func reactorForProfile(with userId: String) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .other, with: userId) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/Views/NotificationViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/Views/NotificationViewController.swift deleted file mode 100644 index b7c8f41e..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/Views/NotificationViewController.swift +++ /dev/null @@ -1,412 +0,0 @@ -// -// NotificationViewController.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class NotificationViewController: BaseViewController, View { - - enum Text { - static let withoutReadHeaderTitle: String = "읽지 않음" - } - - enum Section: Int, CaseIterable { - case withoutRead - case read - case empty - } - - - // MARK: Views - - private lazy var tableView = UITableView().then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - - $0.isHidden = true - - $0.sectionHeaderTopPadding = .zero - $0.decelerationRate = .fast - - $0.refreshControl = SOMRefreshControl() - - $0.register( - NotificationViewCell.self, - forCellReuseIdentifier: NotificationViewCell.cellIdentifier - ) - $0.register( - NotificationWithReportViewCell.self, - forCellReuseIdentifier: NotificationWithReportViewCell.cellIdentifier - ) - $0.register( - NotiPlaceholderViewCell.self, - forCellReuseIdentifier: NotiPlaceholderViewCell.cellIdentifier - ) - - $0.dataSource = self - $0.delegate = self - } - - - // MARK: Variables - - private var notificationsWithoutRead = [CommentHistoryInNoti]() - private var notifications = [CommentHistoryInNoti]() - private var withoutReadNotisCount = "0" - - private var currentOffset: CGFloat = 0 - private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = false - - - // MARK: Variables + Rx - - let willPushCardId = PublishRelay() - - - // MARK: Override func - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.tableView) - self.tableView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - } - - - // MARK: ReactorKit - bind - - func bind(reactor: NotificationViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.withoutReadNotisCount) - .distinctUntilChanged() - .subscribe(with: self) { object, withoutReadNotisCount in - object.withoutReadNotisCount = withoutReadNotisCount - - UIView.performWithoutAnimation { - object.tableView.reloadData() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.notificationsWithoutRead) - .distinctUntilChanged() - .filterNil() - .subscribe(with: self) { object, notificationsWithoutRead in - object.tableView.isHidden = false - - object.notificationsWithoutRead = notificationsWithoutRead - - let indexSetForEmpty = IndexSet(integer: Section.empty.rawValue) - let indexSetForWithoutRead = IndexSet(integer: Section.withoutRead.rawValue) - UIView.performWithoutAnimation { - object.tableView.performBatchUpdates { - object.tableView.reloadSections(indexSetForEmpty, with: .none) - object.tableView.reloadSections(indexSetForWithoutRead, with: .none) - } - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.notifications) - .distinctUntilChanged() - .filterNil() - .subscribe(with: self) { object, notifications in - object.tableView.isHidden = false - - object.notifications = notifications - - let indexSetForEmpty = IndexSet(integer: Section.empty.rawValue) - let indexSetForRead = IndexSet(integer: Section.read.rawValue) - UIView.performWithoutAnimation { - object.tableView.performBatchUpdates { - object.tableView.reloadSections(indexSetForEmpty, with: .none) - object.tableView.reloadSections(indexSetForRead, with: .none) - } - } - } - .disposed(by: self.disposeBag) - } -} - -extension NotificationViewController: UITableViewDataSource { - - func numberOfSections(in tableView: UITableView) -> Int { - return Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - - switch Section.allCases[section] { - case .withoutRead: - return self.notificationsWithoutRead.count - case .read: - return self.notifications.count - case .empty: - return (self.notificationsWithoutRead.isEmpty && self.notifications.isEmpty) ? 1 : 0 - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - switch Section.allCases[indexPath.section] { - case .withoutRead: - - let model: CommentHistoryInNoti = self.notificationsWithoutRead[indexPath.row] - switch model.type { - case .blocked, .delete: - - let cell: NotificationWithReportViewCell = tableView.dequeueReusableCell( - withIdentifier: NotificationWithReportViewCell.cellIdentifier, - for: indexPath - ) as! NotificationWithReportViewCell - cell.selectionStyle = .none - cell.bind(model) - - return cell - - default: - - let cell: NotificationViewCell = tableView.dequeueReusableCell( - withIdentifier: NotificationViewCell.cellIdentifier, - for: indexPath - ) as! NotificationViewCell - cell.selectionStyle = .none - cell.bind(model, isReaded: false) - - return cell - } - - case .read: - - let cell: NotificationViewCell = tableView.dequeueReusableCell( - withIdentifier: NotificationViewCell.cellIdentifier, - for: indexPath - ) as! NotificationViewCell - cell.selectionStyle = .none - cell.bind(self.notifications[indexPath.row], isReaded: true) - - return cell - - case .empty: - - let placeholder: NotiPlaceholderViewCell = tableView.dequeueReusableCell( - withIdentifier: NotiPlaceholderViewCell.cellIdentifier, - for: indexPath - ) as! NotiPlaceholderViewCell - - return placeholder - } - } -} - -extension NotificationViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - switch Section.allCases[indexPath.section] { - case .withoutRead: - let selectedId = self.notificationsWithoutRead[indexPath.row].id - - self.reactor?.action.onNext(.requestRead("\(selectedId)")) - let targetCardId = self.notificationsWithoutRead[indexPath.row].targetCardId - self.willPushCardId.accept("\(targetCardId ?? 0)") - case .read: - let targetCardId = self.notifications[indexPath.row].targetCardId - self.willPushCardId.accept("\(targetCardId ?? 0)") - case .empty: - break - } - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - - switch Section.allCases[section] { - case .withoutRead: - - let backgroundView = UIView().then { - $0.backgroundColor = .som.white - } - - let typography = Typography.som.body2WithBold.withAlignment(.left) - let frame = CGRect(x: 20, y: 16, width: UIScreen.main.bounds.width, height: typography.lineHeight) - let label = UILabel().then { - $0.text = Text.withoutReadHeaderTitle + " (\(self.withoutReadNotisCount)개)" - $0.textColor = .som.black - $0.typography = typography - - $0.frame = frame - } - backgroundView.addSubview(label) - - return self.notificationsWithoutRead.isEmpty ? nil : backgroundView - - case .read: - - let backgroundView = UIView().then { - $0.backgroundColor = .som.white - } - - let frame = CGRect(x: 0, y: 10, width: UIScreen.main.bounds.width, height: 4) - let seperator = UIView().then { - $0.backgroundColor = .som.gray100 - - $0.frame = frame - } - backgroundView.addSubview(seperator) - - return self.notificationsWithoutRead.isEmpty ? nil : backgroundView - - case .empty: - return nil - } - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - - switch Section.allCases[indexPath.section] { - case .withoutRead: - if self.notificationsWithoutRead.isEmpty == false { - let type = self.notificationsWithoutRead[indexPath.row].type - switch type { - case .blocked, .delete: - return 55 - default: - return 64 - } - } else { - return 64 - } - case .read: - return 64 - case .empty: - return self.tableView.bounds.height - } - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - switch Section.allCases[section] { - case .withoutRead: - return self.notificationsWithoutRead.isEmpty ? 0 : 46 - case .read: - return self.notificationsWithoutRead.isEmpty ? 10 : 24 - case .empty: - return 0 - } - } - - func tableView( - _ tableView: UITableView, - willDisplay cell: UITableViewCell, - forRowAt indexPath: IndexPath - ) { - guard self.notificationsWithoutRead.isEmpty == false || - self.notifications.isEmpty == false - else { return } - - let sectionIndexForWithoutRead = Section.withoutRead.rawValue - let lastRowIndexForWithoutRead = tableView.numberOfRows(inSection: sectionIndexForWithoutRead) - 1 - let sectionIndexForRead = Section.read.rawValue - let lastRowIndexForRead = tableView.numberOfRows(inSection: sectionIndexForRead) - 1 - - - if self.isLoadingMore, - indexPath.section == sectionIndexForWithoutRead, - indexPath.row == lastRowIndexForWithoutRead { - - let withoutReadLastId = self.notificationsWithoutRead.last?.id.description - self.reactor?.action.onNext(.moreFind(withoutReadLastId: withoutReadLastId, readLastId: nil)) - } - - if self.isLoadingMore, - indexPath.section == sectionIndexForRead, - indexPath.row == lastRowIndexForRead { - - let readLastId = self.notifications.last?.id.description - self.reactor?.action.onNext(.moreFind(withoutReadLastId: nil, readLastId: readLastId)) - } - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - self.currentOffset = offset - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/Views/NotificationViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/Views/NotificationViewReactor.swift deleted file mode 100644 index d1eae60f..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/Views/NotificationViewReactor.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// NotificationViewReactor.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import ReactorKit - -import Alamofire - - -class NotificationViewReactor: Reactor { - - enum EntranceType { - case total - case comment - case like - } - - enum Action: Equatable { - case landing - case refresh - case moreFind(withoutReadLastId: String?, readLastId: String?) - case requestRead(String) - } - - enum Mutation { - case notificationsWithoutRead([CommentHistoryInNoti]) - case notifications([CommentHistoryInNoti]) - case moreWithoutRead([CommentHistoryInNoti]) - case more([CommentHistoryInNoti]) - case withoutReadNotiscount(String) - case updateIsProcessing(Bool) - case updateIsLoading(Bool) - case updateIsReadCompleted(Bool) - } - - struct State { - var notificationsWithoutRead: [CommentHistoryInNoti]? - var notifications: [CommentHistoryInNoti]? - var withoutReadNotisCount: String - var isProcessing: Bool - var isLoading: Bool - var isReadCompleted: Bool - } - - var initialState: State = .init( - notificationsWithoutRead: nil, - notifications: nil, - withoutReadNotisCount: "0", - isProcessing: false, - isLoading: false, - isReadCompleted: false - ) - - private let entranceType: EntranceType - - let provider: ManagerProviderType - - init(provider: ManagerProviderType, _ entranceType: EntranceType) { - self.provider = provider - self.entranceType = entranceType - } - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - - let combined = Observable.concat([ - self.withoutReadNotisCount(), - self.notifications(with: false), - self.notifications(with: true) - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - - return .concat([ - .just(.updateIsProcessing(true)), - combined, - .just(.updateIsProcessing(false)) - ]) - - case .refresh: - - let combined = Observable.concat([ - self.withoutReadNotisCount(), - self.notifications(with: false), - self.notifications(with: true) - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - - return .concat([ - .just(.updateIsLoading(true)), - combined, - .just(.updateIsLoading(false)) - ]) - - case let .moreFind(withoutReadLastId, readLastId): - - let combined = Observable.concat([ - self.withoutReadNotisCount(), - self.moreNotifications(with: false, lastId: withoutReadLastId), - self.moreNotifications(with: true, lastId: readLastId) - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - - return .concat([ - .just(.updateIsProcessing(true)), - combined, - .just(.updateIsProcessing(false)) - ]) - - case let .requestRead(selectedId): - self.provider.pushManager.deleteNotification(notificationId: selectedId) - let request: NotificationRequest = .requestRead(notificationId: selectedId) - return self.provider.networkManager.request(Empty.self, request: request) - .map { _ in .updateIsReadCompleted(true) } - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state = state - switch mutation { - case let .notificationsWithoutRead(notificationsWithoutRead): - state.notificationsWithoutRead = notificationsWithoutRead - case let .notifications(notifications): - state.notifications = notifications - case let .moreWithoutRead(notificationsWithoutRead): - state.notificationsWithoutRead? += notificationsWithoutRead - case let .more(notifications): - state.notifications? += notifications - case let .withoutReadNotiscount(withoutReadNotisCount): - state.withoutReadNotisCount = withoutReadNotisCount - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsReadCompleted(isReadCompleted): - state.isReadCompleted = isReadCompleted - } - return state - } -} - -extension NotificationViewReactor { - - private func notifications(with isRead: Bool) -> Observable { - - var request: NotificationRequest { - switch self.entranceType { - case .total: - return isRead ? .totalRead(lastId: nil) : .totalWithoutRead(lastId: nil) - case .comment: - return isRead ? .commentRead(lastId: nil) : .commentWithoutRead(lastId: nil) - case .like: - return isRead ? .likeRead(lastId: nil) : .likeWithoutRead(lastId: nil) - } - } - - return self.provider.networkManager.request(CommentHistoryInNotiResponse.self, request: request) - .map(\.commentHistoryInNotis) - .map(isRead ? Mutation.notifications : Mutation.notificationsWithoutRead) - .catch(self.catchClosure) - } - - private func moreNotifications(with isRead: Bool, lastId: String?) -> Observable { - - guard let lastId = lastId else { return .just(.more([])) } - - var request: NotificationRequest { - switch self.entranceType { - case .total: - return isRead ? .totalRead(lastId: lastId) : .totalWithoutRead(lastId: lastId) - case .comment: - return isRead ? .commentRead(lastId: lastId) : .commentWithoutRead(lastId: lastId) - case .like: - return isRead ? .likeRead(lastId: lastId) : .likeWithoutRead(lastId: lastId) - } - } - - return self.provider.networkManager.request(CommentHistoryInNotiResponse.self, request: request) - .map(\.commentHistoryInNotis) - .map(isRead ? Mutation.more : Mutation.moreWithoutRead) - .catch(self.catchClosure) - } - - private func withoutReadNotisCount() -> Observable { - - var request: NotificationRequest { - switch self.entranceType { - case .total: - return .totalWithoutReadCount - case .comment: - return .commentWithoutReadCount - case .like: - return .likeWihoutReadCount - } - } - - return self.provider.networkManager.request(WithoutReadNotisCountResponse.self, request: request) - .map(\.unreadCnt) - .map(Mutation.withoutReadNotiscount) - } - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)) - ]) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift new file mode 100644 index 00000000..dcdf9c56 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift @@ -0,0 +1,122 @@ +// +// NoticeViewCell.swift +// SOOUM +// +// Created by 오현식 on 9/26/25. +// + +import UIKit + +import SnapKit +import Then + + +class NoticeViewCell: UITableViewCell { + + enum Text { + static let title: String = "공지사항" + } + + static let cellIdentifier = String(reflecting: NoticeViewCell.self) + + + // MARK: Views + + private let iconView = UIImageView() + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + private let timeLabel = UILabel().then { + $0.textColor = .som.gray400 + $0.typography = .som.v2.caption2 + } + + private let contentLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle1.withAlignment(.left) + $0.numberOfLines = 0 + $0.textAlignment = .left + } + + + // MARK: Override func + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.selectionStyle = .none + self.backgroundColor = .clear + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.titleLabel.text = nil + self.timeLabel.text = nil + self.contentLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { + + let titleContinaer = UIView() + self.contentView.addSubview(titleContinaer) + titleContinaer.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + } + + titleContinaer.addSubview(self.iconView) + self.iconView.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(16) + } + + titleContinaer.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.iconView.snp.trailing).offset(8) + } + + titleContinaer.addSubview(self.timeLabel) + self.timeLabel.snp.makeConstraints { + $0.verticalEdges.trailing.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.titleLabel.snp.trailing).offset(8) + } + + self.contentView.addSubview(self.contentLabel) + self.contentLabel.snp.makeConstraints { + $0.top.equalTo(titleContinaer.snp.bottom).offset(4) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(48) + $0.trailing.equalToSuperview().offset(-24) + } + } + + func bind(_ model: NoticeInfo) { + + self.iconView.image = model.noticeType.image + self.iconView.tintColor = model.noticeType.tintColor + + self.titleLabel.text = model.noticeType.title + self.titleLabel.typography = .som.v2.caption2 + + self.timeLabel.text = model.createdAt.noticeFormatted + self.timeLabel.typography = .som.v2.caption2 + + self.contentLabel.text = model.message + self.contentLabel.typography = .som.v2.subtitle1.withAlignment(.left) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotiPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift similarity index 55% rename from SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotiPlaceholderViewCell.swift rename to SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift index b56650b0..ab3cc695 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotiPlaceholderViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift @@ -1,5 +1,5 @@ // -// NotiPlaceholderViewCell.swift +// NotificationPlaceholderViewCell.swift // SOOUM // // Created by 오현식 on 1/9/25. @@ -11,21 +11,26 @@ import SnapKit import Then -class NotiPlaceholderViewCell: UITableViewCell { - - static let cellIdentifier = String(reflecting: NotiPlaceholderViewCell.self) +class NotificationPlaceholderViewCell: UITableViewCell { enum Text { - static let placeholderLabelText: String = "알림이 아직 없어요" + static let placeholderLabelText: String = "아직 표시할 알림이 없어요" } + static let cellIdentifier = String(reflecting: NotificationPlaceholderViewCell.self) + // MARK: Views + private let placeholderImage = UIImageView().then { + $0.image = .init(.image(.v2(.placeholder_notification))) + $0.contentMode = .scaleAspectFit + } + private let placeholderLabel = UILabel().then { $0.text = Text.placeholderLabelText - $0.textColor = .init(hex: "#B4B4B4") - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 } @@ -35,7 +40,6 @@ class NotiPlaceholderViewCell: UITableViewCell { super.init(style: style, reuseIdentifier: reuseIdentifier) self.selectionStyle = .none - self.backgroundColor = .clear self.isUserInteractionEnabled = false self.setupConstraints() @@ -50,9 +54,17 @@ class NotiPlaceholderViewCell: UITableViewCell { private func setupConstraints() { + self.contentView.addSubview(self.placeholderImage) + self.placeholderImage.snp.makeConstraints { + $0.top.equalToSuperview().offset(UIScreen.main.bounds.height * 0.2) + $0.centerX.equalToSuperview() + } + self.contentView.addSubview(self.placeholderLabel) + self.placeholderLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(UIScreen.main.bounds.height * 0.3) + $0.top.equalTo(self.placeholderImage.snp.bottom).offset(20) + $0.bottom.equalToSuperview() $0.centerX.equalToSuperview() } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift index ca8e1b52..7dd629d2 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift @@ -10,53 +10,58 @@ import UIKit import SnapKit import Then - class NotificationViewCell: UITableViewCell { + enum Text { + static let cardTitle: String = "카드" + static let feedLikeContents: String = "님이 회원님의 카드에 좋아요를 남겼어요." + static let commentLikeContents: String = "님이 회원님의 댓글카드에 좋아요를 남겼어요." + static let commentWriteContents: String = "님이 댓글카드를 남겼어요. 알림을 눌러 대화를 이어가 보세요." + + static let followTitle: String = "팔로우" + static let followContents: String = "님이 회원님을 팔로우하기 시작했어요." + + static let deletedAndBlockedTitle: String = "제한" + static let deletedContents: String = "운영정책 위반으로 인해 작성된 카드가 삭제 처리되었습니다." + static let blockedLeadingContents: String = "운영정책 위반으로 인해 " + static let blockedTrailingContents: String = "까지 카드추가가 제한됩니다." + + static let tagTitle: String = "태그" + static let tagContents: String = "태그가 포함된 카드가 올라왔어요." + } + static let cellIdentifier = String(reflecting: NotificationViewCell.self) - private let feedCardImageView = UIImageView().then { - $0.layer.cornerRadius = 6 - $0.clipsToBounds = true - } - private let feedCardDimView = UIView().then { - $0.backgroundColor = .black.withAlphaComponent(0.3) - } + // MARK: Views - private let feedCardContentLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.numberOfLines = 0 - $0.lineBreakMode = .byTruncatingTail - $0.typography = .init( - fontContainer: BuiltInFont(size: 3, weight: .bold), - lineHeight: 5, - letterSpacing: -0.04 - ) - } + private let iconView = UIImageView() - private let notificationTitleLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.textAlignment = .center - $0.typography = .som.body3WithBold + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray400 } private let timeGapLabel = UILabel().then { $0.textColor = .som.gray400 - $0.textAlignment = .center - $0.typography = .som.body3WithBold } - private let dotWithoutReadView = UIView().then { - $0.backgroundColor = .som.red - $0.layer.cornerRadius = 6 * 0.5 - $0.clipsToBounds = true + private let contentLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.lineBreakMode = .byWordWrapping + $0.lineBreakStrategy = .hangulWordPriority + $0.numberOfLines = 0 + $0.textAlignment = .left } + + // MARK: Override func + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) + self.selectionStyle = .none + self.backgroundColor = .clear + self.setupConstraints() } @@ -64,63 +69,152 @@ class NotificationViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - private func setupConstraints() { + override func prepareForReuse() { + super.prepareForReuse() - self.contentView.addSubview(self.feedCardImageView) - self.feedCardImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(40) - } + self.titleLabel.text = nil + self.timeGapLabel.text = nil + self.contentLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { - self.feedCardImageView.addSubview(self.feedCardDimView) - self.feedCardDimView.snp.makeConstraints { - $0.edges.equalToSuperview() + let titleContinaer = UIView() + self.contentView.addSubview(titleContinaer) + titleContinaer.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) } - self.feedCardImageView.addSubview(self.feedCardContentLabel) - self.feedCardContentLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().offset(5) - $0.bottom.trailing.equalToSuperview().offset(-5) + titleContinaer.addSubview(self.iconView) + self.iconView.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(16) } - self.contentView.addSubview(self.notificationTitleLabel) - self.notificationTitleLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.feedCardImageView.snp.trailing).offset(20) + titleContinaer.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.iconView.snp.trailing).offset(8) } - self.contentView.addSubview(self.timeGapLabel) + titleContinaer.addSubview(self.timeGapLabel) self.timeGapLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.notificationTitleLabel.snp.trailing).offset(9) - $0.trailing.equalToSuperview().offset(-26) + $0.verticalEdges.trailing.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.titleLabel.snp.trailing).offset(8) } - self.contentView.addSubview(self.dotWithoutReadView) - self.dotWithoutReadView.snp.makeConstraints { - $0.top.equalTo(self.timeGapLabel.snp.top) - $0.leading.equalTo(self.timeGapLabel.snp.trailing) - $0.size.equalTo(6) + self.contentView.addSubview(self.contentLabel) + self.contentLabel.snp.makeConstraints { + $0.top.equalTo(titleContinaer.snp.bottom).offset(4) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(48) + $0.trailing.equalToSuperview().offset(-24) } } - func bind(_ model: CommentHistoryInNoti, isReaded: Bool) { + func bind(_ model: CompositeNotificationInfo, isReaded: Bool) { + + self.backgroundColor = isReaded ? .som.v2.white : .som.v2.pLight1 + + var iconInfo: (image: UIImage?, color: UIColor)? { + switch model { + case .default: + return (.init(.icon(.v2(.filled(.card)))), .som.v2.pMain) + case .follow: + return (.init(.icon(.v2(.filled(.users)))), .som.v2.pMain) + case .deleted, .blocked: + return (.init(.icon(.v2(.filled(.danger)))), .som.v2.yMain) + case .tag: + return (.init(.icon(.v2(.filled(.tag)))), .som.v2.pMain) + } + } + + var titleInfo: (text: String, typography: Typography)? { + let typography = isReaded ? Typography.som.v2.caption2 : Typography.som.v2.caption1 + switch model { + case .default: + return (Text.cardTitle, typography) + case .follow: + return (Text.followTitle, typography) + case .deleted: + return (Text.deletedAndBlockedTitle, typography) + case .blocked: + return (Text.deletedAndBlockedTitle, typography) + case .tag: + return (Text.tagTitle, typography) + } + } - self.feedCardImageView.setImage(strUrl: model.feedCardImgURL?.url) - self.feedCardContentLabel.text = model.content + var timeGapInfo: (text: String, typography: Typography)? { + let typography = isReaded ? Typography.som.v2.caption2 : Typography.som.v2.caption1 + switch model { + case let .default(notification): + let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + return (timeGapText, typography) + case let .follow(notification): + let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + return (timeGapText, typography) + case let .deleted(notification): + let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + return (timeGapText, typography) + case let .blocked(notification): + let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + return (timeGapText, typography) + case let .tag(notification): + let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + return (timeGapText, typography) + } + } - let text: String = { - switch model.type { - case .feedLike, .commentLike: return "님이 카드에 공감하였습니다." - case .commentWrite: return "님이 답카드를 작성했습니다." - default: return "" + var contentsInfo: (text: String, typography: Typography)? { + let typography = isReaded ? Typography.som.v2.subtitle1.withAlignment(.left) : Typography.som.v2.title2.withAlignment(.left) + switch model { + case let .default(notification): + switch notification.notificationInfo.notificationType { + case .feedLike: + return ("\(notification.nickName)\(Text.feedLikeContents)", typography) + case .commentLike: + return ("\(notification.nickName)\(Text.commentLikeContents)", typography) + case .commentWrite: + return ("\(notification.nickName)\(Text.commentWriteContents)", typography) + default: + return nil + } + case let .follow(notification): + return ("\(notification.nickname)\(Text.followContents)", typography) + case .deleted: + return (Text.deletedContents, typography) + case let .blocked(notification): + let text = "\(Text.blockedLeadingContents)\(notification.blockExpirationDateTime.banEndFormatted)\(Text.blockedTrailingContents)" + return (text, typography) + case let .tag(notification): + return ("‘\(notification.tagContent)’ \(Text.tagContents)", typography) } - }() - self.notificationTitleLabel.text = "\(model.nickName ?? "")\(text)" + } - self.timeGapLabel.text = model.createAt.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + if let iconInfo = iconInfo { + self.iconView.image = iconInfo.image + self.iconView.tintColor = iconInfo.color + } - self.dotWithoutReadView.isHidden = isReaded + if let titleInfo = titleInfo { + self.titleLabel.text = titleInfo.text + self.titleLabel.typography = titleInfo.typography + } + + if let timeGapInfo = timeGapInfo { + self.timeGapLabel.text = timeGapInfo.text + self.timeGapLabel.typography = timeGapInfo.typography + } + + if let contentsInfo = contentsInfo { + self.contentLabel.text = contentsInfo.text + self.contentLabel.typography = contentsInfo.typography + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationWithReportViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationWithReportViewCell.swift deleted file mode 100644 index 96fdafa0..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationWithReportViewCell.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// NotificationWithReportViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/20/24. -// - -import UIKit - -import SnapKit -import Then - - -class NotificationWithReportViewCell: UITableViewCell { - - static let cellIdentifier = String(reflecting: NotificationWithReportViewCell.self) - - enum Text { - static let blockTypeText: String = "[정지]" - static let deleteTypeText: String = "[삭제]" - - static let blockTitleText: String = "까지 글쓰기가 제한되었습니다." - static let deleteTitleText: String = "신고로 인해 카드가 삭제 처리 되었습니다." - } - - private let typeLabel = UILabel().then { - $0.textColor = .som.red - $0.textAlignment = .center - $0.typography = .init( - fontContainer: BuiltInFont(size: 12, weight: .medium), - lineHeight: 17, - letterSpacing: -0.004 - ) - } - - private let titleLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.textAlignment = .center - $0.typography = .som.body3WithBold - } - - private let timeGapLabel = UILabel().then { - $0.textColor = .som.gray400 - $0.textAlignment = .center - $0.typography = .som.body3WithRegular - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.contentView.addSubview(self.typeLabel) - self.typeLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - } - - self.contentView.addSubview(self.titleLabel) - self.titleLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.typeLabel.snp.trailing).offset(2) - } - - self.contentView.addSubview(self.timeGapLabel) - self.timeGapLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.titleLabel.snp.trailing) - $0.trailing.equalToSuperview().offset(-26) - } - } - - func bind(_ model: CommentHistoryInNoti) { - - var typeText: String { - switch model.type { - case .blocked: return Text.blockTypeText - case .delete: return Text.deleteTypeText - default: return "" - } - } - self.typeLabel.text = typeText - - var titleText: String { - switch model.type { - case .blocked: return (model.blockExpirationTime ?? Date()).banEndFormatted + Text.blockTitleText - case .delete: return Text.deleteTitleText - default: return "" - } - } - self.titleLabel.text = titleText - - self.timeGapLabel.text = model.createAt.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Popular/MainHomePopularViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Popular/MainHomePopularViewController.swift deleted file mode 100644 index 0fbbe18e..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Popular/MainHomePopularViewController.swift +++ /dev/null @@ -1,273 +0,0 @@ -// -// MainHomePopularViewController.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import UIKit - -import Kingfisher -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class MainHomePopularViewController: BaseViewController, View { - - - // MARK: Views - - private lazy var tableView = UITableView(frame: .zero, style: .plain).then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - - $0.contentInset.top = SOMSwipeTabBar.Height.mainHome - - $0.isHidden = true - - $0.register(MainHomeViewCell.self, forCellReuseIdentifier: "cell") - $0.register(PlaceholderViewCell.self, forCellReuseIdentifier: "placeholder") - - $0.refreshControl = SOMRefreshControl() - - $0.dataSource = self - $0.prefetchDataSource = self - - $0.delegate = self - } - - private let moveTopButton = MoveTopButtonView().then { - $0.isHidden = true - } - - - // MARK: Variables - - // tableView 정보 - private var currentOffset: CGFloat = 0 - private var isRefreshEnabled: Bool = true - - private let cellHeight: CGFloat = { - let width: CGFloat = (UIScreen.main.bounds.width - 20 * 2) * 0.9 - return width + 10 /// 가로 + top inset - }() - - - // MARK: Variables + Rx - - let hidesHeaderContainer = PublishRelay() - let willPushCardId = PublishRelay() - - - // MARK: Override func - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.tableView) - self.tableView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.view.addSubview(self.moveTopButton) - self.view.bringSubviewToFront(self.moveTopButton) - self.moveTopButton.snp.makeConstraints { - let bottomOffset: CGFloat = 24 + 60 + 4 + 20 - $0.bottom.equalTo(self.tableView.snp.bottom).offset(-bottomOffset) - $0.centerX.equalToSuperview() - $0.height.equalTo(MoveTopButtonView.height) - } - } - - override func bind() { - super.bind() - - // tableView 상단 이동 - self.moveTopButton.backgroundButton.rx.throttleTap(.seconds(3)) - .subscribe(with: self) { object, _ in - let indexPath = IndexPath(row: 0, section: 0) - object.tableView.scrollToRow(at: indexPath, at: .top, animated: true) - } - .disposed(by: self.disposeBag) - } - - - // MARK: ReactorKit - bind - - func bind(reactor: MainHomePopularViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() - self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - isLoading - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.displayedCards) - .filterNil() - .distinctUntilChanged() - .subscribe(with: self) { object, displayedCards in - object.tableView.isHidden = false - - object.tableView.reloadData() - } - .disposed(by: self.disposeBag) - } -} - -extension MainHomePopularViewController { - - private func cellForPlaceholder(_ tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell { - - let placeholder = tableView.dequeueReusableCell( - withIdentifier: "placeholder", - for: indexPath - ) as! PlaceholderViewCell - - return placeholder - } - - private func cellForMainHome( - _ tableView: UITableView, - for indexPath: IndexPath, - with reactor: MainHomePopularViewReactor - ) -> UITableViewCell { - guard let displayedCards = reactor.currentState.displayedCards else { return .init(frame: .zero) } - - let model = SOMCardModel(data: displayedCards[indexPath.row]) - let cell: MainHomeViewCell = tableView.dequeueReusableCell( - withIdentifier: "cell", - for: indexPath - ) as! MainHomeViewCell - cell.setModel(model) - // 카드 하단 contents 스택 순서 변경 (인기순) - cell.changeOrderInCardContentStack(1) - - return cell - } -} - - -// MARK: MainHomeViewController DataSource and Delegate - -extension MainHomePopularViewController: UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.reactor?.currentState.displayedCardsCount ?? 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let reactor = self.reactor else { return .init(frame: .zero) } - - if reactor.currentState.isDisplayedCardsEmpty { - - return self.cellForPlaceholder(tableView, for: indexPath) - } else { - - return self.cellForMainHome(tableView, for: indexPath, with: reactor) - } - } -} - -extension MainHomePopularViewController: UITableViewDataSourcePrefetching { - - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - guard let reactor = self.reactor, - let displayedCards = reactor.currentState.displayedCards - else { return } - - indexPaths.forEach { indexPath in - // 데이터 로드 전, 이미지 캐싱 - let strUrl = displayedCards[indexPath.row].backgroundImgURL.url - KingfisherManager.shared.download(strUrl: strUrl) { _ in } - } - } -} - -extension MainHomePopularViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let reactor = self.reactor, - let selectedId = reactor.currentState.displayedCards?[indexPath.row].id - else { return } - - self.willPushCardId.accept(selectedId) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return (self.reactor?.currentState.isDisplayedCardsEmpty ?? true) ? tableView.bounds.height : self.cellHeight - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (self.currentOffset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - if offset <= 0 { - - self.hidesHeaderContainer.accept(false) - self.currentOffset = offset - self.moveTopButton.isHidden = true - - return - } - - guard offset <= (scrollView.contentSize.height - scrollView.frame.height) else { return } - - // offset이 currentOffset보다 크면 아래로 스크롤, 반대일 경우 위로 스크롤 - // 위로 스크롤 중일 때 헤더뷰 표시, 아래로 스크롤 중일 때 헤더뷰 숨김 - self.hidesHeaderContainer.accept(offset > self.currentOffset) - - self.currentOffset = offset - - // 최상단일 때만 moveToButton 숨김 - self.moveTopButton.isHidden = self.currentOffset <= 0 - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Popular/MainHomePopularViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Popular/MainHomePopularViewReactor.swift deleted file mode 100644 index c8e10f45..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Home/Popular/MainHomePopularViewReactor.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// MainHomePopularViewReactor.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import ReactorKit - - -class MainHomePopularViewReactor: Reactor { - - enum Action: Equatable { - case landing - case refresh - } - - enum Mutation { - case cards([Card]) - case updateIsLoading(Bool) - case updateIsProcessing(Bool) - } - - struct State { - fileprivate(set) var displayedCards: [Card]? - fileprivate(set) var isLoading: Bool - fileprivate(set) var isProcessing: Bool - - var isDisplayedCardsEmpty: Bool { - return self.displayedCards?.isEmpty ?? true - } - var displayedCardsCount: Int { - return self.isDisplayedCardsEmpty ? 1 : (self.displayedCards?.count ?? 1) - } - } - - var initialState: State = .init( - displayedCards: nil, - isLoading: false, - isProcessing: false - ) - - let provider: ManagerProviderType - - // TODO: 페이징 - // private let countPerLoading: Int = 10 - - init(provider: ManagerProviderType) { - self.provider = provider - } - - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - - return .concat([ - .just(.updateIsProcessing(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) - case .refresh: - return .concat([ - .just(.updateIsLoading(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsLoading(false)) - ]) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state: State = state - switch mutation { - case let .cards(displayedCards): - state.displayedCards = displayedCards - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - } - return state - } -} - -extension MainHomePopularViewReactor { - - func refresh() -> Observable { - - let latitude = self.provider.locationManager.coordinate.latitude - let longitude = self.provider.locationManager.coordinate.longitude - - let request: CardRequest = .popularCard(latitude: latitude, longitude: longitude) - return self.provider.networkManager.request(PopularCardResponse.self, request: request) - .map(\.embedded.cards) - .map(Mutation.cards) - .catch(self.catchClosure) - } -} - -extension MainHomePopularViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.cards([])), - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } - } - - // TODO: 페이징 - // func separate(displayed displayedCards: [Card], current cards: [Card]) -> [Card] { - // let count = displayedCards.count - // let displayedCards = Array(cards[count..() + + + // MARK: Initialize + + init() { + super.init(nibName: nil, bundle: nil) + + self.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + override func viewDidLoad() { super.viewDidLoad() - self.delegate = self + self.hasFirstLaunchGuide = UserDefaults.showGuideMessage } override func viewWillAppear(_ animated: Bool) { @@ -38,41 +77,37 @@ class MainTabBarController: SOMTabBarController, View { self.navigationController?.navigationBar.isHidden = true } + + // MARK: ReactorKit - bind + func bind(reactor: MainTabBarReactor) { - self.rx.viewDidLoad - .subscribe(with: self) { object, _ in - // 위치 권한 요청 - if reactor.provider.locationManager.checkLocationAuthStatus() == .notDetermined { - reactor.provider.locationManager.requestLocationPermission() - } - } - .disposed(by: self.disposeBag) - - // viewControllers - let mainHomeTabBarController = MainHomeTabBarController() - mainHomeTabBarController.reactor = reactor.reactorForMainHome() + let homeViewController = HomeViewController() + homeViewController.reactor = reactor.reactorForHome() let mainHomeNavigationController = UINavigationController( - rootViewController: mainHomeTabBarController + rootViewController: homeViewController ) - mainHomeTabBarController.tabBarItem = .init( - title: Text.mainHomeTitle, - image: .init(.icon(.outlined(.home))), + mainHomeNavigationController.tabBarItem = .init( + title: Constants.Text.homeTitle, + image: .init(.icon(.v2(.filled(.home)))), tag: 0 ) let writeCardViewController = UIViewController() writeCardViewController.tabBarItem = .init( - title: Text.addCardTitle, - image: .init(.icon(.outlined(.addCard))), + title: Constants.Text.writeTitle, + image: .init(.icon(.v2(.filled(.write)))), tag: 1 ) - let tagNavigationController = TagsViewController() - tagNavigationController.reactor = reactor.reactorForTags() + let tagViewController = TagViewController() + tagViewController.reactor = reactor.reactorForTags() + let tagNavigationController = UINavigationController( + rootViewController: tagViewController + ) tagNavigationController.tabBarItem = .init( - title: Text.tagTitle, - image: .init(.icon(.outlined(.star))), + title: Constants.Text.tagTitle, + image: .init(.icon(.v2(.filled(.tag)))), tag: 2 ) @@ -82,8 +117,8 @@ class MainTabBarController: SOMTabBarController, View { rootViewController: profileViewController ) profileNavigationController.tabBarItem = .init( - title: Text.profileTitle, - image: .init(.icon(.outlined(.profile))), + title: Constants.Text.profileTitle, + image: .init(.icon(.v2(.filled(.user)))), tag: 3 ) @@ -94,50 +129,142 @@ class MainTabBarController: SOMTabBarController, View { profileNavigationController ] + // Action + /// 위치 권한 요청 + self.rx.viewDidLoad + .map { _ in Reactor.Action.requestLocationPermission } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + self.rx.viewWillAppear .map { _ in Reactor.Action.judgeEntrance } .bind(to: reactor.action) .disposed(by: self.disposeBag) - reactor.state.map(\.entranceType) - .distinctUntilChanged() - .subscribe(with: self) { object, entranceType in - - guard let navigationController = object.viewControllers[0] as? UINavigationController, - let mainHomeTabBarController = navigationController.viewControllers.first as? MainHomeTabBarController, - let targetCardId = reactor.pushInfo?.targetCardId, - let notificationId = reactor.pushInfo?.notificationId - else { return } - - mainHomeTabBarController.reactor?.action.onNext(.requestRead(notificationId)) + self.willPushWriteCard.throttle(.seconds(3), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.postingPermission } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.pulse(\.$profileInfo) + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, profileInfo in - switch entranceType { - case .pushForNoti: + switch reactor.currentState.entranceType { + case .pushToDetail: + + object.didSelectedIndex(0) - let notificationTabBarController = NotificationTabBarController() - notificationTabBarController.reactor = reactor.reactorForNoti() - mainHomeTabBarController.navigationPush( - notificationTabBarController, - animated: false, - bottomBarHidden: true - ) - case .pushForDetail: + guard let selectedViewController = object.selectedViewController, + let targetCardId = reactor.pushInfo?.targetCardId + else { return } - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(targetCardId) - mainHomeTabBarController.navigationPush( - detailViewController, - animated: false, - bottomBarHidden: true - ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.setupDetailViewController( + selectedViewController, + with: reactor.reactorForDetail(targetCardId), + completion: { reactor.action.onNext(.cleanup) } + ) + } + case .pushToNotification: + + object.didSelectedIndex(0) + + guard let selectedViewController = object.selectedViewController else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.setupNotificationViewController( + selectedViewController, + with: reactor.reactorForNoti(), + completion: { reactor.action.onNext(.cleanup) } + ) + } + case .pushToTagDetail: + + object.didSelectedIndex(2) + + guard let selectedViewController = object.selectedViewController, + let targetCardId = reactor.pushInfo?.targetCardId + else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.setupTagDetailViewController( + selectedViewController, + with: reactor.reactorForDetail(targetCardId), + completion: { reactor.action.onNext(.cleanup) } + ) + } + case .pushToFollow: + + object.didSelectedIndex(3) + + guard let selectedViewController = object.selectedViewController else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.setupFollowViewController( + selectedViewController, + with: reactor.reactorForFollow(nickname: profileInfo.nickname, with: profileInfo.userId), + completion: { reactor.action.onNext(.cleanup) } + ) + } + case .pushToLaunchScreen: + + guard let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.setupLaunchScreenViewController( + window, + with: reactor.reactorForLaunchScreen() + ) + } case .none: + break } } .disposed(by: self.disposeBag) + + let couldPosting = reactor.pulse(\.$couldPosting).distinctUntilChanged().filterNil() + couldPosting + .filter { $0.isBaned == false } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + object.hasFirstLaunchGuide = false + + let writeCardViewController = WriteCardViewController() + writeCardViewController.reactor = reactor.reactorForWriteCard() + if let selectedViewController = object.selectedViewController { + selectedViewController.navigationPush( + writeCardViewController, + animated: true + ) { _ in + reactor.action.onNext(.cleanup) + } + } + } + .disposed(by: self.disposeBag) + couldPosting + .filter { $0.isBaned } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, postingPermission in + + let banEndGapToDays = postingPermission.expiredAt?.infoReadableTimeTakenFromThisForBanEndPosting(to: Date().toKorea()) + let banEndToString = postingPermission.expiredAt?.banEndDetailFormatted + + object.showDialog(gapDays: banEndGapToDays ?? "", banEndFormatted: banEndToString ?? "") + } + .disposed(by: self.disposeBag) } } + +// MARK: SOMTabBarControllerDelegate + extension MainTabBarController: SOMTabBarControllerDelegate { func tabBarController( @@ -146,21 +273,124 @@ extension MainTabBarController: SOMTabBarControllerDelegate { ) -> Bool { if viewController.tabBarItem.tag == 1 { - - let writeCardViewController = WriteCardViewController() - writeCardViewController.reactor = self.reactor?.reactorForWriteCard() - if let selectedViewController = tabBarController.selectedViewController { - selectedViewController.navigationPush(writeCardViewController, animated: true) - } + + self.willPushWriteCard.accept(()) + + GAHelper.shared.logEvent(event: GAEvent.TabBar.moveToCreateFeedCardView_btn_click) + return false } - + return true } - func tabBarController( _ tabBarController: SOMTabBarController, didSelect viewController: UIViewController ) { } } + + +// MARK: Show dialog + +private extension MainTabBarController { + + func showDialog(gapDays: String, banEndFormatted: String) { + let dialogFirstMessage = Constants.Text.banUserDialogFirstLeadingMessage + + gapDays + + Constants.Text.banUserDialogFirstTrailingMessage + let dialogSecondMessage = Constants.Text.banUserDialogSecondLeadingMessage + + banEndFormatted + + Constants.Text.banUserDialogSecondTrailingMessage + + let confirmAction = SOMDialogAction( + title: Constants.Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + self.reactor?.action.onNext(.cleanup) + } + } + ) + + SOMDialogViewController.show( + title: Constants.Text.banUserDialogTitle, + message: dialogFirstMessage + dialogSecondMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } +} + + +// MARK: Setup ViewController + +private extension MainTabBarController { + + func setupDetailViewController( + _ selectedViewController: UIViewController, + with reactor: DetailViewReactor, + completion: @escaping (() -> Void) + ) { + + let detailViewController = DetailViewController() + detailViewController.reactor = reactor + selectedViewController.navigationPush( + detailViewController, + animated: true, + completion: { _ in completion() } + ) + } + + func setupNotificationViewController( + _ selectedViewController: UIViewController, + with reactor: NotificationViewReactor, + completion: @escaping (() -> Void) + ) { + + let notificationViewController = NotificationViewController() + notificationViewController.reactor = reactor + selectedViewController.navigationPush( + notificationViewController, + animated: true, + completion: { _ in completion() } + ) + } + + func setupTagDetailViewController( + _ selectedViewController: UIViewController, + with reactor: DetailViewReactor, + completion: @escaping (() -> Void) + ) { + + let detailViewController = DetailViewController() + detailViewController.reactor = reactor + selectedViewController.navigationPush( + detailViewController, + animated: true, + completion: { _ in completion() } + ) + } + + func setupFollowViewController( + _ selectedViewController: UIViewController, + with reactor: FollowViewReactor, + completion: @escaping (() -> Void) + ) { + + let followViewController = FollowViewController() + followViewController.reactor = reactor + selectedViewController.navigationPush( + followViewController, + animated: true, + completion: { _ in completion() } + ) + } + + func setupLaunchScreenViewController(_ window: UIWindow, with reactor: LaunchScreenViewReactor) { + + let launchScreenViewController = LaunchScreenViewController() + launchScreenViewController.reactor = reactor + window.rootViewController = UINavigationController(rootViewController: launchScreenViewController) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift b/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift index 0e36f1ab..656722cc 100644 --- a/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift @@ -12,118 +12,175 @@ class MainTabBarReactor: Reactor { enum EntranceType { /// 푸시 알림(알림 화면)으로 진입할 경우 - case pushForNoti - /// 푸시 알림(상세보기 화면)으로 진입할 경우 - case pushForDetail - /// 네비게이션 푸시가 필요 없을 때 + case pushToNotification + /// 푸시 알림(상세 화면)으로 진입할 경우 + case pushToDetail + /// 푸시 알림(피드 상세 화면 + 태그 탭)으로 진입할 경우 + case pushToTagDetail + /// 푸시 알림(내 팔로우 화면 + 팔로우 탭)으로 진입할 경우 + case pushToFollow + /// 푸시 알림(런치 화면)으로 진입할 경우 + case pushToLaunchScreen + /// 일반적인 경우 case none } enum Action: Equatable { + case requestLocationPermission case judgeEntrance - case updateNotificationStatus(Bool) + case postingPermission + case cleanup } enum Mutation { - case updateEntrance - case updateNotificationStatus(Bool) + case updateEntrance(ProfileInfo) + case updatePostingPermission(PostingPermission?) + case cleanup } struct State { - var entranceType: EntranceType - var notificationStatus: Bool + fileprivate(set) var entranceType: EntranceType + @Pulse fileprivate(set) var couldPosting: PostingPermission? + @Pulse fileprivate(set) var profileInfo: ProfileInfo? } - private let disposeBag = DisposeBag() - var initialState: State - private let willNavigate: EntranceType - let pushInfo: NotificationInfo? - - let provider: ManagerProviderType - - init(provider: ManagerProviderType, pushInfo: NotificationInfo? = nil) { - self.provider = provider + var pushInfo: PushNotificationInfo? + + private let dependencies: AppDIContainerable + private let fetchUserInfoUseCase: FetchUserInfoUseCase + private let validateUserUseCase: ValidateUserUseCase + private let notificationUseCase: NotificationUseCase + private let updateNotifyUseCase: UpdateNotifyUseCase + private let locationUseCase: LocationUseCase + + init(dependencies: AppDIContainerable, pushInfo: PushNotificationInfo? = nil) { + self.dependencies = dependencies + self.fetchUserInfoUseCase = dependencies.rootContainer.resolve(FetchUserInfoUseCase.self) + self.validateUserUseCase = dependencies.rootContainer.resolve(ValidateUserUseCase.self) + self.notificationUseCase = dependencies.rootContainer.resolve(NotificationUseCase.self) + self.updateNotifyUseCase = dependencies.rootContainer.resolve(UpdateNotifyUseCase.self) + self.locationUseCase = dependencies.rootContainer.resolve(LocationUseCase.self) var willNavigate: EntranceType { switch pushInfo?.notificationType { - case .feedLike, .commentLike, .commentWrite: - return .pushForDetail - case .blocked, .delete: - return .pushForNoti - default: - return .none + case .feedLike, .commentLike, .commentWrite: return .pushToDetail + case .blocked, .deleted: return .pushToNotification + case .tagUsage: return .pushToTagDetail + case .follow: return .pushToFollow + case .transferSuccess: return .pushToLaunchScreen + default: return .none } } - self.willNavigate = willNavigate self.pushInfo = pushInfo self.initialState = .init( - entranceType: .none, - notificationStatus: provider.pushManager.notificationStatus + entranceType: willNavigate, + couldPosting: nil, + profileInfo: nil ) } func mutate(action: Action) -> Observable { switch action { + case .requestLocationPermission: + + if self.locationUseCase.checkLocationAuthStatus() == .notDetermined { + self.locationUseCase.requestLocationPermission() + } + + return self.updateNotifyUseCase.switchNotification(on: true) + .flatMapLatest { _ -> Observable in .empty() } case .judgeEntrance: - return .concat([ - .just(.updateEntrance), - self.provider.pushManager.switchNotification(on: true) - .flatMapLatest { error -> Observable in .empty() } - ]) - case let .updateNotificationStatus(status): - return .just(.updateNotificationStatus(status)) + + guard let pushInfo = self.pushInfo else { return .empty() } + + return self.fetchUserInfoUseCase.userInfo(userId: nil) + .flatMapLatest { profileInfo -> Observable in + + if let notificationId = pushInfo.notificationId { + + return self.notificationUseCase.requestRead(notificationId: notificationId) + .map { _ in .updateEntrance(profileInfo) } + } else { + + return .just(.updateEntrance(profileInfo)) + } + } + case .postingPermission: + + return self.validateUserUseCase.postingPermission() + .map(Mutation.updatePostingPermission) + case .cleanup: + + return .just(.cleanup) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState = state switch mutation { - case .updateEntrance: - state.entranceType = self.willNavigate - case let .updateNotificationStatus(status): - state.notificationStatus = status + case let .updateEntrance(profileInfo): + newState.profileInfo = profileInfo + case let .updatePostingPermission(couldPosting): + newState.couldPosting = couldPosting + case .cleanup: + newState.entranceType = .none + newState.couldPosting = nil + newState.profileInfo = nil + self.pushInfo = nil } - return state - } -} - -extension MainTabBarReactor { - - private func subscribe() { - - (self.provider.pushManager as? PushManager)?.rx.observe(\.notificationStatus) - .map(Action.updateNotificationStatus) - .bind(to: self.action) - .disposed(by: self.disposeBag) + return newState } } extension MainTabBarReactor { - func reactorForMainHome() -> MainHomeTabBarReactor { - MainHomeTabBarReactor(provider: self.provider) + func reactorForHome() -> HomeViewReactor { + HomeViewReactor(dependencies: self.dependencies) } func reactorForWriteCard() -> WriteCardViewReactor { - WriteCardViewReactor(provider: self.provider, type: .card) + WriteCardViewReactor(dependencies: self.dependencies) } - func reactorForTags() -> TagsViewReactor { - TagsViewReactor(provider: self.provider) + func reactorForTags() -> TagViewReactor { + TagViewReactor(dependencies: self.dependencies) } func reactorForProfile() -> ProfileViewReactor { - ProfileViewReactor(provider: self.provider, type: .my, memberId: nil) + ProfileViewReactor(dependencies: self.dependencies, type: .my) } - func reactorForNoti() -> NotificationTabBarReactor { - NotificationTabBarReactor(provider: self.provider) + func reactorForNoti() -> NotificationViewReactor { + NotificationViewReactor(dependencies: self.dependencies) } func reactorForDetail(_ targetCardId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, type: .push, targetCardId) + DetailViewReactor(dependencies: self.dependencies, with: targetCardId) + } + + func reactorForFollow(nickname: String, with userId: String) -> FollowViewReactor { + FollowViewReactor( + dependencies: self.dependencies, + type: .follower, + view: .my, + nickname: nickname, + with: userId + ) + } + + func reactorForLaunchScreen() -> LaunchScreenViewReactor { + LaunchScreenViewReactor(dependencies: self.dependencies, pushInfo: self.pushInfo) + } +} + +extension MainTabBarReactor.State: Equatable { + + static func == (lhs: MainTabBarReactor.State, rhs: MainTabBarReactor.State) -> Bool { + return lhs.entranceType == rhs.entranceType && + lhs.couldPosting == rhs.couldPosting && + lhs.profileInfo == rhs.profileInfo } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/MyProfileViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/MyProfileViewCell.swift deleted file mode 100644 index 7b9a371e..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/MyProfileViewCell.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// MyProfileViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class MyProfileViewCell: UICollectionViewCell { - - enum Text { - static let cardTitle: String = "카드" - static let follingTitle: String = "팔로잉" - static let followerTitle: String = "팔로워" - - static let updateProfileButtonTitle: String = "프로필 수정" - } - - static let cellIdentifier = String(reflecting: MyProfileViewCell.self) - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - - private let totalCardCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let cardTitleLabel = UILabel().then { - $0.text = Text.cardTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followingButton = UIButton() - private let totalFollowingCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followingTitleLabel = UILabel().then { - $0.text = Text.follingTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followerButton = UIButton() - private let totalFollowerCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followerTitleLabel = UILabel().then { - $0.text = Text.followerTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let updateProfileButton = SOMButton().then { - $0.title = Text.updateProfileButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - override init(frame: CGRect) { - super.init(frame: .zero) - - self.backgroundColor = .clear - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposeBag = DisposeBag() - } - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.centerX.equalToSuperview() - $0.size.equalTo(128) - } - - let cardContainer = UIStackView(arrangedSubviews: [ - self.totalCardCountLabel, - self.cardTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - cardContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - - let followingContainer = UIStackView(arrangedSubviews: [ - self.totalFollowingCountLabel, - self.followingTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followingContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followingContainer.addSubview(self.followingButton) - self.followingButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let followerContainer = UIStackView(arrangedSubviews: [ - self.totalFollowerCountLabel, - self.followerTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followerContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followerContainer.addSubview(self.followerButton) - self.followerButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let totalContainer = UIStackView(arrangedSubviews: [ - cardContainer, - followingContainer, - followerContainer - ]).then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 12 - } - self.contentView.addSubview(totalContainer) - totalContainer.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.bottom).offset(16) - $0.centerX.equalToSuperview() - } - - self.contentView.addSubview(self.updateProfileButton) - self.updateProfileButton.snp.makeConstraints { - $0.top.equalTo(totalContainer.snp.bottom).offset(18) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.bottom.equalToSuperview().offset(-30) - $0.height.equalTo(48) - } - } - - func setModel(_ profile: Profile) { - if let profileImg = profile.profileImg { - self.profileImageView.setImage(strUrl: profileImg.url) - } else { - self.profileImageView.image = .init(.image(.sooumLogo)) - } - self.totalCardCountLabel.text = profile.cardCnt - self.totalFollowingCountLabel.text = profile.followingCnt - self.totalFollowerCountLabel.text = profile.followerCnt - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/OtherProfileViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/OtherProfileViewCell.swift deleted file mode 100644 index 857701fb..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/OtherProfileViewCell.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// OtherProfileViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class OtherProfileViewCell: UICollectionViewCell { - - enum Text { - static let cardTitle: String = "카드" - static let follingTitle: String = "팔로잉" - static let followerTitle: String = "팔로워" - - static let followButtonTitle: String = "팔로우하기" - static let didFollowButtonTitle: String = "팔로우 중" - static let blockedFollowButtonTitle: String = "차단 해제" - } - - static let cellIdentifier = String(reflecting: OtherProfileViewCell.self) - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - - private let totalCardCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let cardTitleLabel = UILabel().then { - $0.text = Text.cardTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followingButton = UIButton() - private let totalFollowingCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followingTitleLabel = UILabel().then { - $0.text = Text.follingTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followerButton = UIButton() - private let totalFollowerCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followerTitleLabel = UILabel().then { - $0.text = Text.followerTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.plus)))?.resized(.init(width: 16, height: 16), color: .som.white) - - $0.title = Text.followButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - override init(frame: CGRect) { - super.init(frame: .zero) - - self.backgroundColor = .clear - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposeBag = DisposeBag() - } - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(128) - } - - let cardContainer = UIStackView(arrangedSubviews: [ - self.totalCardCountLabel, - self.cardTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - cardContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - - let followingContainer = UIStackView(arrangedSubviews: [ - self.totalFollowingCountLabel, - self.followingTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followingContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followingContainer.addSubview(self.followingButton) - self.followingButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let followerContainer = UIStackView(arrangedSubviews: [ - self.totalFollowerCountLabel, - self.followerTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followerContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followerContainer.addSubview(self.followerButton) - self.followerButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let totalContainer = UIStackView(arrangedSubviews: [ - cardContainer, - followingContainer, - followerContainer - ]).then { - $0.axis = .horizontal - $0.alignment = .fill - $0.distribution = .equalSpacing - $0.spacing = 12 - } - self.contentView.addSubview(totalContainer) - totalContainer.snp.makeConstraints { - $0.top.equalToSuperview().offset(43) - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(24) - $0.trailing.equalToSuperview().offset(-20) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.bottom).offset(22) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.bottom.equalToSuperview().offset(-22) - $0.height.equalTo(48) - } - } - - func setModel(_ profile: Profile, isBlocked: Bool) { - if let profileImg = profile.profileImg { - self.profileImageView.setImage(strUrl: profileImg.url) - } else { - self.profileImageView.image = .init(.image(.sooumLogo)) - } - self.totalCardCountLabel.text = profile.cardCnt - self.totalFollowingCountLabel.text = profile.followingCnt - self.totalFollowerCountLabel.text = profile.followerCnt - - let isFollowing = profile.isFollowing ?? false - - if isBlocked { - self.followButton.image = nil - self.followButton.title = Text.blockedFollowButtonTitle - self.followButton.foregroundColor = .som.white - self.followButton.backgroundColor = .som.p300 - } else { - let image = UIImage(.icon(.outlined(.plus)))?.resized(.init(width: 16, height: 16), color: .som.white) - self.followButton.image = isFollowing ? nil : image - self.followButton.title = isFollowing ? Text.didFollowButtonTitle : Text.followButtonTitle - self.followButton.foregroundColor = isFollowing ? .som.gray600 : .som.white - self.followButton.backgroundColor = isFollowing ? .som.gray200 : .som.p300 - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardViewCell.swift new file mode 100644 index 00000000..c91be1fa --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardViewCell.swift @@ -0,0 +1,103 @@ +// +// ProfileCardViewCell.swift +// SOOUM +// +// Created by 오현식 on 12/3/24. +// + +import UIKit + +import SnapKit +import Then + +class ProfileCardViewCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: ProfileCardViewCell.self) + + + // MARK: Views + + private let backgroundImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + } + + private let backgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.dim + $0.layer.cornerRadius = 4 + $0.clipsToBounds = true + } + + private let contentLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.textAlignment = .center + $0.typography = .som.v2.caption4 + $0.numberOfLines = 8 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.backgroundImageView.image = nil + self.contentLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.backgroundImageView) + self.backgroundImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.backgroundImageView.addSubview(self.backgroundDimView) + self.backgroundDimView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + $0.trailing.equalToSuperview().offset(-12) + } + + self.backgroundDimView.addSubview(self.contentLabel) + self.contentLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.bottom.equalToSuperview().offset(-6) + $0.leading.equalToSuperview().offset(8) + $0.trailing.equalToSuperview().offset(-8) + } + } + + + // MARK: Public func + + func setModel(_ model: ProfileCardInfo) { + + self.backgroundImageView.setImage(strUrl: model.imgURL, with: model.imgName) + self.contentLabel.text = model.content + self.contentLabel.textAlignment = .center + let typography: Typography + switch model.font { + case .pretendard: typography = .som.v2.caption4 + case .ridi: typography = .som.v2.ridiProfile + case .yoonwoo: typography = .som.v2.yoonwooProfile + case .kkookkkook: typography = .som.v2.kkookkkookProfile + } + self.contentLabel.typography = typography + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsPlaceholderViewCell.swift new file mode 100644 index 00000000..12c36f9e --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsPlaceholderViewCell.swift @@ -0,0 +1,72 @@ +// +// ProfileCardsPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/7/25. +// + +import UIKit + +import SnapKit +import Then + +class ProfileCardsPlaceholderViewCell: UICollectionViewCell { + + enum Text { + static let message: String = "카드가 없어요" + } + + static let cellIdentifier = String(reflecting: ProfileCardsPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.card)))) + $0.tintColor = .som.v2.gray200 + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .clear + self.isUserInteractionEnabled = false + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + /// (screen height - safe layout guide top - navi height - user cell height) * 0.5 - (icon height + spacing + label height) * 0.5 - tabBar height + let offset = (UIScreen.main.bounds.height - (48 + 84 + 76 + 48 + 16)) * 0.5 - 53 * 0.5 - 88 + $0.top.equalTo(self.safeAreaLayoutGuide.snp.top).offset(offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsViewCell.swift new file mode 100644 index 00000000..96fc0b1d --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsViewCell.swift @@ -0,0 +1,282 @@ +// +// ProfileCardsViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/7/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class ProfileCardsViewCell: UICollectionViewCell { + + enum Text { + static let blockedText: String = "차단한 계정입니다" + } + + enum Section: Int, CaseIterable { + case feed + case comment + case empty + } + + enum Item: Hashable { + case feed(ProfileCardInfo) + case comment(ProfileCardInfo) + case empty + } + + static let cellIdentifier = String(reflecting: ProfileCardsViewCell.self) + + + // MARK: Views + + private let flowLayout = UICollectionViewFlowLayout().then { + $0.scrollDirection = .vertical + $0.minimumLineSpacing = 1 + $0.minimumInteritemSpacing = 1 + } + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: self.flowLayout + ).then { + $0.contentInset = .zero + + $0.isScrollEnabled = false + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register( + ProfileCardViewCell.self, + forCellWithReuseIdentifier: ProfileCardViewCell.cellIdentifier + ) + $0.register( + ProfileCardsPlaceholderViewCell.self, + forCellWithReuseIdentifier: ProfileCardsPlaceholderViewCell.cellIdentifier + ) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + + guard let self = self else { return nil } + + switch item { + case let .feed(profileCardInfo): + + let cell: ProfileCardViewCell = self.cell(collectionView, cellForItemAt: indexPath) + cell.setModel(profileCardInfo) + + return cell + case let .comment(profileCardInfo): + + let cell: ProfileCardViewCell = self.cell(collectionView, cellForItemAt: indexPath) + cell.setModel(profileCardInfo) + + return cell + case .empty: + + return self.placeholder(collectionView, cellForItemAt: indexPath) + } + } + + private(set) var feedCardInfos = [ProfileCardInfo]() + private(set) var commentCardInfos: [ProfileCardInfo]? + private(set) var selectedCardType: EntranceCardType = .feed + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + let cardDidTap = PublishRelay() + let moreFindCards = PublishRelay<(type: EntranceCardType, lastId: String)>() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + + // MARK: Public func + + func setModels( + type selectedCardType: EntranceCardType, + feed feedCardInfos: [ProfileCardInfo], + comment commentCardInfos: [ProfileCardInfo]? + ) { + + self.selectedCardType = selectedCardType + self.feedCardInfos = feedCardInfos + self.commentCardInfos = commentCardInfos + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + switch selectedCardType { + case .feed: + + guard feedCardInfos.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = feedCardInfos.map { Item.feed($0) } + snapshot.appendItems(new, toSection: .feed) + case .comment: + + guard let commentCardInfos = commentCardInfos else { return } + + guard commentCardInfos.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = commentCardInfos.map { Item.comment($0) } + snapshot.appendItems(new, toSection: .comment) + } + + self.dataSource.apply(snapshot, animatingDifferences: false) + } +} + +extension ProfileCardsViewCell { + + func cell( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> ProfileCardViewCell { + + let cell: ProfileCardViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileCardViewCell.cellIdentifier, + for: indexPath + ) as! ProfileCardViewCell + + return cell + } + + func placeholder( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> ProfileCardsPlaceholderViewCell { + + let placeholder: ProfileCardsPlaceholderViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileCardsPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! ProfileCardsPlaceholderViewCell + + return placeholder + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension ProfileCardsViewCell: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + var selectedId: String? { + switch item { + case let .feed(selectedCard): + return selectedCard.id + case let .comment(selectedCard): + return selectedCard.id + case .empty: + return nil + } + } + + guard let selectedId = selectedId else { return } + + self.cardDidTap.accept(selectedId) + } + + func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + + switch self.selectedCardType { + case .feed: + + let lastItemIndexPath = collectionView.numberOfItems(inSection: Section.feed.rawValue) - 1 + if self.feedCardInfos.isEmpty == false, + indexPath.section == Section.feed.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = self.feedCardInfos.last?.id { + + self.moreFindCards.accept((.feed, lastId)) + } + case .comment: + + let lastItemIndexPath = collectionView.numberOfItems(inSection: Section.comment.rawValue) - 1 + if self.commentCardInfos?.isEmpty == false, + indexPath.section == Section.comment.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = self.commentCardInfos?.last?.id { + + self.moreFindCards.accept((.comment, lastId)) + } + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return collectionView.bounds.size + } + + switch item { + case .empty: + return collectionView.bounds.size + default: + let width: CGFloat = (collectionView.bounds.width - 2) / 3 + return CGSize(width: width, height: width) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileUserViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileUserViewCell.swift new file mode 100644 index 00000000..9a59065f --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileUserViewCell.swift @@ -0,0 +1,347 @@ +// +// ProfileViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class ProfileUserViewCell: UICollectionViewCell { + + enum Text { + static let totalVisitedTitle: String = "Total" + static let todayVisitedTitle: String = "Today" + static let cardCntTitle: String = "카드" + static let followerCntTitle: String = "팔로워" + static let followingCntTitle: String = "팔로잉" + static let updateProfileButtonTitle: String = "프로필 편집" + static let followButtonTitle: String = "팔로우" + static let followingButtonTitle: String = "팔로잉" + static let unBlockButtonTitle: String = "차단 해제" + } + + static let cellIdentifier = String(reflecting: ProfileUserViewCell.self) + + // MARK: Views + + private let visitedAndNicknameContainer = UIStackView().then { + $0.axis = .vertical + $0.alignment = .leading + $0.distribution = .fill + $0.spacing = 2 + } + + private let visitedCountContainer = UIView() + + private let totalVisitedTitleLabel = UILabel().then { + $0.text = Text.totalVisitedTitle + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + private let totalVisitedCountLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + private let dot = UIView().then { + $0.backgroundColor = .som.v2.gray400 + $0.layer.cornerRadius = 3 * 0.5 + } + + private let todayVisitedTitleLabel = UILabel().then { + $0.text = Text.todayVisitedTitle + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + private let todayVisitedCountLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.head3 + } + + private let profilImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_large))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 60 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + + private let bottomContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .top + $0.distribution = .equalSpacing + $0.spacing = 0 + } + + let updateProfileButton = SOMButton().then { + $0.title = Text.updateProfileButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 + } + + let followButton = SOMButton().then { + $0.title = Text.followButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + + $0.isHidden = true + } + + let unBlockButton = SOMButton().then { + $0.title = Text.unBlockButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + + $0.isHidden = true + } + + + // MARK: Variables + + private(set) var model: ProfileInfo = .defaultValue + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + let cardContainerDidTap = PublishRelay() + let followerContainerDidTap = PublishRelay() + let followingContainerDidTap = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + let topContainer = UIView() + self.addSubview(topContainer) + topContainer.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() + $0.height.equalTo(84) + } + + topContainer.addSubview(self.visitedAndNicknameContainer) + self.visitedAndNicknameContainer.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + } + + self.visitedCountContainer.addSubview(self.totalVisitedTitleLabel) + self.totalVisitedTitleLabel.snp.makeConstraints { + $0.verticalEdges.leading.equalToSuperview() + } + self.visitedCountContainer.addSubview(self.totalVisitedCountLabel) + self.totalVisitedCountLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.totalVisitedTitleLabel.snp.trailing).offset(4) + } + + self.visitedCountContainer.addSubview(self.dot) + self.dot.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.totalVisitedCountLabel.snp.trailing).offset(7.5) + $0.size.equalTo(3) + } + + self.visitedCountContainer.addSubview(self.todayVisitedTitleLabel) + self.todayVisitedTitleLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.dot.snp.trailing).offset(7.5) + } + self.visitedCountContainer.addSubview(self.todayVisitedCountLabel) + self.todayVisitedCountLabel.snp.makeConstraints { + $0.verticalEdges.trailing.equalToSuperview() + $0.leading.equalTo(self.todayVisitedTitleLabel.snp.trailing).offset(4) + } + + self.visitedAndNicknameContainer.addArrangedSubview(self.visitedCountContainer) + self.visitedAndNicknameContainer.addArrangedSubview(self.nicknameLabel) + + topContainer.addSubview(self.profilImageView) + self.profilImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.visitedAndNicknameContainer.snp.trailing).offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(60) + } + + self.addSubview(self.bottomContainer) + self.bottomContainer.snp.makeConstraints { + $0.top.equalTo(topContainer.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.height.equalTo(76) + } + + self.addSubview(self.updateProfileButton) + self.updateProfileButton.snp.makeConstraints { + $0.top.equalTo(self.bottomContainer.snp.bottom) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + + self.addSubview(self.followButton) + self.followButton.snp.makeConstraints { + $0.top.equalTo(self.bottomContainer.snp.bottom) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + + self.addSubview(self.unBlockButton) + self.unBlockButton.snp.makeConstraints { + $0.top.equalTo(self.bottomContainer.snp.bottom) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + } + + + // MARK: public func + + func setModel(_ model: ProfileInfo) { + + self.model = model + + self.totalVisitedCountLabel.text = model.totalVisitCnt + self.totalVisitedCountLabel.typography = .som.v2.caption2 + self.todayVisitedCountLabel.text = model.todayVisitCnt + self.todayVisitedCountLabel.typography = .som.v2.caption2 + + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.head3 + + if let profileImageUrl = model.profileImageUrl { + self.profilImageView.setImage(strUrl: profileImageUrl, with: model.profileImgName) + } else { + self.profilImageView.image = .init(.image(.v2(.profile_medium))) + } + + var contents: [(content: ProfileInfo.Content, count: String)] { + var contents: [(content: ProfileInfo.Content, count: String)] = [] + + contents.append((.card, model.cardCnt)) + contents.append((.follower, model.followerCnt)) + contents.append((.following, model.followingCnt)) + + return contents + } + self.setupItems(contents) + + self.updateProfileButton.isHidden = model.isAlreadyFollowing != nil + if let isAlreadyFollowing = model.isAlreadyFollowing, let isBlocked = model.isBlocked { + + self.followButton.isHidden = isBlocked + self.unBlockButton.isHidden = isBlocked == false + + self.updateButton(isAlreadyFollowing) + } + } + + /// 상대방 프로필 일 때만 사용 + func updateButton(_ isFollowing: Bool) { + + self.followButton.title = isFollowing ? Text.followingButtonTitle : Text.followButtonTitle + self.followButton.foregroundColor = isFollowing ? .som.v2.gray600 : .som.v2.white + self.followButton.backgroundColor = isFollowing ? .som.v2.gray100 : .som.v2.black + } +} + +private extension ProfileUserViewCell { + + func setupItems(_ items: [(content: ProfileInfo.Content, count: String)]) { + + self.bottomContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } + + items.forEach { item in + + let topSpacing = UIView() + let bottomSpacing = UIView() + + let titleLabel = UILabel().then { + $0.text = item.content.rawValue + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.body1.withAlignment(.left) + } + + let countLabel = UILabel().then { + $0.text = item.count + $0.textColor = .som.v2.black + $0.typography = .som.v2.title1.withAlignment(.left) + } + + let container = UIStackView(arrangedSubviews: [topSpacing, titleLabel, countLabel, bottomSpacing]).then { + $0.axis = .vertical + $0.alignment = .leading + $0.distribution = .equalSpacing + $0.spacing = 0 + } + container.snp.makeConstraints { + $0.width.equalTo(72) + $0.height.equalTo(64) + } + + topSpacing.snp.makeConstraints { + $0.height.equalTo(8) + } + bottomSpacing.snp.makeConstraints { + $0.height.equalTo(8) + } + + container.rx.tapGesture() + .when(.recognized) + .throttle(.seconds(1), scheduler: MainScheduler.instance) + .subscribe(with: self) { object, _ in + switch item.content { + case .card: object.cardContainerDidTap.accept(()) + case .follower: object.followerContainerDidTap.accept(()) + case .following: object.followingContainerDidTap.accept(()) + } + } + .disposed(by: self.disposeBag) + + self.bottomContainer.addArrangedSubview(container) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooter.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooter.swift deleted file mode 100644 index 0b93d0b4..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooter.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// ProfileViewFooter.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - -import RxCocoa -import RxSwift - - -class ProfileViewFooter: UICollectionReusableView { - - enum Text { - static let blockedText: String = "차단한 계정입니다" - } - - private let flowLayout = UICollectionViewFlowLayout().then { - $0.scrollDirection = .vertical - $0.minimumLineSpacing = .zero - $0.minimumInteritemSpacing = .zero - $0.sectionInset = .zero - $0.estimatedItemSize = .zero - } - private lazy var collectionView = UICollectionView( - frame: .zero, - collectionViewLayout: self.flowLayout - ).then { - $0.alwaysBounceVertical = true - - $0.decelerationRate = .fast - - $0.contentInsetAdjustmentBehavior = .never - $0.contentInset = .zero - - $0.showsHorizontalScrollIndicator = false - - $0.register(ProfileViewFooterCell.self, forCellWithReuseIdentifier: ProfileViewFooterCell.cellIdentifier) - - $0.dataSource = self - $0.delegate = self - } - - private let blockedLabel = UILabel().then { - $0.text = Text.blockedText - $0.textColor = .som.gray400 - $0.typography = .som.body1WithBold - $0.isHidden = true - } - - private(set) var writtenCards = [WrittenCard]() - - private var currentOffset: CGFloat = 0 - private var isLoadingMore: Bool = false - - let didTap = PublishRelay() - let moreDisplay = PublishRelay() - - var disposeBag = DisposeBag() - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposeBag = DisposeBag() - } - - private func setupConstraints() { - - self.addSubview(self.collectionView) - self.collectionView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.addSubview(self.blockedLabel) - self.blockedLabel.snp.makeConstraints { - $0.center.equalToSuperview() - } - } - - func setModel(_ writtenCards: [WrittenCard], isBlocked: Bool) { - - self.isLoadingMore = false - - self.blockedLabel.isHidden = isBlocked == false - self.collectionView.isHidden = isBlocked - - self.writtenCards = writtenCards - - UIView.performWithoutAnimation { - self.collectionView.performBatchUpdates { - self.collectionView.reloadSections(IndexSet(integer: 0)) - } - } - } -} - -extension ProfileViewFooter: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return self.writtenCards.count - } - - func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { - - let cell: ProfileViewFooterCell = collectionView.dequeueReusableCell( - withReuseIdentifier: ProfileViewFooterCell.cellIdentifier, - for: indexPath - ) as! ProfileViewFooterCell - let writtenCard = self.writtenCards[indexPath.item] - cell.setModel(writtenCard) - - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let selectedId = self.writtenCards[indexPath.item].id - self.didTap.accept(selectedId) - } -} - -extension ProfileViewFooter: UICollectionViewDelegateFlowLayout { - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width / 3 - return CGSize(width: width, height: width) - } - - func collectionView( - _ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath - ) { - guard self.writtenCards.isEmpty == false else { return } - - let lastSectionIndex = collectionView.numberOfSections - 1 - let lastRowIndex = collectionView.numberOfItems(inSection: lastSectionIndex) - 1 - - if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.item == lastRowIndex { - - self.isLoadingMore = false - - let lastId = self.writtenCards[indexPath.item].id - self.moreDisplay.accept(lastId) - } - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - self.currentOffset = offset - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooterCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooterCell.swift deleted file mode 100644 index f9b9b214..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooterCell.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ProfileViewFooterCell.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - - -class ProfileViewFooterCell: UICollectionViewCell { - - static let cellIdentifier = String(reflecting: ProfileViewFooterCell.self) - - private let backgroundImageView = UIImageView() - - private let backgroundDimView = UIView().then { - $0.backgroundColor = .som.black.withAlphaComponent(0.2) - } - - private let contentLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .som.body3WithRegular - $0.numberOfLines = 0 - $0.lineBreakMode = .byTruncatingTail - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.backgroundImageView.image = nil - self.contentLabel.text = nil - } - - private func setupConstraints() { - - self.contentView.addSubview(self.backgroundImageView) - self.backgroundImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.backgroundDimView) - self.backgroundDimView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.contentLabel) - self.contentLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().offset(10) - $0.bottom.trailing.equalToSuperview().offset(-10) - } - } - - func setModel(_ writtenCard: WrittenCard) { - self.backgroundImageView.setImage(strUrl: writtenCard.backgroundImgURL.url) - self.contentLabel.typography = writtenCard.font == .pretendard ? .som.body3WithRegular : .som.schoolBody1WithLight - self.contentLabel.text = writtenCard.content - self.contentLabel.textAlignment = .center - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewHeader.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewHeader.swift new file mode 100644 index 00000000..f56981de --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewHeader.swift @@ -0,0 +1,83 @@ +// +// ProfileViewFooterHeader.swift +// SOOUM +// +// Created by 오현식 on 11/7/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class ProfileViewHeader: UICollectionReusableView { + + enum Text { + static let tabFeedTitle: String = "카드" + static let tabCommentTitle: String = "댓글카드" + } + + static let cellIdentifier = String(reflecting: ProfileViewHeader.self) + + + // MARK: Views + + private lazy var stickyTabBar = SOMStickyTabBar(alignment: .center).then { + $0.items = [Text.tabFeedTitle, Text.tabCommentTitle] + $0.spacing = 24 + $0.delegate = self + } + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + let tabBarItemDidTap = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.white + + self.addSubview(self.stickyTabBar) + self.stickyTabBar.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +extension ProfileViewHeader: SOMStickyTabBarDelegate { + + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) { + + self.tabBarItemDidTap.accept(index) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowPlaceholderViewCell.swift new file mode 100644 index 00000000..4328ff9f --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowPlaceholderViewCell.swift @@ -0,0 +1,79 @@ +// +// FollowPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/8/25. +// + +import UIKit + +import SnapKit +import Then + +class FollowPlaceholderViewCell: UITableViewCell { + + static let cellIdentifier = String(reflecting: FollowPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.users)))) + $0.tintColor = .som.v2.gray200 + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Variables + + var placeholderText: String? { + set { + self.placeholderMessageLabel.text = newValue + self.placeholderMessageLabel.typography = .som.v2.body1 + } + get { + return self.placeholderMessageLabel.text + } + } + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + let offset = 8 + 21 + $0.centerY.equalToSuperview().offset(-offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowerViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowerViewCell.swift new file mode 100644 index 00000000..abb8d0e2 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowerViewCell.swift @@ -0,0 +1,153 @@ +// +// FollowerViewCell.swift +// SOOUM +// +// Created by 오현식 on 12/7/24. +// + +import UIKit + +import SnapKit +import Then + +import RxSwift + +class FollowerViewCell: UITableViewCell { + + enum Text { + static let willFollowButton: String = "팔로우" + static let didFollowButton: String = "팔로잉" + } + + static let cellIdentifier = String(reflecting: FollowerViewCell.self) + + + // MARK: Views + + let profileBackgroundButton = UIButton() + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_small))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 36 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle2 + } + + let followButton = SOMButton().then { + $0.title = Text.willFollowButton + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.white + + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + + // MARK: Variables + + private(set) var model: FollowInfo = .defaultValue + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + + // MARK: Initalization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + // UI 초기화 + self.profileImageView.image = nil + self.nicknameLabel.text = nil + self.updateButton(false) + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(36) + } + + self.contentView.addSubview(self.nicknameLabel) + self.nicknameLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.profileImageView.snp.trailing).offset(10) + } + + self.contentView.addSubview(self.profileBackgroundButton) + self.profileBackgroundButton.snp.makeConstraints { + $0.top.equalTo(self.profileImageView.snp.top) + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.leading.equalTo(self.profileImageView.snp.leading) + $0.trailing.equalTo(self.nicknameLabel.snp.trailing) + } + + self.contentView.addSubview(self.followButton) + self.followButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.nicknameLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-16) + $0.width.equalTo(68) + $0.height.equalTo(32) + } + } + + + // MARK: Public func + + func setModel(_ model: FollowInfo) { + + if let profileImageUrl = model.profileImageUrl { + self.profileImageView.setImage(strUrl: profileImageUrl) + } else { + self.profileImageView.image = .init(.image(.v2(.profile_small))) + } + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.subtitle2 + + self.followButton.isHidden = model.isRequester + + self.updateButton(model.isFollowing) + } + + func updateButton(_ isFollowing: Bool) { + + self.followButton.title = isFollowing ? Text.didFollowButton : Text.willFollowButton + self.followButton.foregroundColor = isFollowing ? .som.v2.gray600 : .som.v2.white + self.followButton.backgroundColor = isFollowing ? .som.v2.gray100 : .som.v2.black + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowerViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowerViewCell.swift deleted file mode 100644 index 4cc85bce..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowerViewCell.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// MyFollowerViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/7/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class MyFollowerViewCell: UITableViewCell { - - enum Text { - static let didFollowButton: String = "팔로잉" - static let willFollowButton: String = "팔로우" - } - - static let cellIdentifier = String(reflecting: MyFollowerViewCell.self) - - let profilBackgroundButton = UIButton() - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 46 * 0.5 - $0.clipsToBounds = true - } - - private let profileNickname = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - let followButton = SOMButton().then { - $0.title = Text.willFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 26 * 0.5 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - - // MARK: Initalization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.backgroundColor = .clear - self.contentView.clipsToBounds = true - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - // MARK: Override func - - override func prepareForReuse() { - super.prepareForReuse() - - // UI 초기화 - self.profileImageView.image = nil - self.profileNickname.text = nil - self.updateButton(false) - - self.disposeBag = DisposeBag() - } - - - // MARK: Private func - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(46) - } - - self.contentView.addSubview(self.profileNickname) - self.profileNickname.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(12) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(72) - $0.height.equalTo(26) - } - - self.contentView.addSubview(self.profilBackgroundButton) - self.profilBackgroundButton.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.top) - $0.bottom.equalTo(self.profileImageView.snp.bottom) - $0.leading.equalTo(self.profileImageView.snp.leading) - $0.trailing.equalTo(self.profileNickname.snp.trailing) - } - } - - - // MARK: Public func - - func setModel(_ follow: Follow) { - - if let url = follow.backgroundImgURL?.url { - self.profileImageView.setImage(strUrl: url) - } else { - self.profileImageView.image = .init(.image(.sooumLogo)) - } - self.profileNickname.text = follow.nickname - } - - func updateButton(_ isFollowing: Bool) { - - self.followButton.title = isFollowing ? Text.didFollowButton : Text.willFollowButton - self.followButton.foregroundColor = isFollowing ? .som.gray600 : .som.white - self.followButton.backgroundColor = isFollowing ? .som.gray200 : .som.p300 - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift index 43188c17..74d712f3 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift @@ -12,56 +12,61 @@ import Then import RxSwift - class MyFollowingViewCell: UITableViewCell { enum Text { - static let didFollowButton: String = "팔로우 취소" - static let willFollowButton: String = "팔로우" + static let followingButtonTitle: String = "팔로잉" } static let cellIdentifier = String(reflecting: MyFollowingViewCell.self) - let profilBackgroundButton = UIButton() + // MARK: Views + + let profileBackgroundButton = UIButton() private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 46 * 0.5 + $0.image = .init(.image(.v2(.profile_small))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 36 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor $0.clipsToBounds = true } - private let profileNickname = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle2 } let cancelFollowButton = SOMButton().then { - $0.title = Text.didFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.gray400 - $0.hasUnderlined = true - } - - let followButton = SOMButton().then { - $0.title = Text.willFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.white + $0.title = Text.followingButtonTitle + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.gray600 - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 26 * 0.5 + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 8 $0.clipsToBounds = true - $0.isHidden = true } + + // MARK: Variables + + private(set) var model: FollowInfo = .defaultValue + + + // MARK: Variables + Rx + var disposeBag = DisposeBag() - // MARK: Initalization + // MARK: Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) self.backgroundColor = .clear - self.contentView.clipsToBounds = true + self.selectionStyle = .none self.setupConstraints() } @@ -77,7 +82,7 @@ class MyFollowingViewCell: UITableViewCell { super.prepareForReuse() self.profileImageView.image = nil - self.profileNickname.text = nil + self.nicknameLabel.text = nil self.disposeBag = DisposeBag() } @@ -90,58 +95,48 @@ class MyFollowingViewCell: UITableViewCell { self.contentView.addSubview(self.profileImageView) self.profileImageView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(46) + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(36) } - self.contentView.addSubview(self.profileNickname) - self.profileNickname.snp.makeConstraints { + self.contentView.addSubview(self.nicknameLabel) + self.nicknameLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(12) + $0.leading.equalTo(self.profileImageView.snp.trailing).offset(10) } - self.contentView.addSubview(self.cancelFollowButton) - self.cancelFollowButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(29) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(72) - $0.height.equalTo(26) - } - - self.contentView.addSubview(self.profilBackgroundButton) - self.profilBackgroundButton.snp.makeConstraints { + self.contentView.addSubview(self.profileBackgroundButton) + self.profileBackgroundButton.snp.makeConstraints { $0.top.equalTo(self.profileImageView.snp.top) $0.bottom.equalTo(self.profileImageView.snp.bottom) $0.leading.equalTo(self.profileImageView.snp.leading) - $0.trailing.equalTo(self.profileNickname.snp.trailing) + $0.trailing.equalTo(self.nicknameLabel.snp.trailing) + } + + self.contentView.addSubview(self.cancelFollowButton) + self.cancelFollowButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.nicknameLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-16) + $0.width.equalTo(68) + $0.height.equalTo(32) } } // MARK: Public func - func setModel(_ follow: Follow) { + func setModel(_ model: FollowInfo) { - if let url = follow.backgroundImgURL?.url { - self.profileImageView.setImage(strUrl: url) + self.model = model + + if let profileImageUrl = model.profileImageUrl { + self.profileImageView.setImage(strUrl: profileImageUrl) } else { - self.profileImageView.image = .init(.image(.sooumLogo)) + self.profileImageView.image = .init(.image(.v2(.profile_small))) } - self.profileNickname.text = follow.nickname - } - - func updateButton(_ isFollowing: Bool) { - self.cancelFollowButton.isHidden = isFollowing == false - self.followButton.isHidden = isFollowing + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.subtitle2 } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/OtherFollowViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/OtherFollowViewCell.swift deleted file mode 100644 index f36f62ba..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/OtherFollowViewCell.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// OtherFollowViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class OtherFollowViewCell: UITableViewCell { - - enum Text { - static let didFollowButton: String = "팔로잉" - static let willFollowButton: String = "팔로우" - } - - static let cellIdentifier = String(reflecting: OtherFollowViewCell.self) - - let profilBackgroundButton = UIButton() - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 46 * 0.5 - $0.clipsToBounds = true - } - - private let profileNickname = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - let followButton = SOMButton().then { - $0.title = Text.willFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 26 * 0.5 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - - // MARK: Initalization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.backgroundColor = .clear - self.contentView.clipsToBounds = true - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - // MARK: Override func - - override func prepareForReuse() { - super.prepareForReuse() - - // UI 초기화 - self.profileImageView.image = nil - self.profileNickname.text = nil - self.updateButton(false) - - self.disposeBag = DisposeBag() - } - - - // MARK: Private func - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(46) - } - - self.contentView.addSubview(self.profileNickname) - self.profileNickname.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(12) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(72) - $0.height.equalTo(26) - } - - self.contentView.addSubview(self.profilBackgroundButton) - self.profilBackgroundButton.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.top) - $0.bottom.equalTo(self.profileImageView.snp.bottom) - $0.leading.equalTo(self.profileImageView.snp.leading) - $0.trailing.equalTo(self.profileNickname.snp.trailing) - } - } - - - // MARK: Public func - - func setModel(_ follow: Follow) { - - if let url = follow.backgroundImgURL?.url { - self.profileImageView.setImage(strUrl: url) - } else { - self.profileImageView.image = .init(.image(.sooumLogo)) - } - self.profileNickname.text = follow.nickname - - self.followButton.isHidden = follow.isRequester - } - - func updateButton(_ isFollowing: Bool) { - - self.followButton.title = isFollowing ? Text.didFollowButton : Text.willFollowButton - self.followButton.foregroundColor = isFollowing ? .som.gray600 : .som.white - self.followButton.backgroundColor = isFollowing ? .som.gray200 : .som.p300 - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift index 7e9cf184..7e5e619d 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift @@ -14,12 +14,40 @@ import ReactorKit import RxCocoa import RxSwift - class FollowViewController: BaseNavigationViewController, View { enum Text { static let followerTitle: String = "팔로워" static let followingTitle: String = "팔로잉" + + static let followerPlaceholderMessage: String = "팔로우하는 사람이 없어요" + static let followingPlaceholderMessage: String = "팔로우하고 있는 사람이 없어요" + + static let deleteFollowingDialogTitle: String = "님을 팔로워에서 삭제하시겠어요?" + + static let cancelActionTitle: String = "취소" + static let deleteActionTitle: String = "삭제하기" + } + + enum Section: Int, CaseIterable { + case follower + case following + case empty + } + + enum Item: Hashable { + case follower(FollowInfo) + case following(FollowInfo) + case empty + } + + + // MARK: Views + + private lazy var stickyTabBar = SOMStickyTabBar(alignment: .center).then { + $0.items = [Text.followerTitle, Text.followingTitle] + $0.spacing = 24 + $0.delegate = self } private lazy var tableView = UITableView().then { @@ -27,27 +55,185 @@ class FollowViewController: BaseNavigationViewController, View { $0.indicatorStyle = .black $0.separatorStyle = .none - $0.decelerationRate = .fast + $0.contentInsetAdjustmentBehavior = .never + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(FollowerViewCell.self, forCellReuseIdentifier: FollowerViewCell.cellIdentifier) $0.register(MyFollowingViewCell.self, forCellReuseIdentifier: MyFollowingViewCell.cellIdentifier) - $0.register(MyFollowerViewCell.self, forCellReuseIdentifier: MyFollowerViewCell.cellIdentifier) - $0.register(OtherFollowViewCell.self, forCellReuseIdentifier: OtherFollowViewCell.cellIdentifier) + $0.register(FollowPlaceholderViewCell.self, forCellReuseIdentifier: FollowPlaceholderViewCell.cellIdentifier) $0.refreshControl = SOMRefreshControl() - $0.dataSource = self $0.delegate = self } - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Variables + + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(tableView: self.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + + guard let self = self, let reactor = self.reactor else { return nil } + + switch item { + case let .follower(follower): + + let cell: FollowerViewCell = tableView.dequeueReusableCell( + withIdentifier: FollowerViewCell.cellIdentifier, + for: indexPath + ) as! FollowerViewCell + + cell.setModel(follower) + + cell.profileBackgroundButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + if follower.isRequester { + guard let navigationController = object.navigationController, + let tabBarController = navigationController.parent as? SOMTabBarController + else { return } + + if navigationController.viewControllers.first?.isKind(of: ProfileViewController.self) == true { + + object.navigationPopToRoot(animated: false) + } else { + + tabBarController.didSelectedIndex(3) + navigationController.viewControllers.removeAll(where: { $0.isKind(of: HomeViewController.self) == false }) + } + } else { + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(follower.memberId) + object.navigationPush(profileViewController, animated: true) + } + } + .disposed(by: cell.disposeBag) + + cell.followButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if follower.isFollowing { + object.showdeleteFollowingDialog( + nickname: follower.nickname, + with: follower.memberId + ) + } else { + reactor.action.onNext(.updateFollow(follower.memberId, true)) + } + } + .disposed(by: cell.disposeBag) + + return cell + case let .following(following): + + switch reactor.viewType { + case .my: + + let cell: MyFollowingViewCell = tableView.dequeueReusableCell( + withIdentifier: MyFollowingViewCell.cellIdentifier, + for: indexPath + ) as! MyFollowingViewCell + + cell.setModel(following) + + cell.profileBackgroundButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(following.memberId) + object.navigationPush(profileViewController, animated: true) + } + .disposed(by: cell.disposeBag) + + cell.cancelFollowButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.showdeleteFollowingDialog( + nickname: following.nickname, + with: following.memberId + ) + } + .disposed(by: cell.disposeBag) + + return cell + case .other: + + let cell: FollowerViewCell = tableView.dequeueReusableCell( + withIdentifier: FollowerViewCell.cellIdentifier, + for: indexPath + ) as! FollowerViewCell + + cell.setModel(following) + + cell.profileBackgroundButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + if following.isRequester { + guard let navigationController = object.navigationController, + let tabBarController = navigationController.parent as? SOMTabBarController + else { return } + + if navigationController.viewControllers.first?.isKind(of: ProfileViewController.self) == true { + + object.navigationPopToRoot(animated: false) + } else { + + tabBarController.didSelectedIndex(3) + navigationController.viewControllers.removeAll(where: { $0.isKind(of: HomeViewController.self) == false }) + } + } else { + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(following.memberId) + object.navigationPush(profileViewController, animated: true) + } + } + .disposed(by: cell.disposeBag) + + cell.followButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if following.isFollowing { + object.showdeleteFollowingDialog( + nickname: following.nickname, + with: following.memberId + ) + } else { + reactor.action.onNext(.updateFollow(following.memberId, true)) + } + } + .disposed(by: cell.disposeBag) + + return cell + } + case .empty: + + let placeholder: FollowPlaceholderViewCell = tableView.dequeueReusableCell( + withIdentifier: FollowPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! FollowPlaceholderViewCell + + placeholder.placeholderText = reactor.entranceType == .follower ? + Text.followerPlaceholderMessage : + Text.followingPlaceholderMessage + + return placeholder + } } - private(set) var follows = [Follow]() + private(set) var followers: [FollowInfo] = [] + private(set) var followings: [FollowInfo] = [] + private var initialOffset: CGFloat = 0 private var currentOffset: CGFloat = 0 private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = false + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } // MARK: Override func @@ -55,17 +241,24 @@ class FollowViewController: BaseNavigationViewController, View { override func setupNaviBar() { super.setupNaviBar() - let title = self.reactor?.entranceType == .following ? Text.followingTitle : Text.followerTitle - self.navigationBar.title = title + " (0)" + guard let reactor = self.reactor else { return } + + self.navigationBar.title = reactor.nickname } override func setupConstraints() { super.setupConstraints() + self.view.addSubview(self.stickyTabBar) + self.stickyTabBar.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.horizontalEdges.equalToSuperview() + } + self.view.addSubview(self.tableView) self.tableView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.bottom.leading.trailing.equalToSuperview() + $0.top.equalTo(self.stickyTabBar.snp.bottom) + $0.bottom.horizontalEdges.equalToSuperview() } } @@ -74,104 +267,154 @@ class FollowViewController: BaseNavigationViewController, View { func bind(reactor: FollowViewReactor) { + // 팔로우 == 0, 팔로잉 == 1 + let viewDidLoad = self.rx.viewDidLoad.share() + viewDidLoad + .subscribe(with: self.stickyTabBar) { stickyTabBar, _ in + stickyTabBar.didSelectTabBarItem( + reactor.entranceType == .follower ? 0 : 1, + with: false + ) + } + .disposed(by: self.disposeBag) + // Action - self.rx.viewDidLoad + viewDidLoad .map { _ in Reactor.Action.landing } .bind(to: reactor.action) .disposed(by: self.disposeBag) - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) + .withLatestFrom(isRefreshing) .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) .map { _ in Reactor.Action.refresh } .bind(to: reactor.action) .disposed(by: self.disposeBag) // State - isLoading - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() } .disposed(by: self.disposeBag) - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - let follows = reactor.state.map(\.follows).distinctUntilChanged().share() - follows - .map { - let title = reactor.entranceType == .following ? Text.followingTitle : Text.followerTitle - return title + "(\($0.count))" + reactor.state.map { + FollowViewReactor.DisplayStates( + followType: $0.followType, + followers: $0.followers, + followings: $0.followings + ) + } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, displayStates in + + var followerTabItem: String { + if displayStates.followers.isEmpty == false { + return Text.followerTitle + " \(displayStates.followers.count)" + } + return Text.followerTitle } - .bind(to: self.navigationBar.rx.title) - .disposed(by: self.disposeBag) - follows - .subscribe(with: self) { object, follows in - object.follows = follows - object.tableView.reloadData() + var followingTabItem: String { + if displayStates.followings.isEmpty == false { + return Text.followingTitle + " \(displayStates.followings.count)" + } + return Text.followingTitle } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isRequest) - .distinctUntilChanged() - .filter { $0 } - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) + + object.stickyTabBar.items = [followerTabItem, followingTabItem] + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + switch displayStates.followType { + case .follower: + + guard displayStates.followers.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = displayStates.followers.map { Item.follower($0) } + snapshot.appendItems(new, toSection: .follower) + case .following: + + guard displayStates.followings.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = displayStates.followings.map { Item.following($0) } + snapshot.appendItems(new, toSection: .following) + } + + object.dataSource.apply(snapshot, animatingDifferences: false) + } + .disposed(by: self.disposeBag) - reactor.state.map(\.isCancel) - .distinctUntilChanged() + reactor.pulse(\.$isUpdated) + .filterNil() .filter { $0 } - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) + .subscribe(with: self) { object, _ in + reactor.action.onNext(.landing) + NotificationCenter.default.post(name: .reloadProfileData, object: nil, userInfo: nil) + } .disposed(by: self.disposeBag) } } -extension FollowViewController: UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - self.follows.count - } + + +// MARK: show Dialog + +private extension FollowViewController { - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + func showdeleteFollowingDialog(nickname: String, with userId: String) { - guard let reactor = self.reactor else { return .init(frame: .zero) } - - switch reactor.viewType { - case .my: - switch reactor.entranceType { - case .following: - - return self.cellForMyFollowing(indexPath, reactor: reactor) - case .follower: - - return self.cellForMyFollower(indexPath, reactor: reactor) + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() } - case .other: - - return self.cellForOtherFollow(indexPath, reactor: reactor) - } + ) + let deleteAction = SOMDialogAction( + title: Text.deleteActionTitle, + style: .red, + action: { + SOMDialogViewController.dismiss { + self.reactor?.action.onNext(.updateFollow(userId, false)) + } + } + ) + + SOMDialogViewController.show( + title: nickname + Text.deleteFollowingDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [cancelAction, deleteAction] + ) } } + +// MARK: UITableViewDelegate + extension FollowViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 74 + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return 0 } + + switch item { + case .empty: + return tableView.bounds.height + default: + return 60 + } } func tableView( @@ -179,170 +422,84 @@ extension FollowViewController: UITableViewDelegate { willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath ) { - guard self.follows.isEmpty == false else { return } - let lastSectionIndex = tableView.numberOfSections - 1 - let lastRowIndex = tableView.numberOfRows(inSection: lastSectionIndex) - 1 + guard let reactor = self.reactor else { return } - if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.row == lastRowIndex { - let lastId = self.follows[indexPath.row].id - self.reactor?.action.onNext(.moreFind(lastId: lastId)) + switch reactor.currentState.followType { + case .follower: + + let lastItemIndexPath = tableView.numberOfRows(inSection: Section.follower.rawValue) - 1 + if self.followers.isEmpty == false, + indexPath.section == Section.follower.rawValue, + indexPath.row == lastItemIndexPath, + let lastId = self.followers.last?.id { + + reactor.action.onNext(.moreFind(type: .follower, lastId: lastId)) + } + case .following: + + let lastItemIndexPath = tableView.numberOfRows(inSection: Section.following.rawValue) - 1 + if self.followings.isEmpty == false, + indexPath.section == Section.following.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = self.followings.last?.id { + + reactor.action.onNext(.moreFind(type: .following, lastId: lastId)) + } } } + + // MARK: UIScrollViewDelegate + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset } func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.tableView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) + + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset self.currentOffset = offset } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() } } } -extension FollowViewController { - - private func cellForMyFollowing(_ indexPath: IndexPath, reactor: FollowViewReactor) -> MyFollowingViewCell { - - let model = self.follows[indexPath.row] - - let cell: MyFollowingViewCell = self.tableView.dequeueReusableCell( - withIdentifier: MyFollowingViewCell.cellIdentifier, - for: indexPath - ) as! MyFollowingViewCell - cell.selectionStyle = .none - cell.setModel(model) - cell.updateButton(model.isFollowing) - - cell.profilBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - - if model.isRequester { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .myWithNavi, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } else { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .other, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: cell.disposeBag) - - cell.cancelFollowButton.rx.throttleTap(.seconds(1)) - .subscribe(onNext: { _ in - reactor.action.onNext(.cancel(model.id)) - }) - .disposed(by: cell.disposeBag) - - cell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(onNext: { _ in - reactor.action.onNext(.request(model.id)) - }) - .disposed(by: cell.disposeBag) - - return cell - } - - private func cellForMyFollower(_ indexPath: IndexPath, reactor: FollowViewReactor) -> MyFollowerViewCell { - - let model = self.follows[indexPath.row] - - let cell: MyFollowerViewCell = self.tableView.dequeueReusableCell( - withIdentifier: MyFollowerViewCell.cellIdentifier, - for: indexPath - ) as! MyFollowerViewCell - cell.selectionStyle = .none - cell.setModel(model) - cell.updateButton(model.isFollowing) - - cell.profilBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - - if model.isRequester { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .myWithNavi, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } else { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .other, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: cell.disposeBag) - - cell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(onNext: { _ in - reactor.action.onNext(model.isFollowing ? .cancel(model.id) : .request(model.id)) - }) - .disposed(by: cell.disposeBag) - - return cell - } +extension FollowViewController: SOMStickyTabBarDelegate { - private func cellForOtherFollow(_ indexPath: IndexPath, reactor: FollowViewReactor) -> OtherFollowViewCell { - - let model = self.follows[indexPath.row] - - let cell: OtherFollowViewCell = self.tableView.dequeueReusableCell( - withIdentifier: OtherFollowViewCell.cellIdentifier, - for: indexPath - ) as! OtherFollowViewCell - cell.selectionStyle = .none - cell.setModel(model) - cell.updateButton(model.isFollowing) - - cell.profilBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - - if model.isRequester { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .myWithNavi, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } else { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .other, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: cell.disposeBag) - - cell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(with: self) { object, _ in - reactor.action.onNext(model.isFollowing ? .cancel(model.id) : .request(model.id)) - } - .disposed(by: cell.disposeBag) + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) { - return cell + self.reactor?.action.onNext(.updateFollowType(index == 0 ? .follower : .following)) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift index 1a3f38bc..ceaa99cc 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift @@ -9,230 +9,177 @@ import ReactorKit import Alamofire - class FollowViewReactor: Reactor { - enum EntranceType { - case following - case follower - } - - enum ViewType { - case my - case other + struct DisplayStates { + let followType: EntranceType + let followers: [FollowInfo] + let followings: [FollowInfo] } enum Action: Equatable { case landing case refresh - case moreFind(lastId: String) - case request(String) - case cancel(String) + case moreFind(type: EntranceType, lastId: String) + case updateFollowType(EntranceType) + case updateFollow(String, Bool) } enum Mutation { - case follows([Follow]) - case more([Follow]) - case updateIsRequest(Bool) - case updateIsCancel(Bool) - case updateIsProcessing(Bool) - case updateIsLoading(Bool) + case followers([FollowInfo]) + case followings([FollowInfo]) + case moreFollowers([FollowInfo]) + case moreFollowings([FollowInfo]) + case updateFollowType(EntranceType) + case updateIsUpdated(Bool?) + case updateIsRefreshing(Bool) } struct State { - var follows: [Follow] - var isRequest: Bool - var isCancel: Bool - var isProcessing: Bool - var isLoading: Bool + fileprivate(set) var followers: [FollowInfo] + fileprivate(set) var followings: [FollowInfo] + fileprivate(set) var followType: EntranceType + @Pulse fileprivate(set) var isUpdated: Bool? + fileprivate(set) var isRefreshing: Bool } var initialState: State = .init( - follows: [], - isRequest: false, - isCancel: false, - isProcessing: false, - isLoading: false + followers: [], + followings: [], + followType: .follower, + isUpdated: nil, + isRefreshing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let fetchFollowUseCase: FetchFollowUseCase + private let updateFollowUseCase: UpdateFollowUseCase + let entranceType: EntranceType let viewType: ViewType - let memberId: String? + let nickname: String + private let userId: String init( - provider: ManagerProviderType, + dependencies: AppDIContainerable, type entranceType: EntranceType, view viewType: ViewType, - memberId: String? = nil + nickname: String, + with userId: String ) { - self.provider = provider + self.dependencies = dependencies + self.fetchFollowUseCase = dependencies.rootContainer.resolve(FetchFollowUseCase.self) + self.updateFollowUseCase = dependencies.rootContainer.resolve(UpdateFollowUseCase.self) + self.entranceType = entranceType self.viewType = viewType - self.memberId = memberId + self.nickname = nickname + self.userId = userId } func mutate(action: Action) -> Observable { switch action { case .landing: + return .concat([ - .just(.updateIsProcessing(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) + self.followers(with: nil) + .catchAndReturn(.followers([])), + self.followings(with: nil) + .catchAndReturn(.followings([])) ]) case .refresh: + + let emit = self.currentState.followType == .follower ? + self.followers(with: nil) + .catch { _ in + return .concat([ + .just(.updateIsRefreshing(false)), + .just(.followers([])) + ]) + } : + self.followings(with: nil) + .catch { _ in + return .concat([ + .just(.updateIsRefreshing(false)), + .just(.followings([])) + ]) + } + return .concat([ - .just(.updateIsLoading(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsLoading(false)) - ]) - case let .moreFind(lastId): - return .concat([ - .just(.updateIsProcessing(true)), - self.more(lastId: lastId) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) + .just(.updateIsRefreshing(true)), + emit, + .just(.updateIsRefreshing(false)) ]) - case let .request(memberId): - let request: ProfileRequest = .requestFollow(memberId: memberId) + case let .moreFind(type, lastId): - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsRequest(true)) - } + let emit = type == .follower ? self.followers(with: lastId) : self.followings(with: lastId) + return emit + case let .updateFollowType(followType): - case let .cancel(memberId): - let request: ProfileRequest = .cancelFollow(memberId: memberId) + return .just(.updateFollowType(followType)) + case let .updateFollow(userId, isFollow): - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsCancel(true)) - } + return .concat([ + .just(.updateIsUpdated(nil)), + self.updateFollowUseCase.updateFollowing(userId: userId, isFollow: isFollow) + .map(Mutation.updateIsUpdated) + ]) } } func reduce(state: State, mutation: Mutation) -> State { - var state: State = state + var newState: State = state switch mutation { - case let .follows(follows): - state.follows = follows - state.isRequest = false - state.isCancel = false - case let .more(follows): - state.follows += follows - state.isRequest = false - state.isCancel = false - case let .updateIsRequest(isRequest): - state.isRequest = isRequest - case let .updateIsCancel(isCancel): - state.isCancel = isCancel - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateIsLoading(isLoading): - state.isLoading = isLoading + case let .followers(followers): + newState.followers = followers + case let .followings(followings): + newState.followings = followings + case let .moreFollowers(followers): + newState.followers += followers + case let .moreFollowings(followings): + newState.followings += followings + case let .updateFollowType(followType): + newState.followType = followType + case let .updateIsUpdated(isUpdated): + newState.isUpdated = isUpdated + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing } - return state + return newState } } -extension FollowViewReactor { - - private func refresh() -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .following: - switch self.viewType { - case .my: - return .myFollowing(lastId: nil) - case .other: - return .otherFollowing(memberId: self.memberId ?? "", lastId: nil) - } - case .follower: - switch self.viewType { - case .my: - return .myFollower(lastId: nil) - case .other: - return .otherFollower(memberId: self.memberId ?? "", lastId: nil) - } - } - } +private extension FollowViewReactor { + + func followers(with lastId: String?) -> Observable { - switch self.entranceType { - case .following: - return self.provider.networkManager.request(FollowingResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.follows(response.embedded.followings)) - } - .catch(self.catchClosure) - case .follower: - return self.provider.networkManager.request(FollowerResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.follows(response.embedded.followers)) - } - .catch(self.catchClosure) - } + return self.fetchFollowUseCase.followers(userId: self.userId, lastId: lastId) + .map { lastId == nil ? .followers($0) : .moreFollowers($0) } } - private func more(lastId: String) -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .following: - switch self.viewType { - case .my: - return .myFollowing(lastId: lastId) - case .other: - return .otherFollowing(memberId: self.memberId ?? "", lastId: lastId) - } - case .follower: - switch self.viewType { - case .my: - return .myFollower(lastId: lastId) - case .other: - return .otherFollower(memberId: self.memberId ?? "", lastId: lastId) - } - } - } + func followings(with lastId: String?) -> Observable { - switch self.entranceType { - case .following: - return self.provider.networkManager.request(FollowingResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.more(response.embedded.followings)) - } - .catch(self.catchClosure) - case .follower: - return self.provider.networkManager.request(FollowerResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.more(response.embedded.followers)) - } - .catch(self.catchClosure) - } + return self.fetchFollowUseCase.followings(userId: self.userId, lastId: lastId) + .map { lastId == nil ? .followings($0) : .moreFollowings($0) } } } extension FollowViewReactor { - func reactorForProfile(type: ProfileViewReactor.EntranceType, _ memberId: String) -> ProfileViewReactor { - ProfileViewReactor(provider: self.provider, type: type, memberId: memberId) - } - - func reactorForMainTabBar() -> MainTabBarReactor { - MainTabBarReactor(provider: self.provider) + func reactorForProfile( _ userId: String) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .other, with: userId) } } extension FollowViewReactor { - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } + enum EntranceType { + case follower + case following + } + + enum ViewType { + case my + case other } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift index e8ab544b..aac08adb 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift @@ -5,12 +5,13 @@ // Created by 오현식 on 12/3/24. // - import UIKit import SnapKit import Then +import Kingfisher + import ReactorKit import RxCocoa import RxSwift @@ -19,37 +20,48 @@ import RxSwift class ProfileViewController: BaseNavigationViewController, View { enum Text { - static let blockButtonTitle: String = "차단하기" + static let navigationTitle: String = "마이" + static let navigationBlockButtonTitle: String = "차단" - static let blockDialogTitle: String = "정말 차단하시겠어요?" - static let blockDialogMessage: String = "해당 사용자의 카드와 답카드를 볼 수 없어요" + static let blockDialogTitle: String = "차단하시겠어요?" + static let blockDialogMessage: String = "님의 모든 카드를 볼 수 없어요." + + static let unBlockUserDialogTitle: String = "차단 해제하시겠어요?" + static let unBlockUserDialogMessage: String = "님을 팔로우하고, 카드를 볼 수 있어요." + + static let deleteFollowingDialogTitle: String = "님을 팔로워에서 삭제하시겠어요?" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" - static let cancelActionTitle: String = "취소" static let confirmActionTitle: String = "확인" + static let cancelActionTitle: String = "취소" + static let blockActionTitle: String = "차단하기" + static let unBlockActionTitle: String = "차단 해제" + static let deleteActionTitle: String = "삭제하기" } - - // MARK: Navi Views - - private let titleView = UILabel().then { - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold + enum Section: Int, CaseIterable { + case user + case card } - private let subTitleView = UILabel().then { - $0.textColor = .som.gray400 - $0.typography = .som.body3WithRegular + enum Item: Hashable { + case user(ProfileInfo) + case card(type: EntranceCardType, feed: [ProfileCardInfo], comment: [ProfileCardInfo]?) } + + // MARK: Navi Views + private let rightBlockButton = SOMButton().then { - $0.title = Text.blockButtonTitle - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.gray500 + $0.title = Text.navigationBlockButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.black } private let rightSettingButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.menu))) - $0.foregroundColor = .som.black + $0.image = .init(.icon(.v2(.outlined(.settings)))) + $0.foregroundColor = .som.v2.black } @@ -57,12 +69,18 @@ class ProfileViewController: BaseNavigationViewController, View { private let flowLayout = UICollectionViewFlowLayout().then { $0.scrollDirection = .vertical + $0.sectionHeadersPinToVisibleBounds = true + $0.sectionInset = .zero } private lazy var collectionView = UICollectionView( frame: .zero, collectionViewLayout: self.flowLayout ).then { - $0.backgroundColor = .som.white + $0.backgroundColor = .som.v2.white + + $0.contentInset = .zero + + $0.contentInsetAdjustmentBehavior = .never $0.alwaysBounceVertical = true $0.showsVerticalScrollIndicator = false @@ -70,30 +88,188 @@ class ProfileViewController: BaseNavigationViewController, View { $0.refreshControl = SOMRefreshControl() - $0.register(MyProfileViewCell.self, forCellWithReuseIdentifier: MyProfileViewCell.cellIdentifier) - $0.register(OtherProfileViewCell.self, forCellWithReuseIdentifier: OtherProfileViewCell.cellIdentifier) + $0.register(ProfileUserViewCell.self, forCellWithReuseIdentifier: ProfileUserViewCell.cellIdentifier) + $0.register(ProfileCardsViewCell.self, forCellWithReuseIdentifier: ProfileCardsViewCell.cellIdentifier) $0.register( - ProfileViewFooter.self, - forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, - withReuseIdentifier: "footer" + ProfileViewHeader.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: ProfileViewHeader.cellIdentifier ) - $0.dataSource = self $0.delegate = self } // MARK: Variables - private(set) var profile = Profile() - private(set) var writtenCards = [WrittenCard]() - private(set) var isBlocked = false + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot - override var navigationBarHeight: CGFloat { - 68 - } + private lazy var dataSource: DataSource = { + + let dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + + guard let self = self, let reactor = self.reactor else { return nil } + + switch item { + case let .user(profileInfo): + + let cell: ProfileUserViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileUserViewCell.cellIdentifier, + for: indexPath + ) as! ProfileUserViewCell + + cell.setModel(profileInfo) + + cell.cardContainerDidTap + .subscribe(with: self) { object, _ in + switch reactor.currentState.cardType { + case .feed: + guard reactor.currentState.feedCardInfos.isEmpty == false else { return } + object.collectionView.setContentOffset( + CGPoint(x: 0, y: 84 + 76 + 48 + 16), + animated: true + ) + case .comment: + guard reactor.currentState.commentCardInfos.isEmpty == false else { return } + object.collectionView.setContentOffset( + CGPoint(x: 0, y: 84 + 76 + 48 + 16), + animated: true + ) + } + } + .disposed(by: cell.disposeBag) + + cell.followerContainerDidTap + .subscribe(with: self) { object, _ in + let followViewController = FollowViewController() + followViewController.reactor = reactor.reactorForFollow( + type: .follower, + view: profileInfo.isAlreadyFollowing == nil ? .my : .other, + nickname: profileInfo.nickname, + with: profileInfo.userId + ) + let base = profileInfo.isAlreadyFollowing == nil ? object.parent : object + base?.navigationPush(followViewController, animated: true) + } + .disposed(by: cell.disposeBag) + + cell.followingContainerDidTap + .subscribe(with: self) { object, _ in + let followViewController = FollowViewController() + followViewController.reactor = reactor.reactorForFollow( + type: .following, + view: profileInfo.isAlreadyFollowing == nil ? .my : .other, + nickname: profileInfo.nickname, + with: profileInfo.userId + ) + let base = profileInfo.isAlreadyFollowing == nil ? object.parent : object + base?.navigationPush(followViewController, animated: true) + } + .disposed(by: cell.disposeBag) + + cell.updateProfileButton.rx.throttleTap + .subscribe(with: self) { object, _ in + KingfisherManager.shared.download( + strUrl: profileInfo.profileImageUrl, + with: profileInfo.profileImgName + ) { [weak object] profileImage in + let updateProfileViewController = UpdateProfileViewController() + updateProfileViewController.reactor = reactor.reactorForUpdate( + nickname: profileInfo.nickname, + image: profileImage, + imageName: profileInfo.profileImgName + ) + object?.parent?.navigationPush(updateProfileViewController, animated: true) + } + } + .disposed(by: cell.disposeBag) + + cell.followButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if profileInfo.isAlreadyFollowing == true { + object.showdeleteFollowingDialog(with: profileInfo.nickname) + } else { + reactor.action.onNext(.follow) + } + } + .disposed(by: cell.disposeBag) + + cell.unBlockButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.showUnblockDialog(nickname: profileInfo.nickname, with: profileInfo.userId) + } + .disposed(by: cell.disposeBag) + + return cell + case let .card(type, feeds, comments): + + let cell: ProfileCardsViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileCardsViewCell.cellIdentifier, + for: indexPath + ) as! ProfileCardsViewCell + + if case .other = reactor.entranceType { + cell.setModels(type: .feed, feed: feeds, comment: nil) + } else { + cell.setModels(type: type, feed: feeds, comment: comments ?? []) + } + + cell.cardDidTap + .throttle(.seconds(3), scheduler: MainScheduler.instance) + .map(Reactor.Action.hasDetailCard) + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + + cell.moreFindCards + .subscribe(with: self) { object, moreInfo in + reactor.action.onNext(.moreFind(moreInfo.type, moreInfo.lastId)) + } + .disposed(by: cell.disposeBag) + + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath -> UICollectionReusableView? in + + guard let self = self else { return nil } + + if kind == UICollectionView.elementKindSectionHeader { + + let header: ProfileViewHeader = collectionView.dequeueReusableSupplementaryView( + ofKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: ProfileViewHeader.cellIdentifier, + for: indexPath + ) as! ProfileViewHeader + + header.tabBarItemDidTap + .subscribe(with: self) { object, selectedIndex in + object.reactor?.action.onNext(.updateCardType(selectedIndex == 0 ? .feed : .comment)) + } + .disposed(by: header.disposeBag) + + return header + } else { + return nil + } + } + + return dataSource + }() + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + floating button height + padding + return self.reactor?.entranceType == .other ? 34 + 8 : 88 + } // MARK: Override func @@ -101,37 +277,15 @@ class ProfileViewController: BaseNavigationViewController, View { override func setupNaviBar() { super.setupNaviBar() - let isMine = self.reactor?.entranceType == .my || self.reactor?.entranceType == .myWithNavi + guard let reactor = self.reactor else { return } - let titleContainer = UIView() - titleContainer.addSubview(self.titleView) - self.titleView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.centerX.equalToSuperview() - } + let isMine = reactor.entranceType == .my - titleContainer.addSubview(self.subTitleView) - self.subTitleView.snp.makeConstraints { - $0.top.equalTo(self.titleView.snp.bottom).offset(2) - $0.bottom.leading.trailing.equalToSuperview() - } + self.navigationBar.hidesBackButton = isMine + self.navigationBar.title = isMine ? Text.navigationTitle : nil + self.navigationBar.titlePosition = .left - self.navigationBar.hidesBackButton = self.reactor?.entranceType == .my - self.navigationBar.titleView = titleContainer - if isMine { - self.navigationBar.setRightButtons([self.rightSettingButton]) - } else { - self.navigationBar.setRightButtons([self.rightBlockButton]) - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.navigationController?.delegate = self - - // 탭바 표시 - self.hidesBottomBarWhenPushed = self.reactor?.entranceType == .my ? false : true + self.navigationBar.setRightButtons(isMine ? [self.rightSettingButton] : [self.rightBlockButton]) } override func setupConstraints() { @@ -140,313 +294,425 @@ class ProfileViewController: BaseNavigationViewController, View { self.view.addSubview(self.collectionView) self.collectionView.snp.makeConstraints { $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.bottom.leading.trailing.equalToSuperview() + $0.bottom.horizontalEdges.equalToSuperview() } } - override func bind() { + override func viewDidLoad() { + super.viewDidLoad() - self.backButton.rx.tap - .subscribe(with: self) { object, _ in - if object.isBlocked { - object.navigationPop(to: MainHomeTabBarController.self, animated: true) - } else { - object.navigationPop() - } - } - .disposed(by: self.disposeBag) + // 제스처 뒤로가기를 위한 델리게이트 설정 + self.parent?.navigationController?.interactivePopGestureRecognizer?.delegate = self + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadProfileData(_:)), + name: .reloadProfileData, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadCardsData(_:)), + name: .reloadHomeData, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadCardsData(_:)), + name: .deletedFeedCardWithId, + object: nil + ) + } + + + // MARK: ReactorKit - Bind + + func bind(reactor: ProfileViewReactor) { - self.rightSettingButton.rx.tap + // 설정 화면 전환 + self.rightSettingButton.rx.throttleTap(.seconds(3)) .subscribe(with: self) { object, _ in let settingsViewController = SettingsViewController() - settingsViewController.reactor = object.reactor?.reactorForSettings() - object.navigationPush(settingsViewController, animated: true, bottomBarHidden: true) + settingsViewController.reactor = reactor.reactorForSettings() + object.parent?.navigationPush(settingsViewController, animated: true) } .disposed(by: self.disposeBag) - self.rightBlockButton.rx.tap + // 상대방 차단 요청 + self.rightBlockButton.rx.throttleTap .subscribe(with: self) { object, _ in let cancelAction = SOMDialogAction( title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, + title: Text.blockActionTitle, + style: .red, action: { - object.reactor?.action.onNext(.block) + SOMDialogViewController.dismiss { + + reactor.action.onNext(.block) + } } ) SOMDialogViewController.show( title: Text.blockDialogTitle, message: Text.blockDialogMessage, + textAlignment: .left, actions: [cancelAction, confirmAction] ) } .disposed(by: self.disposeBag) - } - - - // MARK: Bind - - func bind(reactor: ProfileViewReactor) { // Action - self.rx.viewWillAppear + self.rx.viewDidLoad .map { _ in Reactor.Action.landing } .bind(to: reactor.action) .disposed(by: self.disposeBag) + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) + .withLatestFrom(isRefreshing) .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) .map { _ in Reactor.Action.refresh } .bind(to: reactor.action) .disposed(by: self.disposeBag) // State - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .subscribe(with: self.collectionView) { collectionView, isLoading in - if isLoading { - collectionView.refreshControl?.beginRefreshingFromTop() - } else { - collectionView.refreshControl?.endRefreshing() - } + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.collectionView) { collectionView, _ in + collectionView.refreshControl?.endRefreshing() } .disposed(by: self.disposeBag) - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.profile) - .distinctUntilChanged() - .subscribe(with: self) { object, profile in - object.profile = profile - object.titleView.text = profile.nickname - object.subTitleView.text = "TOTAL \(profile.totalVisitorCnt) TODAY \(profile.currentDayVisitors)" - - UIView.performWithoutAnimation { - object.collectionView.performBatchUpdates { - object.collectionView.reloadSections(IndexSet(integer: 0)) - } - } + let displayStates = reactor.state.map { + ProfileViewReactor.DisplayStates( + cardType: $0.cardType, + profileInfo: $0.profileInfo, + feedCardInfos: $0.feedCardInfos, + commentCardInfos: $0.commentCardInfos + ) + } + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .withLatestFrom(displayStates.map(\.cardType)) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, cardType in + object.showPungedCardDialog(reactor, with: cardType) } .disposed(by: self.disposeBag) - reactor.state.map(\.writtenCards) - .distinctUntilChanged() - .subscribe(with: self) { object, writtenCards in - object.writtenCards = writtenCards - - UIView.performWithoutAnimation { - object.collectionView.performBatchUpdates { - object.collectionView.reloadSections(IndexSet(integer: 0)) - } + cardIsDeleted + .filter { $0.isDeleted == false } + .map(\.selectedId) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(selectedId) + let base = reactor.entranceType == .my ? object.parent : object + base?.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .profile + ) + ) } } .disposed(by: self.disposeBag) - reactor.state.map(\.isBlocked) - .distinctUntilChanged() - .subscribe(with: self) { object, isBlocked in - UIApplication.topViewController?.dismiss(animated: true) { - object.isBlocked = isBlocked - object.rightBlockButton.isHidden = isBlocked - - UIView.performWithoutAnimation { - object.collectionView.performBatchUpdates { - object.collectionView.reloadSections(IndexSet(integer: 0)) - } - } - } + displayStates + .distinctUntilChanged(reactor.canUpdateCells) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, displayStates in + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + guard let profileInfo = displayStates.profileInfo else { return } + + if reactor.entranceType == .other, let isBlocked = profileInfo.isBlocked { + object.rightBlockButton.isHidden = isBlocked } - .disposed(by: self.disposeBag) + + let profileItem = Item.user(profileInfo) + snapshot.appendItems([profileItem], toSection: .user) + + let cardItem = Item.card( + type: displayStates.cardType, + feed: displayStates.feedCardInfos, + comment: displayStates.commentCardInfos.isEmpty ? nil : displayStates.commentCardInfos + ) + snapshot.appendItems([cardItem], toSection: .card) + + object.dataSource.apply(snapshot, animatingDifferences: false) + } + .disposed(by: self.disposeBag) - reactor.state.map(\.isFollow) - .distinctUntilChanged() - .filter { $0 != nil } - .subscribe(onNext: { _ in - reactor.action.onNext(.landing) - }) - .disposed(by: self.disposeBag) + Observable.merge( + reactor.pulse(\.$isBlocked).filterNil().filter { $0 }, + reactor.pulse(\.$isFollowing).filterNil().filter { $0 } + ) + .subscribe(with: self) { object, _ in + reactor.action.onNext(.updateProfile) + } + .disposed(by: self.disposeBag) + } + + + // MARK: Private func + + private func updateCollectionViewHeight(numberOfItems: Int) -> CGFloat { + + let lineSpacing: CGFloat = 1.0 + // TODO: 임시, 행 개수 3 고정 + let itemsPerRow: CGFloat = 3.0 + let numberOfRows = ceil(CGFloat(numberOfItems) / itemsPerRow) + + let itemHeight = (self.collectionView.bounds.width - 2) / 3 + let newHeight = (numberOfRows * itemHeight) + ((numberOfRows - 1) * lineSpacing) + + let cellHeight: CGFloat = 84 + 76 + 48 + 16 + let headerHeight: CGFloat = 56 + let defaultHeight: CGFloat = collectionView.bounds.height - cellHeight - headerHeight + + return max(newHeight, defaultHeight) + } + + + // MARK: Objc + + @objc + private func reloadProfileData(_ notification: Notification) { + + self.reactor?.action.onNext(.updateProfile) + } + + @objc + private func reloadCardsData(_ notification: Notification) { + + self.reactor?.action.onNext(.updateCards) } } -extension ProfileViewController: UICollectionViewDataSource { + +// MARK: show Dialog + +private extension ProfileViewController { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 1 + func showdeleteFollowingDialog(with nickname: String) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let deleteAction = SOMDialogAction( + title: Text.deleteActionTitle, + style: .red, + action: { + SOMDialogViewController.dismiss { + self.reactor?.action.onNext(.follow) + } + } + ) + + SOMDialogViewController.show( + title: nickname + Text.deleteFollowingDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [cancelAction, deleteAction] + ) } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let entranceType = self.reactor?.entranceType ?? .my - switch entranceType { - case .my, .myWithNavi: - let myCell: MyProfileViewCell = collectionView.dequeueReusableCell( - withReuseIdentifier: MyProfileViewCell.cellIdentifier, - for: indexPath - ) as! MyProfileViewCell - myCell.setModel(self.profile) - - myCell.updateProfileButton.rx.tap - .subscribe(with: self) { object, _ in - let updateProfileViewController = UpdateProfileViewController() - updateProfileViewController.reactor = object.reactor?.reactorForUpdate() - object.navigationPush(updateProfileViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: myCell.disposeBag) - - myCell.followingButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .following) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: myCell.disposeBag) - - myCell.followerButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .follower) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: myCell.disposeBag) - - return myCell - case .other: - let otherCell: OtherProfileViewCell = collectionView.dequeueReusableCell( - withReuseIdentifier: OtherProfileViewCell.cellIdentifier, - for: indexPath - ) as! OtherProfileViewCell - otherCell.setModel(self.profile, isBlocked: self.isBlocked) - - otherCell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(with: self) { object, _ in - if object.isBlocked { - object.reactor?.action.onNext(.block) - } else { - object.reactor?.action.onNext(.follow) - } - } - .disposed(by: otherCell.disposeBag) - - otherCell.followingButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .following) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) + func showUnblockDialog(nickname: String, with userId: String) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + + let unBlockAction = SOMDialogAction( + title: Text.unBlockActionTitle, + style: .red, + action: { + SOMDialogViewController.dismiss { + self.reactor?.action.onNext(.block) } - .disposed(by: otherCell.disposeBag) - - otherCell.followerButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .follower) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) + } + ) + + SOMDialogViewController.show( + title: Text.unBlockUserDialogTitle, + message: nickname + Text.unBlockUserDialogMessage, + textAlignment: .left, + actions: [cancelAction, unBlockAction] + ) + } + + func showPungedCardDialog(_ reactor: ProfileViewReactor, with cardType: EntranceCardType) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + reactor.action.onNext(.updateCards) } - .disposed(by: otherCell.disposeBag) - - return otherCell + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension ProfileViewController: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForHeaderInSection section: Int + ) -> CGSize { + + if self.reactor?.entranceType == .other { + return .zero + } + + guard let section = self.dataSource.sectionIdentifier(for: section) else { return .zero } + + if case .card = section { + return CGSize(width: collectionView.bounds.width, height: 56) + } else { + return .zero } } func collectionView( _ collectionView: UICollectionView, - viewForSupplementaryElementOfKind kind: String, - at indexPath: IndexPath - ) -> UICollectionReusableView { + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForFooterInSection section: Int + ) -> CGSize { - if kind == UICollectionView.elementKindSectionFooter { - - let footer: ProfileViewFooter = collectionView - .dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: "footer", - for: indexPath - ) as! ProfileViewFooter - footer.setModel(self.writtenCards, isBlocked: self.isBlocked) + return .zero + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let section = self.dataSource.sectionIdentifier(for: indexPath.section), + let reactor = self.reactor + else { return .zero } + + let width: CGFloat = collectionView.bounds.width + switch section { + case .user: + /// top container height + bottom container height + button height + padding + let height: CGFloat = 84 + 76 + 48 + 16 + return CGSize(width: width, height: height) + case .card: - footer.didTap - .subscribe(with: self) { object, selectedId in - let detailViewController = DetailViewController() - detailViewController.reactor = object.reactor?.ractorForDetail(selectedId) - object.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: footer.disposeBag) + let feeds = reactor.currentState.feedCardInfos + let comments = reactor.currentState.commentCardInfos - footer.moreDisplay - .subscribe(with: self) { object, lastId in - object.reactor?.action.onNext(.moreFind(lastId)) + var height: CGFloat { + let cellHeight: CGFloat = 84 + 76 + 48 + 16 + let headerHeight: CGFloat = 56 + let defaultHeight: CGFloat = collectionView.bounds.height - cellHeight - headerHeight + switch reactor.currentState.cardType { + case .feed: + + let newHeight = self.updateCollectionViewHeight(numberOfItems: feeds.count) + if reactor.entranceType == .my { + collectionView.contentInset.bottom = defaultHeight <= newHeight ? 88 + 16 : 0 + } + + return feeds.isEmpty ? defaultHeight : newHeight + case .comment: + + let newHeight = self.updateCollectionViewHeight(numberOfItems: comments.count) + collectionView.contentInset.bottom = defaultHeight <= newHeight ? 88 + 16 : 0 + + return comments.isEmpty ? defaultHeight : newHeight } - .disposed(by: footer.disposeBag) + } - return footer - } else { - return .init(frame: .zero) + return CGSize(width: width, height: height) } } -} - -extension ProfileViewController: UICollectionViewDelegateFlowLayout { + + + // MARK: UIScrollViewDelegate func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // currentOffset <= 0 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.collectionView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.collectionView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) - refreshControl.beginRefreshingFromTop() + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset } + + self.currentOffset = offset } - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width - // 내 프로필 일 때, 프로필 - 컨텐츠 + 컨텐츠 - 버튼 + 버튼 - 하단 - // 상대 프로필 일 때, 프로필 - 버튼 + 버튼 - 하단 - let isMine = self.reactor?.entranceType == .my || self.reactor?.entranceType == .myWithNavi - let spacing: CGFloat = isMine ? (16 + 18 + 30) : (22 + 22) - // 내 프로필 일 떄, 프로필 + 간격 + 컨텐츠 + 버튼 - // 상대 프로필 일 때, 프로필 + 간격 + 버튼 - let height: CGFloat = isMine ? (128 + spacing + 42 + 48) : (128 + spacing + 48) - return CGSize(width: width, height: height) - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - referenceSizeForFooterInSection section: Int - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width - // 내 프로필 일 때, 프로필 - 컨텐츠 + 컨텐츠 - 버튼 + 버튼 - 하단 - // 상대 프로필 일 때, 프로필 - 버튼 + 버튼 - 하단 - let isMine = self.reactor?.entranceType == .my || self.reactor?.entranceType == .myWithNavi - let spacing: CGFloat = isMine ? (16 + 18 + 30) : (22 + 22) - // 내 프로필 일 떄, 프로필 + 간격 + 컨텐츠 + 버튼 - // 상대 프로필 일 때, 프로필 + 간격 + 버튼 - let cellHeight: CGFloat = isMine ? (128 + spacing + 42 + 48) : (128 + spacing + 48) - let height: CGFloat = collectionView.bounds.height - cellHeight - return CGSize(width: width, height: height) + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.collectionView.refreshControl?.beginRefreshing() + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift index 423a0894..a906bde6 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift @@ -9,255 +9,330 @@ import ReactorKit import Alamofire - class ProfileViewReactor: Reactor { - enum EntranceType { - case my - case myWithNavi - case other + struct DisplayStates { + let cardType: EntranceCardType + let profileInfo: ProfileInfo? + let feedCardInfos: [ProfileCardInfo] + let commentCardInfos: [ProfileCardInfo] } enum Action: Equatable { case landing case refresh - case moreFind(String) + case moreFind(EntranceCardType, String) case block case follow + case updateProfile + case updateCards + case updateCardType(EntranceCardType) + case hasDetailCard(String) + case cleanup } enum Mutation { - case profile(Profile) - case writtenCards([WrittenCard]) - case moreWrittenCards([WrittenCard]) - case updateIsBlocked(Bool) - case updateIsFollow(Bool) - case updateIsLoading(Bool) - case updateIsProcessing(Bool) + case profile(ProfileInfo) + case feedCardInfos([ProfileCardInfo]) + case moreFeedCardInfos([ProfileCardInfo]) + case commentCardInfos([ProfileCardInfo]) + case moreCommentCardInfos([ProfileCardInfo]) + case updateCardType(EntranceCardType) + case cardIsDeleted((String, Bool)?) + case updateIsBlocked(Bool?) + case updateIsFollowing(Bool?) + case updateIsRefreshing(Bool) } struct State { - var profile: Profile - var writtenCards: [WrittenCard] - var isBlocked: Bool - var isFollow: Bool? - var isLoading: Bool - var isProcessing: Bool + fileprivate(set) var profileInfo: ProfileInfo? + fileprivate(set) var feedCardInfos: [ProfileCardInfo] + fileprivate(set) var commentCardInfos: [ProfileCardInfo] + fileprivate(set) var cardType: EntranceCardType + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? + @Pulse fileprivate(set) var isBlocked: Bool? + @Pulse fileprivate(set) var isFollowing: Bool? + fileprivate(set) var isRefreshing: Bool } var initialState: State = .init( - profile: .init(), - writtenCards: [], - isBlocked: false, - isFollow: nil, - isLoading: false, - isProcessing: false + profileInfo: nil, + feedCardInfos: [], + commentCardInfos: [], + cardType: .feed, + cardIsDeleted: nil, + isBlocked: nil, + isFollowing: nil, + isRefreshing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let fetchUserInfoUseCase: FetchUserInfoUseCase + private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase + private let blockUserUseCase: BlockUserUseCase + private let updateFollowUseCase: UpdateFollowUseCase + let entranceType: EntranceType - private let memberId: String? + private let userId: String? - init(provider: ManagerProviderType, type entranceType: EntranceType, memberId: String?) { - self.provider = provider + init(dependencies: AppDIContainerable, type entranceType: EntranceType, with userId: String? = nil) { + self.dependencies = dependencies + self.fetchUserInfoUseCase = dependencies.rootContainer.resolve(FetchUserInfoUseCase.self) + self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) + self.blockUserUseCase = dependencies.rootContainer.resolve(BlockUserUseCase.self) + self.updateFollowUseCase = dependencies.rootContainer.resolve(UpdateFollowUseCase.self) + self.entranceType = entranceType - self.memberId = memberId + self.userId = userId } func mutate(action: Action) -> Observable { switch action { case .landing: - let combined = Observable.concat([ - self.profile(), - self.writtenCards() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - - return .concat([ - .just(.updateIsProcessing(true)), - combined, - .just(.updateIsProcessing(false)) - ]) + return self.fetchUserInfoUseCase.userInfo(userId: self.userId) + .withUnretained(self) + .flatMapLatest { object, profileInfo -> Observable in + + if object.entranceType == .other { + + return .concat([ + .just(.profile(profileInfo)), + object.fetchCardUseCase.writtenFeedCards(userId: profileInfo.userId, lastId: nil) + .map(Mutation.feedCardInfos) + ]) + } else { + // 사용자 닉네임 업데이트 + UserDefaults.standard.nickname = profileInfo.nickname + + return .concat([ + .just(.profile(profileInfo)), + object.fetchCardUseCase.writtenFeedCards(userId: profileInfo.userId, lastId: nil) + .map(Mutation.feedCardInfos), + object.fetchCardUseCase.writtenCommentCards(lastId: nil) + .map(Mutation.commentCardInfos) + ]) + } + } case .refresh: - let combined = Observable.concat([ - self.profile(), - self.writtenCards() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) + return self.fetchUserInfoUseCase.userInfo(userId: self.userId) + .withUnretained(self) + .flatMapLatest { object, profileInfo -> Observable in + + if object.entranceType == .my { + // 사용자 닉네임 업데이트 + UserDefaults.standard.nickname = profileInfo.nickname + } + + if object.currentState.cardType == .feed { + + return .concat([ + .just(.updateIsRefreshing(true)), + .just(.profile(profileInfo)) + .catch(self.catchClosure), + object.fetchCardUseCase.writtenFeedCards(userId: profileInfo.userId, lastId: nil) + .map(Mutation.feedCardInfos) + .catch(self.catchClosure), + .just(.updateIsRefreshing(false)) + ]) + } else { + + return .concat([ + .just(.updateIsRefreshing(true)), + .just(.profile(profileInfo)) + .catch(self.catchClosure), + object.fetchCardUseCase.writtenCommentCards(lastId: nil) + .map(Mutation.commentCardInfos) + .catch(self.catchClosure), + .just(.updateIsRefreshing(false)) + ]) + } + } + .catch(self.catchClosure) + case let .moreFind(cardType, lastId): - return .concat([ - .just(.updateIsLoading(true)), - combined, - .just(.updateIsLoading(false)) - ]) + guard let userId = self.currentState.profileInfo?.userId else { return .empty() } - case let .moreFind(lastId): + if cardType == .feed { + + return self.fetchCardUseCase.writtenFeedCards(userId: userId, lastId: lastId) + .map(Mutation.moreFeedCardInfos) + } else { + + return self.fetchCardUseCase.writtenCommentCards(lastId: lastId) + .map(Mutation.moreCommentCardInfos) + } + case .updateProfile: + + return self.fetchUserInfoUseCase.userInfo(userId: self.userId) + .map { profileInfo -> ProfileInfo in + // 사용자 닉네임 업데이트 + UserDefaults.standard.nickname = profileInfo.nickname + + return profileInfo + } + .map(Mutation.profile) + case .updateCards: + + guard let userId = self.currentState.profileInfo?.userId else { return .empty() } + + switch self.entranceType { + case .my: + + return .concat([ + self.fetchCardUseCase.writtenFeedCards(userId: userId, lastId: nil) + .map(Mutation.feedCardInfos), + self.fetchCardUseCase.writtenCommentCards(lastId: nil) + .map(Mutation.commentCardInfos) + ]) + case .other: + + return self.fetchCardUseCase.writtenFeedCards(userId: userId, lastId: nil) + .map(Mutation.feedCardInfos) + } + case let .updateCardType(cardType): + + return .just(.updateCardType(cardType)) + case let .hasDetailCard(selectedId): return .concat([ - .just(.updateIsProcessing(true)), - self.moreWrittenCards(lastId: lastId) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) ]) + case .cleanup: + return .just(.cardIsDeleted(nil)) case .block: - if self.currentState.isBlocked { - let request: ReportRequest = .cancelBlockMember(id: self.memberId ?? "") - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsBlocked(false)) - } - } else { - let request: ReportRequest = .blockMember(id: self.memberId ?? "") - return self.provider.networkManager.request(Status.self, request: request) - .map { .updateIsBlocked($0.httpCode == 201) } - } + guard let userId = self.currentState.profileInfo?.userId, + let isBlocked = self.currentState.profileInfo?.isBlocked + else { return .empty() } + return .concat([ + .just(.updateIsBlocked(nil)), + self.blockUserUseCase.updateBlocked(userId: userId, isBlocked: !isBlocked) + .map(Mutation.updateIsBlocked) + ]) case .follow: - if self.currentState.isFollow == true { - let request: ProfileRequest = .cancelFollow(memberId: self.memberId ?? "") - - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsFollow(false)) - } - } else { - let request: ProfileRequest = .requestFollow(memberId: self.memberId ?? "") - - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsFollow(true)) - } - } + guard let userId = self.currentState.profileInfo?.userId, + let isFollowing = self.currentState.profileInfo?.isAlreadyFollowing + else { return .empty() } + + return .concat([ + .just(.updateIsFollowing(nil)), + self.updateFollowUseCase.updateFollowing(userId: userId, isFollow: !isFollowing) + .map(Mutation.updateIsFollowing) + ]) } } func reduce(state: State, mutation: Mutation) -> State { - var state: State = state + var newState: State = state switch mutation { - case let .profile(profile): - state.profile = profile - case let .writtenCards(writtenCards): - state.writtenCards = writtenCards - case let .moreWrittenCards(writtenCards): - state.writtenCards += writtenCards + case let .profile(profileInfo): + newState.profileInfo = profileInfo + case let .feedCardInfos(feedCardInfos): + newState.feedCardInfos = feedCardInfos + case let .moreFeedCardInfos(feedCardInfos): + newState.feedCardInfos += feedCardInfos + case let .commentCardInfos(commentCardInfos): + newState.commentCardInfos = commentCardInfos + case let .moreCommentCardInfos(commentCardInfos): + newState.commentCardInfos += commentCardInfos + case let .updateCardType(cardType): + newState.cardType = cardType + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted case let .updateIsBlocked(isBlocked): - state.isBlocked = isBlocked - case let .updateIsFollow(isFollow): - state.isFollow = isFollow - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing + newState.isBlocked = isBlocked + case let .updateIsFollowing(isFollowing): + newState.isFollowing = isFollowing + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing } - return state + return newState } } extension ProfileViewReactor { - private func profile() -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .my, .myWithNavi: - return .myProfile - case .other: - return .otherProfile(memberId: self.memberId ?? "") - } - } - - return self.provider.networkManager.request(ProfileResponse.self, request: request) - .flatMapLatest { response -> Observable in - if (200...204).contains(response.status.httpCode) { - return .just(.profile(response.profile)) - } else { - return .just(.profile(.init())) - } - } - .catch(self.catchClosure) + var catchClosure: ((Error) throws -> Observable ) { + return { _ in .just(.updateIsRefreshing(false)) } } - private func writtenCards() -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .my, .myWithNavi: - return .myCards(lastId: nil) - case .other: - return .otherCards(memberId: self.memberId ?? "", lastId: nil) - } - } - - return self.provider.networkManager.request(WrittenCardResponse.self, request: request) - .flatMapLatest { response -> Observable in - if (200...204).contains(response.status.httpCode) { - return .just(.writtenCards(response.embedded.writtenCards)) - } else { - return .just(.writtenCards(.init())) - } - } - .catch(self.catchClosure) + func canUpdateCells( + prev prevDisplayState: DisplayStates, + curr currDisplayState: DisplayStates + ) -> Bool { + return prevDisplayState.cardType == currDisplayState.cardType && + prevDisplayState.profileInfo == currDisplayState.profileInfo && + prevDisplayState.feedCardInfos == currDisplayState.feedCardInfos && + prevDisplayState.commentCardInfos == currDisplayState.commentCardInfos } - private func moreWrittenCards(lastId: String) -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .my, .myWithNavi: - return .myCards(lastId: lastId) - case .other: - return .otherCards(memberId: self.memberId ?? "", lastId: lastId) - } - } - - return self.provider.networkManager.request(WrittenCardResponse.self, request: request) - .flatMapLatest { response -> Observable in - if (200...204).contains(response.status.httpCode) { - return .just(.moreWrittenCards(response.embedded.writtenCards)) - } else { - return .just(.moreWrittenCards(.init())) - } - } - .catch(self.catchClosure) - } - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted } } extension ProfileViewReactor { func reactorForSettings() -> SettingsViewReactor { - SettingsViewReactor(provider: self.provider) + SettingsViewReactor(dependencies: self.dependencies) } - func reactorForUpdate() -> UpdateProfileViewReactor { - UpdateProfileViewReactor(provider: self.provider, self.currentState.profile) + func reactorForUpdate( + nickname: String, + image profileImage: UIImage?, + imageName profileImageName: String? + ) -> UpdateProfileViewReactor { + UpdateProfileViewReactor( + dependencies: self.dependencies, + nickname: nickname, + image: profileImage, + imageName: profileImageName + ) } - func ractorForDetail(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, selectedId) + func reactorForDetail(_ selectedId: String) -> DetailViewReactor { + DetailViewReactor( + dependencies: self.dependencies, + with: selectedId + ) } - func reactorForFollow(type entranceType: FollowViewReactor.EntranceType) -> FollowViewReactor { + func reactorForFollow( + type entranceType: FollowViewReactor.EntranceType, + view viewType: FollowViewReactor.ViewType, + nickname: String, + with userId: String + ) -> FollowViewReactor { FollowViewReactor( - provider: self.provider, + dependencies: self.dependencies, type: entranceType, - view: self.entranceType == .my ? .my : .other, - memberId: self.memberId + view: viewType, + nickname: nickname, + with: userId ) } } + +extension ProfileViewReactor { + + enum EntranceType { + case my + case other + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift index 9903adf3..a3d192ac 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift @@ -21,11 +21,16 @@ class AnnouncementViewController: BaseNavigationViewController, View { static let navigationTitle: String = "공지사항" } + + // MARK: Views + private lazy var tableView = UITableView().then { $0.backgroundColor = .clear $0.indicatorStyle = .black $0.separatorStyle = .none + $0.rowHeight = UITableView.automaticDimension + $0.register(AnnouncementViewCell.self, forCellReuseIdentifier: "cell") $0.refreshControl = SOMRefreshControl() @@ -34,7 +39,18 @@ class AnnouncementViewController: BaseNavigationViewController, View { $0.delegate = self } - private(set) var announcements = [Announcement]() + + // MARK: Variables + + private(set) var announcements = [NoticeInfo]() + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } // MARK: Override func @@ -58,7 +74,10 @@ class AnnouncementViewController: BaseNavigationViewController, View { // MARK: Variables + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false // MARK: ReactorKit - bind @@ -71,30 +90,33 @@ class AnnouncementViewController: BaseNavigationViewController, View { .bind(to: reactor.action) .disposed(by: self.disposeBag) + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) + .withLatestFrom(isRefreshing) .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) .map { _ in Reactor.Action.refresh } .bind(to: reactor.action) .disposed(by: self.disposeBag) // State - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() } .disposed(by: self.disposeBag) reactor.state.map(\.announcements) .distinctUntilChanged() + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, announcements in object.announcements = announcements - object.tableView.reloadData() + + UIView.performWithoutAnimation { + object.tableView.reloadData() + } } .disposed(by: self.disposeBag) } @@ -114,6 +136,7 @@ extension AnnouncementViewController: UITableViewDataSource { withIdentifier: "cell", for: indexPath ) as! AnnouncementViewCell + cell.selectionStyle = .none cell.setModel(announcement) @@ -123,38 +146,59 @@ extension AnnouncementViewController: UITableViewDataSource { extension AnnouncementViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + + if let strUrl = self.announcements[indexPath.row].url, let url = URL(string: strUrl) { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + } + + + // MARK: UIScrollViewDelegate + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.tableView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) - refreshControl.beginRefreshingFromTop() + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset } + + self.currentOffset = offset } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { - let link = self.announcements[indexPath.row].link - if let url = URL(string: link) { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() } } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 73 - } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift index 98e76f5f..362cfd8f 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift @@ -7,75 +7,70 @@ import ReactorKit - class AnnouncementViewReactor: Reactor { enum Action: Equatable { case landing case refresh + case more(lastId: String) } enum Mutation { - case announcements([Announcement]) - case updateIsLoading(Bool) + case announcements([NoticeInfo]) + case more([NoticeInfo]) + case updateIsRefreshing(Bool) } struct State { - var announcements: [Announcement] - var isLoading: Bool + var announcements: [NoticeInfo] + var isRefreshing: Bool } var initialState: State = .init( announcements: [], - isLoading: false + isRefreshing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let fetchNoticeUseCase: FetchNoticeUseCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.fetchNoticeUseCase = dependencies.rootContainer.resolve(FetchNoticeUseCase.self) } func mutate(action: Action) -> Observable { switch action { case .landing: - let request: SettingsRequest = .announcement - return self.provider.networkManager.request(AnnouncementResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.announcements(response.embedded.announcements)) - } + return self.fetchNoticeUseCase.notices(lastId: nil, size: 10, requestType: .settings) + .map(Mutation.announcements) case .refresh: - let request: SettingsRequest = .announcement return .concat([ - .just(.updateIsLoading(true)), - self.provider.networkManager.request(AnnouncementResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.announcements(response.embedded.announcements)) - } - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsLoading(false)) + .just(.updateIsRefreshing(true)), + self.fetchNoticeUseCase.notices(lastId: nil, size: 10, requestType: .settings) + .map(Mutation.announcements) + .catchAndReturn(.updateIsRefreshing(false)), + .just(.updateIsRefreshing(false)) ]) + case let .more(lastId): + + return self.fetchNoticeUseCase.notices(lastId: lastId, size: 10, requestType: .settings) + .map(Mutation.more) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { case let .announcements(announcements): - state.announcements = announcements - case let .updateIsLoading(isLoading): - state.isLoading = isLoading + newState.announcements = announcements + case let .more(announcements): + newState.announcements += announcements + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing } - return state - } -} - -extension AnnouncementViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in .just(.updateIsLoading(false)) } + return newState } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift index b7c9a62f..5cdae115 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift @@ -13,36 +13,30 @@ import Then class AnnouncementViewCell: UITableViewCell { - enum Text { - static let announcementText: String = "공지사항" - static let maintenanceText: String = "점검안내" - } - private let announcementTypeLabel = UILabel().then { - $0.textColor = .som.p300 - $0.typography = .som.body2WithBold - } + // MARK: Views private let titleLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body2WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.subtitle3.withAlignment(.left) + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority } private let dateLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body3WithRegular + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption1 } - private let arrowImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.tintColor = .som.gray400 - } + + // MARK: Initialize override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) self.backgroundColor = .clear - self.contentView.clipsToBounds = true + self.selectionStyle = .none self.setupConstraints() } @@ -51,31 +45,23 @@ class AnnouncementViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { - self.addSubview(self.announcementTypeLabel) - self.announcementTypeLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.leading.equalToSuperview().offset(20) - } - self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.leading.equalTo(self.announcementTypeLabel.snp.trailing).offset(6) + $0.top.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } self.addSubview(self.dateLabel) self.dateLabel.snp.makeConstraints { - $0.bottom.equalToSuperview().offset(-10) - $0.leading.equalToSuperview().offset(20) - } - - self.addSubview(self.arrowImageView) - self.arrowImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(14) - $0.trailing.equalToSuperview().offset(-24) - $0.size.equalTo(24) + $0.top.equalTo(self.titleLabel.snp.bottom).offset(8) + $0.leading.equalToSuperview().offset(16) + $0.bottom.equalToSuperview().offset(-16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } let bottomSeperator = UIView().then { @@ -83,15 +69,19 @@ class AnnouncementViewCell: UITableViewCell { } self.addSubview(bottomSeperator) bottomSeperator.snp.makeConstraints { - $0.bottom.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) $0.height.equalTo(1) } } - func setModel(_ model: Announcement) { + + // MARK: Public func + + func setModel(_ model: NoticeInfo) { - self.announcementTypeLabel.text = model.noticeType == .announcement ? Text.announcementText : Text.maintenanceText - self.titleLabel.text = model.title - self.dateLabel.text = Date(from: model.noticeDate)?.announcementFormatted + self.titleLabel.text = model.message + self.dateLabel.text = model.createdAt.announcementFormatted } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift new file mode 100644 index 00000000..7d0aa893 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift @@ -0,0 +1,329 @@ +// +// BlockUsersViewController.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +import ReactorKit +import RxCocoa +import RxSwift + +class BlockUsersViewController: BaseNavigationViewController, View { + + enum Text { + static let navigationTitle: String = "차단 사용자 관리" + + static let unBlockUserDialogTitle: String = "차단 해제하시겠어요?" + static let unBlockUserDialogMessage: String = "님을 팔로우하고, 카드를 볼 수 있어요." + + static let cancelActionButtonTitle: String = "취소" + static let unBlockActionButtonTitle: String = "차단 해제" + } + + enum Section: Int, CaseIterable { + case main + case empty + } + + enum Item: Hashable { + case main(BlockUserInfo) + case empty + } + + + // MARK: Views + + private lazy var tableView = UITableView().then { + $0.backgroundColor = .clear + $0.indicatorStyle = .black + $0.separatorStyle = .none + + $0.contentInsetAdjustmentBehavior = .never + + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(BlockUserViewCell.self, forCellReuseIdentifier: BlockUserViewCell.cellIdentifier) + $0.register(BlockUserPlaceholderViewCell.self, forCellReuseIdentifier: BlockUserPlaceholderViewCell.cellIdentifier) + + $0.refreshControl = SOMRefreshControl() + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(tableView: self.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + + guard let self = self, let reactor = self.reactor else { return nil } + + switch item { + case let .main(blockUserInfo): + + let cell: BlockUserViewCell = tableView.dequeueReusableCell( + withIdentifier: BlockUserViewCell.cellIdentifier, + for: indexPath + ) as! BlockUserViewCell + + cell.setModel(blockUserInfo) + + cell.profileBackgroundButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(blockUserInfo.userId) + object.navigationPush(profileViewController, animated: true) + } + .disposed(by: cell.disposeBag) + + cell.unBlockUserButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.showUnblockDialog( + nickname: blockUserInfo.nickname, + with: blockUserInfo.userId + ) + } + .disposed(by: cell.disposeBag) + + return cell + case .empty: + + let placeholder: BlockUserPlaceholderViewCell = tableView.dequeueReusableCell( + withIdentifier: BlockUserPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! BlockUserPlaceholderViewCell + + return placeholder + } + } + + private(set) var blockUserInfos: [BlockUserInfo] = [] + + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } + + + // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.tableView) + self.tableView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + + // MARK: ReactorKit - bind + + func bind(reactor: BlockUsersViewReactor) { + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.tableView.refreshControl?.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + let blockUserInfos = reactor.state.map(\.blockUserInfos).distinctUntilChanged() + blockUserInfos + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, blockUserInfos in + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + if blockUserInfos.isEmpty { + snapshot.appendItems([.empty], toSection: .empty) + } else { + let new = blockUserInfos.map { Item.main($0) } + snapshot.appendItems(new, toSection: .main) + } + + object.dataSource.apply(snapshot, animatingDifferences: false) + } + .disposed(by: self.disposeBag) + + Observable.combineLatest( + blockUserInfos, + reactor.state.map(\.isCanceledWithId) + .distinctUntilChanged(reactor.canUpdateCanceledWithId) + .filterNil() + .filter { $0.isCanceled } + .map(\.userId) + ) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { combined in + + NotificationCenter.default.post(name: .reloadHomeData, object: nil, userInfo: nil) + + let (blockuserInfos, userId) = combined + + reactor.action.onNext( + .updateBlockUserInfos(blockuserInfos.filter { $0.userId != userId }) + ) + }) + .disposed(by: self.disposeBag) + } +} + + +// MARK: Show dialog + +extension BlockUsersViewController { + + func showUnblockDialog(nickname: String, with userId: String) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionButtonTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + + let unBlockAction = SOMDialogAction( + title: Text.unBlockActionButtonTitle, + style: .red, + action: { + SOMDialogViewController.dismiss { + self.reactor?.action.onNext(.cancelBlock(userId: userId)) + } + } + ) + + SOMDialogViewController.show( + title: Text.unBlockUserDialogTitle, + message: nickname + Text.unBlockUserDialogMessage, + textAlignment: .left, + actions: [cancelAction, unBlockAction] + ) + } +} + + +// MARK: UITableViewDelegate + +extension BlockUsersViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return 0 } + + switch item { + case .empty: + return tableView.bounds.height + default: + return 60 + } + } + + func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + + guard let reactor = self.reactor else { return } + + let lastItemIndexPath = tableView.numberOfRows(inSection: Section.main.rawValue) - 1 + if self.blockUserInfos.isEmpty == false, + indexPath.section == Section.main.rawValue, + indexPath.row == lastItemIndexPath, + let lastId = self.blockUserInfos.last?.userId { + + reactor.action.onNext(.moreFind(lastId: lastId)) + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.tableView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) + + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift new file mode 100644 index 00000000..a6382774 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift @@ -0,0 +1,110 @@ +// +// BlockUsersViewReactor.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import ReactorKit + +class BlockUsersViewReactor: Reactor { + + enum Action: Equatable { + case landing + case refresh + case moreFind(lastId: String) + case cancelBlock(userId: String) + case updateBlockUserInfos([BlockUserInfo]) + } + + enum Mutation { + case blockUserInfos([BlockUserInfo]) + case more([BlockUserInfo]) + case updateIsCanceled((isCanceled: Bool, userId: String)?) + case updateIsRefreshing(Bool) + } + + struct State { + fileprivate(set) var blockUserInfos: [BlockUserInfo] + fileprivate(set) var isCanceledWithId: (isCanceled: Bool, userId: String)? + fileprivate(set) var isRefreshing: Bool + } + + var initialState: State = .init( + blockUserInfos: [], + isCanceledWithId: nil, + isRefreshing: false + ) + + private let dependencies: AppDIContainerable + private let fetchBlockUserUseCase: FetchBlockUserUseCase + private let blockUserUseCase: BlockUserUseCase + + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.fetchBlockUserUseCase = dependencies.rootContainer.resolve(FetchBlockUserUseCase.self) + self.blockUserUseCase = dependencies.rootContainer.resolve(BlockUserUseCase.self) + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return self.fetchBlockUserUseCase.blockUsers(lastId: nil) + .map(Mutation.blockUserInfos) + case .refresh: + + return .concat([ + .just(.updateIsRefreshing(true)), + self.fetchBlockUserUseCase.blockUsers(lastId: nil) + .map(Mutation.blockUserInfos), + .just(.updateIsRefreshing(false)) + ]) + case let .moreFind(lastId): + + return self.fetchBlockUserUseCase.blockUsers(lastId: lastId) + .map(Mutation.more) + case let .cancelBlock(userId): + + return self.blockUserUseCase.updateBlocked(userId: userId, isBlocked: false) + .map { ($0, userId) } + .map(Mutation.updateIsCanceled) + case let .updateBlockUserInfos(blockUserInfos): + + return .just(.blockUserInfos(blockUserInfos)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState: State = state + switch mutation { + case let .blockUserInfos(blockUserInfos): + newState.blockUserInfos = blockUserInfos + case let .more(blockUserInfos): + newState.blockUserInfos += blockUserInfos + case let .updateIsCanceled(isCanceledWithId): + newState.isCanceledWithId = isCanceledWithId + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + } + return newState + } +} + +extension BlockUsersViewReactor { + + func canUpdateCanceledWithId( + prev prevIsCanceledWithId: (isCanceled: Bool, userId: String)?, + curr currIsCanceledWithId: (isCanceled: Bool, userId: String)? + ) -> Bool { + return prevIsCanceledWithId?.isCanceled == currIsCanceledWithId?.isCanceled && + prevIsCanceledWithId?.userId == currIsCanceledWithId?.userId + } +} + +extension BlockUsersViewReactor { + + func reactorForProfile(_ userId: String) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .other, with: userId) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserPlaceholderViewCell.swift new file mode 100644 index 00000000..6bdbc539 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserPlaceholderViewCell.swift @@ -0,0 +1,72 @@ +// +// BlockUserPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +class BlockUserPlaceholderViewCell: UITableViewCell { + + enum Text { + static let placeholderText: String = "차단한 사용자가 없어요" + } + + static let cellIdentifier = String(reflecting: BlockUserPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.hide)))) + $0.tintColor = .som.v2.gray200 + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.placeholderText + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + /// (screen height - safe layout guide top - navi height - header height) * 0.5 - (icon height + spacing + label height) + let offset = (UIScreen.main.bounds.height - 48 - 56) * 0.5 - (24 + 8 + 21) * 0.5 + $0.top.equalTo(self.safeAreaLayoutGuide.snp.top).offset(offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserViewCell.swift new file mode 100644 index 00000000..ea36a962 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserViewCell.swift @@ -0,0 +1,142 @@ +// +// BlockUserViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +import RxSwift + +class BlockUserViewCell: UITableViewCell { + + enum Text { + static let unBlockUserButtonTitle: String = "차단 해제" + } + + static let cellIdentifier = String(reflecting: BlockUserViewCell.self) + + + // MARK: Views + + let profileBackgroundButton = UIButton() + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_small))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 36 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle2 + } + + let unBlockUserButton = SOMButton().then { + $0.title = Text.unBlockUserButtonTitle + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.white + + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + + // MARK: Variables + + private(set) var model: BlockUserInfo = .defaultValue + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.profileImageView.image = nil + self.nicknameLabel.text = nil + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(36) + } + + self.contentView.addSubview(self.nicknameLabel) + self.nicknameLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.profileImageView.snp.trailing).offset(10) + } + + self.contentView.addSubview(self.profileBackgroundButton) + self.profileBackgroundButton.snp.makeConstraints { + $0.top.equalTo(self.profileImageView.snp.top) + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.leading.equalTo(self.profileImageView.snp.leading) + $0.trailing.equalTo(self.nicknameLabel.snp.trailing) + } + + self.contentView.addSubview(self.unBlockUserButton) + self.unBlockUserButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.nicknameLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-16) + $0.width.equalTo(83) + $0.height.equalTo(32) + } + } + + + // MARK: Public func + + func setModel(_ model: BlockUserInfo) { + + self.model = model + + if let profileImageUrl = model.profileImageUrl { + self.profileImageView.setImage(strUrl: profileImageUrl) + } else { + self.profileImageView.image = .init(.image(.v2(.profile_small))) + } + + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.subtitle2 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/Cells/CommentHistoryViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/Cells/CommentHistoryViewCell.swift deleted file mode 100644 index 0209280b..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/Cells/CommentHistoryViewCell.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// CommentHistoryViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import UIKit - -import SnapKit -import Then - - -class CommentHistoryViewCell: UICollectionViewCell { - - static let cellIdentifier = String(reflecting: CommentHistoryViewCell.self) - - private let backgroundImageView = UIImageView() - - private let backgroundDimView = UIView().then { - $0.backgroundColor = .som.black.withAlphaComponent(0.2) - } - - private let contentLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .init( - fontContainer: BuiltInFont(size: 12, weight: .bold), - lineHeight: 21, - letterSpacing: -0.04 - ) - $0.numberOfLines = 0 - $0.lineBreakMode = .byTruncatingTail - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.contentView.addSubview(self.backgroundImageView) - self.backgroundImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.backgroundDimView) - self.backgroundDimView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.contentLabel) - self.contentLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().offset(10) - $0.bottom.trailing.equalToSuperview().offset(-10) - } - } - - func setModel(_ strUrl: String, content: String) { - self.backgroundImageView.setImage(strUrl: strUrl) - self.contentLabel.text = content - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewController.swift deleted file mode 100644 index 2457cb06..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewController.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// CommentHistroyViewController.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class CommentHistroyViewController: BaseNavigationViewController, View { - - enum Text { - static let navigationTitle: String = "답카드 히스토리" - } - - private let flowLayout = UICollectionViewFlowLayout().then { - $0.scrollDirection = .vertical - $0.minimumLineSpacing = .zero - $0.minimumInteritemSpacing = .zero - $0.sectionInset = .zero - } - private lazy var collectionView = UICollectionView( - frame: .zero, - collectionViewLayout: self.flowLayout - ).then { - $0.alwaysBounceVertical = true - - $0.contentInsetAdjustmentBehavior = .never - $0.contentInset = .zero - - $0.decelerationRate = .fast - - $0.showsHorizontalScrollIndicator = false - - $0.refreshControl = SOMRefreshControl() - - $0.register(CommentHistoryViewCell.self, forCellWithReuseIdentifier: CommentHistoryViewCell.cellIdentifier) - - $0.dataSource = self - $0.delegate = self - } - - private(set) var commentHistroies = [CommentHistory]() - - private var currentOffset: CGFloat = 0 - private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = true - - override var navigationBarHeight: CGFloat { - 46 - } - - - // MARK: Override func - - override func setupNaviBar() { - super.setupNaviBar() - - self.navigationBar.title = Text.navigationTitle - } - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.collectionView) - self.collectionView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(12) - $0.bottom.leading.trailing.equalToSuperview() - } - } - - - // MARK: ReactorKit - bind - - func bind(reactor: CommentHistroyViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.collectionView) { collectionView, isLoading in - if isLoading { - collectionView.refreshControl?.beginRefreshingFromTop() - } else { - collectionView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.commentHistories) - .distinctUntilChanged() - .subscribe(with: self) { object, commentHistories in - object.commentHistroies = commentHistories - object.collectionView.reloadData() - } - .disposed(by: self.disposeBag) - } -} - -extension CommentHistroyViewController: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return self.commentHistroies.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell: CommentHistoryViewCell = collectionView.dequeueReusableCell( - withReuseIdentifier: CommentHistoryViewCell.cellIdentifier, - for: indexPath - ) as! CommentHistoryViewCell - let commentHistory = self.commentHistroies[indexPath.row] - cell.setModel(commentHistory.backgroundImgURL.url, content: commentHistory.content) - - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let selectedId = self.commentHistroies[indexPath.row].id - - let detailViewController = DetailViewController() - detailViewController.reactor = self.reactor?.reactorForDetail(selectedId) - self.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } -} - -extension CommentHistroyViewController: UICollectionViewDelegateFlowLayout { - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width / 3 - return CGSize(width: width, height: width) - } - - func collectionView( - _ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath - ) { - guard self.commentHistroies.isEmpty == false else { return } - - let lastSectionIndex = collectionView.numberOfSections - 1 - let lastRowIndex = collectionView.numberOfItems(inSection: lastSectionIndex) - 1 - - if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.item == lastRowIndex { - let lastId = self.commentHistroies[indexPath.item].id - self.reactor?.action.onNext(.moreFind(lastId)) - } - } - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - self.currentOffset = offset - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.collectionView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewReactor.swift deleted file mode 100644 index 2bc26f3d..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewReactor.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// CommentHistroyViewReactor.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import ReactorKit - - -class CommentHistroyViewReactor: Reactor { - - enum Action: Equatable { - case landing - case refresh - case moreFind(String) - } - - enum Mutation { - case commentHistories([CommentHistory]) - case more([CommentHistory]) - case updateIsProcessing(Bool) - case updateIsLoading(Bool) - } - - struct State { - var commentHistories: [CommentHistory] - var isProcessing: Bool - var isLoading: Bool - } - - var initialState: State = .init( - commentHistories: [], - isProcessing: false, - isLoading: false - ) - - let provider: ManagerProviderType - - init(provider: ManagerProviderType) { - self.provider = provider - } - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - let request: SettingsRequest = .commentHistory(lastId: nil) - - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(CommentHistoryResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.commentHistories(response.embedded.commentHistories)) - } - .delaySubscription(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) - case .refresh: - - let request: SettingsRequest = .commentHistory(lastId: nil) - - return .concat([ - .just(.updateIsLoading(true)), - self.provider.networkManager.request(CommentHistoryResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.commentHistories(response.embedded.commentHistories)) - } - .delaySubscription(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsLoading(false)) - ]) - case let .moreFind(lastId): - let request: SettingsRequest = .commentHistory(lastId: lastId) - - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(CommentHistoryResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.more(response.embedded.commentHistories)) - } - .delaySubscription(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state = state - switch mutation { - case let .commentHistories(commentHistories): - state.commentHistories = commentHistories - case let .more(commentHistories): - state.commentHistories += commentHistories - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - } - return state - } -} - -extension CommentHistroyViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } - } -} - -extension CommentHistroyViewReactor { - - func reactorForDetail(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, selectedId) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift index 210baf36..86c1d77f 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift @@ -18,132 +18,170 @@ import RxSwift class EnterMemberTransferViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "계정 이관 코드 입력" - static let transferEnterMessage: String = "발급받은 코드를 입력해주세요" - static let transferEnterButtonTitle: String = "계정 이관하기" - } - - private let transferEnterMessageLabel = UILabel().then { - $0.text = Text.transferEnterMessage - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold - } - - private lazy var textFieldBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.borderColor = UIColor.som.p300.cgColor - $0.layer.borderWidth = 2 - $0.layer.cornerRadius = 12 + static let navigationTitle: String = "이전 계정 불러오기" - let gestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(self.touch) - ) - $0.addGestureRecognizer(gestureRecognizer) - } - private lazy var textField = UITextField().then { - let paragraphStyle = NSMutableParagraphStyle() - $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle - $0.defaultTextAttributes[.foregroundColor] = UIColor.som.black - $0.defaultTextAttributes[.font] = Typography.som.body1WithRegular.font - $0.tintColor = .som.p300 + static let transferEnterTitle: String = "기존 계정이 있으신가요?" + static let transferEnterGuideMessage: String = "이전에 사용하던 기기에서 로그인 코드를 입력해주세요." - $0.textAlignment = .center + static let placeholderText: String = "코드 입력" - $0.enablesReturnKeyAutomatically = true - $0.returnKeyType = .go + static let bottomGuideTitle: String = "이전 계정 불러오기란?" + static let bottomGuideMessage: String = "기존 휴대폰의 숨 앱[설정>다른 기기에서 로그인하기]에서 발급한 코드를 입력하면, 기존 계정을 현재 휴대폰에서 그대로 사용할 수 있어요" - $0.autocapitalizationType = .none - $0.autocorrectionType = .no - $0.spellCheckingType = .no + static let dialogTitle: String = "잘못된 코드예요" + static let dialogMessage: String = "코드를 확인한 뒤 다시 시도해주세요." - $0.setContentHuggingPriority(.defaultLow, for: .horizontal) - $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) + static let transferSuccessDialogTitle: String = "이전 계정 불러오기 완료" + static let transferSuccessDialogMessage: String = "이전 계정 불러오기가 성공적으로 완료되었습니다." - $0.delegate = self + static let confirmButtonTitle: String = "확인" } - private let transferMemberButton = SOMButton().then { - $0.title = Text.transferEnterButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.white + + // MARK: Views + + private let transferEnterTitleLabel = UILabel().then { + $0.text = Text.transferEnterTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2 + } + + private let transferEnterGuideMessageLabel = UILabel().then { + $0.text = Text.transferEnterGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.title2 + } + + private let transferTextField = EnterMemberTransferTextFieldView().then { + $0.placeholder = Text.placeholderText + } + + private let bottomContainer = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 16 + } + + private let confirmButton = SOMButton().then { + $0.title = Text.confirmButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true + $0.backgroundColor = .som.v2.black + $0.isEnabled = false } - // MARK: Override func + // MARK: Override variables - @objc - private func touch(sender: UIGestureRecognizer) { - if !self.textField.isFirstResponder { - self.textField.becomeFirstResponder() - } + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + confirm button height + padding + return 34 + 56 + 8 } + + // MARK: Override func + override func setupNaviBar() { super.setupNaviBar() self.navigationBar.title = Text.navigationTitle } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.textField.becomeFirstResponder() - } - override func setupConstraints() { super.setupConstraints() - let transferBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.cornerRadius = 22 - $0.clipsToBounds = true + self.view.addSubview(self.transferEnterTitleLabel) + self.transferEnterTitleLabel.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - self.view.addSubview(transferBackgroundView) - transferBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(149) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + + self.view.addSubview(self.transferEnterGuideMessageLabel) + self.transferEnterGuideMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.transferEnterTitleLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - transferBackgroundView.addSubview(self.transferEnterMessageLabel) - self.transferEnterMessageLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(44) - $0.centerX.equalToSuperview() + self.view.addSubview(self.transferTextField) + self.transferTextField.snp.makeConstraints { + $0.top.equalTo(self.transferEnterGuideMessageLabel.snp.bottom).offset(32) + $0.leading.trailing.equalToSuperview() } - transferBackgroundView.addSubview(self.textFieldBackgroundView) - self.textFieldBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.transferEnterMessageLabel.snp.bottom).offset(46) - $0.bottom.trailing.equalToSuperview().offset(-20) - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(64) + let guideTitleView = UIView() + let guideTitleImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.info)))) + $0.tintColor = .som.v2.black + } + let guideTitleLabel = UILabel().then { + $0.text = Text.bottomGuideTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.subtitle3 } - self.textFieldBackgroundView.addSubview(self.textField) - self.textField.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + guideTitleView.addSubview(guideTitleImageView) + guideTitleImageView.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(16) + } + guideTitleView.addSubview(guideTitleLabel) + guideTitleLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(guideTitleImageView.snp.trailing).offset(6) + $0.trailing.lessThanOrEqualToSuperview() + } + + let guideMessageLabel = UILabel().then { + $0.text = Text.bottomGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2.withAlignment(.left) + $0.numberOfLines = 0 + $0.lineBreakMode = .byCharWrapping + $0.lineBreakStrategy = .hangulWordPriority } - self.view.addSubview(self.transferMemberButton) - self.transferMemberButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + let guideView = UIView().then { + $0.backgroundColor = .som.v2.pLight1 + $0.layer.cornerRadius = 10 + } + + self.bottomContainer.addArrangedSubview(guideView) + guideView.addSubview(guideTitleView) + guideTitleView.snp.makeConstraints { + $0.top.equalToSuperview().offset(14) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + guideView.addSubview(guideMessageLabel) + guideMessageLabel.snp.makeConstraints { + $0.top.equalTo(guideTitleView.snp.bottom).offset(4) + $0.bottom.equalToSuperview().offset(-14) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + self.bottomContainer.addArrangedSubview(self.confirmButton) + self.confirmButton.snp.makeConstraints { + $0.height.equalTo(56) + } + + self.view.addSubview(self.bottomContainer) + self.bottomContainer.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } } override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { super.updatedKeyboard(withoutBottomSafeInset: height) - let margin: CGFloat = height + 24 - self.transferMemberButton.snp.updateConstraints { + let margin: CGFloat = height + 12 + self.bottomContainer.snp.updateConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-margin) } } @@ -154,45 +192,83 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { func bind(reactor: EnterMemberTransferViewReactor) { // Action - let transferCode = self.textField.rx.text.orEmpty.distinctUntilChanged() + let transferCode = self.transferTextField.rx.text.orEmpty.distinctUntilChanged().share() transferCode - .map { $0.isEmpty } - .subscribe(with: self) { object, isEmpty in - - object.transferMemberButton.isEnabled = isEmpty == false - object.transferMemberButton.foregroundColor = isEmpty ? .som.gray600 : .som.white - object.transferMemberButton.backgroundColor = isEmpty ? .som.gray300 : .som.p300 - } + .map { $0.isEmpty == false } + .bind(to: self.confirmButton.rx.isEnabled) .disposed(by: self.disposeBag) - self.transferMemberButton.rx.throttleTap(.seconds(1)) + self.confirmButton.rx.throttleTap .withLatestFrom(transferCode) .map(Reactor.Action.enterTransferCode) .bind(to: reactor.action) .disposed(by: self.disposeBag) // State - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + let isSuccess = reactor.state.map(\.isSuccess).filterNil().share() + isSuccess + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + guard let window = object.view.window else { return } + + GAHelper.shared.logEvent(event: GAEvent.TransferView.accountTransferSuccess) + + object.showSuccessDialog { + + let launchScreenViewController = LaunchScreenViewController() + launchScreenViewController.reactor = reactor.reactorForLaunchScreen() + launchScreenViewController.modalTransitionStyle = .crossDissolve + window.rootViewController = launchScreenViewController + } + } .disposed(by: self.disposeBag) - reactor.state.map(\.isSuccess) - .distinctUntilChanged() - .filter { $0 } + isSuccess + .filter { $0 == false } + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in - let launchScreenViewController = LaunchScreenViewController() - launchScreenViewController.reactor = reactor.reactorForLaunch() - object.view.window?.rootViewController = launchScreenViewController + object.showErrorDialog() } .disposed(by: self.disposeBag) } } -extension EnterMemberTransferViewController: UITextFieldDelegate { +extension EnterMemberTransferViewController { + + func showErrorDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmButtonTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss() + } + ) + + SOMDialogViewController.show( + title: Text.dialogTitle, + message: Text.dialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true + func showSuccessDialog(completion: @escaping (() -> Void)) { + + let confirmAction = SOMDialogAction( + title: Text.confirmButtonTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { completion() } + } + ) + + SOMDialogViewController.show( + title: Text.transferSuccessDialogTitle, + message: Text.transferSuccessDialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift index d399ce7e..bbdc0c01 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift @@ -22,27 +22,23 @@ class EnterMemberTransferViewReactor: Reactor { } enum Mutation { - case enterTransferCode(Bool) - case updateIsProcessing(Bool) + case enterTransferCode(Bool?) } struct State { - var isSuccess: Bool - var isProcessing: Bool - let entranceType: EntranceType + var isSuccess: Bool? } - var initialState: State + var initialState: State = State(isSuccess: nil) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let authUseCase: AuthUseCase + private let transferAccountUseCase: TransferAccountUseCase - init(provider: ManagerProviderType, entranceType: EntranceType) { - self.provider = provider - self.initialState = .init( - isSuccess: false, - isProcessing: false, - entranceType: entranceType - ) + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) + self.transferAccountUseCase = dependencies.rootContainer.resolve(TransferAccountUseCase.self) } func mutate(action: Action) -> Observable { @@ -50,35 +46,26 @@ class EnterMemberTransferViewReactor: Reactor { case let .enterTransferCode(transferCode): return .concat([ - .just(.updateIsProcessing(true)), - - self.provider.networkManager.request(RSAKeyResponse.self, request: AuthRequest.getPublicKey) - .map(\.publicKey) + .just(.enterTransferCode(nil)), + self.authUseCase.encryptedDeviceId() .withUnretained(self) - .flatMapLatest { object, publicKey -> Observable in + .flatMapLatest { object, encryptedDeviceId -> Observable in - if let secKey = object.provider.authManager.convertPEMToSecKey(pemString: publicKey), - let encryptedDeviceId = object.provider.authManager.encryptUUIDWithPublicKey(publicKey: secKey) { - - let request: SettingsRequest = .transferMember( - transferId: transferCode, + if let encryptedDeviceId = encryptedDeviceId { + return object.transferAccountUseCase.enter( + code: transferCode, encryptedDeviceId: encryptedDeviceId ) - - return self.provider.networkManager.request(Status.self, request: request) - .withUnretained(self) - .flatMapLatest { object, response -> Observable in - object.provider.authManager.initializeAuthInfo() + .flatMapLatest { isSuccess -> Observable in + if isSuccess { object.authUseCase.initializeAuthInfo() } - return .just(.enterTransferCode(response.httpCode != 400)) + return .just(.enterTransferCode(isSuccess)) } + .catchAndReturn(.enterTransferCode(false)) } else { return .just(.enterTransferCode(false)) } } - .catch(self.catchClosure), - - .just(.updateIsProcessing(false)) ]) } } @@ -88,8 +75,6 @@ class EnterMemberTransferViewReactor: Reactor { switch mutation { case let .enterTransferCode(isSuccess): state.isSuccess = isSuccess - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing } return state } @@ -97,19 +82,7 @@ class EnterMemberTransferViewReactor: Reactor { extension EnterMemberTransferViewReactor { - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.enterTransferCode(false)), - .just(.updateIsProcessing(false)) - ]) - } - } -} - -extension EnterMemberTransferViewReactor { - - func reactorForLaunch() -> LaunchScreenViewReactor { - LaunchScreenViewReactor(provider: self.provider) + func reactorForLaunchScreen() -> LaunchScreenViewReactor { + LaunchScreenViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift new file mode 100644 index 00000000..ff92bdab --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift @@ -0,0 +1,18 @@ +// +// EnterMemberTransferTextFieldView+Rx.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import RxCocoa +import RxSwift + +extension Reactive where Base: EnterMemberTransferTextFieldView { + + var text: ControlProperty { + self.base.textField.rx.text + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift new file mode 100644 index 00000000..1f78f97b --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift @@ -0,0 +1,165 @@ +// +// EnterMemberTransferTextFieldView.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import SnapKit +import Then + +class EnterMemberTransferTextFieldView: UIView { + + + // MARK: Views + + private lazy var textFieldBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 + + let gestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.touch) + ) + $0.addGestureRecognizer(gestureRecognizer) + } + + lazy var textField = UITextField().then { + let paragraphStyle = NSMutableParagraphStyle() + $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle + $0.defaultTextAttributes[.foregroundColor] = UIColor.som.v2.black + $0.defaultTextAttributes[.font] = Typography.som.v2.subtitle1.font + $0.tintColor = UIColor.som.v2.black + + $0.enablesReturnKeyAutomatically = true + $0.returnKeyType = .go + + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.spellCheckingType = .no + + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) + + $0.delegate = self + } + + private let guideMessageLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + + + // MARK: Variables + + var text: String? { + set { + self.textField.text = newValue + } + get { + return self.textField.text + } + } + + var placeholder: String? { + set { + if let string: String = newValue { + self.textField.attributedPlaceholder = NSAttributedString( + string: string, + attributes: [ + .foregroundColor: UIColor.som.v2.gray500, + .font: Typography.som.v2.subtitle1.font + ] + ) + } else { + self.textField.attributedPlaceholder = nil + } + } + + get { + return self.textField.attributedPlaceholder?.string + } + } + + var isTextEmpty: Bool { + return self.text?.isEmpty ?? false + } + + + // MARK: Initalization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override var isFirstResponder: Bool { + return self.textField.isFirstResponder + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + return self.textField.becomeFirstResponder() + } + + @discardableResult + override func resignFirstResponder() -> Bool { + return self.textField.resignFirstResponder() + } + + + // MARK: Objc func + + @objc + private func touch(sender: UIGestureRecognizer) { + if !self.textField.isFirstResponder { + self.textField.becomeFirstResponder() + } + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.textFieldBackgroundView) + self.textFieldBackgroundView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(54) + } + self.textFieldBackgroundView.addSubview(self.textField) + self.textField.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-20) + } + + self.addSubview(self.guideMessageLabel) + self.guideMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(8) + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + } +} + +extension EnterMemberTransferTextFieldView: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift index e742696d..c9cff304 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift @@ -19,61 +19,64 @@ import RxSwift class IssueMemberTransferViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "계정 이관 코드 발급" - static let topTransferIssueMessage: String = "계정을 다른 기기로 이관하기 위한" - static let bottomTransferIssueMessage: String = "코드를 발급합니다" - static let firstTransferIssueGuide: String = "발급된 코드는 24시간만 유효합니다" - static let topSecondTransferIssueGuide: String = "코드가 유출되면 타인이 해당 계정을" - static let bottomSecondTransferIssueGuide: String = "가져갈 수 있으니 주의하세요" + static let navigationTitle: String = "다른 기기에서 로그인하기" + static let transferIssueTitle: String = "다른 기기로 계정을 옮길 수 있는 코드를 드릴게요" + static let transferIssueGuideMessage: String = "코드는 1시간 동안 유효해요" static let transferReIssueButtonTitle: String = "코드 재발급하기" - - static let toastMessage: String = "코드가 복사되었습니다" } - private let topTransferIssueMessageLabel = UILabel().then { - $0.text = Text.topTransferIssueMessage - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold - } - private let bottomTransferIssueMessageLabel = UILabel().then { - $0.text = Text.bottomTransferIssueMessage - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold + + // MARK: Views + + private let transferIssueTitleLabel = UILabel().then { + $0.text = Text.transferIssueTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2.withAlignment(.left) + $0.numberOfLines = 0 + $0.lineBreakMode = .byWordWrapping + $0.lineBreakStrategy = .hangulWordPriority } private let transferCodeLabel = UILabel().then { - $0.textColor = .som.black - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.subtitle1 } - private let firstTransferIssueGuideLabel = UILabel().then { - $0.text = Text.firstTransferIssueGuide - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold + private let transferIssueGuideMessageLabel = UILabel().then { + $0.text = Text.transferIssueGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 } - private let topSecondTransferIssueGuideLabel = UILabel().then { - $0.text = Text.topSecondTransferIssueGuide - $0.textColor = .som.red - $0.typography = .som.body3WithBold - } - private let bottomSecondTransferIssueGuideLabel = UILabel().then { - $0.text = Text.bottomSecondTransferIssueGuide - $0.textColor = .som.red - $0.typography = .som.body3WithBold + private let transferExpireLabel = UILabel().then { + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.body2 } private let updateTransferCodeButton = SOMButton().then { $0.title = Text.transferReIssueButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.white + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 10 $0.clipsToBounds = true } + // MARK: Variables + + private var serialTimer: Disposable? + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + update transfer button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -85,72 +88,46 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { override func setupConstraints() { super.setupConstraints() - let transferBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.cornerRadius = 22 - $0.clipsToBounds = true - } - self.view.addSubview(transferBackgroundView) - transferBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(149) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - transferBackgroundView.addSubview(self.topTransferIssueMessageLabel) - self.topTransferIssueMessageLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(34) - $0.centerX.equalToSuperview() - } - transferBackgroundView.addSubview(self.bottomTransferIssueMessageLabel) - self.bottomTransferIssueMessageLabel.snp.makeConstraints { - $0.top.equalTo(self.topTransferIssueMessageLabel.snp.bottom) - $0.centerX.equalToSuperview() + self.view.addSubview(self.transferIssueTitleLabel) + self.transferIssueTitleLabel.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(8) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } let transferCodeBackgroundView = UIView().then { - $0.backgroundColor = .som.white - $0.layer.borderColor = UIColor.som.p300.cgColor - $0.layer.borderWidth = 2 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 $0.clipsToBounds = true } - transferBackgroundView.addSubview(transferCodeBackgroundView) + + self.view.addSubview(transferCodeBackgroundView) transferCodeBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.bottomTransferIssueMessageLabel.snp.bottom).offset(32) - $0.bottom.trailing.equalToSuperview().offset(-20) - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(64) + $0.top.equalTo(self.transferIssueTitleLabel.snp.bottom).offset(32) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(54) } transferCodeBackgroundView.addSubview(self.transferCodeLabel) self.transferCodeLabel.snp.makeConstraints { - $0.center.equalToSuperview() + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(24) } - self.view.addSubview(self.firstTransferIssueGuideLabel) - self.firstTransferIssueGuideLabel.snp.makeConstraints { - $0.top.equalTo(transferBackgroundView.snp.bottom).offset(28) - $0.centerX.equalToSuperview() - } - - self.view.addSubview(self.topSecondTransferIssueGuideLabel) - self.topSecondTransferIssueGuideLabel.snp.makeConstraints { - $0.top.equalTo(self.firstTransferIssueGuideLabel.snp.bottom).offset(8) - $0.centerX.equalToSuperview() - } - self.view.addSubview(self.bottomSecondTransferIssueGuideLabel) - self.bottomSecondTransferIssueGuideLabel.snp.makeConstraints { - $0.top.equalTo(self.topSecondTransferIssueGuideLabel.snp.bottom) - $0.centerX.equalToSuperview() + transferCodeBackgroundView.addSubview(self.transferExpireLabel) + self.transferExpireLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.transferCodeLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-20) } self.view.addSubview(self.updateTransferCodeButton) self.updateTransferCodeButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } @@ -165,7 +142,7 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.updateTransferCodeButton.rx.throttleTap(.seconds(1)) + self.updateTransferCodeButton.rx.throttleTap(.seconds(3)) .map { _ in Reactor.Action.updateTransferCode } .bind(to: reactor.action) .disposed(by: self.disposeBag) @@ -173,25 +150,69 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { // State reactor.state.map(\.isProcessing) .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.loadingIndicatorView) { loadingIndicatorView, isLoading in + if isLoading { + loadingIndicatorView.startAnimating() + } else { + loadingIndicatorView.stopAnimating() + } + } .disposed(by: self.disposeBag) - let transferCode = reactor.state.map(\.trnsferCode).distinctUntilChanged().share() - transferCode + let trnsferCodeInfo = reactor.state.map(\.trnsferCodeInfo).distinctUntilChanged().filterNil().share() + trnsferCodeInfo + .map(\.code) + .observe(on: MainScheduler.instance) .bind(to: self.transferCodeLabel.rx.text) .disposed(by: self.disposeBag) - self.transferCodeLabel.rx.tapGesture() - .when(.recognized) - .withLatestFrom(transferCode) - .filter { $0.isEmpty == false } - .subscribe(with: self) { object, transferCode in - - // 계정 이관 코드 클립보드에 저장 - UIPasteboard.general.string = transferCode - // Toast 표시, offset == 코드 재발급하기 버튼 height + margin - self.showToast(message: Text.toastMessage, offset: 12 + 48 + 8) + trnsferCodeInfo + .map(\.expiredAt) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, expiredAt in + object.subscribePungTime(expiredAt) } .disposed(by: self.disposeBag) + + // TODO: 임시, 현재 복사 허용 X + // self.transferCodeLabel.rx.tapGesture() + // .when(.recognized) + // .withLatestFrom(transferCode) + // .filter { $0.isEmpty == false } + // .subscribe(with: self) { object, transferCode in + // + // // 계정 이관 코드 클립보드에 저장 + // UIPasteboard.general.string = transferCode + // // Toast 표시, offset == 코드 재발급하기 버튼 height + margin + // self.showToast(message: Text.toastMessage, offset: 12 + 48 + 8) + // } + // .disposed(by: self.disposeBag) + } + + + // MARK: Private func + + private func subscribePungTime(_ pungTime: Date?) { + self.serialTimer?.dispose() + self.serialTimer = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) + .withUnretained(self) + .startWith((self, 0)) + .map { object, _ in + guard let pungTime = pungTime else { + object.serialTimer?.dispose() + return "00:00" + } + + let currentDate = Date() + let remainingTime = currentDate.infoReadableTimeTakenFromThisForPungToHoursAndMinutes(to: pungTime) + if remainingTime == "00 : 00" { + object.serialTimer?.dispose() + object.transferExpireLabel.text = remainingTime + } + + return remainingTime + } + .bind(to: self.transferExpireLabel.rx.text) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift index def2bec5..0621b7c8 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift @@ -16,50 +16,42 @@ class IssueMemberTransferViewReactor: Reactor { } enum Mutation { - case updateTransferCode(String) + case updateTransferInfo(TransferCodeInfo) case updateIsProcessing(Bool) } struct State { - var trnsferCode: String + var trnsferCodeInfo: TransferCodeInfo? var isProcessing: Bool } var initialState: State = .init( - trnsferCode: "", + trnsferCodeInfo: nil, isProcessing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let transferAccountUseCase: TransferAccountUseCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.transferAccountUseCase = dependencies.rootContainer.resolve(TransferAccountUseCase.self) } func mutate(action: Action) -> Observable { switch action { case .landing: - let request: SettingsRequest = .transferCode(isUpdate: false) - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(TransferCodeResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.updateTransferCode(response.transferCode)) - } - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) + return self.transferAccountUseCase.issue() + .map(Mutation.updateTransferInfo) case .updateTransferCode: - let request: SettingsRequest = .transferCode(isUpdate: true) return .concat([ .just(.updateIsProcessing(true)), - self.provider.networkManager.request(TransferCodeResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.updateTransferCode(response.transferCode)) - } - .catch(self.catchClosure), + self.transferAccountUseCase.update() + .map(Mutation.updateTransferInfo) + .catchAndReturn(.updateIsProcessing(false)) + .delay(.milliseconds(1000), scheduler: MainScheduler.instance), .just(.updateIsProcessing(false)) ]) } @@ -68,22 +60,11 @@ class IssueMemberTransferViewReactor: Reactor { func reduce(state: State, mutation: Mutation) -> State { var state = state switch mutation { - case let .updateTransferCode(transferCode): - state.trnsferCode = transferCode + case let .updateTransferInfo(trnsferCodeInfo): + state.trnsferCodeInfo = trnsferCodeInfo case let .updateIsProcessing(isProcessing): state.isProcessing = isProcessing } return state } } - -extension IssueMemberTransferViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)) - ]) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift index 89fccf68..c0b4d90b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift @@ -14,90 +14,70 @@ import ReactorKit import RxCocoa import RxSwift - class ResignViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "계정 탈퇴" - static let firstResignTitle: String = "탈퇴하기 전" - static let secondResignTitle: String = "몇가지 안내가 있어요" - static let dot: String = "•" - static let firstResignGuide: String = "지금까지 작성한 카드와 정보들이 모두 삭제될 예정이에요" - static let secondResignGuide: String = "재가입은 탈퇴 일자를 기준으로 일주일이후 가능해요" - static let secondResignGuideWithBanFrom: String = "계정이 정지 상태 이므로, 정지 해지 날짜인" - static let secondResignGuideWithBanTo: String = "까지 재가입이 불가능해요" - static let checkResignGuide: String = "위 안내사항을 모두 확인했습니다" + static let navigationTitle: String = "탈퇴하기" + + static let placeholderText: String = "계정을 삭제하려는 이유를 알려주세요" + + static let resignGuideMessage: String = "탈퇴하려는 이유가 무엇인가요?" static let resignButtonTitle: String = "탈퇴하기" - static let dialogTitle: String = "계정이 이전된 기기입니다" - static let dialogMessge: String = "탈퇴 요청은 계정 이관코드를 입력한 기기에서 진행해주세요" + static let successDialogTitle: String = "탈퇴 완료" + static let successDialogMessage: String = "탈퇴 처리가 성공적으로 완료되었습니다." static let confirmActionTitle: String = "확인" } - private let firstResignTitleLabel = UILabel().then { - $0.text = Text.firstResignTitle - $0.textColor = .som.gray800 - $0.typography = .som.head2WithBold - } - private let secondResignTitleLabel = UILabel().then { - $0.text = Text.secondResignTitle - $0.textColor = .som.gray800 - $0.typography = .som.head2WithBold - } + // MARK: Views - private let firstDotLabel = UILabel().then { - $0.text = Text.dot - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular - } - private let firstResignGuideLabel = UILabel().then { - $0.text = Text.firstResignGuide - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular.withAlignment(.left) - $0.lineBreakMode = .byWordWrapping - $0.lineBreakStrategy = .hangulWordPriority - $0.numberOfLines = 0 + private let scrollView = UIScrollView().then { + $0.isScrollEnabled = true + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false } - private let secondDotLabel = UILabel().then { - $0.text = Text.dot - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular - } - private let secondResignGuideLabel = UILabel().then { - $0.text = Text.secondResignGuide - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular.withAlignment(.left) - $0.lineBreakMode = .byWordWrapping - $0.lineBreakStrategy = .hangulWordPriority - $0.numberOfLines = 0 + private let resignGuideMessage = UILabel().then { + $0.text = Text.resignGuideMessage + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2.withAlignment(.left) } - private let checkBoxButton = UIButton() - private let checkBox = UIImageView().then { - $0.image = .init(.icon(.outlined(.checkBox))) - $0.tintColor = .som.gray500 + private let container = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 10 } - private let checkResignGuideLabel = UILabel().then { - $0.text = Text.checkResignGuide - $0.textColor = .som.gray600 - $0.typography = .som.body1WithRegular + + private let resignTextField = ResignTextFieldView().then { + $0.placeholder = Text.placeholderText + $0.isHidden = true } private let resignButton = SOMButton().then { $0.title = Text.resignButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.gray600 + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.gray300 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 10 $0.clipsToBounds = true $0.isEnabled = false } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + resign button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -109,80 +89,65 @@ class ResignViewController: BaseNavigationViewController, View { override func setupConstraints() { super.setupConstraints() - self.view.addSubview(self.firstResignTitleLabel) - self.firstResignTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(137) - $0.centerX.equalToSuperview() - } - self.view.addSubview(self.secondResignTitleLabel) - self.secondResignTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.firstResignTitleLabel.snp.bottom) - $0.centerX.equalToSuperview() + self.view.addSubview(self.resignButton) + self.resignButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } - let resignGuideBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.cornerRadius = 13 - $0.clipsToBounds = true - } - self.view.addSubview(resignGuideBackgroundView) - resignGuideBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.secondResignTitleLabel.snp.bottom).offset(24) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + self.view.addSubview(self.scrollView) + self.scrollView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.equalTo(self.resignButton.snp.top).offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } - resignGuideBackgroundView.addSubview(self.firstDotLabel) - self.firstDotLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(20) - $0.leading.equalToSuperview().offset(19) + let guideContainer = UIView() + guideContainer.addSubview(self.resignGuideMessage) + self.resignGuideMessage.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.bottom.equalToSuperview().offset(-16) + $0.horizontalEdges.equalToSuperview() } - resignGuideBackgroundView.addSubview(self.firstResignGuideLabel) - self.firstResignGuideLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(20) - $0.leading.equalTo(self.firstDotLabel.snp.trailing).offset(4) - $0.trailing.equalToSuperview().offset(-19) + self.scrollView.addSubview(guideContainer) + guideContainer.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() } - resignGuideBackgroundView.addSubview(self.secondDotLabel) - self.secondDotLabel.snp.makeConstraints { - $0.top.equalTo(self.firstResignGuideLabel.snp.bottom) - $0.leading.equalToSuperview().offset(19) - } - resignGuideBackgroundView.addSubview(self.secondResignGuideLabel) - self.secondResignGuideLabel.snp.makeConstraints { - $0.top.equalTo(self.firstResignGuideLabel.snp.bottom) - $0.bottom.equalToSuperview().offset(-20) - $0.leading.equalTo(self.secondDotLabel.snp.trailing).offset(4) - $0.trailing.equalToSuperview().offset(-19) + self.scrollView.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalTo(guideContainer.snp.bottom).offset(16) + $0.bottom.horizontalEdges.equalToSuperview() } - self.view.addSubview(self.checkBox) - self.checkBox.snp.makeConstraints { - $0.top.equalTo(resignGuideBackgroundView.snp.bottom).offset(28) - $0.leading.equalToSuperview().offset(24) - $0.size.equalTo(24) - } - self.view.addSubview(self.checkResignGuideLabel) - self.checkResignGuideLabel.snp.makeConstraints { - $0.top.equalTo(resignGuideBackgroundView.snp.bottom).offset(28) - $0.leading.equalTo(self.checkBox.snp.trailing).offset(11) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - } - self.view.addSubview(self.checkBoxButton) - self.checkBoxButton.snp.makeConstraints { - $0.top.equalTo(self.checkBox.snp.top) - $0.leading.equalTo(self.checkBox.snp.leading) - $0.trailing.equalTo(self.checkResignGuideLabel.snp.trailing) - $0.height.equalTo(24) + self.setupReportButtons() + } + + override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { + super.updatedKeyboard(withoutBottomSafeInset: height) + + let height = height == 0 ? 0 : height + 12 + + self.resignButton.snp.updateConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide).offset(-height) } - self.view.addSubview(self.resignButton) - self.resignButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + guard height > 0 else { return } + + let contentHeight = self.scrollView.contentSize.height + let boundsHeight = self.scrollView.bounds.height - height + let bottomOffset = CGPoint(x: 0, y: contentHeight - boundsHeight + 10) + // 키보드 및 스크롤 애니메이션 동기화를 위해 `UIView.animate` 사용 + UIView.animate(withDuration: 0.25) { [weak self] in + self?.view.layoutIfNeeded() + + // 스크롤이 필요할 때만 적용 + if bottomOffset.y > 0 { + self?.scrollView.setContentOffset(bottomOffset, animated: false) + } } } @@ -191,16 +156,17 @@ class ResignViewController: BaseNavigationViewController, View { func bind(reactor: ResignViewReactor) { - if let banEndAt = reactor.banEndAt { - self.secondResignGuideLabel.text = "\(Text.secondResignGuideWithBanFrom) \(banEndAt.banEndFormatted)\(Text.secondResignGuideWithBanTo)" - } - // Action - self.checkBoxButton.rx.throttleTap(.seconds(1)) - .withLatestFrom(reactor.state.map(\.isCheck)) - .map(Reactor.Action.check) + let reason = self.resignTextField.rx.text.orEmpty.distinctUntilChanged() + reason + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .map(Reactor.Action.updateOtherReason) .bind(to: reactor.action) .disposed(by: self.disposeBag) + reason + .map { $0.isEmpty == false } + .bind(to: self.resignButton.rx.isEnabled) + .disposed(by: self.disposeBag) self.resignButton.rx.throttleTap(.seconds(3)) .map { _ in Reactor.Action.resign } @@ -208,54 +174,107 @@ class ResignViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) // State - reactor.state.map(\.isCheck) + reactor.state.map(\.isSuccess) .distinctUntilChanged() - .subscribe(with: self) { object, isCheck in - object.checkBox.image = isCheck ? .init(.icon(.filled(.checkBox))) : .init(.icon(.outlined(.checkBox))) + .filterNil() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + guard let window = object.view.window else { return } - object.resignButton.isEnabled = isCheck - object.resignButton.foregroundColor = isCheck ? .som.white : .som.gray600 - object.resignButton.backgroundColor = isCheck ? .som.p300 : .som.gray300 + object.showSuccessReportedDialog { + + let onboardingViewController = OnboardingViewController() + onboardingViewController.reactor = reactor.reactorForOnboarding() + onboardingViewController.modalTransitionStyle = .crossDissolve + + let navigationViewController = UINavigationController(rootViewController: onboardingViewController) + window.rootViewController = navigationViewController + } } .disposed(by: self.disposeBag) - reactor.state.map(\.isSuccess) + reactor.state.map(\.reason) .distinctUntilChanged() - .filter { $0 } - .subscribe(with: self) { object, _ in - guard let window = object.view.window else { return } + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, reason in - let onboardingViewController = OnboardingViewController() - onboardingViewController.reactor = reactor.reactorForOnboarding() - onboardingViewController.modalTransitionStyle = .crossDissolve + let items = object.container.arrangedSubviews.compactMap { $0 as? SOMButton } - let navigationViewController = UINavigationController(rootViewController: onboardingViewController) - window.rootViewController = navigationViewController + items.forEach { item in + item.isSelected = reason.identifier == item.tag + } - object.navigationController?.viewControllers = [] + if reason != .other { + object.resignButton.isEnabled = true + } } .disposed(by: self.disposeBag) + } +} + + +// MARK: setup buttons and show dialog + +private extension ResignViewController { + + func setupReportButtons() { - reactor.state.map(\.isError) - .distinctUntilChanged() - .filter { $0 } - .subscribe(with: self) { object, _ in - let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) { - object.navigationPop() - } - } - ) + guard let reactor = self.reactor else { return } + + WithdrawType.allCases.forEach { withdrawType in + + let item = SOMButton().then { + + $0.title = withdrawType.message + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 - SOMDialogViewController.show( - title: Text.dialogTitle, - message: Text.dialogMessge, - actions: [confirmAction] - ) + $0.inset = .init(top: 0, left: 16, bottom: 0, right: 0) + $0.contentHorizontalAlignment = .left + + $0.tag = withdrawType.identifier } - .disposed(by: self.disposeBag) + item.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width - 16 * 2) + $0.height.equalTo(48) + } + item.rx.throttleTap + .subscribe(with: self) { object, _ in + + object.resignTextField.isHidden = withdrawType != .other + if withdrawType == .other { + object.resignTextField.becomeFirstResponder() + } else { + object.resignTextField.resignFirstResponder() + } + reactor.action.onNext(.updateReason(withdrawType)) + } + .disposed(by: self.disposeBag) + + self.container.addArrangedSubview(item) + } + + self.container.addArrangedSubview(self.resignTextField) + } + + func showSuccessReportedDialog(completion: @escaping (() -> Void)) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { completion() } + } + ) + + SOMDialogViewController.show( + title: Text.successDialogTitle, + message: Text.successDialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift index 3d6b05ef..36aa9725 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift @@ -13,107 +13,83 @@ import Alamofire class ResignViewReactor: Reactor { enum Action: Equatable { - case check(Bool) case resign + case updateReason(WithdrawType) + case updateOtherReason(String) } enum Mutation { - case updateCheck(Bool) + case updateReason(WithdrawType) + case updateOtherReason(String?) case updateIsSuccess(Bool) - case updateIsProcessing(Bool) - case updateError(Bool) } struct State { - var isCheck: Bool - var isSuccess: Bool - var isProcessing: Bool - var isError: Bool + fileprivate(set) var reason: WithdrawType? + fileprivate(set) var otherReason: String? + fileprivate(set) var isSuccess: Bool? } var initialState: State = .init( - isCheck: false, - isSuccess: false, - isProcessing: false, - isError: false + reason: nil, + otherReason: nil, + isSuccess: nil ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let authUseCase: AuthUseCase - let banEndAt: Date? - - init(provider: ManagerProviderType, banEndAt: Date? = nil) { - self.provider = provider - self.banEndAt = banEndAt + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) } func mutate(action: Action) -> Observable { switch action { - case let .check(isCheck): - return .just(.updateCheck(!isCheck)) case .resign: - let requset: SettingsRequest = .resign(token: self.provider.authManager.authInfo.token) - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(Status.self, request: requset) - .withUnretained(self) - .flatMapLatest { object, response -> Observable in - switch response.httpCode { - case 418: - return .just(.updateError(true)) - case 0: - object.provider.authManager.initializeAuthInfo() - SimpleDefaults.shared.initRemoteNotificationActivation() - - return .concat([ - object.provider.pushManager.switchNotification(on: false) - .flatMapLatest { error -> Observable in .empty() }, - .just(.updateIsSuccess(true)) - ]) - default: - return .empty() - } - } - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) + guard let reason = self.currentState.reason else { return .empty() } + + return self.authUseCase.withdraw( + reaseon: reason == .other ? + (self.currentState.otherReason ?? reason.message) : + reason.message + ) + .withUnretained(self) + .flatMapLatest { object, isSuccess -> Observable in + // 사용자 닉네임 제거 + UserDefaults.standard.nickname = nil + // 인증 토큰 제거 + object.authUseCase.initializeAuthInfo() + + return .just(.updateIsSuccess(isSuccess)) + } + case let .updateReason(reason): + + return .just(.updateReason(reason)) + case let .updateOtherReason(otherReason): + + return .just(.updateOtherReason(otherReason)) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { - case let .updateCheck(isCheck): - state.isCheck = isCheck + case let .updateReason(reason): + newState.reason = reason + case let .updateOtherReason(otherReason): + newState.otherReason = otherReason case let .updateIsSuccess(isSuccess): - state.isSuccess = isSuccess - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateError(isError): - state.isError = isError + newState.isSuccess = isSuccess } - return state + return newState } } extension ResignViewReactor { func reactorForOnboarding() -> OnboardingViewReactor { - OnboardingViewReactor(provider: self.provider) - } -} - -extension ResignViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { error in - - let nsError = error as NSError - return .concat([ - .just(.updateError(nsError.code == 418)), - .just(.updateIsProcessing(false)) - ]) - } + OnboardingViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView+Rx.swift new file mode 100644 index 00000000..5dd637dc --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView+Rx.swift @@ -0,0 +1,18 @@ +// +// ResignTextFieldView+Rx.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import RxCocoa +import RxSwift + +extension Reactive where Base: ResignTextFieldView { + + var text: ControlProperty { + self.base.textField.rx.text + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView.swift new file mode 100644 index 00000000..7ce0b769 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView.swift @@ -0,0 +1,175 @@ +// +// ResignTextFieldView.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +class ResignTextFieldView: UIView { + + enum Constants { + static let maxCharacters: Int = 250 + } + + + // MARK: Views + + private lazy var textFieldBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 + + let gestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.touch) + ) + $0.addGestureRecognizer(gestureRecognizer) + } + + lazy var textField = UITextField().then { + let paragraphStyle = NSMutableParagraphStyle() + $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle + $0.defaultTextAttributes[.foregroundColor] = UIColor.som.v2.black + $0.defaultTextAttributes[.font] = Typography.som.v2.subtitle1.font + $0.tintColor = UIColor.som.v2.black + + $0.enablesReturnKeyAutomatically = true + $0.returnKeyType = .go + + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.spellCheckingType = .no + + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) + + $0.delegate = self + } + + + // MARK: Variables + + var text: String? { + set { + self.textField.text = newValue + } + get { + return self.textField.text + } + } + + var placeholder: String? { + set { + if let string: String = newValue { + self.textField.attributedPlaceholder = NSAttributedString( + string: string, + attributes: [ + .foregroundColor: UIColor.som.v2.gray500, + .font: Typography.som.v2.subtitle1.font + ] + ) + } else { + self.textField.attributedPlaceholder = nil + } + } + + get { + return self.textField.attributedPlaceholder?.string + } + } + + var isTextEmpty: Bool { + return self.text?.isEmpty ?? false + } + + + // MARK: Initalization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override var isFirstResponder: Bool { + return self.textField.isFirstResponder + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + return self.textField.becomeFirstResponder() + } + + @discardableResult + override func resignFirstResponder() -> Bool { + return self.textField.resignFirstResponder() + } + + + // MARK: Objc func + + @objc + private func touch(sender: UIGestureRecognizer) { + if !self.textField.isFirstResponder { + self.textField.becomeFirstResponder() + } + } + + + // MARK: Private func + + private func setupConstraints() { + + self.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width - 16 * 2) + $0.height.equalTo(54) + } + + self.addSubview(self.textFieldBackgroundView) + self.textFieldBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.textFieldBackgroundView.addSubview(self.textField) + self.textField.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + } + } +} + + +// MARK: UITextFieldDelegate + +extension ResignTextFieldView: UITextFieldDelegate { + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + + return textField.shouldChangeCharactersIn( + in: range, + replacementString: string, + maxCharacters: Constants.maxCharacters + ) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift index e35afd21..930ef1cf 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift @@ -20,23 +20,32 @@ class SettingsViewController: BaseNavigationViewController, View { enum Text { static let navigationTitle: String = "설정" - static let appSettingTitle: String = "앱 설정" static let notificationSettingTitle: String = "알림 설정" - static let commentHistoryTitle: String = "작성된 답카드 히스토리" - static let userSettingTitle: String = "계정 설정" - static let issueUserTransferCodeTitle: String = "계정 이관 코드 발급" - static let enterUserTransferCodeTitle: String = "계정 이관 코드 입력" - static let acceptTermsTitle: String = "이용약관 및 개인정보 처리 방침" - static let resignTitle: String = "계정 탈퇴" + static let issueUserTransferCodeTitle: String = "다른 기기에서 로그인하기" + static let enterUserTransferCodeTitle: String = "이전 계정 불러오기" + + static let blockUsersTitle: String = "차단 사용자 관리" - static let serviceCenterTitle: String = "고객센터" static let announcementTitle: String = "공지사항" - static let inquiryTitle: String = "1:1 문의하기" - static let suggestionTitle: String = "제안하기" + static let inquiryTitle: String = "문의하기" + + static let acceptTermsTitle: String = "약관 및 개인정보 처리 동의" + + static let appVersionTitle: String = "최신버전 업데이트" + static let latestVersionTitle: String = "최신버전 : " + + static let resignTitle: String = "탈퇴하기" + + static let serviceCenterTitle: String = "고객센터" - static let userBlockedGuideMessage: String = "계정이 정지된 상태에요" - static let unBlockDate: String = "차단 해제 날짜 : " + static let postingBlockedTitle: String = "이용 제한 안내" + static let postingBlockedLeadingGuideMessage: String = """ + 신고된 카드 인해 카드 추가 기능이 제한된 계정입니다. + 필요한 경우 아래 ‘문의하기’를 이용해 주세요. + 제한 기간 : + """ + static let postingBlockedTrailingGuideMessage: String = "까지" static let adminMailStrUrl: String = "sooum1004@gmail.com" static let identificationInfo: String = "식별 정보: " @@ -55,8 +64,24 @@ class SettingsViewController: BaseNavigationViewController, View { 단, 본 양식에 비방, 욕설, 허위 사실 유포 등의 부적절한 내용이 포함될 경우, 관련 법령에 따라 민·형사상 법적 조치가 이루어질 수 있음을 알려드립니다. """ + + static let bottomToastEntryName: String = "bottomToastEntryName" + static let latestVersionToastTitle: String = "현재 최신버전을 사용중입니다" + + static let testFlightStrUrl: String = "itms-beta://testflight.apple.com/v1/app" + static let appStoreStrUrl: String = "itms-apps://itunes.apple.com/app/id" + + static let resignDialogTitle: String = "정말 탈퇴하시겠습니까?" + static let resignDialogMessage: String = "계정이 삭제되면 모든 정보가 영구적으로 삭제되며, 탈퇴일 기준 7일 후부터 재가입이 가능합니다." + static let resignDialogBannedLeadingMessage: String = "계정이 삭제되면 모든 정보가 영구 삭제되며, 재가입은 이용 제한 해지 날짜인 " + static let resignDialogBannedTrailingMessage: String = "부터 가능합니다." + + static let cancelActionButtonTitle: String = "취소" } + + // MARK: views + private let scrollView = UIScrollView().then { $0.isScrollEnabled = true $0.alwaysBounceVertical = true @@ -64,40 +89,42 @@ class SettingsViewController: BaseNavigationViewController, View { $0.showsHorizontalScrollIndicator = false } - private let appSettingHeader = SettingScrollViewHeader(title: Text.appSettingTitle) private let notificationSettingCellView = SettingTextCellView(buttonStyle: .toggle, title: Text.notificationSettingTitle) - private let commentHistoryCellView = SettingTextCellView(title: Text.commentHistoryTitle) - private let userSettingHeader = SettingScrollViewHeader(title: Text.userSettingTitle) private let issueUserTransferCodeCellView = SettingTextCellView(title: Text.issueUserTransferCodeTitle) private let enterUserTransferCodeCellView = SettingTextCellView(title: Text.enterUserTransferCodeTitle) - private let acceptTermsCellView = SettingTextCellView(title: Text.acceptTermsTitle) - private let resignCellView = SettingTextCellView(title: Text.resignTitle, titleColor: .som.red) - private let serviceCenterHeader = SettingScrollViewHeader(title: Text.serviceCenterTitle) + private let blockUsersCellView = SettingTextCellView(title: Text.blockUsersTitle) + private let announcementCellView = SettingTextCellView(title: Text.announcementTitle) private let inquiryCellView = SettingTextCellView(title: Text.inquiryTitle) - private let suggestionCellView = SettingTextCellView(title: Text.suggestionTitle) - private let userBlockedBackgroundView = UIView() - private let userBlockedLabel = UILabel().then { - let range = (Text.userBlockedGuideMessage as NSString).range(of: "정지") - let typography = Typography.som.body1WithBold - let attributedString = NSMutableAttributedString( - string: Text.userBlockedGuideMessage, - attributes: typography.attributes - ) - attributedString.addAttribute(.foregroundColor, value: UIColor.som.red, range: range) - $0.attributedText = attributedString + private let acceptTermsCellView = SettingTextCellView(title: Text.acceptTermsTitle) + + private let appVersionCellView = SettingVersionCellView(title: Text.appVersionTitle) + + private let resignCellView = SettingTextCellView(title: Text.resignTitle) + + private let postingBlockedBackgroundView = UIView().then { + $0.isHidden = true } - private let unBlockDateLabel = UILabel().then { - $0.text = Text.unBlockDate - $0.textColor = .som.gray500 - $0.typography = .som.body2WithRegular + private let postingBlockedTitleLabel = UILabel().then { + $0.text = Text.postingBlockedTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.caption1 } + private let postingBlockedMessageLabel = UILabel().then { + $0.text = Text.postingBlockedLeadingGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption3 + } + + + // MARK: Override variables - override var navigationBarHeight: CGFloat { - 46 + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 } @@ -111,72 +138,60 @@ class SettingsViewController: BaseNavigationViewController, View { override func setupConstraints() { - self.view.backgroundColor = .som.white + self.view.backgroundColor = .som.v2.gray100 self.view.addSubview(self.scrollView) self.scrollView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(12) + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) $0.bottom.leading.trailing.equalToSuperview() } let container = UIStackView(arrangedSubviews: [ - self.appSettingHeader, self.notificationSettingCellView, - self.commentHistoryCellView, - self.userSettingHeader, self.issueUserTransferCodeCellView, self.enterUserTransferCodeCellView, - self.acceptTermsCellView, - self.resignCellView, - self.serviceCenterHeader, + self.blockUsersCellView, self.announcementCellView, self.inquiryCellView, - self.suggestionCellView, - self.userBlockedBackgroundView + self.acceptTermsCellView, + self.appVersionCellView, + self.resignCellView, + self.postingBlockedBackgroundView ]).then { $0.axis = .vertical $0.alignment = .fill - $0.setCustomSpacing(18, after: self.commentHistoryCellView) - $0.setCustomSpacing(18, after: self.resignCellView) - $0.setCustomSpacing(20, after: self.suggestionCellView) + $0.setCustomSpacing(16, after: self.notificationSettingCellView) + $0.setCustomSpacing(16, after: self.enterUserTransferCodeCellView) + $0.setCustomSpacing(16, after: self.blockUsersCellView) + $0.setCustomSpacing(16, after: self.inquiryCellView) + $0.setCustomSpacing(16, after: self.acceptTermsCellView) + $0.setCustomSpacing(16, after: self.appVersionCellView) } self.scrollView.addSubview(container) container.snp.makeConstraints { $0.edges.equalToSuperview() } - let userBlockContainer = UIStackView(arrangedSubviews: [ - self.userBlockedLabel, - self.unBlockDateLabel - ]).then { - $0.axis = .vertical - $0.spacing = 5 - $0.alignment = .center - $0.distribution = .equalSpacing + self.postingBlockedBackgroundView.addSubview(self.postingBlockedTitleLabel) + self.postingBlockedTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - self.userBlockedBackgroundView.addSubview(userBlockContainer) - userBlockContainer.snp.makeConstraints { - $0.top.equalToSuperview().offset(22) - $0.bottom.equalToSuperview().offset(-22) - $0.leading.trailing.equalToSuperview() + self.postingBlockedBackgroundView.addSubview(self.postingBlockedMessageLabel) + self.postingBlockedMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.postingBlockedTitleLabel.snp.bottom).offset(6) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } } - override func bind() { - super.bind() - -#if DEVELOP - self.setupDebugging() -#endif - } - - // MARK: ReactorKit bind + // MARK: ReactorKit - bind func bind(reactor: SettingsViewReactor) { - self.notificationSettingCellView.toggleSwitch.isOn = reactor.initialState.notificationStatus - // Action self.rx.viewWillAppear .map { _ in Reactor.Action.landing } @@ -190,112 +205,142 @@ class SettingsViewController: BaseNavigationViewController, View { .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.commentHistoryCellView.rx.didSelect - .subscribe(with: self) { object, _ in - let commentHistoryViewController = CommentHistroyViewController() - commentHistoryViewController.reactor = reactor.reactorForCommentHistory() - object.navigationPush(commentHistoryViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - self.issueUserTransferCodeCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in let issueMemberTransferViewController = IssueMemberTransferViewController() issueMemberTransferViewController.reactor = reactor.reactorForTransferIssue() - object.navigationPush(issueMemberTransferViewController, animated: true, bottomBarHidden: true) + object.navigationPush(issueMemberTransferViewController, animated: true) } .disposed(by: self.disposeBag) self.enterUserTransferCodeCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in let enterMemberTransferViewController = EnterMemberTransferViewController() enterMemberTransferViewController.reactor = reactor.reactorForTransferEnter() - object.navigationPush(enterMemberTransferViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - - self.acceptTermsCellView.rx.didSelect - .subscribe(with: self) { object, _ in - let rermsOfServiceViewController = TermsOfServiceViewController() - object.navigationPush(rermsOfServiceViewController, animated: true, bottomBarHidden: true) + object.navigationPush(enterMemberTransferViewController, animated: true) } .disposed(by: self.disposeBag) - self.resignCellView.rx.didSelect + self.blockUsersCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in - let resignViewController = ResignViewController() - resignViewController.reactor = reactor.reactorForResign() - object.navigationPush(resignViewController, animated: true, bottomBarHidden: true) + let blockUsersViewController = BlockUsersViewController() + blockUsersViewController.reactor = reactor.reactorForBlock() + object.navigationPush(blockUsersViewController, animated: true) } .disposed(by: self.disposeBag) self.announcementCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in let announcementViewController = AnnouncementViewController() announcementViewController.reactor = reactor.reactorForAnnouncement() - object.navigationPush(announcementViewController, animated: true, bottomBarHidden: true) + object.navigationPush(announcementViewController, animated: true) } .disposed(by: self.disposeBag) self.inquiryCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(onNext: { _ in + let subject = Text.inquiryMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let guideMessage = """ \(Text.identificationInfo) - \(reactor.provider.authManager.authInfo.token.refreshToken)\n + \(reactor.initialState.tokens.refreshToken)\n \(Text.inquiryMailGuideMessage) """ let body = guideMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let mailToString = "mailto:\(Text.adminMailStrUrl)?subject=\(subject)&body=\(body)" - + if let mailtoUrl = URL(string: mailToString), UIApplication.shared.canOpenURL(mailtoUrl) { - + UIApplication.shared.open(mailtoUrl, options: [:], completionHandler: nil) } }) .disposed(by: self.disposeBag) - self.suggestionCellView.rx.didSelect - .subscribe(onNext: { _ in - let subject = Text.suggestMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let guideMessage = """ - \(Text.identificationInfo) - \(reactor.provider.authManager.authInfo.token.refreshToken)\n - \(Text.suggestMailGuideMessage) - """ - let body = guideMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let mailToString = "mailto:\(Text.adminMailStrUrl)?subject=\(subject)&body=\(body)" - - if let mailtoUrl = URL(string: mailToString), - UIApplication.shared.canOpenURL(mailtoUrl) { + self.acceptTermsCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) + .subscribe(with: self) { object, _ in + let rermsOfServiceViewController = TermsOfServiceViewController() + object.navigationPush(rermsOfServiceViewController, animated: true) + } + .disposed(by: self.disposeBag) + + let version = reactor.state.map(\.version).distinctUntilChanged().filterNil().share() + self.appVersionCellView.rx.didSelect + .throttle(.seconds(1), scheduler: MainScheduler.instance) + .withLatestFrom(version) + .subscribe(with: self) { object, version in + if version.mustUpdate { + #if DEVELOP + // 개발 버전일 때 testFlight로 전환 + let strUrl = "\(Text.testFlightStrUrl)/\(Info.appId)" + if let testFlightUrl = URL(string: strUrl) { + UIApplication.shared.open(testFlightUrl, options: [:], completionHandler: nil) + } + #elseif PRODUCTION + // 운영 버전일 때 app store로 전환 + let strUrl = "\(Text.appStoreStrUrl)\(Info.appId)" + if let appStoreUrl = URL(string: strUrl) { + UIApplication.shared.open(appStoreUrl, options: [:], completionHandler: nil) + } + #endif + } else { + let bottomFloatView = SOMBottomToastView(title: Text.latestVersionToastTitle, actions: nil) - UIApplication.shared.open(mailtoUrl, options: [:], completionHandler: nil) + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 8, displayDuration: 4) } - }) + } .disposed(by: self.disposeBag) - // State - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + self.resignCellView.rx.didSelect + .throttle(.seconds(1), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.rejoinableDate } + .bind(to: reactor.action) .disposed(by: self.disposeBag) + // State reactor.state.map(\.banEndAt) .distinctUntilChanged() + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, banEndAt in - object.userBlockedBackgroundView.isHidden = (banEndAt == nil) - object.unBlockDateLabel.text = "\(Text.unBlockDate) \(banEndAt?.banEndFormatted ?? "")" + object.postingBlockedBackgroundView.isHidden = (banEndAt == nil) + object.postingBlockedMessageLabel.text = Text.postingBlockedLeadingGuideMessage + + (banEndAt?.banEndDetailFormatted ?? "") + + Text.postingBlockedTrailingGuideMessage + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.rejoinableDate) + .filterNil() + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, rejoinableDate in + object.showResignDialog(rejoinableDate: rejoinableDate) } .disposed(by: self.disposeBag) reactor.state.map(\.notificationStatus) .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) .bind(to: self.notificationSettingCellView.toggleSwitch.rx.isOn) .disposed(by: self.disposeBag) + version + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, version in + object.appVersionCellView.setLatestVersion(Text.latestVersionTitle + version.latestVersion) + } + .disposed(by: self.disposeBag) + reactor.state.map(\.shouldHideTransfer) .distinctUntilChanged() + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, shouldHide in object.issueUserTransferCodeCellView.isHidden = shouldHide object.enterUserTransferCodeCellView.isHidden = shouldHide @@ -304,24 +349,57 @@ class SettingsViewController: BaseNavigationViewController, View { } } + +// MARK: Show dialog + extension SettingsViewController { - private func setupDebugging() { - - let longPressRecognizer = UILongPressGestureRecognizer() - self.appSettingHeader.addGestureRecognizer(longPressRecognizer) - - longPressRecognizer.rx.event - .flatMapLatest { _ in Log.extract() } - .subscribe( - with: self, - onNext: { object, viewController in - object.navigationController?.present(viewController, animated: true) - }, - onError: { _, error in - Log.error(error.localizedDescription) + func showResignDialog(rejoinableDate: RejoinableDateInfo) { + + guard let reactor = self.reactor else { return } + + let cancelAction = SOMDialogAction( + title: Text.cancelActionButtonTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) } - ) - .disposed(by: self.disposeBag) + } + ) + + let resignAction = SOMDialogAction( + title: Text.resignTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + let resignViewController = ResignViewController() + resignViewController.reactor = reactor.reactorForResign() + self.navigationPush( + resignViewController, + animated: true + ) { _ in + reactor.action.onNext(.cleanup) + } + } + } + ) + + var message: String { + if rejoinableDate.isActivityRestricted == false { + return Text.resignDialogMessage + } else { + return Text.resignDialogBannedLeadingMessage + + rejoinableDate.rejoinableDate.banEndFormatted + + Text.resignDialogBannedTrailingMessage + } + } + + SOMDialogViewController.show( + title: Text.resignDialogTitle, + message: message, + textAlignment: .left, + actions: [cancelAction, resignAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift index 8d56b94a..8981d38d 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift @@ -15,34 +15,48 @@ class SettingsViewReactor: Reactor { enum Action: Equatable { case landing case updateNotificationStatus(Bool) + case rejoinableDate + case cleanup } enum Mutation { case updateBanEndAt(Date?) + case updateVersion(Version?) + case updateShouldHideTransfer(Bool) case updateNotificationStatus(Bool) - case updateIsProcessing(Bool) + case rejoinableDate(RejoinableDateInfo?) + case cleanup } struct State { + fileprivate(set) var tokens: Token fileprivate(set) var banEndAt: Date? + fileprivate(set) var version: Version? fileprivate(set) var notificationStatus: Bool - fileprivate(set) var isProcessing: Bool fileprivate(set) var shouldHideTransfer: Bool + fileprivate(set) var rejoinableDate: RejoinableDateInfo? } var initialState: State - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let appVersionUseCase: AppVersionUseCase + private let authUseCase: AuthUseCase + private let validateUserUseCase: ValidateUserUseCase + private let updateNotifyUseCase: UpdateNotifyUseCase - private let disposeBag = DisposeBag() - - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.appVersionUseCase = dependencies.rootContainer.resolve(AppVersionUseCase.self) + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) + self.validateUserUseCase = dependencies.rootContainer.resolve(ValidateUserUseCase.self) + self.updateNotifyUseCase = dependencies.rootContainer.resolve(UpdateNotifyUseCase.self) self.initialState = .init( + tokens: self.authUseCase.tokens(), banEndAt: nil, - notificationStatus: provider.pushManager.notificationStatus, - isProcessing: false, + version: nil, + notificationStatus: self.updateNotifyUseCase.notificationStatus(), shouldHideTransfer: UserDefaults.standard.bool(forKey: "AppFlag") ) } @@ -52,78 +66,76 @@ class SettingsViewReactor: Reactor { case .landing: return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(SettingsResponse.self, request: SettingsRequest.activate) - .flatMapLatest { response -> Observable in - return .just(.updateBanEndAt(response.banEndAt)) - } - .catch(self.catchClosure), - self.provider.networkManager.request( - NotificationAllowResponse.self, - request: SettingsRequest.notificationAllow(isAllowNotify: nil) - ) - .flatMapLatest { response -> Observable in - return .just(.updateNotificationStatus(response.isAllowNotify)) - } - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) + self.appVersionUseCase.version() + .flatMapLatest { version -> Observable in + + UserDefaults.standard.set(version.shouldHideTransfer, forKey: "AppFlag") + + return .concat([ + .just(.updateShouldHideTransfer(version.shouldHideTransfer)), + .just(.updateVersion(version)) + ]) + }, + self.validateUserUseCase.postingPermission() + .map(\.expiredAt) + .map(Mutation.updateBanEndAt), + self.updateNotifyUseCase.updateNotify(isAllowNotify: self.initialState.notificationStatus) + .map(Mutation.updateNotificationStatus) ]) case let .updateNotificationStatus(state): - return self.provider.networkManager.request( - Empty.self, - request: SettingsRequest.notificationAllow(isAllowNotify: state) - ) - .flatMapLatest { _ -> Observable in - return .just(.updateNotificationStatus(state)) - } - .catchAndReturn(.updateNotificationStatus(!state)) + + return self.updateNotifyUseCase.updateNotify(isAllowNotify: state) + .map { _ in state } + .map(Mutation.updateNotificationStatus) + case .rejoinableDate: + + return self.validateUserUseCase.iswithdrawn() + .map(Mutation.rejoinableDate) + case .cleanup: + + return .just(.cleanup) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { case let .updateBanEndAt(banEndAt): - state.banEndAt = banEndAt + newState.banEndAt = banEndAt + case let .updateVersion(version): + newState.version = version + case let .updateShouldHideTransfer(shouldHideTransfer): + newState.shouldHideTransfer = shouldHideTransfer case let .updateNotificationStatus(notificationStatus): - state.notificationStatus = notificationStatus - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing + newState.notificationStatus = notificationStatus + case let .rejoinableDate(rejoinableDate): + newState.rejoinableDate = rejoinableDate + case .cleanup: + newState.rejoinableDate = nil } - return state + return newState } } extension SettingsViewReactor { - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)) - ]) - } - } -} - -extension SettingsViewReactor { - - func reactorForCommentHistory() -> CommentHistroyViewReactor { - CommentHistroyViewReactor(provider: self.provider) - } - func reactorForTransferIssue() -> IssueMemberTransferViewReactor { - IssueMemberTransferViewReactor(provider: self.provider) + IssueMemberTransferViewReactor(dependencies: self.dependencies) } func reactorForTransferEnter() -> EnterMemberTransferViewReactor { - EnterMemberTransferViewReactor(provider: self.provider, entranceType: .settings) + EnterMemberTransferViewReactor(dependencies: self.dependencies) + } + + func reactorForBlock() -> BlockUsersViewReactor { + BlockUsersViewReactor(dependencies: self.dependencies) } func reactorForResign() -> ResignViewReactor { - ResignViewReactor(provider: self.provider, banEndAt: self.currentState.banEndAt) + ResignViewReactor(dependencies: self.dependencies) } func reactorForAnnouncement() -> AnnouncementViewReactor { - AnnouncementViewReactor(provider: self.provider) + AnnouncementViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift index 5feef60f..ebafb811 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift @@ -8,7 +8,6 @@ import RxCocoa import RxSwift - extension Reactive where Base: SettingTextCellView { var isOn: ControlProperty { diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift index f4602ea4..ae321a2b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift @@ -10,7 +10,6 @@ import UIKit import SnapKit import Then - class SettingTextCellView: UIView { enum ButtonStyle { @@ -18,37 +17,45 @@ class SettingTextCellView: UIView { case toggle } + + // MARK: Views + + let backgroundButton = UIButton() + private let titleLabel = UILabel().then { - $0.typography = .som.body2WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 } private let arrowImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.tintColor = .som.gray400 + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.tintColor = .som.gray300 } let toggleSwitch = UISwitch().then { $0.isOn = false - $0.onTintColor = .som.p300 - $0.thumbTintColor = .som.white + $0.onTintColor = .som.v2.pMain + $0.tintColor = .som.v2.gray200 + $0.thumbTintColor = .som.v2.white - $0.transform = CGAffineTransform(scaleX: 0.75, y: 0.75) + if let thumb = $0.subviews.first?.subviews.last?.subviews.last { + thumb.transform = CGAffineTransform(scaleX: 0.85, y: 0.85) + } } - let backgroundButton = UIButton() - var buttonStyle: ButtonStyle? + // MARK: Variables + + private(set) var buttonStyle: ButtonStyle? - convenience init( - buttonStyle: ButtonStyle = .arrow, - title: String, - titleColor: UIColor = .som.gray500 - ) { + + // MARK: Initialize + + convenience init(buttonStyle: ButtonStyle = .arrow, title: String) { self.init(frame: .zero) self.buttonStyle = buttonStyle self.titleLabel.text = title - self.titleLabel.textColor = titleColor self.setupConstraints() } @@ -61,11 +68,16 @@ class SettingTextCellView: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { + self.backgroundColor = .som.v2.white + self.snp.makeConstraints { $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(46) + $0.height.equalTo(48) } switch self.buttonStyle { @@ -73,17 +85,14 @@ class SettingTextCellView: UIView { self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(13) - $0.bottom.equalToSuperview().offset(-13) - $0.leading.equalToSuperview().offset(20) + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) } self.addSubview(self.toggleSwitch) self.toggleSwitch.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(40) - $0.height.equalTo(24) + $0.trailing.equalToSuperview().offset(-16) } self.addSubview(self.backgroundButton) @@ -96,23 +105,20 @@ class SettingTextCellView: UIView { let backgroundView = UIView() self.addSubview(backgroundView) backgroundView.snp.makeConstraints { - $0.top.equalToSuperview().offset(7) - $0.bottom.equalToSuperview().offset(-7) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + $0.verticalEdges.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } backgroundView.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview() + $0.centerY.leading.equalToSuperview() } backgroundView.addSubview(self.arrowImageView) self.arrowImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview() - $0.size.equalTo(24) + $0.centerY.trailing.equalToSuperview() + $0.size.equalTo(16) } self.addSubview(self.backgroundButton) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView+Rx.swift new file mode 100644 index 00000000..2bed1124 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView+Rx.swift @@ -0,0 +1,16 @@ +// +// SettingVersionCellView+Rx.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import RxCocoa +import RxSwift + +extension Reactive where Base: SettingVersionCellView { + + var didSelect: ControlEvent { + self.base.backgroundButton.rx.tap + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView.swift new file mode 100644 index 00000000..08551ac8 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView.swift @@ -0,0 +1,114 @@ +// +// SettingVersionCellView.swift +// SOOUM +// +// Created by 오현식 on 12/4/24. +// + +import UIKit + +import SnapKit +import Then + +class SettingVersionCellView: UIView { + + + // MARK: Views + + let backgroundButton = UIButton() + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 + } + + private let latestVersionLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption3 + } + + private let currentVersionLabel = UILabel().then { + $0.text = Info.appVersion + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.body1 + } + + private let arrowImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.tintColor = .som.gray300 + } + + + // MARK: Initialize + + convenience init(title: String) { + self.init(frame: .zero) + + self.titleLabel.text = title + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.white + + self.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width) + $0.height.equalTo(48) + } + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.leading.equalToSuperview().offset(16) + } + + self.addSubview(self.latestVersionLabel) + self.latestVersionLabel.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.bottom) + $0.bottom.equalToSuperview().offset(-6) + $0.leading.equalToSuperview().offset(16) + } + + self.addSubview(self.arrowImageView) + self.arrowImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(16) + } + + self.addSubview(self.currentVersionLabel) + self.currentVersionLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalTo(self.arrowImageView.snp.leading).offset(-10) + } + + self.addSubview(self.backgroundButton) + self.backgroundButton.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.top) + $0.bottom.equalTo(self.latestVersionLabel.snp.bottom) + $0.leading.equalTo(self.titleLabel.snp.leading) + $0.trailing.equalTo(self.arrowImageView.snp.trailing) + } + } + + + // MAKR: Public func + func setLatestVersion(_ latestVersion: String) { + + self.latestVersionLabel.text = latestVersion + self.latestVersionLabel.typography = .som.v2.caption3 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift index b6fe15a4..46a0e12a 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift @@ -17,10 +17,14 @@ import RxSwift class TermsOfServiceViewController: BaseNavigationViewController { enum Text { - static let navigationTitle: String = "이용약관 및 개인정보 처리 방침" + static let navigationTitle: String = "약관 및 개인정보 처리 동의" static let privacyPolicyTitle: String = "개인정보처리방침" static let termsOfServiceTitle: String = "서비스 이용약관" static let termsOfLocationInfoTitle: String = "위치정보 이용약관" + + static let privacyPolicyURLString: String = "https://adjoining-guanaco-d0a.notion.site/26b2142ccaa38059a1dbf3e6b6b6b4e6?pvs=74" + static let termsOfServiceURLString: String = "https://adjoining-guanaco-d0a.notion.site/26b2142ccaa38076b491df099cd7b559" + static let termsOfLocationInfoURLString: String = "https://adjoining-guanaco-d0a.notion.site/26b2142ccaa380f1bfafe99f5f8a10f1?pvs=74" } private let scrollView = UIScrollView().then { @@ -28,14 +32,20 @@ class TermsOfServiceViewController: BaseNavigationViewController { $0.alwaysBounceVertical = true $0.showsVerticalScrollIndicator = false $0.showsHorizontalScrollIndicator = false + + $0.contentInsetAdjustmentBehavior = .never } private let privacyPolicyCellView = TermsOfServiceTextCellView(title: Text.privacyPolicyTitle) private let termsOfServiceCellView = TermsOfServiceTextCellView(title: Text.termsOfServiceTitle) private let termsOfLocationInfoCellView = TermsOfServiceTextCellView(title: Text.termsOfLocationInfoTitle) - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 } @@ -77,8 +87,9 @@ class TermsOfServiceViewController: BaseNavigationViewController { super.bind() self.privacyPolicyCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in - if let url = URL(string: "https://mewing-space-6d3.notion.site/3f92380d536a4b569921d2809ed147ef?pvs=4") { + if let url = URL(string: Text.privacyPolicyURLString) { if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } @@ -87,8 +98,9 @@ class TermsOfServiceViewController: BaseNavigationViewController { .disposed(by: self.disposeBag) self.termsOfServiceCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in - if let url = URL(string: "https://mewing-space-6d3.notion.site/45d151f68ba74b23b24483ad8b2662b4?pvs=4") { + if let url = URL(string: Text.termsOfServiceURLString) { if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } @@ -97,8 +109,9 @@ class TermsOfServiceViewController: BaseNavigationViewController { .disposed(by: self.disposeBag) self.termsOfLocationInfoCellView.rx.didSelect + .throttle(.seconds(3), scheduler: MainScheduler.instance) .subscribe(with: self) { object, _ in - if let url = URL(string: "https://mewing-space-6d3.notion.site/44e378c9d11d45159859492434b6b128?pvs=4") { + if let url = URL(string: Text.termsOfLocationInfoURLString) { if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift index 2f09d5ec..c6e79a14 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift @@ -10,21 +10,26 @@ import UIKit import SnapKit import Then - class TermsOfServiceTextCellView: UIView { + + // MARK: Views + private let titleLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body2WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 } private let arrowImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.tintColor = .som.gray400 + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.tintColor = .som.v2.gray300 } let backgroundButton = UIButton() + + // MARK: Initialize + convenience init(title: String) { self.init(frame: .zero) @@ -41,24 +46,27 @@ class TermsOfServiceTextCellView: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { self.snp.makeConstraints { $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(46) + $0.height.equalTo(48) } self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) + $0.leading.equalToSuperview().offset(16) } self.addSubview(self.arrowImageView) self.arrowImageView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview().offset(-20) - $0.size.equalTo(24) + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(16) } self.addSubview(self.backgroundButton) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift index 64b0ee05..13837b3e 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift @@ -7,42 +7,103 @@ import UIKit -import Kingfisher import SnapKit import Then + +import Photos +import SwiftEntryKit import YPImagePicker +import Clarity + import ReactorKit import RxCocoa +import RxGesture import RxSwift - class UpdateProfileViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "프로필 수정" - static let textFieldPlaceholder: String = "8글자 이내 닉네임을 입력해주세요" + static let navigationTitle: String = "프로필 편집" + static let guideMessage: String = "최대 8자까지 입력할 수 있어요" + static let saveButtonTitle: String = "저장" + + static let cancelActionTitle: String = "취소" + static let settingActionTitle: String = "설정" static let completeButtonTitle: String = "완료" + static let passButtonTitle: String = "건너뛰기" + + static let libraryDialogTitle: String = "앱 접근 권한 안내" + static let libraryDialogMessage: String = "사진첨부를 위해 접근 권한이 필요해요. [설정 > 앱 > 숨 > 사진]에서 사진 보관함 접근 권한을 허용해 주세요." + + static let inappositeDialogTitle: String = "부적절한 사진으로 보여져요" + static let inappositeDialogMessage: String = "다른 사진으로 변경하거나 기본 이미지를 사용해 주세요." + static let inappositeDialogConfirmButtonTitle: String = "확인" + + static let selectProfileEntryName: String = "SOMBottomFloatView" + + static let selectProfileFirstButtonTitle: String = "앨범에서 사진 선택" + static let selectProfileSecondButtonTitle: String = "사진 찍기" + static let selectProfileThirdButtonTitle: String = "기본 이미지 적용" + + static let selectPhotoFullScreenNextTitle: String = "다음" + static let selectPhotoFullScreenCancelTitle: String = "취소" + static let selectPhotoFullScreenSaveTitle: String = "저장" + static let selectPhotoFullScreenAlbumsTitle: String = "앨범" + static let selectPhotoFullScreenCameraTitle: String = "카메라" + static let selectPhotoFullScreenLibraryTitle: String = "갤러리" + static let selectPhotoFullScreenCropTitle: String = "자르기" } - private let updateProfileView = UpdateProfileView().then { - $0.placeholder = Text.textFieldPlaceholder + + // MARK: Views + + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_large))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 120 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + private let cameraButton = SOMButton().then { + $0.image = .init(.icon(.v2(.filled(.camera)))) + $0.foregroundColor = .som.v2.gray400 + + $0.backgroundColor = .som.v2.white + $0.layer.borderColor = UIColor.som.v2.gray200.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 32 * 0.5 + } + + private let nicknameTextField = SOMNicknameTextField().then { + $0.guideMessage = Text.guideMessage } - private let completeButton = SOMButton().then { - $0.title = Text.completeButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.white + private let saveButton = SOMButton().then { + $0.title = Text.saveButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 10 $0.clipsToBounds = true $0.isEnabled = false } - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Variables + + private var actions: [SOMBottomFloatView.FloatAction] = [] + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + save button height + padding + return 34 + 56 + 8 } @@ -54,35 +115,48 @@ class UpdateProfileViewController: BaseNavigationViewController, View { self.navigationBar.title = Text.navigationTitle } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.updateProfileView.becomeFirstResponder() - } - override func setupConstraints() { super.setupConstraints() - self.view.addSubview(self.updateProfileView) - self.updateProfileView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() + self.view.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(24) + $0.centerX.equalToSuperview() + $0.size.equalTo(120) + } + self.view.addSubview(self.cameraButton) + self.cameraButton.snp.makeConstraints { + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.trailing.equalTo(self.profileImageView.snp.trailing) + $0.size.equalTo(32) + } + + self.view.addSubview(self.nicknameTextField) + self.nicknameTextField.snp.makeConstraints { + $0.top.equalTo(self.profileImageView.snp.bottom).offset(40) + $0.horizontalEdges.equalToSuperview() } - self.view.addSubview(self.completeButton) - self.completeButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + self.view.addSubview(self.saveButton) + self.saveButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } + override func viewDidLoad() { + super.viewDidLoad() + + PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in } + } + override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { super.updatedKeyboard(withoutBottomSafeInset: height) - let margin: CGFloat = height + 24 - self.completeButton.snp.updateConstraints { + let margin: CGFloat = height == 0 ? 0 : height + 12 + self.saveButton.snp.updateConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-margin) } } @@ -92,109 +166,264 @@ class UpdateProfileViewController: BaseNavigationViewController, View { func bind(reactor: UpdateProfileViewReactor) { - KingfisherManager.shared.download(strUrl: reactor.profile.profileImg?.url) { [weak self] image in - self?.updateProfileView.image = image ?? .init(.image(.sooumLogo)) - } - self.updateProfileView.text = reactor.profile.nickname - - // Action - self.updateProfileView.changeProfileButton.rx.throttleTap(.seconds(3)) + self.rx.viewDidLoad .subscribe(with: self) { object, _ in - object.showPicker(reactor) + + var actions: [SOMBottomFloatView.FloatAction] = [ + .init( + title: Text.selectProfileFirstButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .library) + } + } + ), + .init( + title: Text.selectProfileSecondButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .photo) + } + } + ) + ] + + if let profileImage = reactor.initialState.profileImage { + + object.profileImageView.image = profileImage + + actions.append( + .init( + title: Text.selectProfileThirdButtonTitle, + action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + reactor.action.onNext(.setInitialImage) + } + } + ) + ) + } + + object.actions = actions + object.nicknameTextField.text = reactor.nickname } .disposed(by: self.disposeBag) - let nickname = self.updateProfileView.textField.rx.text.orEmpty.distinctUntilChanged() + // Action + Observable.merge( + self.profileImageView.rx.tapGesture().when(.ended).map { _ in }, + self.cameraButton.rx.tap.asObservable() + ) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if status == .authorized || status == .limited { + + let selectProfileBottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = selectProfileBottomFloatView.sek + wrapper.entryName = Text.selectProfileEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } else { + + object.showLibraryPermissionDialog() + } + } + .disposed(by: self.disposeBag) + + let nickname = self.nicknameTextField.textField.rx.text.orEmpty.distinctUntilChanged().share() nickname - .debounce(.seconds(1), scheduler: MainScheduler.instance) + .skip(1) + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) .map(Reactor.Action.checkValidate) .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.completeButton.rx.throttleTap(.seconds(3)) + self.saveButton.rx.throttleTap(.seconds(3)) .withLatestFrom(nickname) .map(Reactor.Action.updateProfile) .bind(to: reactor.action) .disposed(by: self.disposeBag) - + // State - reactor.state.map(\.errorMessage) - .distinctUntilChanged() - .bind(to: self.updateProfileView.rx.errorMessage) + let profileImage = reactor.state.map(\.profileImage).distinctUntilChanged().share() + profileImage + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, profileImage in + object.profileImageView.image = profileImage ?? .init(.image(.v2(.profile_large))) + + var actions: [SOMBottomFloatView.FloatAction] = [ + .init( + title: Text.selectProfileFirstButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .library) + } + } + ), + .init( + title: Text.selectProfileSecondButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .photo) + } + } + ) + ] + + if profileImage != nil { + actions.append(.init( + title: Text.selectProfileThirdButtonTitle, + action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + reactor.action.onNext(.setInitialImage) + } + } + )) + } + + object.actions = actions + } .disposed(by: self.disposeBag) - - reactor.state.map(\.isValid) + + // State + reactor.state.map(\.isUpdatedSuccess) .distinctUntilChanged() - .subscribe(with: self) { object, isValid in - object.completeButton.foregroundColor = isValid ? .som.white : .som.gray600 - object.completeButton.backgroundColor = isValid ? .som.p300 : .som.gray300 - object.completeButton.isEnabled = isValid + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + NotificationCenter.default.post(name: .reloadProfileData, object: nil, userInfo: nil) + object.navigationPop() } .disposed(by: self.disposeBag) - reactor.state.map(\.isSuccess) + Observable.combineLatest( + reactor.state.map(\.isValid).distinctUntilChanged(), + profileImage.startWith(reactor.initialState.profileImage), + resultSelector: { $0 || $1 != reactor.initialState.profileImage } + ) + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.saveButton.rx.isEnabled) + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) .distinctUntilChanged() + .filter { $0 == true } + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in - object.navigationPop() + + object.showInappositeDialog(reactor) } .disposed(by: self.disposeBag) - reactor.state.map(\.isProcessing) + reactor.state.map(\.errorMessage) .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, errorMessage in + object.nicknameTextField.guideMessage = errorMessage == nil ? Text.guideMessage : errorMessage + object.nicknameTextField.hasError = errorMessage != nil + } .disposed(by: self.disposeBag) } } -extension UpdateProfileViewController { +private extension UpdateProfileViewController { + + func showLibraryPermissionDialog() { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let settingAction = SOMDialogAction( + title: Text.settingActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } + } + } + ) + + SOMDialogViewController.show( + title: Text.libraryDialogTitle, + message: Text.libraryDialogMessage, + actions: [cancelAction, settingAction] + ) + } + + func showInappositeDialog(_ reactor: UpdateProfileViewReactor) { + + let actions: [SOMDialogAction] = [ + .init( + title: Text.inappositeDialogConfirmButtonTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.setDefaultImage) + } + } + ) + ] + + SOMDialogViewController.show( + title: Text.inappositeDialogTitle, + message: Text.inappositeDialogMessage, + textAlignment: .left, + actions: actions + ) + } - private func showPicker(_ reactor: UpdateProfileViewReactor) { + func showPicker(for screen: YPPickerScreen) { var config = YPImagePickerConfiguration() + config.library.options = nil - config.library.onlySquare = false - config.library.isSquareByDefault = true config.library.minWidthForItem = nil - config.library.mediaType = YPlibraryMediaType.photo - config.library.defaultMultipleSelection = false - config.library.maxNumberOfItems = 1 - config.library.minNumberOfItems = 1 - config.library.numberOfItemsInRow = 4 - config.library.spacingBetweenItems = 1.0 - config.showsCrop = .rectangle(ratio: 1) + config.showsCrop = .rectangle(ratio: 1.0) config.showsPhotoFilters = false - config.library.skipSelectionsGallery = false config.library.preselectedItems = nil - config.library.preSelectItemOnMultipleSelection = true - config.startOnScreen = .library + config.screens = [screen] + config.startOnScreen = screen config.shouldSaveNewPicturesToAlbum = false - config.wordings.next = "다음" - config.wordings.cancel = "취소" - config.wordings.save = "저장" - config.wordings.albumsTitle = "앨범" - config.wordings.cameraTitle = "카메라" - config.wordings.libraryTitle = "갤러리" - config.wordings.crop = "자르기" + config.wordings.next = Text.selectPhotoFullScreenNextTitle + config.wordings.cancel = Text.selectPhotoFullScreenCancelTitle + config.wordings.save = Text.selectPhotoFullScreenSaveTitle + config.wordings.albumsTitle = Text.selectPhotoFullScreenAlbumsTitle + config.wordings.cameraTitle = Text.selectPhotoFullScreenCameraTitle + config.wordings.libraryTitle = Text.selectPhotoFullScreenLibraryTitle + config.wordings.crop = Text.selectPhotoFullScreenCropTitle let picker = YPImagePicker(configuration: config) - picker.didFinishPicking { [weak self] items, cancelled in - - guard let self = self, let reactor = self.reactor else { return } + picker.didFinishPicking { [weak self, weak picker] items, cancelled in if cancelled { - Log.error("Picker was canceled") - picker.dismiss(animated: true, completion: nil) + Log.debug("Picker was canceled") + picker?.dismiss(animated: true, completion: nil) return } - if let image = items.singlePhoto?.image { - self.updateProfileView.image = image - reactor.action.onNext(.updateImage(image)) + if let image = items.singlePhoto?.image, let reactor = self?.reactor { + reactor.action.onNext(.uploadImage(image)) + } else { + Log.error("Error occured while picking an image") } - picker.dismiss(animated: true, completion: nil) + picker?.dismiss(animated: true) { ClaritySDK.resume() } } - self.present(picker, animated: true, completion: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.present(picker, animated: true) { ClaritySDK.pause() } + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift index 8d631b2c..5bd968db 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift @@ -7,7 +7,7 @@ import ReactorKit -import Alamofire +import Kingfisher class UpdateProfileViewReactor: Reactor { @@ -18,131 +18,188 @@ class UpdateProfileViewReactor: Reactor { } enum Action: Equatable { - case updateImage(UIImage) + case uploadImage(UIImage) + case setDefaultImage + case setInitialImage case checkValidate(String) case updateProfile(String) } enum Mutation { - case updateImage(UIImage) + case updateImageInfo(UIImage?, String?) case updateIsValid(Bool) case updateIsSuccess(Bool) case updateIsProcessing(Bool) + case updateErrors(Bool?) case updateErrorMessage(String?) } struct State { - var profileImage: UIImage? - var isValid: Bool - var isSuccess: Bool - var isProcessing: Bool - var errorMessage: String? + fileprivate(set) var profileImage: UIImage? + fileprivate(set) var profileImageName: String? + fileprivate(set) var isValid: Bool + fileprivate(set) var isUpdatedSuccess: Bool + fileprivate(set) var isProcessing: Bool + fileprivate(set) var hasErrors: Bool? + fileprivate(set) var errorMessage: String? } - var initialState: State = .init( - profileImage: nil, - isValid: false, - isSuccess: false, - isProcessing: false, - errorMessage: nil - ) + var initialState: State - private var imageName: String? + private let dependencies: AppDIContainerable + private let validateNicknameUseCase: ValidateNicknameUseCase + private let uploadUserImageUseCase: UploadUserImageUseCase + private let updateUserInfoUseCase: UpdateUserInfoUseCase - let provider: ManagerProviderType - var profile: Profile + let nickname: String - init(provider: ManagerProviderType, _ profile: Profile) { - self.provider = provider - self.profile = profile + init( + dependencies: AppDIContainerable, + nickname: String, + image profileImage: UIImage?, + imageName profileImageName: String? + ) { + self.dependencies = dependencies + self.validateNicknameUseCase = dependencies.rootContainer.resolve(ValidateNicknameUseCase.self) + self.uploadUserImageUseCase = dependencies.rootContainer.resolve(UploadUserImageUseCase.self) + self.updateUserInfoUseCase = dependencies.rootContainer.resolve(UpdateUserInfoUseCase.self) + + self.nickname = nickname + + self.initialState = .init( + profileImage: profileImage, + profileImageName: profileImageName, + isValid: false, + isUpdatedSuccess: false, + isProcessing: false, + hasErrors: nil, + errorMessage: nil + ) } func mutate(action: Action) -> Observable { switch action { + case let .uploadImage(image): + + return .concat([ + .just(.updateErrors(false)), + self.uploadImage(image) + .catch(self.catchClosure) + ]) + case .setDefaultImage: + + return .just(.updateImageInfo(self.initialState.profileImage, nil)) + case .setInitialImage: + + return .just(.updateImageInfo(nil, nil)) case let .checkValidate(nickname): + + guard nickname != self.nickname else { + return .concat([ + .just(.updateIsValid(false)), + .just(.updateErrorMessage(nil)) + ]) + } + if nickname.isEmpty { return .concat([ .just(.updateIsValid(false)), .just(.updateErrorMessage(ErrorMessages.isEmpty.rawValue)) ]) } - let request: JoinRequest = .validateNickname(nickname: nickname) return .concat([ .just(.updateErrorMessage(nil)), - self.provider.networkManager.request(NicknameValidationResponse.self, request: request) - .flatMapLatest { response -> Observable in - let isAvailable = response.isAvailable - let errorMessage = isAvailable ? nil : ErrorMessages.inValid.rawValue + self.validateNicknameUseCase.checkValidation(nickname: nickname) + .withUnretained(self) + .flatMapLatest { object, isValid -> Observable in + + let errorMessage = isValid ? nil : ErrorMessages.inValid.rawValue return .concat([ - .just(.updateIsValid(isAvailable)), + .just(.updateIsValid(isValid)), .just(.updateErrorMessage(errorMessage)) ]) } ]) - case let .updateImage(image): - return self.updateImage(image) case let .updateProfile(nickname): - let trimedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) - let request: ProfileRequest = .updateProfile(nickname: trimedNickname, profileImg: self.imageName) + let trimedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedNickname = trimedNickname == self.nickname ? nil : trimedNickname return .concat([ - .just(.updateIsProcessing(true)), - - self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsSuccess(true)) - }, - - .just(.updateIsProcessing(false)) + .just(.updateErrors(false)), + self.updateUserInfoUseCase.updateUserInfo( + nickname: updatedNickname, + imageName: self.currentState.profileImageName + ) + .map(Mutation.updateIsSuccess) + .catch(self.catchClosure) ]) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { + case let .updateImageInfo(profileImage, profileImageName): + newState.profileImage = profileImage + newState.profileImageName = profileImageName case let .updateIsValid(isValid): - state.isValid = isValid - case let .updateImage(profileImage): - state.profileImage = profileImage - case let .updateIsSuccess(isSuccess): - state.isSuccess = isSuccess + newState.isValid = isValid + case let .updateIsSuccess(isUpdatedSuccess): + newState.isUpdatedSuccess = isUpdatedSuccess case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing + newState.isProcessing = isProcessing + case let .updateErrors(hasErrors): + newState.hasErrors = hasErrors case let .updateErrorMessage(errorMessage): - state.errorMessage = errorMessage + newState.errorMessage = errorMessage } - return state + return newState } } extension UpdateProfileViewReactor { - private func updateImage(_ image: UIImage) -> Observable { + private func uploadImage(_ image: UIImage) -> Observable { + return self.presignedURL() .withUnretained(self) - .flatMapLatest { object, presignedResponse -> Observable in + .flatMapLatest { object, presignedInfo -> Observable in if let imageData = image.jpegData(compressionQuality: 0.5), - let url = URL(string: presignedResponse.strUrl) { - return object.provider.networkManager.upload(imageData, to: url) - .flatMapLatest { _ -> Observable in - return .empty() + let url = URL(string: presignedInfo.imgUrl) { + + return object.uploadUserImageUseCase.uploadToS3(imageData, with: url) + .flatMapLatest { isSuccess -> Observable in + + let image = isSuccess ? image : nil + let imageName = isSuccess ? presignedInfo.imgName : nil + + return .just(.updateImageInfo(image, imageName)) } + } else { + return .empty() } - return .empty() } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) } - private func presignedURL() -> Observable<(strUrl: String, imageName: String)> { - let request: JoinRequest = .profileImagePresignedURL + private func presignedURL() -> Observable { - return self.provider.networkManager.request(PresignedStorageResponse.self, request: request) - .withUnretained(self) - .flatMapLatest { object, response -> Observable<(strUrl: String, imageName: String)> in - object.imageName = response.imgName - let result = (response.url.url, response.imgName) - return .just(result) - } + return self.uploadUserImageUseCase.presignedURL() + } + + private var catchClosure: ((Error) throws -> Observable ) { + return { error in + + let nsError = error as NSError + let endProcessing = Observable.concat([ + // TODO: 부적절한 사진일 때, `확인` 버튼 탭 시 이미지 변경 + .just(.updateIsProcessing(false)), + // 부적절한 이미지 업로드 에러 코드 == 422 + .just(.updateErrors(nsError.code == 422)) + ]) + + return endProcessing + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/Views/UpdateProfileView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/Views/UpdateProfileView.swift deleted file mode 100644 index 150ab34e..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/Views/UpdateProfileView.swift +++ /dev/null @@ -1,311 +0,0 @@ -// -// UpdateProfileView.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - - -class UpdateProfileView: UIView { - - enum Text { - static let textFieldTitle: String = "닉네임" - static let errorMessage: String = "한글자 이상 입력해주세요" - } - - private let profileImageView = UIImageView().then { - $0.image = .init(.image(.sooumLogo)) - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - - let changeProfileButton = UIButton() - private let cameraBackgroundView = UIView().then { - $0.backgroundColor = UIColor(hex: "#B4B4B4") - $0.layer.cornerRadius = 32 * 0.5 - $0.clipsToBounds = true - } - private let cameraImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.camera))) - $0.tintColor = .som.white - } - - private let textFieldTitleLabel = UILabel().then { - $0.text = Text.textFieldTitle - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - private lazy var textFieldBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.borderColor = UIColor.som.gray50.cgColor - $0.layer.borderWidth = 1 - $0.layer.cornerRadius = 12 - - let gestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(self.touch) - ) - $0.addGestureRecognizer(gestureRecognizer) - } - lazy var textField = UITextField().then { - let paragraphStyle = NSMutableParagraphStyle() - $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle - $0.defaultTextAttributes[.foregroundColor] = UIColor.som.gray500 - $0.defaultTextAttributes[.font] = Typography.som.body1WithRegular.font - $0.tintColor = .som.p300 - - $0.enablesReturnKeyAutomatically = true - $0.returnKeyType = .go - - $0.autocapitalizationType = .none - $0.autocorrectionType = .no - $0.spellCheckingType = .no - - $0.setContentHuggingPriority(.defaultLow, for: .horizontal) - $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) - - $0.delegate = self - } - - private let errorMessageContainer = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - - private let errorImageView = UIImageView().then { - $0.image = .init(.image(.errorTriangle)) - } - - private let errorMessageLabel = UILabel().then { - $0.text = Text.errorMessage - $0.textColor = .som.red - $0.typography = .som.body2WithBold - } - - private let characterLabel = UILabel().then { - $0.text = "0/8" - $0.textColor = .som.gray500 - $0.typography = .init( - fontContainer: BuiltInFont(size: 14, weight: .medium), - lineHeight: 14, - letterSpacing: 0.04 - ) - } - - private let maxCharacter: Int = 8 - - var image: UIImage? { - didSet { - self.profileImageView.image = self.image - } - } - - var text: String? { - set { - self.textField.text = newValue - } - get { - return self.textField.text - } - } - - var placeholder: String? { - set { - if let string: String = newValue { - self.textField.attributedPlaceholder = NSAttributedString( - string: string, - attributes: [ - .foregroundColor: UIColor.som.gray500, - .font: Typography.som.body1WithRegular.font - ] - ) - } else { - self.textField.attributedPlaceholder = nil - } - } - - get { - return self.textField.attributedPlaceholder?.string - } - } - - var errorMessage: String? { - set { - self.errorMessageContainer.isHidden = newValue == nil - self.errorMessageLabel.text = newValue - } - get { - return self.errorMessageLabel.text - } - } - - override var isFirstResponder: Bool { - return self.textField.isFirstResponder - } - - @discardableResult - override func becomeFirstResponder() -> Bool { - return self.textField.becomeFirstResponder() - } - - @discardableResult - override func resignFirstResponder() -> Bool { - return self.textField.resignFirstResponder() - } - - @objc - private func touch(sender: UIGestureRecognizer) { - if !self.textField.isFirstResponder { - self.textField.becomeFirstResponder() - } - } - - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(42) - $0.centerX.equalToSuperview() - $0.size.equalTo(128) - } - - self.addSubview(self.cameraBackgroundView) - self.cameraBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.top).offset(100) - $0.leading.equalTo(self.profileImageView.snp.leading).offset(95) - $0.size.equalTo(32) - } - self.cameraBackgroundView.addSubview(self.cameraImageView) - self.cameraImageView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.size.equalTo(24) - } - self.cameraBackgroundView.addSubview(self.changeProfileButton) - self.changeProfileButton.snp.makeConstraints { - $0.edges.equalTo(self.cameraBackgroundView) - } - - self.addSubview(self.textFieldTitleLabel) - self.textFieldTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.bottom).offset(32) - $0.leading.equalToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - } - - self.addSubview(self.textFieldBackgroundView) - self.textFieldBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.textFieldTitleLabel.snp.bottom).offset(12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(52) - } - self.textFieldBackgroundView.addSubview(self.textField) - self.textField.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(12) - $0.trailing.equalToSuperview().offset(-12) - } - - self.addSubviews(self.errorMessageContainer) - self.errorMessageContainer.snp.makeConstraints { - $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(4) - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(24) - } - self.errorMessageContainer.addArrangedSubview(self.errorImageView) - self.errorImageView.snp.makeConstraints { - $0.size.equalTo(24) - } - self.errorMessageContainer.addArrangedSubview(self.errorMessageLabel) - - self.addSubview(self.characterLabel) - self.characterLabel.snp.makeConstraints { - $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(10) - $0.bottom.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.errorMessageContainer.snp.trailing).offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - } - - private func animate(outlineColor: UIColor) { - - UIView.animate( - withDuration: 0.25, - delay: 0, - options: [ - .allowUserInteraction, - .beginFromCurrentState, - .curveEaseOut - ] - ) { - self.textFieldBackgroundView.layer.borderColor = outlineColor.cgColor - } - } -} - -extension UpdateProfileView: UITextFieldDelegate { - - func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { - self.animate(outlineColor: .som.p300) - return true - } - - func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { - self.animate(outlineColor: .som.gray50) - return true - } - - func textField( - _ textField: UITextField, - shouldChangeCharactersIn range: NSRange, - replacementString string: String - ) -> Bool { - - let nsString: NSString? = textField.text as NSString? - let newString: String = nsString?.replacingCharacters(in: range, with: string) ?? "" - - return newString.count < self.maxCharacter + 1 - } - - func textFieldDidChangeSelection(_ textField: UITextField) { - - let text = textField.text ?? "" - self.characterLabel.text = text.count.description + "/" + self.maxCharacter.description - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } -} - -extension String { - - var isConsonant: Bool { - guard let scalar = UnicodeScalar(self)?.value else { return false } - let consonantScalarRange: ClosedRange = 12593...12622 - return consonantScalarRange ~= scalar - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagHeaderView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagHeaderView.swift new file mode 100644 index 00000000..c8408014 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagHeaderView.swift @@ -0,0 +1,64 @@ +// +// FavoriteTagHeaderView.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +class FavoriteTagHeaderView: UIView { + + // MARK: Views + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.title1 + } + + + // MARK: Varaibles + + var title: String? { + set { + self.titleLabel.text = newValue + self.titleLabel.typography = .som.v2.title1 + } + get { + return self.titleLabel.text + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.snp.makeConstraints { + $0.height.equalTo(59) + } + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(24) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagPlaceholderViewCell.swift new file mode 100644 index 00000000..b701d6a2 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagPlaceholderViewCell.swift @@ -0,0 +1,69 @@ +// +// FavoriteTagPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/19/25. +// + +import UIKit + +import SnapKit +import Then + +class FavoriteTagPlaceholderViewCell: UICollectionViewCell { + + enum Text { + static let message: String = "관심 태그가 포함된 카드가 작성되면\n알림을 받을 수 있어요" + } + + static let cellIdentifier = String(reflecting: FavoriteTagPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.star)))) + $0.tintColor = .som.v2.gray200 + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + $0.numberOfLines = 0 + } + + + // MARK: Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + /// bottom padding + placeholderMessageLabel height + let offset = (8 + 42) * 0.5 + $0.centerY.equalToSuperview().offset(-offset) + $0.centerX.equalToSuperview() + $0.size.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagViewCell.swift new file mode 100644 index 00000000..851906e6 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagViewCell.swift @@ -0,0 +1,145 @@ +// +// FavoriteTagViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/19/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class FavoriteTagViewCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: FavoriteTagViewCell.self) + + + // MARK: Views + + private let container = UIStackView().then { + $0.axis = .vertical + $0.alignment = .top + $0.distribution = .equalSpacing + $0.spacing = 0 + } + + + // MARK: Variables + + private(set) var model: FavoriteTagsViewModel? + + let favoriteIconDidTap = PublishRelay() + let backgroundDidTap = PublishRelay() + + var disposeBag = DisposeBag() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.container.arrangedSubviews.forEach { $0.removeFromSuperview() } + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + /// trailing padding + iconView padding + let offset = 16 + 12 + $0.trailing.equalToSuperview().offset(-offset) + } + } + + + // MARK: Public func + + func setModels(_ model: FavoriteTagsViewModel) { + + self.model = model + + self.container.arrangedSubviews.forEach { $0.removeFromSuperview() } + + model.tags.forEach { model in + + let label = UILabel().then { + $0.text = model.text + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle1 + } + + let iconView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.star)))) + $0.tintColor = model.isFavorite ? .som.v2.yMain : .som.v2.gray200 + } + + let separator = UIView().then { + $0.backgroundColor = .som.v2.gray200 + } + + let itemContainer = UIView() + itemContainer.addSubview(label) + label.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + } + itemContainer.addSubview(iconView) + iconView.snp.makeConstraints { + $0.centerY.trailing.equalToSuperview() + $0.size.equalTo(24) + } + itemContainer.addSubview(separator) + separator.snp.makeConstraints { + $0.bottom.horizontalEdges.equalToSuperview() + $0.height.equalTo(1) + } + self.container.addArrangedSubview(itemContainer) + itemContainer.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width - 16 * 2 - 12) + $0.height.equalTo(48) + } + + itemContainer.rx.tapGesture() + .throttle(.seconds(2), scheduler: MainScheduler.instance) + .when(.recognized) + .subscribe(with: self) { object, gesture in + guard itemContainer.isTappedDirectly(gesture: gesture) else { return } + + object.backgroundDidTap.accept(model) + } + .disposed(by: self.disposeBag) + + iconView.rx.tapGesture() + .throttle(.seconds(1), scheduler: MainScheduler.instance) + .when(.recognized) + .subscribe(with: self.favoriteIconDidTap) { favoriteIconDidTap, _ in + favoriteIconDidTap.accept(model) + } + .disposed(by: self.disposeBag) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagViewModel.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagViewModel.swift new file mode 100644 index 00000000..fdb9617a --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagViewModel.swift @@ -0,0 +1,42 @@ +// +// FavoriteTagViewModel.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +class FavoriteTagViewModel { + + // let identifier: String + let id: String + let text: String + var isFavorite: Bool + + init( + id: String, + text: String, + isFavorite: Bool = true + ) { + // self.identifier = UUID().uuidString + self.id = id + self.text = text + self.isFavorite = isFavorite + } +} + +extension FavoriteTagViewModel: Hashable { + + static func == (lhs: FavoriteTagViewModel, rhs: FavoriteTagViewModel) -> Bool { + return /* lhs.identifier == rhs.identifier && */ + lhs.text == rhs.text && + lhs.isFavorite == rhs.isFavorite + } + + func hash(into hasher: inout Hasher) { + // hasher.combine(self.identifier) + hasher.combine(self.text) + hasher.combine(self.isFavorite) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagsView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagsView.swift new file mode 100644 index 00000000..8d431b30 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagsView.swift @@ -0,0 +1,322 @@ +// +// FavoriteTagsView.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class FavoriteTagsView: UIView { + + enum Section: Int, CaseIterable { + case main + case empty + } + + enum Item: Hashable { + case main(FavoriteTagsViewModel) + case empty + } + + + // MARK: Views + + private let indicatorContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .center + $0.distribution = .equalSpacing + $0.spacing = 4 + + $0.isHidden = true + } + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .horizontal + $0.minimumLineSpacing = 0 + $0.minimumInteritemSpacing = 0 + $0.sectionInset = .zero + } + ).then { + $0.backgroundColor = .clear + + $0.alwaysBounceHorizontal = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .zero + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.isPagingEnabled = true + + $0.register(FavoriteTagViewCell.self, forCellWithReuseIdentifier: FavoriteTagViewCell.cellIdentifier) + $0.register(FavoriteTagPlaceholderViewCell.self, forCellWithReuseIdentifier: FavoriteTagPlaceholderViewCell.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item -> UICollectionViewCell in + + switch item { + case let .main(models): + + let cell: FavoriteTagViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: FavoriteTagViewCell.cellIdentifier, + for: indexPath + ) as! FavoriteTagViewCell + cell.setModels(models) + + cell.backgroundDidTap + .subscribe(with: self) { object, model in + object.backgroundDidTap.accept(model) + } + .disposed(by: cell.disposeBag) + + cell.favoriteIconDidTap + .subscribe(with: self) { object, model in + object.favoriteIconDidTap.accept(model) + + guard var new = object.models, + let index = new.firstIndex(where: { $0 == model }) + else { return } + + new[index].isFavorite.toggle() + + object.setModels(new) + } + .disposed(by: cell.disposeBag) + + return cell + case .empty: + + let placeholder: FavoriteTagPlaceholderViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: FavoriteTagPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! FavoriteTagPlaceholderViewCell + + return placeholder + } + } + + private var currentIndexForIndicator: Int = 0 { + didSet { + + guard oldValue != self.currentIndexForIndicator else { return } + + self.indicatorContainer.subviews.enumerated().forEach { index, indicator in + indicator.backgroundColor = index == self.currentIndexForIndicator ? .som.v2.black : .som.v2.gray300 + } + } + } + + private(set) var models: [FavoriteTagViewModel]? + + private var collectionViewHeightConstraint: Constraint? + + let favoriteIconDidTap = PublishRelay() + let backgroundDidTap = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + let container = UIStackView(arrangedSubviews: [self.collectionView, self.indicatorContainer]).then { + $0.axis = .vertical + $0.alignment = .center + $0.distribution = .equalSpacing + $0.spacing = 12 + } + self.addSubview(container) + container.snp.makeConstraints { + $0.edges.equalToSuperview() + } + self.collectionView.snp.makeConstraints { + $0.width.equalTo(container.snp.width) + self.collectionViewHeightConstraint = $0.height.equalTo(0).constraint + } + self.indicatorContainer.snp.makeConstraints { + $0.height.equalTo(6) + } + } + + + // MARK: Public func + + func setModels(_ models: [FavoriteTagViewModel]) { + + self.models = models + + let slicedBySize = models.sliceBySize(into: 3).map { FavoriteTagsViewModel(tags: $0) } + + self.indicatorContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + var finalCollectionViewHeight: CGFloat = 0 + if slicedBySize.isEmpty { + snapshot.appendItems([.empty], toSection: .empty) + + finalCollectionViewHeight = 144 + self.indicatorContainer.isHidden = true + } else { + if slicedBySize.count > 1 { + + for index in 0.. 1 { + guard let first = slicedBySize.first, + let last = slicedBySize.last + else { return slicedBySize } + + let toFirst = FavoriteTagsViewModel(tags: first.tags) + let toLast = FavoriteTagsViewModel(tags: last.tags) + + return [toLast] + slicedBySize + [toFirst] + } else { + return slicedBySize + } + } + + let items = infiniteModels.map { Item.main($0) } + snapshot.appendItems(items, toSection: .main) + + let indicatorIsHidden = slicedBySize.count < 2 + self.indicatorContainer.isHidden = indicatorIsHidden + + let indicatorHiddenHeight = CGFloat(slicedBySize[0].tags.count * 48) + let indicatorVisibleHeight = CGFloat(48 * 3 + 12 + 6) + finalCollectionViewHeight = indicatorIsHidden ? indicatorHiddenHeight : indicatorVisibleHeight + } + + self.collectionViewHeightConstraint?.update(offset: finalCollectionViewHeight) + + self.layoutIfNeeded() + + self.dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + guard let self = self, slicedBySize.count > 1 else { return } + + DispatchQueue.main.async { + let newIndexForIndicator = self.currentIndexForIndicator == 0 ? 1 : self.currentIndexForIndicator + 1 + let initialIndexPath: IndexPath = IndexPath(item: newIndexForIndicator, section: Section.main.rawValue) + self.collectionView.scrollToItem( + at: initialIndexPath, + at: .centeredHorizontally, + animated: false + ) + } + } + } +} + + +// MARK: UICollectionViewDelegateFlowLayout and UIScrollViewDelegate + +extension FavoriteTagsView: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + let width = collectionView.bounds.width + let placeholderSize = CGSize(width: width, height: 144) + guard let item = self.dataSource.itemIdentifier(for: indexPath), + let models = self.models + else { return placeholderSize } + + let modelsCount = ceil(Double(models.count) / 3) + + switch item { + case let .main(models): + + var height: Int { + return modelsCount > 1 ? 48 * 3 + 12 + 6 : 48 * models.tags.count + } + return CGSize(width: width, height: CGFloat(height)) + case .empty: + + return placeholderSize + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + + self.infiniteScroll(scrollView) + } + + private func infiniteScroll(_ scrollView: UIScrollView) { + + guard let models = self.models else { return } + + let slicedCount = Int(ceil(Double(models.count) / 3.0)) + guard slicedCount > 1 else { return } + + let cellWidth: CGFloat = scrollView.bounds.width + let currentIndex: Int = Int(round(scrollView.contentOffset.x / cellWidth)) + + var targetIndex: Int? { + switch currentIndex { + case 0: return slicedCount + case slicedCount + 1: return 1 + default: return nil + } + } + + guard let targetIndex = targetIndex else { + self.currentIndexForIndicator = currentIndex - 1 + return + } + + let targetIndexPath: IndexPath = IndexPath(item: targetIndex, section: Section.main.rawValue) + self.collectionView.scrollToItem( + at: targetIndexPath, + at: .centeredHorizontally, + animated: false + ) + self.currentIndexForIndicator = targetIndex - 1 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagsViewModel.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagsViewModel.swift new file mode 100644 index 00000000..e4ef0963 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/FavoriteTagsViewModel.swift @@ -0,0 +1,31 @@ +// +// FavoriteTagsViewModel.swift +// SOOUM +// +// Created by 오현식 on 11/25/25. +// + +import UIKit + +class FavoriteTagsViewModel { + + let identifier: String + var tags: [FavoriteTagViewModel] + + init(tags: [FavoriteTagViewModel]) { + self.identifier = UUID().uuidString + self.tags = tags + } +} + +extension FavoriteTagsViewModel: Hashable { + + static func == (lhs: FavoriteTagsViewModel, rhs: FavoriteTagsViewModel) -> Bool { + return lhs.identifier == rhs.identifier && lhs.tags == rhs.tags + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.identifier) + hasher.combine(self.tags) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingScrollViewHeader.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagHeaderView.swift similarity index 57% rename from SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingScrollViewHeader.swift rename to SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagHeaderView.swift index 4777aa6b..07be3e04 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingScrollViewHeader.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagHeaderView.swift @@ -1,8 +1,8 @@ // -// SettingScrollViewHeader.swift +// PopularTagHeaderView.swift // SOOUM // -// Created by 오현식 on 12/4/24. +// Created by 오현식 on 11/20/25. // import UIKit @@ -10,14 +10,18 @@ import UIKit import SnapKit import Then - -class SettingScrollViewHeader: UIView { +class PopularTagHeaderView: UIView { + + // MARK: Views private let titleLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.title1 } + + // MARK: Initialize + convenience init(title: String) { self.init(frame: .zero) @@ -34,19 +38,20 @@ class SettingScrollViewHeader: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { self.snp.makeConstraints { - $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(36) + $0.height.equalTo(75) } self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(4) - $0.bottom.equalToSuperview().offset(-8) - $0.leading.equalToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) + $0.top.equalToSuperview().offset(40) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagViewCell.swift new file mode 100644 index 00000000..b79f0836 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagViewCell.swift @@ -0,0 +1,129 @@ +// +// PopularTagViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +class PopularTagViewCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: PopularTagViewCell.self) + + + // MARK: Views + + private let container = UIView() + + private let numberLabel = UILabel().then { + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.title2 + } + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle1 + $0.lineBreakMode = .byTruncatingTail + } + + private let countLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 + } + + + // MARK: Variables + + private(set) var model: TagInfo? + + override var isHighlighted: Bool { + didSet { + if oldValue != self.isHighlighted { + self.updateColors(self.isHighlighted) + } + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.numberLabel.text = nil + self.titleLabel.text = nil + self.countLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.numberLabel) + self.numberLabel.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + } + + self.contentView.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(4) + $0.leading.equalToSuperview().offset(32) + $0.trailing.lessThanOrEqualToSuperview().offset(-12) + } + + self.contentView.addSubview(self.countLabel) + self.countLabel.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.bottom).offset(2) + $0.bottom.equalToSuperview().offset(-4) + $0.leading.equalToSuperview().offset(32) + $0.trailing.lessThanOrEqualToSuperview().offset(-12) + } + + self.contentView.addSubview(self.container) + self.container.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func updateColors(_ isTouchesBegan: Bool) { + + self.numberLabel.textColor = isTouchesBegan ? .som.v2.pLight2 : .som.v2.pDark + self.titleLabel.textColor = isTouchesBegan ? .som.v2.gray400 : .som.v2.gray600 + self.countLabel.textColor = isTouchesBegan ? .som.v2.gray300 : .som.v2.gray500 + } + + + // MARK: Public func + + func setModel(_ model: TagInfo, with number: Int) { + + self.model = model + + self.numberLabel.text = "\(number)" + self.numberLabel.typography = .som.v2.title2 + + self.titleLabel.text = model.name + self.titleLabel.typography = .som.v2.subtitle1 + + self.countLabel.text = "\(model.usageCnt)" + self.countLabel.typography = .som.v2.caption2 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagsView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagsView.swift new file mode 100644 index 00000000..6ea68ba7 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Cells/PopularTagsView.swift @@ -0,0 +1,159 @@ +// +// PopularTagsView.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class PopularTagsView: UIView { + + enum Section: Int, CaseIterable { + case main + } + + enum Item: Hashable { + case main(TagInfo) + } + + + // MARK: Views + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .vertical + $0.minimumLineSpacing = 0 + $0.minimumInteritemSpacing = 0 + $0.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16) + } + ).then { + $0.backgroundColor = .clear + + $0.alwaysBounceVertical = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .zero + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(PopularTagViewCell.self, forCellWithReuseIdentifier: PopularTagViewCell.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + + switch item { + case let .main(model): + + let cell: PopularTagViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: PopularTagViewCell.cellIdentifier, + for: indexPath + ) as! PopularTagViewCell + cell.setModel(model, with: indexPath.item + 1) + + return cell + } + } + + private(set) var models: [TagInfo]? + + private var collectionViewHeightConstraint: Constraint? + + let backgroundDidTap = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + self.collectionViewHeightConstraint = $0.height.equalTo(0).constraint + } + } + + + // MARK: Public func + + func setModels(_ models: [TagInfo]) { + + self.models = models + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + guard models.isEmpty == false else { + snapshot.appendItems([], toSection: .main) + self.dataSource.apply(snapshot, animatingDifferences: false) + return + } + + let items = models.map { Item.main($0) } + snapshot.appendItems(items, toSection: .main) + + self.collectionViewHeightConstraint?.update(offset: 52 * CGFloat(models.count / 2)) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension PopularTagsView: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + if case let .main(model) = item { + self.backgroundDidTap.accept(model) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return .zero } + + switch item { + case .main: + + let width = (collectionView.bounds.width - 16 * 2) * 0.5 + return CGSize(width: width, height: 52) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectCardViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectCardViewCell.swift new file mode 100644 index 00000000..4806a7fa --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectCardViewCell.swift @@ -0,0 +1,103 @@ +// +// TagCollectCardViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +class TagCollectCardViewCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: TagCollectCardViewCell.self) + + + // MARK: Views + + private let backgroundImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + } + + private let backgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.dim + $0.layer.cornerRadius = 4 + $0.clipsToBounds = true + } + + private let contentLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.textAlignment = .center + $0.typography = .som.v2.caption4 + $0.numberOfLines = 8 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.backgroundImageView.image = nil + self.contentLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.backgroundImageView) + self.backgroundImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.backgroundImageView.addSubview(self.backgroundDimView) + self.backgroundDimView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + $0.trailing.equalToSuperview().offset(-12) + } + + self.backgroundDimView.addSubview(self.contentLabel) + self.contentLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.bottom.equalToSuperview().offset(-6) + $0.leading.equalToSuperview().offset(8) + $0.trailing.equalToSuperview().offset(-8) + } + } + + + // MARK: Public func + + func setModel(_ model: ProfileCardInfo) { + + self.backgroundImageView.setImage(strUrl: model.imgURL, with: model.imgName) + self.contentLabel.text = model.content + self.contentLabel.textAlignment = .center + let typography: Typography + switch model.font { + case .pretendard: typography = .som.v2.caption4 + case .ridi: typography = .som.v2.ridiProfile + case .yoonwoo: typography = .som.v2.yoonwooProfile + case .kkookkkook: typography = .som.v2.kkookkkookProfile + } + self.contentLabel.typography = typography + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectCardsView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectCardsView.swift new file mode 100644 index 00000000..4725e476 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectCardsView.swift @@ -0,0 +1,254 @@ +// +// TagCollectCardsView.swift +// SOOUM +// +// Created by 오현식 on 11/22/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class TagCollectCardsView: UIView { + + enum Section: Int, CaseIterable { + case main + case empty + } + + enum Item: Hashable { + case main(ProfileCardInfo) + case empty + } + + + // MARK: Views + + let refreshControl = SOMRefreshControl() + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .vertical + $0.minimumLineSpacing = 1 + $0.minimumInteritemSpacing = 1 + $0.sectionInset = .zero + } + ).then { + $0.contentInset = .zero + + $0.contentInsetAdjustmentBehavior = .never + + $0.isScrollEnabled = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.refreshControl = self.refreshControl + + $0.register( + TagCollectCardViewCell.self, + forCellWithReuseIdentifier: TagCollectCardViewCell.cellIdentifier + ) + $0.register( + TagCollectPlaceholderViewCell.self, + forCellWithReuseIdentifier: TagCollectPlaceholderViewCell.cellIdentifier + ) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + + switch item { + case let .main(model): + + let cell: TagCollectCardViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: TagCollectCardViewCell.cellIdentifier, + for: indexPath + ) as! TagCollectCardViewCell + + cell.setModel(model) + + return cell + case .empty: + + let placeholder: TagCollectPlaceholderViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: TagCollectPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! TagCollectPlaceholderViewCell + + return placeholder + } + } + + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + /// 외부에서 입력받는 값 + var isRefreshing: Bool = false { + didSet { + if self.isRefreshing == false { + self.collectionView.refreshControl?.endRefreshing() + } + } + } + + private(set) var models: [ProfileCardInfo]? + + let cardDidTapped = PublishRelay() + let moreFindWithId = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + + // MARK: Public func + + func setModels(_ models: [ProfileCardInfo]) { + + self.models = models + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + guard models.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + self.dataSource.apply(snapshot, animatingDifferences: false) + return + } + + let new = models.map { Item.main($0) } + snapshot.appendItems(new, toSection: .main) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension TagCollectCardsView: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + if case let .main(model) = item { + self.cardDidTapped.accept(model) + } + } + + func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + + let lastItemIndexPath = collectionView.numberOfItems(inSection: Section.main.rawValue) - 1 + if let tagCardInfos = self.models, + tagCardInfos.isEmpty == false, + indexPath.section == Section.main.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = tagCardInfos.last?.id { + + self.moreFindWithId.accept(lastId) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return collectionView.bounds.size + } + + switch item { + case .empty: + return collectionView.bounds.size + default: + let width: CGFloat = (collectionView.bounds.width - 2) / 3 + return CGSize(width: width, height: width) + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.collectionView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) + + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.collectionView.refreshControl?.beginRefreshing() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectPlaceholderViewCell.swift new file mode 100644 index 00000000..39470b45 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/Cells/TagCollectPlaceholderViewCell.swift @@ -0,0 +1,68 @@ +// +// TagCollectPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +class TagCollectPlaceholderViewCell: UICollectionViewCell { + + enum Text { + static let message: String = "조회할 수 있는 카드가 없어요" + } + + static let cellIdentifier = String(reflecting: TagCollectPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.image(.v2(.detail_delete_card))) + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .clear + self.isUserInteractionEnabled = false + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + let offset = 20 + 21 + $0.centerY.equalToSuperview().offset(-offset) + $0.centerX.equalToSuperview() + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift new file mode 100644 index 00000000..f30ccd80 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift @@ -0,0 +1,280 @@ +// +// TagCollectViewController.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import UIKit + +import SnapKit +import Then + +import SwiftEntryKit + +import ReactorKit +import RxCocoa +import RxSwift + +class TagCollectViewController: BaseNavigationViewController, View { + + enum Text { + static let bottomToastEntryName: String = "bottomToastEntryName" + static let addToastMessage: String = "을 관심 태그에 추가했어요" + static let deleteToastMessage: String = "을 관심 태그에서 삭제했어요" + + static let bottomToastEntryNameWithAction: String = "bottomToastEntryNameWithAction" + static let failedToastMessage: String = "네트워크 확인 후 재시도해주세요." + static let failToastActionTitle: String = "재시도" + + static let bottomToastEntryNameWithoutAction: String = "bottomToastEntryNameWithoutAction" + static let addAdditionalLimitedFloatMessage: String = "관심 태그는 9개까지 추가할 수 있어요" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let confirmActionTitle: String = "확인" + } + + enum Section: Int, CaseIterable { + case main + case empty + } + + enum Item: Hashable { + case main(ProfileCardInfo) + case empty + } + + + // MARK: Views + + private let rightFavoriteButton = SOMButton().then { + $0.image = .init(.icon(.v2(.filled(.star)))) + $0.foregroundColor = .som.v2.yMain + } + + private let tagCollectCardsView = TagCollectCardsView() + + + // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = self.reactor?.title ?? "" + + self.rightFavoriteButton.snp.makeConstraints { + $0.size.equalTo(24) + } + self.navigationBar.setRightButtons([self.rightFavoriteButton]) + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.tagCollectCardsView) + self.tagCollectCardsView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + override func bind() { + /// 뒤로가기로 TagViewController를 표시할 때, 관심 태그만 리로드 + self.navigationBar.backButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if object.reactor?.initialState.isFavorite != object.reactor?.currentState.isFavorite { + NotificationCenter.default.post( + name: .reloadFavoriteTagData, + object: nil, + userInfo: nil + ) + } + object.navigationPop() + } + .disposed(by: self.disposeBag) + } + + + // MARK: ReactorKit - bind + + func bind(reactor: TagCollectViewReactor) { + + // 상세화면 전환 + self.tagCollectCardsView.cardDidTapped + .map(\.id) + .throttle(.seconds(3), scheduler: MainScheduler.instance) + .map(Reactor.Action.hasDetailCard) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.tagCollectCardsView.moreFindWithId + .map(Reactor.Action.more) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isFavorite = reactor.state.map(\.isFavorite).share() + self.rightFavoriteButton.rx.throttleTap + .withLatestFrom(isFavorite) + .map(Reactor.Action.updateIsFavorite) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.tagCollectCardsView.refreshControl.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tagCollectCardsView) { tagCollectCardsView, _ in + tagCollectCardsView.isRefreshing = false + } + .disposed(by: self.disposeBag) + + isFavorite + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFavorite in + object.rightFavoriteButton.foregroundColor = isFavorite ? .som.v2.yMain : .som.v2.gray200 + } + .disposed(by: self.disposeBag) + + let isUpdated = reactor.state.map(\.isUpdated).distinctUntilChanged().filterNil() + isUpdated + .filter { $0 } + .withLatestFrom(isFavorite) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFavorite in + + if isFavorite { + GAHelper.shared.logEvent(event: GAEvent.TagView.favoriteTagRegister_btn_click) + } + + let message = isFavorite ? Text.addToastMessage : Text.deleteToastMessage + let bottomToastView = SOMBottomToastView( + title: "‘\(reactor.title)’" + message, + actions: nil + ) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 8) + } + .disposed(by: self.disposeBag) + + isUpdated + .filter { $0 == false } + .withLatestFrom(isFavorite) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFavorite in + + let actions = [ + SOMBottomToastView.ToastAction(title: Text.failToastActionTitle, action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomToastEntryNameWithAction)) { + reactor.action.onNext(.updateIsFavorite(isFavorite)) + } + }) + ] + let bottomToastView = SOMBottomToastView(title: Text.failedToastMessage, actions: actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryNameWithAction + wrapper.showBottomToast(verticalOffset: 34 + 8) + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) + .distinctUntilChanged() + .filterNil() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let bottomToastView = SOMBottomToastView(title: Text.addAdditionalLimitedFloatMessage, actions: nil) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryNameWithoutAction + wrapper.showBottomToast(verticalOffset: 34 + 8) + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.tagCardInfos) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, tagCardInfos in + + object.tagCollectCardsView.setModels(tagCardInfos) + } + .disposed(by: self.disposeBag) + + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .map(\.selectedId) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + object.showPungedCardDialog(reactor, with: selectedId) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(selectedId) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .tag_collect + ) + ) + } + } + .disposed(by: self.disposeBag) + } +} + +extension TagCollectViewController { + + func showPungedCardDialog(_ reactor: TagCollectViewReactor, with selectedId: String) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext( + .updateTagCards( + reactor.currentState.tagCardInfos.filter { $0.id != selectedId } + ) + ) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift new file mode 100644 index 00000000..d257a20e --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift @@ -0,0 +1,192 @@ +// +// TagCollectViewReactor.swift +// SOOUM +// +// Created by 오현식 on 11/20/25. +// + +import ReactorKit + +class TagCollectViewReactor: Reactor { + + enum Action: Equatable { + case landing + case refresh + case more(String) + case updateTagCards([ProfileCardInfo]) + case hasDetailCard(String) + case updateIsFavorite(Bool) + case cleanup + } + + enum Mutation { + case tagCardInfos([ProfileCardInfo]) + case moreFind([ProfileCardInfo]) + case cardIsDeleted((String, Bool)?) + case updateIsFavorite(Bool) + case updateIsUpdate(Bool?) + case updateIsRefreshing(Bool) + case updateHasErrors(Bool?) + } + + struct State { + fileprivate(set) var tagCardInfos: [ProfileCardInfo] + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? + fileprivate(set) var isFavorite: Bool + fileprivate(set) var isUpdated: Bool? + fileprivate(set) var isRefreshing: Bool + fileprivate(set) var hasErrors: Bool? + } + + var initialState: State + + private let dependencies: AppDIContainerable + private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase + private let updateTagFavoriteUseCase: UpdateTagFavoriteUseCase + + private let id: String + let title: String + + init(dependencies: AppDIContainerable, with id: String, title: String, isFavorite: Bool) { + self.dependencies = dependencies + self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) + self.updateTagFavoriteUseCase = dependencies.rootContainer.resolve(UpdateTagFavoriteUseCase.self) + + self.id = id + self.title = title + + self.initialState = .init( + tagCardInfos: [], + cardIsDeleted: nil, + isFavorite: isFavorite, + isUpdated: nil, + isRefreshing: false, + hasErrors: nil + ) + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return self.fetchCardUseCase.cardsWithTag(tagId: self.id, lastId: nil) + .flatMapLatest { tagCardsInfo -> Observable in + + let isFavorite = tagCardsInfo.cardInfos.isEmpty ? + self.initialState.isFavorite : + tagCardsInfo.isFavorite + return .concat([ + .just(.tagCardInfos(tagCardsInfo.cardInfos)), + .just(.updateIsFavorite(isFavorite)) + ]) + } + case .refresh: + + return .concat([ + .just(.updateIsRefreshing(true)), + self.fetchCardUseCase.cardsWithTag(tagId: self.id, lastId: nil) + .flatMapLatest { tagCardsInfo -> Observable in + + let isFavorite = tagCardsInfo.cardInfos.isEmpty ? + self.initialState.isFavorite : + tagCardsInfo.isFavorite + return .concat([ + .just(.tagCardInfos(tagCardsInfo.cardInfos)), + .just(.updateIsFavorite(isFavorite)) + ]) + }, + .just(.updateIsRefreshing(false)) + ]) + case let .more(lastId): + + return self.fetchCardUseCase.cardsWithTag(tagId: self.id, lastId: lastId) + .map(\.cardInfos) + .map(Mutation.moreFind) + case let .updateTagCards(tagCardInfos): + + return .just(.tagCardInfos(tagCardInfos)) + case let .hasDetailCard(selectedId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) + ]) + case let .updateIsFavorite(isFavorite): + + return .concat([ + .just(.updateIsUpdate(nil)), + .just(.updateHasErrors(nil)), + self.updateTagFavoriteUseCase.updateFavorite(tagId: self.id, isFavorite: !isFavorite) + .flatMapLatest { isUpdated -> Observable in + + let isFavorite = isUpdated ? !isFavorite : isFavorite + return .concat([ + .just(.updateIsFavorite(isFavorite)), + .just(.updateIsUpdate(isUpdated)) + ]) + } + .catch(self.catchClosure) + ]) + case .cleanup: + + return .just(.cardIsDeleted(nil)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState: State = state + switch mutation { + case let .tagCardInfos(tagCardInfos): + newState.tagCardInfos = tagCardInfos + case let .moreFind(tagCardInfos): + newState.tagCardInfos += tagCardInfos + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted + case let .updateIsFavorite(isFavorite): + newState.isFavorite = isFavorite + case let .updateIsUpdate(isUpdated): + newState.isUpdated = isUpdated + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + case let .updateHasErrors(hasErrors): + newState.hasErrors = hasErrors + } + return newState + } +} + +extension TagCollectViewReactor { + + var catchClosure: ((Error) throws -> Observable) { + return { error in + + let nsError = error as NSError + + if case 400 = nsError.code { + + return .just(.updateHasErrors(true)) + } + + return .just(.updateIsUpdate(false)) + } + } + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } +} + +extension TagCollectViewReactor { + + func reactorForDetail(_ selectedId: String) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: selectedId) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermPlaceholderViewCell.swift new file mode 100644 index 00000000..4362d14d --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermPlaceholderViewCell.swift @@ -0,0 +1,68 @@ +// +// SearchTermPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/23/25. +// + +import UIKit + +import SnapKit +import Then + +class SearchTermPlaceholderViewCell: UICollectionViewCell { + + enum Text { + static let message: String = "검색 결과가 없어요" + } + + static let cellIdentifier = String(reflecting: SearchTermPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.image(.v2(.placeholder_notification))) + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .clear + self.isUserInteractionEnabled = false + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + let offset = 20 + 21 + $0.centerY.equalToSuperview().offset(-offset) + $0.centerX.equalToSuperview() + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermViewCell.swift new file mode 100644 index 00000000..1b699bcd --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermViewCell.swift @@ -0,0 +1,153 @@ +// +// SearchTermViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/22/25. +// + +import UIKit + +import SnapKit +import Then + +class SearchTermViewCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: SearchTermViewCell.self) + + + // MARK: Views + + private let container = UIView() + + private let iconView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.hash)))) + $0.tintColor = .som.v2.gray400 + } + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 + $0.lineBreakMode = .byTruncatingTail + } + + private let countLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption3 + } + + + // MARK: Variables + + private(set) var model: TagInfo? + + override var isHighlighted: Bool { + didSet { + if oldValue != self.isHighlighted { + self.updateColors(self.isHighlighted) + } + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.titleLabel.text = nil + self.countLabel.text = nil + } + + // Set highlighted color +// override func touchesBegan(_ touches: Set, with event: UIEvent?) { +// +// guard let touch = touches.first else { return } +// +// let location = touch.location(in: self.container) +// if self.container.frame.contains(location) { +// +// self.updateColors(true) +// } +// +// super.touchesBegan(touches, with: event) +// } +// +// override func touchesEnded(_ touches: Set, with event: UIEvent?) { +// +// self.updateColors(false) +// +// super.touchesEnded(touches, with: event) +// } +// +// override func touchesCancelled(_ touches: Set, with event: UIEvent?) { +// +// self.updateColors(false) +// +// super.touchesCancelled(touches, with: event) +// } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.iconView) + self.iconView.snp.makeConstraints { + $0.centerY.leading.equalToSuperview() + $0.size.equalTo(16) + } + + self.contentView.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.leading.equalTo(self.iconView.snp.trailing).offset(10) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + + self.contentView.addSubview(self.countLabel) + self.countLabel.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.bottom) + $0.bottom.equalToSuperview().offset(-6) + $0.leading.equalTo(self.iconView.snp.trailing).offset(10) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + + self.contentView.addSubview(self.container) + self.container.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + private func updateColors(_ isTouchesBegan: Bool) { + + self.titleLabel.textColor = isTouchesBegan ? .som.v2.gray500 : .som.v2.black + self.countLabel.textColor = isTouchesBegan ? .som.v2.gray400 : .som.v2.gray500 + } + + + // MARK: Public func + + func setModel(_ model: TagInfo) { + + self.model = model + + self.titleLabel.text = model.name + self.titleLabel.typography = .som.v2.body1 + + self.countLabel.text = "\(model.usageCnt)" + self.countLabel.typography = .som.v2.caption3 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermsView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermsView.swift new file mode 100644 index 00000000..2601ee5e --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Cells/SearchTermsView.swift @@ -0,0 +1,183 @@ +// +// SearchTermsView.swift +// SOOUM +// +// Created by 오현식 on 11/22/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class SearchTermsView: UIView { + + enum Section: Int, CaseIterable { + case main + case empty + } + + enum Item: Hashable { + case main(TagInfo) + case empty + } + + + // MARK: Views + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .vertical + $0.minimumLineSpacing = 0 + $0.minimumInteritemSpacing = 0 + $0.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16) + } + ).then { + $0.backgroundColor = .clear + + $0.alwaysBounceVertical = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .zero + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(SearchTermViewCell.self, forCellWithReuseIdentifier: SearchTermViewCell.cellIdentifier) + $0.register(SearchTermPlaceholderViewCell.self, forCellWithReuseIdentifier: SearchTermPlaceholderViewCell.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + + switch item { + case let .main(model): + + let cell: SearchTermViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: SearchTermViewCell.cellIdentifier, + for: indexPath + ) as! SearchTermViewCell + cell.setModel(model) + + return cell + case .empty: + + let placeholder: SearchTermPlaceholderViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: SearchTermPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! SearchTermPlaceholderViewCell + + return placeholder + } + } + + private(set) var models: [TagInfo]? + + let backgroundDidTap = PublishRelay() + let didScrolled = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + + // MARK: Public func + + func setModels(_ models: [TagInfo], with returnKeyDidTap: Bool = false) { + + self.models = models + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + guard models.isEmpty == false else { + if returnKeyDidTap { + snapshot.appendItems([.empty], toSection: .empty) + } else { + snapshot.deleteSections(Section.allCases) + } + self.dataSource.apply(snapshot, animatingDifferences: false) + return + } + + let items = models.map { Item.main($0) } + snapshot.appendItems(items, toSection: .main) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension SearchTermsView: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + if case let .main(model) = item { + self.backgroundDidTap.accept(model) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return collectionView.bounds.size + } + + switch item { + case .main: + + let width = (collectionView.bounds.width - 16 * 2) + return CGSize(width: width, height: 48) + case .empty: + + return collectionView.bounds.size + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + self.didScrolled.accept(()) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift new file mode 100644 index 00000000..fd70de41 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift @@ -0,0 +1,273 @@ +// +// TagSearchCollectViewController.swift +// SOOUM +// +// Created by 오현식 on 11/24/25. +// + +import UIKit + +import SnapKit +import Then + +import SwiftEntryKit + +import ReactorKit +import RxCocoa +import RxSwift + +class TagSearchCollectViewController: BaseNavigationViewController, View { + + enum Text { + static let bottomToastEntryName: String = "bottomToastEntryName" + static let addToastMessage: String = "을 관심 태그에 추가했어요" + static let deleteToastMessage: String = "을 관심 태그에서 삭제했어요" + + static let bottomToastEntryNameWithAction: String = "bottomToastEntryNameWithAction" + static let failedToastMessage: String = "네트워크 확인 후 재시도해주세요." + static let failToastActionTitle: String = "재시도" + + static let bottomToastEntryNameWithoutAction: String = "bottomToastEntryNameWithoutAction" + static let addAdditionalLimitedFloatMessage: String = "관심 태그는 9개까지 추가할 수 있어요" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let confirmActionTitle: String = "확인" + } + + + // MARK: Views + + private let searchViewButtonView = SearchViewButton() + + private let rightFavoriteButton = SOMButton().then { + $0.image = .init(.icon(.v2(.filled(.star)))) + $0.foregroundColor = .som.v2.yMain + } + + private let tagCollectCardsView = TagCollectCardsView() + + + // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.searchViewButtonView.snp.makeConstraints { + let leftAndRightButtonsWidth: CGFloat = 24 * 2 + let leftAndRightPadding: CGFloat = 12 * 2 + let width = UIScreen.main.bounds.width - leftAndRightButtonsWidth - leftAndRightPadding + $0.width.equalTo(width) + $0.height.equalTo(44) + } + self.rightFavoriteButton.snp.makeConstraints { + $0.size.equalTo(24) + } + + self.navigationBar.titleView = self.searchViewButtonView + + self.navigationBar.setLeftButtons([]) + self.navigationBar.setRightButtons([self.rightFavoriteButton]) + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.tagCollectCardsView) + self.tagCollectCardsView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(8) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + + // MARK: ReactorKit - bind + + func bind(reactor: TagSearchCollectViewReactor) { + + // 상세 화면 전환 + self.tagCollectCardsView.cardDidTapped + .map(\.id) + .throttle(.seconds(3), scheduler: MainScheduler.instance) + .map(Reactor.Action.hasDetailCard) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let viewDidLoad = self.rx.viewDidLoad + // 네비게이션 타이틀 + viewDidLoad + .subscribe(with: self) { object, _ in + object.searchViewButtonView.placeholder = reactor.title + } + .disposed(by: self.disposeBag) + + // Action + viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.tagCollectCardsView.moreFindWithId + .map(Reactor.Action.more) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isFavorite = reactor.state.map(\.isFavorite).share() + self.rightFavoriteButton.rx.throttleTap + .withLatestFrom(isFavorite) + .map(Reactor.Action.updateIsFavorite) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.tagCollectCardsView.refreshControl.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .observe(on: MainScheduler.asyncInstance) + .filter { $0 == false } + .subscribe(with: self.tagCollectCardsView) { tagCollectCardsView, _ in + tagCollectCardsView.isRefreshing = false + } + .disposed(by: self.disposeBag) + + isFavorite + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFavorite in + object.rightFavoriteButton.foregroundColor = isFavorite ? .som.v2.yMain : .som.v2.gray200 + } + .disposed(by: self.disposeBag) + + let isUpdated = reactor.state.map(\.isUpdated).distinctUntilChanged().filterNil() + isUpdated + .filter { $0 } + .withLatestFrom(isFavorite) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFavorite in + + if isFavorite { + GAHelper.shared.logEvent(event: GAEvent.TagView.favoriteTagRegister_btn_click) + } + + let message = isFavorite ? Text.addToastMessage : Text.deleteToastMessage + let bottomToastView = SOMBottomToastView( + title: "‘\(reactor.title)’" + message, + actions: nil + ) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 8) + } + .disposed(by: self.disposeBag) + + isUpdated + .filter { $0 == false } + .withLatestFrom(isFavorite) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFavorite in + + let actions = [ + SOMBottomToastView.ToastAction(title: Text.failToastActionTitle, action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomToastEntryNameWithAction)) { + reactor.action.onNext(.updateIsFavorite(isFavorite)) + } + }) + ] + let bottomToastView = SOMBottomToastView(title: Text.failedToastMessage, actions: actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryNameWithAction + wrapper.showBottomToast(verticalOffset: 34 + 8) + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) + .distinctUntilChanged() + .filterNil() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let bottomToastView = SOMBottomToastView(title: Text.addAdditionalLimitedFloatMessage, actions: nil) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryNameWithoutAction + wrapper.showBottomToast(verticalOffset: 34 + 8) + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.tagCardInfos) + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, tagCardInfos in + + object.tagCollectCardsView.setModels(tagCardInfos) + } + .disposed(by: self.disposeBag) + + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + object.showPungedCardDialog(reactor, with: selectedId) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(with: selectedId) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .tag_search_collect + ) + ) + } + } + .disposed(by: self.disposeBag) + } +} + +private extension TagSearchCollectViewController { + + func showPungedCardDialog(_ reactor: TagSearchCollectViewReactor, with selectedId: String) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext( + .updateTagCards( + reactor.currentState.tagCardInfos.filter { $0.id != selectedId } + ) + ) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift new file mode 100644 index 00000000..5be7ac67 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift @@ -0,0 +1,202 @@ +// +// TagSearchCollectViewReactor.swift +// SOOUM +// +// Created by 오현식 on 11/24/25. +// + +import ReactorKit + +class TagSearchCollectViewReactor: Reactor { + + enum Action: Equatable { + case landing + case refresh + case more(String) + case updateTagCards([ProfileCardInfo]) + case hasDetailCard(String) + case updateIsFavorite(Bool) + case cleanup + } + + enum Mutation { + case tagCardInfos([ProfileCardInfo]) + case moreFind([ProfileCardInfo]) + case cardIsDeleted((String, Bool)?) + case updateIsUpdate(Bool?) + case updateIsFavorite(Bool) + case updateIsRefreshing(Bool) + case updateHasErrors(Bool?) + } + + struct State { + fileprivate(set) var tagCardInfos: [ProfileCardInfo] + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? + fileprivate(set) var isUpdated: Bool? + fileprivate(set) var isFavorite: Bool + fileprivate(set) var isRefreshing: Bool + fileprivate(set) var hasErrors: Bool? + } + + var initialState: State = .init( + tagCardInfos: [], + cardIsDeleted: nil, + isUpdated: nil, + isFavorite: false, + isRefreshing: false, + hasErrors: nil + ) + + private let dependencies: AppDIContainerable + private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase + private let fetchTagUseCase: FetchTagUseCase + private let updateTagFavoriteUseCase: UpdateTagFavoriteUseCase + + private let tagId: String + let title: String + + init(dependencies: AppDIContainerable, with tagId: String, title: String) { + self.dependencies = dependencies + self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) + self.fetchTagUseCase = dependencies.rootContainer.resolve(FetchTagUseCase.self) + self.updateTagFavoriteUseCase = dependencies.rootContainer.resolve(UpdateTagFavoriteUseCase.self) + + self.tagId = tagId + self.title = title + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return self.cardsWithTag(with: nil) + .catchAndReturn(.tagCardInfos([])) + case .refresh: + + return .concat([ + .just(.updateIsRefreshing(true)), + self.cardsWithTag(with: nil) + .catchAndReturn(.tagCardInfos([])), + .just(.updateIsRefreshing(false)) + ]) + case let .more(lastId): + + return self.fetchCardUseCase.cardsWithTag(tagId: self.tagId, lastId: lastId) + .map(\.cardInfos) + .map(Mutation.moreFind) + case let .updateTagCards(tagCardInfos): + + return .just(.tagCardInfos(tagCardInfos)) + case let .hasDetailCard(selectedId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) + ]) + case let .updateIsFavorite(isFavorite): + + return .concat([ + .just(.updateIsUpdate(nil)), + .just(.updateHasErrors(nil)), + self.updateTagFavoriteUseCase.updateFavorite(tagId: self.tagId, isFavorite: !isFavorite) + .flatMapLatest { isUpdated -> Observable in + + let isFavorite = isUpdated ? !isFavorite : isFavorite + return .concat([ + .just(.updateIsFavorite(isFavorite)), + .just(.updateIsUpdate(isUpdated)) + ]) + } + .catch(self.catchClosure) + ]) + case .cleanup: + + return .just(.cardIsDeleted(nil)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState: State = state + switch mutation { + case let .tagCardInfos(tagCardInfos): + newState.tagCardInfos = tagCardInfos + case let .moreFind(tagCardInfos): + newState.tagCardInfos += tagCardInfos + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted + case let .updateIsUpdate(isUpdated): + newState.isUpdated = isUpdated + case let .updateIsFavorite(isFavorite): + newState.isFavorite = isFavorite + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + case let .updateHasErrors(hasErrors): + newState.hasErrors = hasErrors + } + return newState + } +} + +private extension TagSearchCollectViewReactor { + + func cardsWithTag(with lastId: String?) -> Observable { + + return self.fetchCardUseCase.cardsWithTag(tagId: self.tagId, lastId: nil) + .withUnretained(self) + .flatMapLatest { object, tagCardsInfo -> Observable in + + if tagCardsInfo.cardInfos.isEmpty { + let tagInfo = FavoriteTagInfo(id: object.tagId, title: object.title) + return object.fetchTagUseCase.isFavorites(with: tagInfo) + .flatMapLatest { isFavorite -> Observable in + + return .concat([ + .just(.tagCardInfos(tagCardsInfo.cardInfos)), + .just(.updateIsFavorite(isFavorite)) + ]) + } + } + + return .concat([ + .just(.tagCardInfos(tagCardsInfo.cardInfos)), + .just(.updateIsFavorite(tagCardsInfo.isFavorite)) + ]) + } + } + + var catchClosure: ((Error) throws -> Observable) { + return { error in + + let nsError = error as NSError + + if case 400 = nsError.code { + + return .just(.updateHasErrors(true)) + } + + return .just(.updateIsUpdate(false)) + } + } +} + +extension TagSearchCollectViewReactor { + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } +} + +extension TagSearchCollectViewReactor { + + func reactorForDetail(with id: String) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: id) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift new file mode 100644 index 00000000..238a8486 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift @@ -0,0 +1,170 @@ +// +// TagSearchViewController.swift +// SOOUM +// +// Created by 오현식 on 11/22/25. +// + +import UIKit + +import SnapKit +import Then + +import ReactorKit +import RxCocoa +import RxSwift + +class TagSearchViewController: BaseNavigationViewController, View { + + enum Text { + static let placeholderText: String = "태그를 검색하세요" + } + + + // MARK: Views + + private let searchTextFieldView = SearchTextFieldView().then { + $0.placeholder = Text.placeholderText + } + + private let searchTermsView = SearchTermsView().then { + $0.isHidden = true + } + + + // MARK: Override func + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { } + + override func setupNaviBar() { + super.setupNaviBar() + + self.searchTextFieldView.snp.makeConstraints { + let width = (UIScreen.main.bounds.width - 16 * 2) - 24 - 12 + $0.width.equalTo(width) + $0.height.equalTo(44) + } + self.navigationBar.titleView = self.searchTextFieldView + self.navigationBar.titlePosition = .left + + self.navigationBar.setRightButtons([]) + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.searchTermsView) + self.searchTermsView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(8) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + /// 기본 뒤로가기 기능 제거 + override func bind() { } + + + // MARK: ReactorKit - bind + + func bind(reactor: TagSearchViewReactor) { + + // 검색 화면 진입 시 포커스 + self.rx.viewDidAppear + .subscribe(with: self) { object, _ in + object.searchTextFieldView.becomeFirstResponder() + } + .disposed(by: self.disposeBag) + + let searchTerms = reactor.state.map(\.searchTerms).share() + /// 뒤로가기로 시 TagViewController를 표시할 때, 관심 태그만 리로드 및 검색 초기화 + self.navigationBar.backButton.rx.throttleTap + .withLatestFrom(searchTerms) + .map { $0 == nil } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isNil in + // 검색 결과가 없을 때만 + if isNil { + /// 뒤로가기로 TagViewController를 표시할 때, 관심 태그만 리로드 + NotificationCenter.default.post( + name: .reloadFavoriteTagData, + object: nil, + userInfo: nil + ) + object.navigationPop() + } else { + object.reactor?.action.onNext(.reset) + object.searchTextFieldView.text = nil + object.searchTextFieldView.resignFirstResponder() + } + } + .disposed(by: self.disposeBag) + + // 태그 카드 모아보기 화면 전환 + self.searchTermsView.backgroundDidTap + .throttle(.seconds(3), scheduler: MainScheduler.instance) + .subscribe(with: self) { object, model in + object.searchTextFieldView.text = nil + reactor.action.onNext(.reset) + + let tagSearchCollectViewController = TagSearchCollectViewController() + tagSearchCollectViewController.reactor = reactor.reactorForSearchCollect( + with: model.id, + title: model.name + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak object] in + object?.navigationPush( + tagSearchCollectViewController, + animated: true + ) + } + } + .disposed(by: self.disposeBag) + + // 스크롤 시 키보드 내림 + self.searchTermsView.didScrolled.asObservable() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in object.view.endEditing(true) } + .disposed(by: self.disposeBag) + + // Action + let searchText = self.searchTextFieldView.textField.rx.text.orEmpty.distinctUntilChanged().share() + // 태그 검색 + searchText + .skip(1) + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .map(Reactor.Action.search) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + searchTerms + .filter { $0 == nil } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + object.searchTermsView.isHidden = true + } + .disposed(by: self.disposeBag) + + searchTerms + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, searchTerms in + object.searchTermsView.setModels(searchTerms) + object.searchTermsView.isHidden = false + } + .disposed(by: self.disposeBag) + + Observable.combineLatest( + searchTerms.filterNil(), + self.searchTextFieldView.textFieldDidReturn, + resultSelector: { ($0, $1) } + ) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, searchTermInfos in + let (searchTerms, returnKeyDidTap) = searchTermInfos + + object.searchTermsView.setModels(searchTerms, with: returnKeyDidTap != nil) + object.searchTermsView.isHidden = false + } + .disposed(by: self.disposeBag) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewReactor.swift new file mode 100644 index 00000000..e1fd8d30 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewReactor.swift @@ -0,0 +1,69 @@ +// +// TagSearchViewReactor.swift +// SOOUM +// +// Created by 오현식 on 11/22/25. +// + +import ReactorKit + +class TagSearchViewReactor: Reactor { + + enum Action: Equatable { + case reset + case search(String) + } + + enum Mutation { + case searchTerms([TagInfo]?) + case updateIsUpdate(Bool?) + } + + struct State { + fileprivate(set) var searchTerms: [TagInfo]? + fileprivate(set) var isUpdated: Bool? + } + + var initialState: State = .init( + searchTerms: nil, + isUpdated: nil + ) + + private let dependencies: AppDIContainerable + private let fetchTagUseCase: FetchTagUseCase + + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.fetchTagUseCase = dependencies.rootContainer.resolve(FetchTagUseCase.self) + } + + func mutate(action: Action) -> Observable { + switch action { + case .reset: + + return .just(.searchTerms(nil)) + case let .search(terms): + + return self.fetchTagUseCase.related(keyword: terms, size: 20) + .map(Mutation.searchTerms) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState: State = state + switch mutation { + case let .searchTerms(searchTerms): + newState.searchTerms = searchTerms + case let .updateIsUpdate(isUpdated): + newState.isUpdated = isUpdated + } + return newState + } +} + +extension TagSearchViewReactor { + + func reactorForSearchCollect(with id: String, title: String) -> TagSearchCollectViewReactor { + TagSearchCollectViewReactor(dependencies: self.dependencies, with: id, title: title) + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/Views/OnboardingNicknameTextFieldView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Views/SearchTextFieldView.swift similarity index 55% rename from SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/Views/OnboardingNicknameTextFieldView.swift rename to SOOUM/SOOUM/Presentations/Main/Tags/Search/Views/SearchTextFieldView.swift index 32641fbb..673250e1 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/Views/OnboardingNicknameTextFieldView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Views/SearchTextFieldView.swift @@ -1,8 +1,8 @@ // -// OnboardingNicknameTextFieldView.swift +// SearchTextFieldView.swift // SOOUM // -// Created by JDeoks on 11/7/24. +// Created by 오현식 on 11/20/25. // import UIKit @@ -10,15 +10,22 @@ import UIKit import SnapKit import Then +import RxCocoa -class OnboardingNicknameTextFieldView: UIView { +class SearchTextFieldView: UIView { // MARK: Views + private let iconView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.search)))) + $0.tintColor = .som.v2.gray400 + } + private lazy var textFieldBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 + $0.clipsToBounds = true let gestureRecognizer = UITapGestureRecognizer( target: self, @@ -30,12 +37,12 @@ class OnboardingNicknameTextFieldView: UIView { lazy var textField = UITextField().then { let paragraphStyle = NSMutableParagraphStyle() $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle - $0.defaultTextAttributes[.foregroundColor] = UIColor.som.black - $0.defaultTextAttributes[.font] = Typography.som.body1WithRegular.font - $0.tintColor = .som.p300 + $0.defaultTextAttributes[.foregroundColor] = UIColor.som.v2.black + $0.defaultTextAttributes[.font] = Typography.som.v2.subtitle1.font + $0.tintColor = UIColor.som.v2.black $0.enablesReturnKeyAutomatically = true - $0.returnKeyType = .go + $0.returnKeyType = .search $0.autocapitalizationType = .none $0.autocorrectionType = .no @@ -47,33 +54,10 @@ class OnboardingNicknameTextFieldView: UIView { $0.delegate = self } - private let errorMessageContainer = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - - $0.isHidden = true - } - - private let errorImageView = UIImageView().then { - $0.image = .init(.image(.errorTriangle)) - $0.tintColor = .som.red - } - - private let errorMessageLabel = UILabel().then { - $0.textColor = .som.red - $0.typography = .som.body2WithBold - } - - private let characterLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body1WithRegular - } - private lazy var clearButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.cancel))) - $0.foregroundColor = .som.black + $0.image = .init(.icon(.v2(.outlined(.delete_full)))) + $0.foregroundColor = .som.v2.gray500 + $0.isHidden = true let gestureRecognizer = UITapGestureRecognizer( target: self, @@ -85,11 +69,10 @@ class OnboardingNicknameTextFieldView: UIView { // MARK: Variables - private let maxCharacter: Int = 8 - var text: String? { set { self.textField.text = newValue + self.textField.sendActions(for: .editingChanged) } get { return self.textField.text @@ -102,8 +85,8 @@ class OnboardingNicknameTextFieldView: UIView { self.textField.attributedPlaceholder = NSAttributedString( string: string, attributes: [ - .foregroundColor: UIColor.som.gray500, - .font: Typography.som.body1WithRegular.font + .foregroundColor: UIColor.som.v2.gray500, + .font: Typography.som.v2.subtitle1.font ] ) } else { @@ -116,20 +99,12 @@ class OnboardingNicknameTextFieldView: UIView { } } - var errorMessage: String? { - set { - self.errorMessageContainer.isHidden = newValue == nil - self.errorMessageLabel.text = newValue - } - get { - return self.errorMessageLabel.text - } - } - var isTextEmpty: Bool { - return self.text?.isEmpty ?? false + return self.text?.isEmpty ?? true } + let textFieldDidReturn = PublishRelay() + // MARK: Initalization @@ -165,7 +140,7 @@ class OnboardingNicknameTextFieldView: UIView { @objc private func touch(sender: UIGestureRecognizer) { - if !self.textField.isFirstResponder { + if self.textField.isFirstResponder == false { self.textField.becomeFirstResponder() } } @@ -175,6 +150,9 @@ class OnboardingNicknameTextFieldView: UIView { self.clearButton.isHidden = true self.text = nil self.textField.sendActions(for: .editingChanged) + if self.textField.isFirstResponder == false { + self.textField.becomeFirstResponder() + } } @@ -184,79 +162,58 @@ class OnboardingNicknameTextFieldView: UIView { self.addSubview(self.textFieldBackgroundView) self.textFieldBackgroundView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(52) + $0.edges.equalToSuperview() + $0.height.equalTo(44) } - self.textFieldBackgroundView.addSubview(self.textField) - self.textField.snp.makeConstraints { + + self.textFieldBackgroundView.addSubview(self.iconView) + self.iconView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(18) } self.textFieldBackgroundView.addSubview(self.clearButton) self.clearButton.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalTo(self.textField.snp.trailing).offset(9) - $0.trailing.equalToSuperview().offset(-8) - $0.size.equalTo(32) - } - - self.addSubview(self.errorMessageContainer) - self.errorMessageContainer.snp.makeConstraints { - $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(10) - $0.bottom.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(24) - } - self.errorMessageContainer.addArrangedSubview(self.errorImageView) - self.errorImageView.snp.makeConstraints { + $0.trailing.equalToSuperview().offset(-20) $0.size.equalTo(24) } - self.errorMessageContainer.addArrangedSubview(self.errorMessageLabel) - self.addSubview(self.characterLabel) - self.characterLabel.snp.makeConstraints { - $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(12) - $0.leading.greaterThanOrEqualTo(self.errorMessageContainer.snp.trailing).offset(20) - $0.trailing.equalToSuperview().offset(-20) + self.textFieldBackgroundView.addSubview(self.textField) + self.textField.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.iconView.snp.trailing).offset(10) + $0.trailing.lessThanOrEqualTo(self.clearButton.snp.leading).offset(-14) } } } -extension OnboardingNicknameTextFieldView: UITextFieldDelegate { + +// MARK: UITextFieldDelegate + +extension SearchTextFieldView: UITextFieldDelegate { func textFieldDidBeginEditing(_ textField: UITextField) { self.clearButton.isHidden = self.isTextEmpty } - func textFieldDidEndEditing(_ textField: UITextField) { - self.clearButton.isHidden = true - } - func textField( _ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String ) -> Bool { - - let nsString: NSString? = textField.text as NSString? - let newString: String = nsString?.replacingCharacters(in: range, with: string) ?? "" - - self.clearButton.isHidden = newString.isEmpty - return newString.count < self.maxCharacter + 1 + self.clearButton.isHidden = self.isTextEmpty + return true } - func textFieldDidChangeSelection(_ textField: UITextField) { - - let text = textField.text ?? "" - self.clearButton.isHidden = text.isEmpty - self.characterLabel.text = text.count.description + "/" + self.maxCharacter.description + func textFieldDidEndEditing(_ textField: UITextField) { + self.clearButton.isHidden = true } func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() + self.textFieldDidReturn.accept(textField.text) return true } } diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/Cells/EmptyTagDetailTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/Cells/EmptyTagDetailTableViewCell.swift deleted file mode 100644 index 722424b3..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/Cells/EmptyTagDetailTableViewCell.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// EmptyTagDetailTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 12/19/24. -// - -import UIKit - -class EmptyTagDetailTableViewCell: UITableViewCell { - - enum Mode { - case noCardsCanView - case noCardsRegistered - - var title: String { - switch self { - case .noCardsCanView: - "조회할 수 있는 카드가 없어요" - case .noCardsRegistered: - "등록된 카드가 없어요" - } - } - - var desc: String { - switch self { - case .noCardsCanView: - "차단된 사용자의 카드는\n확인할 수 없어요" - case .noCardsRegistered: - "해당 태그를 사용한\n카드가 없어요" - } - } - } - - let stackView = UIStackView().then { - $0.axis = .vertical - $0.spacing = 12 - } - - let titleLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.textColor = .som.black - $0.textAlignment = .center - } - - let descLabel = UILabel().then { - $0.typography = .som.body2WithBold - $0.textColor = .som.gray500 - $0.textAlignment = .center - $0.numberOfLines = 2 - } - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setData(mode: Mode) { - titleLabel.text = mode.title - descLabel.text = mode.desc - } - - // MARK: - setupConstraint - private func setupConstraint() { - contentView.addSubview(stackView) - stackView.addArrangedSubviews(titleLabel, descLabel) - stackView.snp.makeConstraints { - $0.center.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/TagDetailViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/TagDetailViewController.swift deleted file mode 100644 index f7ccccbf..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/TagDetailViewController.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// TagDetailViewController.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import UIKit - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then - -class TagDetailViewController: BaseViewController, View { - - let navBarView = TagDetailNavigationBarView() - - lazy var tableView = UITableView(frame: .zero, style: .plain).then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - - $0.register( - MainHomeViewCell.self, - forCellReuseIdentifier: String(describing: MainHomeViewCell.self) - ) - $0.register( - EmptyTagDetailTableViewCell.self, - forCellReuseIdentifier: String(describing: EmptyTagDetailTableViewCell.self) - ) - $0.refreshControl = SOMRefreshControl() - $0.contentInsetAdjustmentBehavior = .never - $0.dataSource = self - $0.delegate = self - } - - var isRefreshEnabled = false - - // TODO: 임시 UINavigationBar 숨김처리 - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.navigationController?.navigationBar.isHidden = true - } - - override func setupConstraints() { - self.view.addSubview(navBarView) - navBarView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide) - $0.leading.trailing.equalToSuperview() - } - - self.view.addSubview(tableView) - tableView.snp.makeConstraints { - $0.leading.trailing.bottom.equalToSuperview() - $0.top.equalTo(self.navBarView.snp.bottom) - } - } - - override func bind() { - navBarView.backButton.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.navigationController?.popViewController(animated: true) - } - .disposed(by: self.disposeBag) - } - - func bind(reactor: TagDetailViewrReactor) { - self.rx.viewDidLoad - .subscribe(with: self) { object, _ in - reactor.action.onNext(.fetchTagCards) - reactor.action.onNext(.fetchTagInfo) - } - .disposed(by: self.disposeBag) - - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() - tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) - .filter { $0 == false } - .subscribe(with: self) { object, _ in - reactor.action.onNext(.fetchTagCards) - reactor.action.onNext(.fetchTagInfo) - } - .disposed(by: self.disposeBag) - - self.navBarView.favoriteButton.rx.tap - .subscribe(with: self) { object, _ in - reactor.action.onNext(.updateFavorite) - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.tagCards) - .subscribe(with: self) { object, cards in - object.tableView.reloadData() - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.tagInfo) - .subscribe(with: self) { object, tagInfo in - guard let tagInfo = tagInfo else { - return - } - object.updateTagInfo(tagInfo: tagInfo) - } - .disposed(by: self.disposeBag) - - isLoading - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - } - - func updateTagInfo(tagInfo: TagInfoResponse) { - self.navBarView.setData(tagInfo: tagInfo) - } -} - -extension TagDetailViewController: UITableViewDataSource, UITableViewDelegate { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let reactor = self.reactor else { - return 0 - } - return max(reactor.currentState.tagCards.count, 1) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let reactor = self.reactor else { - return UITableViewCell() - } - if reactor.currentState.tagCards.isEmpty { - return createEmptyTableViewCell(indexPath: indexPath, mode: reactor.emptyTagMode) - } - return createMainHomeViewCell(indexPath: indexPath) - } - - private func createMainHomeViewCell(indexPath: IndexPath) -> MainHomeViewCell { - let cell: MainHomeViewCell = tableView.dequeueReusableCell( - withIdentifier: String(describing: MainHomeViewCell.self), - for: indexPath - ) as! MainHomeViewCell - - guard let reactor = self.reactor, reactor.currentState.tagCards.indices.contains(indexPath.row) else { - return cell - } - - cell.setData(tagCard: reactor.currentState.tagCards[indexPath.row]) - cell.contentView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail( - reactor.currentState.tagCards[indexPath.row].id - ) - object.navigationPush(detailViewController, animated: true) - } - .disposed(by: cell.cardView.disposeBag) - return cell - } - - private func createEmptyTableViewCell( - indexPath: IndexPath, - mode: EmptyTagDetailTableViewCell.Mode - ) -> EmptyTagDetailTableViewCell { - - let cell: EmptyTagDetailTableViewCell = tableView.dequeueReusableCell( - withIdentifier: String(describing: EmptyTagDetailTableViewCell.self), - for: indexPath - ) as! EmptyTagDetailTableViewCell - cell.setData(mode: mode) - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - guard let reactor = self.reactor else { - return 0 - } - if reactor.currentState.tagCards.isEmpty { - return self.tableView.bounds.height// - 200 - } - let width: CGFloat = (UIScreen.main.bounds.width - 20 * 2) * 0.9 - let height: CGFloat = width + 10 /// 가로 + top inset - return height - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - 0 - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/TagDetailViewrReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/TagDetailViewrReactor.swift deleted file mode 100644 index 65d0e4c6..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/TagDetailViewrReactor.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// TagDetailViewrReactor.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import ReactorKit -import RxSwift - -class TagDetailViewrReactor: Reactor { - - enum Action { - case fetchTagCards - case fetchTagInfo - case updateFavorite - } - - enum Mutation { - /// 해당 태그 정보 fetch - case tagInfo(TagInfoResponse) - /// 태그 카드 fetch - case tagCards([TagDetailCardResponse.TagFeedCard]) - /// 태그 즐겨찾기 여부 변경 - case updateFavorite(TagInfoResponse) - case setLoading(Bool) - } - - struct State { - /// 태그 카드 리스트 - fileprivate(set) var tagCards: [TagDetailCardResponse.TagFeedCard] = [] - fileprivate(set) var tagInfo: TagInfoResponse? - fileprivate(set) var isLoading: Bool = false - } - - var initialState = State() - - let provider: ManagerProviderType - - private let tagID: String - - var emptyTagMode: EmptyTagDetailTableViewCell.Mode { - guard let cardCnt = self.currentState.tagInfo?.cardCnt else { - return .noCardsRegistered - } - return cardCnt == 0 ? .noCardsRegistered : .noCardsCanView - } - - init(provider: ManagerProviderType, tagID: String) { - self.provider = provider - self.tagID = tagID - } - - func mutate(action: Action) -> Observable { - switch action { - case .fetchTagCards: - return .concat([ - .just(.setLoading(true)), - self.fetchTagCards() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.setLoading(false)) - ]) - - case .fetchTagInfo: - return .concat([ - .just(.setLoading(true)), - self.fetchTagInfo() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.setLoading(false)) - ]) - - case .updateFavorite: - return updateFavorite() - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var newState = state - switch mutation { - case let .tagCards(tagCards): - newState.tagCards = tagCards - - case let .tagInfo(tagInfo): - newState.tagInfo = tagInfo - - case let .updateFavorite(tagInfo): - newState.tagInfo = tagInfo - - case let .setLoading(isLoading): - newState.isLoading = isLoading - } - - return newState - } - - private func fetchTagCards() -> Observable { - let request: TagRequest = .tagCard(tagID: tagID) - - return self.provider.networkManager.request(TagDetailCardResponse.self, request: request) - .map { response in - return Mutation.tagCards(response.embedded.tagFeedCardDtoList) - } - .catch { _ in - return .just(.tagCards([])) - } - } - - private func fetchTagInfo() -> Observable { - let request: TagRequest = .tagInfo(tagID: tagID) - - return self.provider.networkManager.request(TagInfoResponse.self, request: request) - .map { response in - return Mutation.tagInfo(response) - } - } - - private func updateFavorite() -> Observable { - guard let tagInfo = self.currentState.tagInfo else { - return .empty() - } - - let request: TagRequest = tagInfo.isFavorite ? .deleteFavorite(tagID: tagID) : .addFavorite(tagID: tagID) - - return self.provider.networkManager.request(AddFavoriteTagResponse.self, request: request) - .map { _ in - let newTagInfo = TagInfoResponse( - content: tagInfo.content, - cardCnt: tagInfo.cardCnt, - isFavorite: !tagInfo.isFavorite, - status: tagInfo.status - ) - return Mutation.updateFavorite(newTagInfo) - } - } -} - -extension TagDetailViewrReactor { - - func reactorForDetail(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, selectedId) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/Views/TagDetailNavigationBarView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/Views/TagDetailNavigationBarView.swift deleted file mode 100644 index 10aef2a5..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagDetail/Views/TagDetailNavigationBarView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// TagDetailNavigationBarView.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import UIKit - -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then - -class TagDetailNavigationBarView: UIView { - - let backButton = UIButton().then { - $0.setImage(.arrowBackOutlined, for: .normal) - $0.tintColor = .som.black - } - - let titleLabel = UILabel().then { - $0.typography = .som.body2WithBold - $0.textColor = .som.black - $0.text = " " - } - - let subtitleLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.textColor = .som.gray500 - $0.text = " " - } - - let favoriteButton = UIButton().then { - $0.setImage(.starOutlined, for: .normal) - $0.tintColor = .som.black - } - - // MARK: - init - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - self.backgroundColor = .som.white - - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - setupConstraints - private func setupConstraints() { - self.snp.makeConstraints { - $0.height.equalTo(77) - } - - self.addSubview(backButton) - backButton.snp.makeConstraints { - $0.leading.equalToSuperview().offset(20) - $0.centerY.equalToSuperview() - $0.size.equalTo(40) - } - - self.addSubview(favoriteButton) - favoriteButton.snp.makeConstraints { - $0.trailing.equalToSuperview().offset(-20) - $0.centerY.equalToSuperview() - $0.size.equalTo(40) - } - - self.addSubview(titleLabel) - titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(18) - $0.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(20) - $0.trailing.lessThanOrEqualTo(favoriteButton.snp.leading).offset(-20) - $0.centerX.equalToSuperview() - } - - self.addSubview(subtitleLabel) - subtitleLabel.snp.makeConstraints { - $0.bottom.equalToSuperview().offset(-18) - $0.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(20) - $0.trailing.lessThanOrEqualTo(favoriteButton.snp.leading).offset(-20) - $0.centerX.equalToSuperview() - } - } - - func setData(tagInfo: TagInfoResponse) { - titleLabel.text = "#\(tagInfo.content)" - subtitleLabel.text = "카드 \(tagInfo.cardCnt) 개" - favoriteButton.setImage(tagInfo.isFavorite ? .starFilled : .starOutlined, for: .normal) - favoriteButton.tintColor = tagInfo.isFavorite ? .som.blue300 : .som.black - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagSearch/TagSearchViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagSearch/TagSearchViewController.swift deleted file mode 100644 index ffd93380..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagSearch/TagSearchViewController.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// TagSearchViewController.swift -// SOOUM -// -// Created by JDeoks on 11/26/24. -// - -import UIKit - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then - -class TagSearchViewController: BaseNavigationViewController, View { - - let tagBackButton = UIButton().then { - $0.setImage(.arrowBackOutlined, for: .normal) - $0.tintColor = .som.black - } - - let hideKeyboardUIBarButton = UIBarButtonItem().then { - $0.title = "완료" - $0.style = .done - } - - let tagSearchTextFieldView = TagSearchTextFieldView(isInteractive: true) - - lazy var tableView = UITableView().then { - $0.separatorStyle = .none - $0.sectionHeaderTopPadding = 0 - $0.contentInset.top = 28 - $0.register( - RecommendTagTableViewCell.self, - forCellReuseIdentifier: String( - describing: RecommendTagTableViewCell.self - ) - ) - $0.dataSource = self - $0.delegate = self - } - - override func viewDidLoad() { - super.viewDidLoad() - isNavigationBarHidden = true - self.setupToolbar() - } - - func setupToolbar() { - let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) - let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) - toolbar.items = [space, self.hideKeyboardUIBarButton] - self.tagSearchTextFieldView.textField.inputAccessoryView = toolbar - } - - - override func viewDidAppear(_ animated: Bool) { - tagSearchTextFieldView.textField.becomeFirstResponder() - } - - override func bind() { - backButton.rx.tap - .subscribe(with: self) { object, _ in - object.navigationPop(animated: false) - } - .disposed(by: self.disposeBag) - - hideKeyboardUIBarButton.rx.tap - .subscribe(with: self) { object, _ in - object.view.endEditing(true) - } - .disposed(by: self.disposeBag) - } - - func bind(reactor: TagSearchViewReactor) { - tagSearchTextFieldView.textField.rx.text.orEmpty - .debounce(.seconds(1), scheduler: MainScheduler.instance) - .distinctUntilChanged() - .map { - Reactor.Action.searchTag($0) - } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - reactor.state.map(\.searchTags) - .subscribe(with: self) { object, _ in - object.tableView.reloadData() - } - .disposed(by: self.disposeBag) - } - - override func setupConstraints() { - super.setupConstraints() - self.view.addSubview(backButton) - backButton.snp.makeConstraints { - $0.leading.equalToSuperview().offset(16) - $0.size.equalTo(40) - } - - self.view.addSubview(tagSearchTextFieldView) - tagSearchTextFieldView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(20) - $0.leading.equalTo(backButton.snp.trailing).offset(4) - $0.trailing.equalToSuperview().offset(-16) - $0.centerY.equalTo(backButton) - } - - self.view.addSubview(tableView) - tableView.snp.makeConstraints { - $0.leading.trailing.equalToSuperview() - $0.top.equalTo(self.tagSearchTextFieldView.snp.bottom).offset(4) - $0.bottom.equalToSuperview() - } - } - - func pop() { - UIView.animate( - withDuration: 0.15, - delay: 0, - animations: { - self.view.alpha = 0 - }, - completion: { _ in - self.navigationPop(animated: false) - } - ) - } -} - -extension TagSearchViewController: UITableViewDataSource, UITableViewDelegate { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let reactor = self.reactor else { - return 0 - } - return reactor.currentState.searchTags.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - createRecommendTagTableViewCell(indexPath: indexPath) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 57 + 12 - } - - private func createRecommendTagTableViewCell(indexPath: IndexPath) -> RecommendTagTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: RecommendTagTableViewCell.self), - for: indexPath - ) as! RecommendTagTableViewCell - - guard let reactor = self.reactor else { - return cell - } - if reactor.currentState.searchTags.indices.contains(indexPath.row) { - let tag = reactor.currentState.searchTags[indexPath.row] - cell.setData(searchRelatedTag: reactor.currentState.searchTags[indexPath.row]) - cell.contentView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - GAManager.shared.logEvent( - event: SOMEvent.Tag.tag_click( - tag_text: tag.content, - click_position: SOMEvent.Tag.ClickPositionKey.search_result - ) - ) - let tagID = reactor.currentState.searchTags[indexPath.row].tagId - let tagDetailVC = TagDetailViewController() - tagDetailVC.reactor = reactor.reactorForTagDetail(tagID) - object.navigationController?.pushViewController(tagDetailVC, animated: true) - } - .disposed(by: cell.disposeBag) - } - return cell - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagSearch/TagSearchViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagSearch/TagSearchViewReactor.swift deleted file mode 100644 index c4bca8e8..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagSearch/TagSearchViewReactor.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// TagSearchViewReactor.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import ReactorKit - -class TagSearchViewReactor: Reactor { - - enum Action { - case searchTag(String) -// case selectTag(String) - } - - enum Mutation { - /// 즐겨찾기 태그 fetch - case searchTags([SearchTagsResponse.RelatedTag]) -// case setSelectTagFinished - } - - struct State { - /// 즐겨찾기 태그 리스트 - fileprivate(set) var searchTags: [SearchTagsResponse.RelatedTag] = [] - /// 추천 태그 리스트 -// fileprivate(set) var recommendTags: [RecommendTagsResponse.RecommendTag] = [] - } - - var initialState = State() - - let provider: ManagerProviderType - - init(provider: ManagerProviderType) { - self.provider = provider - } - - func mutate(action: Action) -> Observable { - switch action { - case let .searchTag(keyword): - return self.searchTags(keyword: keyword) - -// case .selectTag(String) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var newState = state - switch mutation { - case let .searchTags(searchTags): - newState.searchTags = searchTags - -// case let .recommendTags(recommendTags): -// newState.recommendTags = recommendTags - } - return newState - } - - private func searchTags(keyword: String) -> Observable { - let request: TagRequest = .search(keyword: keyword) - if keyword.isEmpty { - return .just(.searchTags([])) - } - - return self.provider.networkManager.request(SearchTagsResponse.self, request: request) - .map { response in - return Mutation.searchTags(response.embedded.relatedTagList) - } - .catch { _ in - return .just(.searchTags([])) - } - } -} - -extension TagSearchViewReactor { - - func reactorForTagDetail(_ tagID: String) -> TagDetailViewrReactor { - TagDetailViewrReactor(provider: self.provider, tagID: tagID) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift new file mode 100644 index 00000000..6b400a79 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift @@ -0,0 +1,330 @@ +// +// TagViewController.swift +// SOOUM +// +// Created by 오현식 on 11/18/25. +// + +import UIKit + +import SnapKit +import Then + +import SwiftEntryKit + +import ReactorKit +import RxCocoa +import RxSwift + +class TagViewController: BaseNavigationViewController, View { + + enum Text { + static let navigationTitle: String = "태그" + + static let placeholderText: String = "태그를 검색하세요" + + static let favoriteTagHeaderTitle: String = "님의 관심 태그" + static let popularTagHeaderTitle: String = "인기 태그" + + static let bottomToastEntryName: String = "bottomToastEntryName" + static let addToastMessage: String = "을 관심 태그에 추가했어요" + static let deleteToastMessage: String = "을 관심 태그에서 삭제했어요" + + static let bottomToastEntryNameWithAction: String = "bottomToastEntryNameWithAction" + static let failedToastMessage: String = "네트워크 확인 후 재시도해주세요." + static let failToastActionTitle: String = "재시도" + } + + + // MARK: Views + + private let searchViewButtonView = SearchViewButton().then { + $0.placeholder = Text.placeholderText + } + + private lazy var scrollView = UIScrollView().then { + $0.isScrollEnabled = true + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.contentInsetAdjustmentBehavior = .never + + $0.refreshControl = SOMRefreshControl() + + $0.delegate = self + } + + private let container = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fillProportionally + } + + private let favoriteTagHeaderView = FavoriteTagHeaderView() + private let favoriteTagsView = FavoriteTagsView() + + private let popularTagHeaderView = PopularTagHeaderView(title: Text.popularTagHeaderTitle).then { + $0.isHidden = true + } + private let popularTagsView = PopularTagsView().then { + $0.isHidden = true + } + + + // MARK: Variables + + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + + // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + self.navigationBar.titlePosition = .left + + self.navigationBar.hidesBackButton = true + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.scrollView) + self.scrollView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.horizontalEdges.equalToSuperview() + } + + self.scrollView.addSubview(self.searchViewButtonView) + self.searchViewButtonView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + self.scrollView.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalTo(self.searchViewButtonView.snp.bottom) + $0.bottom.horizontalEdges.equalToSuperview() + $0.width.equalTo(UIScreen.main.bounds.width) + } + + self.container.addArrangedSubview(self.favoriteTagHeaderView) + self.container.addArrangedSubview(self.favoriteTagsView) + + self.container.addArrangedSubview(self.popularTagHeaderView) + self.container.addArrangedSubview(self.popularTagsView) + } + + override func viewDidLoad() { + super.viewDidLoad() + + // 제스처 뒤로가기를 위한 델리게이트 설정 + self.parent?.navigationController?.interactivePopGestureRecognizer?.delegate = self + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadData(_:)), + name: .reloadFavoriteTagData, + object: nil + ) + } + + + // MARK: ReactorKit - bind + + func bind(reactor: TagViewReactor) { + + self.searchViewButtonView.rx.didTap + .subscribe(with: self) { object, _ in + let tagSearchViewController = TagSearchViewController() + tagSearchViewController.reactor = reactor.reactorForSearch() + object.parent?.navigationPush(tagSearchViewController, animated: true) { _ in + GAHelper.shared.logEvent(event: GAEvent.TagView.tagMenuSearchBar_click) + } + } + .disposed(by: self.disposeBag) + + self.favoriteTagsView.backgroundDidTap + .subscribe(with: self) { object, model in + let tagCollectViewController = TagCollectViewController() + tagCollectViewController.reactor = reactor.reactorForCollect( + with: model.id, + title: model.text, + isFavorite: model.isFavorite + ) + object.parent?.navigationPush(tagCollectViewController, animated: true) + } + .disposed(by: self.disposeBag) + + self.popularTagsView.backgroundDidTap + .throttle(.seconds(2), scheduler: MainScheduler.instance) + .subscribe(with: self) { object, model in + let tagCollectViewController = TagCollectViewController() + tagCollectViewController.reactor = reactor.reactorForCollect( + with: model.id, + title: model.name, + isFavorite: reactor.currentState.favoriteTags.contains(where: { $0.id == model.id }) + ) + object.parent?.navigationPush(tagCollectViewController, animated: true) { _ in + GAHelper.shared.logEvent(event: GAEvent.TagView.popularTag_item_click) + } + } + .disposed(by: self.disposeBag) + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + self.favoriteTagsView.favoriteIconDidTap + .map(Reactor.Action.updateIsFavorite) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.scrollView.refreshControl?.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .observe(on: MainScheduler.asyncInstance) + .filter { $0 == false } + .subscribe(with: self.scrollView) { scrollView, _ in + scrollView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + reactor.state.map { + TagViewReactor.DisplayStates( + favoriteTags: $0.favoriteTags, + popularTags: $0.popularTags + ) + } + .distinctUntilChanged(reactor.canUpdateCells) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, displayStats in + + object.favoriteTagHeaderView.title = (UserDefaults.standard.nickname ?? "") + Text.favoriteTagHeaderTitle + + guard let favoriteTags = displayStats.favoriteTags else { return } + + object.favoriteTagsView.setModels(favoriteTags) + + guard let popularTags = displayStats.popularTags else { return } + + object.popularTagHeaderView.isHidden = popularTags.isEmpty + object.popularTagsView.isHidden = popularTags.isEmpty + + object.popularTagsView.setModels(popularTags) + } + .disposed(by: self.disposeBag) + + let isUpdatedWithInfo = reactor.pulse(\.$isUpdatedWithInfo).filterNil() + isUpdatedWithInfo + .filter { $0.isUpdated } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isUpdatedWithInfo in + + let message = isUpdatedWithInfo.model.isFavorite ? Text.addToastMessage : Text.deleteToastMessage + let bottomToastView = SOMBottomToastView( + title: "‘\(isUpdatedWithInfo.model.text)’" + message, + actions: nil + ) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 54 + 8) + } + .disposed(by: self.disposeBag) + + isUpdatedWithInfo + .filter { $0.isUpdated == false } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isUpdatedWithInfo in + + let actions = [ + SOMBottomToastView.ToastAction(title: Text.failToastActionTitle, action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomToastEntryNameWithAction)) { + reactor.action.onNext(.updateIsFavorite(isUpdatedWithInfo.model)) + } + }) + ] + let bottomToastView = SOMBottomToastView(title: Text.failedToastMessage, actions: actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 54 + 8) + } + .disposed(by: self.disposeBag) + } + + + // MARK: Objc func + + @objc + private func reloadData(_ notification: Notification) { + + self.reactor?.action.onNext(.favoriteTags) + } +} + + +// MARK: UIScrollViewDelegate + +extension TagViewController: UIScrollViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset, + let refreshControl = self.scrollView.refreshControl as? SOMRefreshControl { + + refreshControl.updateProgress( + offset: scrollView.contentOffset.y, + topInset: scrollView.adjustedContentInset.top + ) + + let pulledOffset = self.initialOffset - offset + /// refreshControl heigt + top padding + let refreshingOffset: CGFloat = 44 + 12 + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + } + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.scrollView.refreshControl?.beginRefreshing() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagViewReactor.swift new file mode 100644 index 00000000..794fada5 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/TagViewReactor.swift @@ -0,0 +1,154 @@ +// +// TagViewReactor.swift +// SOOUM +// +// Created by 오현식 on 11/18/25. +// + +import ReactorKit + +class TagViewReactor: Reactor { + + enum Action: Equatable { + case landing + case refresh + case favoriteTags + case updatefavoriteTags([FavoriteTagViewModel]) + case updateIsFavorite(FavoriteTagViewModel) + } + + enum Mutation { + case favoriteTags([FavoriteTagViewModel]) + case popularTags([TagInfo]) + case updateIsFavorite((FavoriteTagViewModel, Bool)?) + case updateIsRefreshing(Bool) + } + + struct State { + fileprivate(set) var favoriteTags: [FavoriteTagViewModel] + fileprivate(set) var popularTags: [TagInfo] + @Pulse fileprivate(set) var isUpdatedWithInfo: (model: FavoriteTagViewModel, isUpdated: Bool)? + fileprivate(set) var isRefreshing: Bool + } + + var initialState: State = .init( + favoriteTags: [], + popularTags: [], + isUpdatedWithInfo: nil, + isRefreshing: false + ) + + private let dependencies: AppDIContainerable + private let fetchUserInfoUseCase: FetchUserInfoUseCase + private let fetchTagUseCase: FetchTagUseCase + private let updateTagFavoriteUseCase: UpdateTagFavoriteUseCase + + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.fetchUserInfoUseCase = dependencies.rootContainer.resolve(FetchUserInfoUseCase.self) + self.fetchTagUseCase = dependencies.rootContainer.resolve(FetchTagUseCase.self) + self.updateTagFavoriteUseCase = dependencies.rootContainer.resolve(UpdateTagFavoriteUseCase.self) + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return self.fetchUserInfoUseCase.myNickname() + .withUnretained(self) + .flatMapLatest { object, nickname -> Observable in + + UserDefaults.standard.nickname = nickname + + return .concat([ + object.favoriteTags(), + object.popularTags() + ]) + } + case .refresh: + + return .concat([ + .just(.updateIsRefreshing(true)), + self.favoriteTags() + .catchAndReturn(.updateIsRefreshing(false)), + self.popularTags() + .catchAndReturn(.updateIsRefreshing(false)), + .just(.updateIsRefreshing(false)) + ]) + case .favoriteTags: + + return self.favoriteTags() + case let .updatefavoriteTags(models): + + return .just(.favoriteTags(models)) + case let .updateIsFavorite(model): + + return .concat([ + .just(.updateIsFavorite(nil)), + self.updateTagFavoriteUseCase.updateFavorite(tagId: model.id, isFavorite: !model.isFavorite) + .flatMapLatest { isUpdated -> Observable in + return .just(.updateIsFavorite((model, isUpdated))) + } + .catchAndReturn(.updateIsFavorite((model, false))) + ]) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState: State = state + switch mutation { + case let .favoriteTags(favoriteTags): + newState.favoriteTags = favoriteTags + case let .popularTags(popularTags): + newState.popularTags = popularTags + case let .updateIsFavorite(isUpdatedWithInfo): + newState.isUpdatedWithInfo = isUpdatedWithInfo + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + } + return newState + } +} + +private extension TagViewReactor { + + func favoriteTags() -> Observable { + + return self.fetchTagUseCase.favorites() + .map { favorites in favorites.map { FavoriteTagViewModel(id: $0.id, text: $0.title) } } + .map(Mutation.favoriteTags) + } + + func popularTags() -> Observable { + + return self.fetchTagUseCase.ranked() + .map(Mutation.popularTags) + } +} + +extension TagViewReactor { + + struct DisplayStates { + let favoriteTags: [FavoriteTagViewModel]? + let popularTags: [TagInfo]? + } + + func canUpdateCells( + prev prevDisplayState: DisplayStates, + curr currDisplayState: DisplayStates + ) -> Bool { + return prevDisplayState.favoriteTags == currDisplayState.favoriteTags && + prevDisplayState.popularTags == currDisplayState.popularTags + } +} + +extension TagViewReactor { + + func reactorForSearch() -> TagSearchViewReactor { + TagSearchViewReactor(dependencies: self.dependencies) + } + + func reactorForCollect(with id: String, title: String, isFavorite: Bool) -> TagCollectViewReactor { + TagCollectViewReactor(dependencies: self.dependencies, with: id, title: title, isFavorite: isFavorite) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/Cells/TagPreviewCard/TagPreviewCardCollectionViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/Cells/TagPreviewCard/TagPreviewCardCollectionViewCell.swift deleted file mode 100644 index 1c9e05f6..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/Cells/TagPreviewCard/TagPreviewCardCollectionViewCell.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// TagPreviewCardCollectionViewCell.swift -// SOOUM -// -// Created by JDeoks on 11/26/24. -// - -import UIKit - -import RxCocoa -import RxGesture -import RxSwift - -class TagPreviewCardCollectionViewCell: UICollectionViewCell { - - var disposeBag = DisposeBag() - - let tagPreviewCardView = TagPreviewCardView() - - // MARK: - init - override init(frame: CGRect) { - super.init(frame: frame) - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - self.disposeBag = DisposeBag() - } - - func setData(previewCard: FavoriteTagsResponse.PreviewCard) { - tagPreviewCardView.rootContainerImageView.setImage(strUrl: previewCard.backgroundImgURL.href) - tagPreviewCardView.cardTextContentLabel.text = previewCard.content - } - - private func setupConstraint() { - self.contentView.addSubview(tagPreviewCardView) - tagPreviewCardView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/Cells/TagPreviewCard/TagPreviewCardView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/Cells/TagPreviewCard/TagPreviewCardView.swift deleted file mode 100644 index d763ca65..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/Cells/TagPreviewCard/TagPreviewCardView.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// TagPreviewCardView.swift -// SOOUM -// -// Created by JDeoks on 11/26/24. -// - -import UIKit - -class TagPreviewCardView: UIView { - - /// 배경 이미지 - let rootContainerImageView = UIImageView().then { - $0.backgroundColor = .clear - $0.layer.cornerRadius = 20 - $0.layer.masksToBounds = true - } - - /// cardTextContentLabel를 감싸는 불투명 컨테이너 뷰 - let cardTextBackgroundView = UIView().then { - $0.backgroundColor = .clear - $0.layer.cornerRadius = 20 - $0.clipsToBounds = true - } - - /// cardTextContentLabel를 감싸는 블러 컨테이너 뷰 - let cardTextBackgroundBlurView = UIVisualEffectView().then { - $0.layer.cornerRadius = 20 - $0.clipsToBounds = true - let blurEffect = UIBlurEffect(style: .dark) - $0.effect = blurEffect - $0.backgroundColor = .som.dim - $0.alpha = 0.8 - } - - /// 본문 표시 라벨 - let cardTextContentLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.textColor = .som.white - $0.numberOfLines = 0 - $0.textAlignment = .center - $0.text = "본문입니다본문입니다본문입니다본문입니다" - } - - // MARK: - init - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - self.backgroundColor = .som.gray200 - self.layer.cornerRadius = 20 - - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - setupConstraints - private func setupConstraints() { - self.addSubview(rootContainerImageView) - rootContainerImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - rootContainerImageView.addSubview(cardTextBackgroundView) - cardTextBackgroundView.snp.makeConstraints { - $0.leading.equalToSuperview().offset(20) - $0.top.greaterThanOrEqualToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.bottom.lessThanOrEqualToSuperview().offset(-20) - $0.center.equalToSuperview() - } - - rootContainerImageView.addSubview(cardTextBackgroundBlurView) - cardTextBackgroundBlurView.snp.makeConstraints { - $0.edges.equalTo(cardTextBackgroundView) - } - - rootContainerImageView.addSubview(cardTextContentLabel) - cardTextContentLabel.snp.makeConstraints { - $0.leading.equalTo(cardTextBackgroundView).offset(16) - $0.top.equalTo(cardTextBackgroundView).offset(12) - $0.trailing.equalTo(cardTextBackgroundView).offset(-16) - $0.bottom.equalTo(cardTextBackgroundView).offset(-12) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/FavoriteTagTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/FavoriteTagTableViewCell.swift deleted file mode 100644 index c013196f..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/FavoriteTagTableViewCell.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// FavoriteTagCell.swift -// SOOUM -// -// Created by JDeoks on 11/24/24. -// - -import UIKit - -import RxCocoa -import RxGesture -import RxSwift - -final class FavoriteTagTableViewCell: UITableViewCell { - - var favoriteTag: FavoriteTagsResponse.FavoriteTagList? - let previewCardTapped = PublishSubject() - var disposeBag = DisposeBag() - - lazy var favoriteTagView = FavoriteTagView().then { - $0.cardPreviewCollectionView.delegate = self - $0.cardPreviewCollectionView.dataSource = self - $0.cardPreviewCollectionView.register( - TagPreviewCardCollectionViewCell.self, - forCellWithReuseIdentifier: String( - describing: TagPreviewCardCollectionViewCell.self - ) - ) - } - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - self.contentView.clipsToBounds = true - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - self.disposeBag = DisposeBag() - } - - func setData(favoriteTag: FavoriteTagsResponse.FavoriteTagList) { - self.disposeBag = DisposeBag() - - self.favoriteTag = favoriteTag - self.favoriteTagView.tagNameLabel.text = "#\(favoriteTag.tagContent)" - self.favoriteTagView.tagsCountLabel.text = favoriteTag.tagUsageCnt - favoriteTagView.cardPreviewCollectionView.reloadData() - } - - private func setupConstraint() { - self.contentView.addSubview(favoriteTagView) - favoriteTagView.snp.makeConstraints { - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.top.equalToSuperview() - $0.bottom.equalToSuperview().offset(-12) - } - } -} - -extension FavoriteTagTableViewCell: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - guard let favoriteTag else { - return 0 - } - return favoriteTag.previewCards.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: String(describing: TagPreviewCardCollectionViewCell.self), - for: indexPath - ) as! TagPreviewCardCollectionViewCell - guard let previewCards = favoriteTag?.previewCards, previewCards.indices.contains(indexPath.row) else { - return cell - } - cell.setData(previewCard: previewCards[indexPath.row]) - cell.contentView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.previewCardTapped.onNext(previewCards[indexPath.row].id) - } - .disposed(by: cell.disposeBag) - return cell - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let height = favoriteTagView.cardPreviewCollectionView.frame.height - return CGSize(width: height, height: height) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { - return UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/FavoriteTagView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/FavoriteTagView.swift deleted file mode 100644 index ab9295db..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/FavoriteTag/FavoriteTagView.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// FavoriteTagView.swift -// SOOUM -// -// Created by JDeoks on 11/24/24. -// - -import UIKit - -class FavoriteTagView: UIView { - - let tagNameLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.text = "#태그이름" - $0.textColor = .som.gray800 - } - - let tagsCountLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.text = "999" - $0.textColor = .som.gray500 - } - - let moreButtonStackView = UIStackView().then { - $0.axis = .horizontal - } - - let moreButtonLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.text = "더보기" - $0.textColor = .som.blue300 - } - - let moreImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.contentMode = .scaleAspectFit - $0.tintColor = .som.blue300 - } - - let cardPreviewCollectionView = UICollectionView( - frame: .zero, - collectionViewLayout: UICollectionViewFlowLayout().then { - $0.scrollDirection = .horizontal - } - ).then { - $0.showsHorizontalScrollIndicator = false - } - - // MARK: - init - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - self.clipsToBounds = true - self.layer.cornerRadius = 28 - self.layer.borderColor = UIColor.som.gray200.cgColor - self.layer.borderWidth = 1 - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraint() { - self.addSubview(tagNameLabel) - tagNameLabel.snp.makeConstraints { - $0.leading.top.equalToSuperview().offset(20) - $0.height.equalTo(17) - } - - self.addSubview(tagsCountLabel) - tagsCountLabel.snp.makeConstraints { - $0.leading.equalTo(self.tagNameLabel.snp.trailing).offset(2) - $0.top.equalTo(self.tagNameLabel) - $0.height.equalTo(17) - } - - self.addSubview(moreButtonStackView) - moreButtonStackView.addArrangedSubviews(moreButtonLabel, moreImageView) - moreButtonStackView.snp.makeConstraints { - $0.trailing.equalToSuperview().offset(-16) - $0.top.equalToSuperview().offset(20) - $0.height.equalTo(17) - } - - self.addSubview(cardPreviewCollectionView) - cardPreviewCollectionView.snp.makeConstraints { - $0.leading.trailing.equalToSuperview() - $0.top.equalTo(tagNameLabel.snp.bottom).offset(15) - $0.bottom.equalToSuperview().offset(-15) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/RecommendTag/RecommendTagTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/RecommendTag/RecommendTagTableViewCell.swift deleted file mode 100644 index 00ab505a..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/RecommendTag/RecommendTagTableViewCell.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// RecommendTagTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 11/26/24. -// - -import UIKit - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then - -class RecommendTagTableViewCell: UITableViewCell { - - /// 셀 사용 모드 - enum Mode { - case recommendTag - case searchTag - } - - var mode: Mode = .recommendTag - - var recommendTag: RecommendTagsResponse.RecommendTag? - var searchTag: SearchTagsResponse.RelatedTag? - - var disposeBag = DisposeBag() - - let recommendTagView = RecommendTagView() - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - self.disposeBag = DisposeBag() - } - - /// 추천 태그 setData - func setData(recommendTag: RecommendTagsResponse.RecommendTag) { - self.disposeBag = DisposeBag() - self.mode = .recommendTag - - self.recommendTag = recommendTag - self.recommendTagView.tagNameLabel.text = "#\(recommendTag.tagContent)" - self.recommendTagView.tagsCountLabel.text = recommendTag.tagUsageCnt - } - - /// 검색 태그 setData - func setData(searchRelatedTag: SearchTagsResponse.RelatedTag) { - self.disposeBag = DisposeBag() - - self.mode = .searchTag - self.searchTag = searchRelatedTag - self.recommendTagView.tagNameLabel.text = "#\(searchRelatedTag.content)" - self.recommendTagView.tagsCountLabel.text = "\(searchRelatedTag.count)" - } - - private func setupConstraint() { - self.contentView.addSubview(recommendTagView) - recommendTagView.snp.makeConstraints { - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.top.equalToSuperview() - $0.height.equalTo(57) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/RecommendTag/RecommendTagView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/RecommendTag/RecommendTagView.swift deleted file mode 100644 index c4b00f63..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Cells/RecommendTag/RecommendTagView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// RecommendTagView.swift -// SOOUM -// -// Created by JDeoks on 11/26/24. -// - -import UIKit - -class RecommendTagView: UIView { - - let tagNameLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.text = "#태그이름" - $0.textColor = .som.gray800 - } - - let tagsCountLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.text = "999" - $0.textColor = .som.gray500 - } - - let moreButtonStackView = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - } - - let moreButtonLabel = UILabel().then { - $0.typography = .som.body3WithBold - $0.text = "더보기" - $0.textColor = .som.blue300 - } - - let moreImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.contentMode = .scaleAspectFit - $0.tintColor = .som.blue300 - } - - // MARK: - init - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - self.clipsToBounds = true - self.layer.cornerRadius = 12 - self.layer.borderColor = UIColor.som.gray200.cgColor - self.layer.borderWidth = 1 - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraint() { - self.addSubview(tagNameLabel) - tagNameLabel.snp.makeConstraints { - $0.leading.equalToSuperview().offset(16) - $0.centerY.equalToSuperview() - } - - self.addSubview(tagsCountLabel) - tagsCountLabel.snp.makeConstraints { - $0.leading.equalTo(self.tagNameLabel.snp.trailing).offset(2) - $0.centerY.equalToSuperview() - } - - self.addSubview(moreButtonStackView) - moreButtonStackView.addArrangedSubviews(moreButtonLabel, moreImageView) - moreButtonStackView.snp.makeConstraints { - $0.trailing.equalToSuperview().offset(-16) - $0.centerY.equalToSuperview() - $0.height.equalTo(32) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/TagsViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/TagsViewController.swift deleted file mode 100644 index 20b0533c..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/TagsViewController.swift +++ /dev/null @@ -1,332 +0,0 @@ -// -// TagsViewController.swift -// SOOUM -// -// Created by JDeoks on 11/24/24. -// - -import UIKit - -import ReactorKit - -class TagsViewController: BaseNavigationViewController, View { - - enum TagType: Int, CaseIterable { - case favorite - case recommend - - var headerText: String { - switch self { - case .favorite: - return "내가 즐겨찾기한 태그" - case .recommend: - return "추천태그" - } - } - } - - let tagSearchTextFieldView = TagSearchTextFieldView(isInteractive: false) - - lazy var tableView = UITableView().then { - $0.separatorStyle = .none - $0.sectionHeaderTopPadding = 0 - $0.register( - FavoriteTagTableViewCell.self, - forCellReuseIdentifier: String( - describing: FavoriteTagTableViewCell.self - ) - ) - $0.register( - RecommendTagTableViewCell.self, - forCellReuseIdentifier: String( - describing: RecommendTagTableViewCell.self - ) - ) - $0.dataSource = self - $0.delegate = self - $0.refreshControl = SOMRefreshControl() - } - - private let loadMoreTrigger = PublishSubject() - - func bind(reactor: TagsViewReactor) { - tagSearchTextFieldView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - let searchVC = TagSearchViewController() - searchVC.reactor = reactor.reactorForSearch() - self.navigationPush(searchVC, animated: false) - } - .disposed(by: self.disposeBag) - - self.rx.viewDidLoad - .subscribe(with: self) { object, _ in - reactor.action.onNext(.initialize) - } - .disposed(by: self.disposeBag) - - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() - self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - loadMoreTrigger - .throttle(.milliseconds(500), scheduler: MainScheduler.instance) - .subscribe(with: self) { object, _ in - guard let reactor = object.reactor else { return } - reactor.action.onNext(.loadMoreFavorite) - } - .disposed(by: disposeBag) - - reactor.state.map(\.favoriteTags) - .subscribe(with: self) { object, _ in - object.tableView.reloadData() - } - .disposed(by: self.disposeBag) - - isLoading - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - } - - override func setupConstraints() { - super.setupConstraints() - - isNavigationBarHidden = true - self.view.addSubview(tagSearchTextFieldView) - tagSearchTextFieldView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(20) - $0.leading.equalToSuperview().offset(16) - $0.trailing.equalToSuperview().offset(-16) - } - - self.view.addSubview(tableView) - tableView.snp.makeConstraints { - $0.leading.trailing.equalToSuperview() - $0.top.equalTo(self.tagSearchTextFieldView.snp.bottom).offset(4) - $0.bottom.equalToSuperview() - } - } -} - -extension TagsViewController: UITableViewDataSource, UITableViewDelegate { - - func numberOfSections(in tableView: UITableView) -> Int { - guard let reactor = self.reactor else { - return 0 - } - return reactor.isFavoriteTagsEmpty ? 1 : 2 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let reactor = self.reactor else { - return 0 - } - if reactor.isFavoriteTagsEmpty { - return reactor.currentState.recommendTags.count - } - - switch TagType.allCases[section] { - case .favorite: - return reactor.currentState.favoriteTags.count - - case .recommend: - return reactor.currentState.recommendTags.count - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let reactor = self.reactor else { - return UITableViewCell() - } - if reactor.isFavoriteTagsEmpty { - return createRecommendTagTableViewCell(indexPath: indexPath) - } - switch TagType.allCases[indexPath.section] { - case .favorite: - return createFavoriteTagCell(indexPath: indexPath) - - case .recommend: - return createRecommendTagTableViewCell(indexPath: indexPath) - } - } - - private func createFavoriteTagCell(indexPath: IndexPath) -> FavoriteTagTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: FavoriteTagTableViewCell.self), - for: indexPath - ) as! FavoriteTagTableViewCell - - guard let reactor = self.reactor, reactor.currentState.favoriteTags.indices.contains(indexPath.row) else { - return cell - } - let favoriteTag = reactor.currentState.favoriteTags[indexPath.row] - - cell.setData(favoriteTag: favoriteTag) - - cell.favoriteTagView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, gesture in - if cell.favoriteTagView.isTappedDirectly(gesture: gesture) { - GAManager.shared.logEvent( - event: SOMEvent.Tag.tag_click( - tag_text: favoriteTag.tagContent, - click_position: SOMEvent.Tag.ClickPositionKey.favorite - ) - ) - object.pushTagdetailVC(reactor: reactor, tagID: favoriteTag.id) - } - } - .disposed(by: cell.disposeBag) - - cell.favoriteTagView.moreButtonStackView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, gesture in - GAManager.shared.logEvent( - event: SOMEvent.Tag.tag_click( - tag_text: favoriteTag.tagContent, - click_position: SOMEvent.Tag.ClickPositionKey.favorite - ) - ) - object.pushTagdetailVC(reactor: reactor, tagID: favoriteTag.id) - } - .disposed(by: cell.disposeBag) - - cell.previewCardTapped - .subscribe(with: self) { object, previewCardID in - GAManager.shared.logEvent( - event: SOMEvent.Tag.tag_click( - tag_text: favoriteTag.tagContent, - click_position: SOMEvent.Tag.ClickPositionKey.favorite_preview - ) - ) - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(previewCardID) - object.navigationPush(detailViewController, animated: true) - } - .disposed(by: cell.disposeBag) - - return cell - } - - private func pushTagdetailVC(reactor: TagsViewReactor, tagID: String) { - let tagDetailVC = TagDetailViewController() - tagDetailVC.reactor = reactor.reactorForTagDetail(tagID) - self.navigationPush(tagDetailVC, animated: true) - } - - private func createRecommendTagTableViewCell(indexPath: IndexPath) -> RecommendTagTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: RecommendTagTableViewCell.self), - for: indexPath - ) as! RecommendTagTableViewCell - guard let reactor = self.reactor else { - return cell - } - - if reactor.currentState.recommendTags.indices.contains(indexPath.row) { - cell.setData(recommendTag: reactor.currentState.recommendTags[indexPath.row]) - - cell.contentView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - let recommendTag = reactor.currentState.recommendTags[indexPath.row] - let tagID = recommendTag.tagID - let tagDetailVC = TagDetailViewController() - GAManager.shared.logEvent( - event: SOMEvent.Tag.tag_click( - tag_text: recommendTag.tagContent, - click_position: SOMEvent.Tag.ClickPositionKey.recommendation - ) - ) - tagDetailVC.reactor = reactor.reactorForTagDetail(tagID) - object.navigationPush(tagDetailVC, animated: true) - } - .disposed(by: cell.disposeBag) - } - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let favoriteTagHeight = CGFloat(228 + 12) - let recommendTagHeight = CGFloat(57 + 12) - guard let reactor = self.reactor else { - return 0 - } - if reactor.isFavoriteTagsEmpty { - return recommendTagHeight - } - switch TagType.allCases[indexPath.section] { - case .favorite: - return favoriteTagHeight - - case .recommend: - return recommendTagHeight - } - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 60 - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let reactor = self.reactor else { - return nil - } - if reactor.isFavoriteTagsEmpty { - return TagsHeaderView(type: TagType.recommend) - } - return TagsHeaderView(type: TagType.allCases[section]) - } - - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - return UIView() - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - guard let reactor = self.reactor else { - return 0 - } - let height = 58 + 6 + self.additionalSafeAreaInsets.bottom - if reactor.currentState.favoriteTags.isEmpty { - return height - } - switch TagType.allCases[section] { - case .favorite: - return 0 - case .recommend: - return height - } - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let offsetY = scrollView.contentOffset.y - let contentHeight = scrollView.contentSize.height - let height = scrollView.frame.size.height - - if offsetY > contentHeight - height - 1200 { - loadMoreTrigger.onNext(()) - } - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/TagsViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/TagsViewReactor.swift deleted file mode 100644 index 0a807d0f..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/TagsViewReactor.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// TagsViewReactor.swift -// SOOUM -// -// Created by JDeoks on 12/4/24. -// - -import Foundation - -import ReactorKit - -class TagsViewReactor: Reactor { - - enum Action { - case initialize - case refresh - case loadMoreFavorite - } - - enum Mutation { - /// 즐겨찾기 태그 fetch - case setFavoriteTags([FavoriteTagsResponse.FavoriteTagList]) - /// 즐겨찾기 태그 more - case appendFavoriteTags([FavoriteTagsResponse.FavoriteTagList]) - /// 추천 태그 fetch - case setRecommendTags([RecommendTagsResponse.RecommendTag]) - case setLoading(Bool) - case setProcessing(Bool) - } - - struct State { - /// 즐겨찾기 태그 리스트 - fileprivate(set) var favoriteTags: [FavoriteTagsResponse.FavoriteTagList] = [] - /// 추천 태그 리스트 - fileprivate(set) var recommendTags: [RecommendTagsResponse.RecommendTag] = [] - fileprivate(set) var isLoading: Bool = false - fileprivate(set) var isProcessing: Bool = false - } - - var initialState = State() - var isFavoriteTagsEmpty: Bool { - self.currentState.favoriteTags.isEmpty - } - - private var lastID: String? - private var isFetching = false - private let pageSize = 20 - private var isLastPage = false - - let provider: ManagerProviderType - - init(provider: ManagerProviderType) { - self.provider = provider - } - - func mutate(action: Action) -> Observable { - switch action { - - case .initialize: - isFetching = false - isLastPage = false - lastID = nil - let zipped = Observable.concat([ - self.fetchFavoriteTags(), - self.fetchRecommendTags() - ]) - return .concat([ - .just(.setLoading(true)), - zipped, - .just(.setLoading(false)) - ]) - - case .refresh: - isFetching = false - isLastPage = false - lastID = nil - let zipped = Observable.concat([ - self.fetchFavoriteTags(), - self.fetchRecommendTags() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - - return .concat([ - .just(.setLoading(true)), - zipped, - .just(.setLoading(false)) - ]) - - case .loadMoreFavorite: - guard !isFetching, !isLastPage else { - return .empty() - } - return .concat([ - .just(.setProcessing(true)), - self.fetchFavoriteTags(with: currentState.favoriteTags.last?.id), - .just(.setProcessing(false)) - ]) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var newState = state - switch mutation { - case let .setFavoriteTags(favoriteTags): - newState.favoriteTags = favoriteTags - - case let .appendFavoriteTags(favoriteTags): - newState.favoriteTags += favoriteTags - - case let .setRecommendTags(recommendTags): - newState.recommendTags = recommendTags - - case let .setLoading(isLoading): - newState.isLoading = isLoading - - case let .setProcessing(isProcessing): - newState.isProcessing = isProcessing - } - return newState - } - - private func fetchFavoriteTags(with lastId: String? = nil) -> Observable { - guard !isFetching, !isLastPage else { - return .empty() - } - - isFetching = true - - let request: TagRequest = .favorite(last: lastId) - - return self.provider.networkManager.request(FavoriteTagsResponse.self, request: request) - .map { response -> Mutation in - let items = response.embedded.favoriteTagList - self.isFetching = false - return lastId == nil ? .setFavoriteTags(items) : .appendFavoriteTags(items) - } - .catch { _ in - self.isFetching = false - self.isLastPage = true - return .empty() - } - } - - private func fetchRecommendTags() -> Observable { - let request: TagRequest = .recommend - - return self.provider.networkManager.request(RecommendTagsResponse.self, request: request) - .map { response in - return Mutation.setRecommendTags(response.embedded.recommendTagList) - } - .catch { _ in - return .just(.setRecommendTags([])) - } - } -} - -extension TagsViewReactor { - - func reactorForDetail(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(provider: self.provider, selectedId) - } - - func reactorForSearch() -> TagSearchViewReactor { - TagSearchViewReactor(provider: self.provider) - } - - func reactorForTagDetail(_ tagID: String) -> TagDetailViewrReactor { - TagDetailViewrReactor(provider: self.provider, tagID: tagID) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Views/TagSearchTextFieldView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Views/TagSearchTextFieldView.swift deleted file mode 100644 index ff166d74..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Views/TagSearchTextFieldView.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// TagSearchTextFieldView.swift -// SOOUM -// -// Created by JDeoks on 11/24/24. -// - -import UIKit - -import ReactorKit -import RxGesture -import RxSwift - -import SnapKit -import Then - -class TagSearchTextFieldView: UIView { - - private let disposeBag = DisposeBag() - - let textField = UITextField().then { - $0.textColor = .som.black - $0.typography = .som.body2WithBold - $0.placeholder = "태그 키워드를 입력해주세요" - } - - let magnifyingGlassImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.search))) - $0.tintColor = .som.gray500 - } - - // MARK: - init - convenience init(isInteractive: Bool) { - self.init(frame: .zero, isInteractive: isInteractive) - } - - init(frame: CGRect, isInteractive: Bool) { - super.init(frame: frame) - self.backgroundColor = .som.gray50 - self.layer.cornerRadius = 12 - - setupConstraints() - setInteractive(isInteractive) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setInteractive(_ isInteractive: Bool) { - if isInteractive { - self.layer.borderColor = UIColor.som.blue300.cgColor - self.layer.borderWidth = 1 - magnifyingGlassImageView.tintColor = .som.p300 - } - self.textField.isUserInteractionEnabled = isInteractive - } - - // MARK: - setupConstraints - private func setupConstraints() { - self.snp.makeConstraints { - $0.height.equalTo(48) - } - - self.addSubview(textField) - textField.snp.makeConstraints { - $0.leading.equalToSuperview().offset(14) - $0.centerY.equalToSuperview() - $0.height.equalTo(20) - } - - self.addSubview(magnifyingGlassImageView) - magnifyingGlassImageView.snp.makeConstraints { - $0.leading.equalTo(textField.snp.trailing).offset(8) - $0.trailing.equalToSuperview().offset(-14) - $0.centerY.equalToSuperview() - $0.size.equalTo(24) - } - } - - override var isFirstResponder: Bool { - return self.textField.isFirstResponder - } - - @discardableResult - override func becomeFirstResponder() -> Bool { - return self.textField.becomeFirstResponder() - } - - @discardableResult - override func resignFirstResponder() -> Bool { - return self.textField.resignFirstResponder() - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { } - - @objc - private func touch(sender: UIGestureRecognizer) { - if !self.textField.isFirstResponder { - self.textField.becomeFirstResponder() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Views/TagsHeaderView.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Views/TagsHeaderView.swift deleted file mode 100644 index 19c09986..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Tags/Views/TagsHeaderView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// TagsHeaderView.swift -// SOOUM -// -// Created by JDeoks on 11/24/24. -// - -import UIKit - -class TagsHeaderView: UIView { - - let titlelabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.text = "내가 즐겨찾기한 태그" - } - - // MARK: - init - convenience init(type: TagsViewController.TagType) { - self.init(frame: .zero, type: type) - } - - init(frame: CGRect, type: TagsViewController.TagType) { - super.init(frame: frame) - self.backgroundColor = .som.white - titlelabel.text = type.headerText - setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - setupConstraints - private func setupConstraints() { - self.addSubview(titlelabel) - titlelabel.snp.makeConstraints { - $0.leading.equalToSuperview().offset(32) - $0.top.equalToSuperview().offset(20) - $0.height.equalTo(24) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Views/SearchViewButton+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Views/SearchViewButton+Rx.swift new file mode 100644 index 00000000..ebcce92e --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Views/SearchViewButton+Rx.swift @@ -0,0 +1,18 @@ +// +// SearchViewButton+Rx.swift +// SOOUM +// +// Created by 오현식 on 11/19/25. +// + +import UIKit + +import RxCocoa +import RxSwift + +extension Reactive where Base: SearchViewButton { + + var didTap: Observable { + self.base.backgroundButton.rx.throttleTap(.seconds(3)) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Views/SearchViewButton.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Views/SearchViewButton.swift new file mode 100644 index 00000000..2b76c30a --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Views/SearchViewButton.swift @@ -0,0 +1,90 @@ +// +// SearchViewButton.swift +// SOOUM +// +// Created by 오현식 on 11/19/25. +// + +import UIKit + +import SnapKit +import Then + +class SearchViewButton: UIView { + + // MARK: Views + + let backgroundButton = UIButton() + + private let iconView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.search)))) + $0.tintColor = .som.v2.gray400 + } + + private let placeholderLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.subtitle1.withAlignment(.left) + } + + + // MARK: Variables + + var placeholder: String? { + set { + self.placeholderLabel.text = newValue + self.placeholderLabel.typography = .som.v2.subtitle1.withAlignment(.left) + } + get { + return self.placeholderLabel.text + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + let backgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 + $0.clipsToBounds = true + } + self.addSubview(backgroundView) + backgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(48) + } + + backgroundView.addSubview(self.iconView) + self.iconView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(18) + } + + backgroundView.addSubview(self.placeholderLabel) + self.placeholderLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.iconView.snp.trailing).offset(10) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + + self.addSubview(self.backgroundButton) + self.backgroundButton.snp.makeConstraints { + $0.edges.equalTo(backgroundView.snp.edges) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagView.swift new file mode 100644 index 00000000..b84f7b94 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagView.swift @@ -0,0 +1,96 @@ +// +// RelatedTagView.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import UIKit + +import SnapKit +import Then + +class RelatedTagView: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: RelatedTagView.self) + + + // MARK: Views + + private let imageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.hash)))) + $0.tintColor = .som.v2.pMain + } + + private let label = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.caption2 + } + + private let countLabel = UILabel().then { + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption3 + $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + } + + + // MARK: Variables + + private(set) var model: RelatedTagViewModel? + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.backgroundColor = .som.v2.white + self.contentView.layer.cornerRadius = 6 + self.contentView.clipsToBounds = true + + self.contentView.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(8) + $0.size.equalTo(14) + } + + self.contentView.addSubview(self.label) + self.label.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.imageView.snp.trailing).offset(2) + } + + self.contentView.addSubview(self.countLabel) + self.countLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.label.snp.trailing).offset(2) + $0.trailing.equalToSuperview().offset(-8) + } + } + + + // MARK: Public func + + func setModel(_ model: RelatedTagViewModel) { + + self.model = model + + self.label.text = model.originalText + self.label.typography = .som.v2.caption2 + self.countLabel.text = model.count + self.countLabel.typography = .som.v2.caption3 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagViewModel.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagViewModel.swift new file mode 100644 index 00000000..13a66f63 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagViewModel.swift @@ -0,0 +1,46 @@ +// +// RelatedTagViewModel.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import Foundation + +class RelatedTagViewModel { + + + // MARK: Variables + + let id: String + let originalText: String + let count: String + + var identifier: AnyHashable { + self.originalText + } + + + // MARK: Initialize + + init(originalText: String, count: String) { + self.id = UUID().uuidString + self.originalText = originalText + self.count = count + } +} + + +// MARK: Hashable + +extension RelatedTagViewModel: Hashable { + + static func == (lhs: RelatedTagViewModel, rhs: RelatedTagViewModel) -> Bool { + return lhs.identifier == rhs.identifier && + lhs.count == rhs.count + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.identifier) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsView+Rx.swift new file mode 100644 index 00000000..8b8ef5bd --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsView+Rx.swift @@ -0,0 +1,17 @@ +// +// RelatedTagsView+Rx.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import RxSwift + +extension Reactive where Base: RelatedTagsView { + + func models() -> Binder<[T]> { + return Binder(self.base) { tags, models in + tags.setModels(models) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsView.swift new file mode 100644 index 00000000..d9c2b42a --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsView.swift @@ -0,0 +1,202 @@ +// +// RelatedTagsView.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa + +class RelatedTagsView: UIView { + + enum Section: Int { + case main + } + + enum Item: Hashable { + case tag(RelatedTagViewModel) + } + + + // MARK: Views + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: RelatedTagsViewLayout().then { + $0.scrollDirection = .vertical + $0.minimumInteritemSpacing = 8 + $0.minimumLineSpacing = 8 + $0.sectionInset = .init(top: 16, left: 16, bottom: 16, right: 16) + } + ).then { + $0.backgroundColor = .som.v2.gray200 + + $0.alwaysBounceHorizontal = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .zero + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(RelatedTagView.self, forCellWithReuseIdentifier: RelatedTagView.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return nil } + + switch item { + case let .tag(model): + + let cell: RelatedTagView = collectionView.dequeueReusableCell( + withReuseIdentifier: RelatedTagView.cellIdentifier, + for: indexPath + ) as! RelatedTagView + cell.setModel(model) + + return cell + } + } + + var selectedRelatedTag = BehaviorRelay(value: nil) + var updatedContentHeight = BehaviorRelay(value: nil) + + private(set) var models = [RelatedTagViewModel]() + + + // MARK: Constraint + + private var collectionViewHeightConstraint: Constraint? + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + self.collectionViewHeightConstraint = $0.height.equalTo(0).constraint + } + } + + private func updateContentHeight(animated: Bool = true) { + + guard self.models.isEmpty == false else { + self.collectionViewHeightConstraint?.update(offset: 0) + self.updatedContentHeight.accept(nil) + return + } + + let contentHeight = self.collectionView.collectionViewLayout.collectionViewContentSize.height + if self.collectionViewHeightConstraint?.layoutConstraints.first?.constant != contentHeight { + self.collectionViewHeightConstraint?.update(offset: contentHeight) + self.updatedContentHeight.accept(contentHeight) + } + } + + + // MARK: Public func + + func setModels(_ models: [RelatedTagViewModel]) { + + guard models.isEmpty == false else { + self.models = [] + var snapshot = Snapshot() + snapshot.appendSections([.main]) + self.dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.updateContentHeight() + } + return + } + + let current = self.models + var new = models + /// 변경사항이 없다면 종료 + guard current != new else { return } + + /// 새로운 태그가 유효한지 확인 (중복 여부 확인) + if new.count != Set(new).count { + Log.warning("중복된 태그가 존재합니다. 태그의 순서를 유지하고 중복된 태그를 제거합니다.") + new = new.removeOlderfromDuplicated() + } + + self.models = new + + var snapshot = Snapshot() + snapshot.appendSections([.main]) + + let items = new.map { Item.tag($0) } + snapshot.appendItems(items, toSection: .main) + self.dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.updateContentHeight() + } + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension RelatedTagsView: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + if case let .tag(model) = item { + self.selectedRelatedTag.accept(model) + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return .zero } + + switch item { + case let .tag(model): + + var size: CGSize { + let textWidth: CGFloat = (model.originalText as NSString).size( + withAttributes: [.font: Typography.som.v2.caption2.font] + ).width + let countWidth: CGFloat = (model.count as NSString).size( + withAttributes: [.font: Typography.som.v2.caption3.font] + ).width + /// leading offset + hash image width + spacing + text width + spacing + remove button width + trailing offset + let tagWidth: CGFloat = 8 + 14 + 2 + ceil(textWidth) + 2 + ceil(countWidth) + 8 + return CGSize(width: tagWidth, height: 28) + } + + return size + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsViewLayout.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsViewLayout.swift new file mode 100644 index 00000000..2d07e115 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/RelatedTags/RelatedTagsViewLayout.swift @@ -0,0 +1,37 @@ +// +// RelatedTagsViewLayout.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import UIKit + +class RelatedTagsViewLayout: UICollectionViewFlowLayout { + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil } + + // 수직 방향일 경우, 좌측으로 정렬 + if self.scrollDirection == .vertical { + + var leadingOffset: CGFloat = self.sectionInset.left + var maxY: CGFloat = -1.0 + + attributes.forEach { attribute in + if attribute.representedElementCategory == .cell { + + if attribute.frame.minY >= maxY { + leadingOffset = self.sectionInset.left + } + attribute.frame.origin.x = leadingOffset + + leadingOffset += attribute.frame.width + self.minimumInteritemSpacing + maxY = attribute.frame.maxY + } + } + } + + return attributes + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardDefaultImageCell.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardDefaultImageCell.swift new file mode 100644 index 00000000..8d44a0d0 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardDefaultImageCell.swift @@ -0,0 +1,101 @@ +// +// WriteCardDefaultImageCell.swift +// SOOUM +// +// Created by 오현식 on 10/10/25. +// + +import UIKit + +import SnapKit +import Then + +class WriteCardDefaultImageCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: WriteCardDefaultImageCell.self) + + + // MARK: Views + + private let imageView = UIImageView() + + private let checkBackgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.black.withAlphaComponent(0.3) + $0.isHidden = true + } + + private let checkBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.white + $0.layer.borderColor = UIColor.som.v2.pMain.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 32 * 0.5 + $0.clipsToBounds = true + $0.isHidden = true + } + + private let checkImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.check)))) + $0.tintColor = .som.v2.pMain + } + + + // MARK: Variables + + private(set) var model: ImageUrlInfo = .defaultValue + + override var isSelected: Bool { + didSet { + self.checkBackgroundDimView.isHidden = self.isSelected == false + self.checkBackgroundView.isHidden = self.isSelected == false + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.contentView.addSubview(self.checkBackgroundDimView) + self.checkBackgroundDimView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.checkBackgroundView.addSubview(self.checkImageView) + self.checkImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(24) + } + + self.contentView.addSubview(self.checkBackgroundView) + self.checkBackgroundView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(32) + } + } + + + // MARK: Public func + + func setModel(_ model: ImageUrlInfo) { + + self.model = model + self.imageView.setImage(strUrl: model.imgUrl, with: model.imgName) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift new file mode 100644 index 00000000..71531ca5 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift @@ -0,0 +1,17 @@ +// +// WriteCardSelectImageView+Rx.swift +// SOOUM +// +// Created by 오현식 on 10/14/25. +// + +import RxSwift + +extension Reactive where Base: WriteCardSelectImageView { + + var setModels: Binder<(DefaultImages, EntranceCardType)> { + return Binder(self.base) { imgaeView, tuple in + imgaeView.setModels(tuple.0, cardType: tuple.1) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift new file mode 100644 index 00000000..3e2834a3 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift @@ -0,0 +1,461 @@ +// +// WriteCardSelectImageView.swift +// SOOUM +// +// Created by 오현식 on 10/10/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa + +class WriteCardSelectImageView: UIView { + + enum Text { + static let title: String = "배경" + + static let colorTitle: String = "컬러" + static let natureTitle: String = "자연" + static let sensitivitytitle: String = "감성" + static let foodTitle: String = "푸드" + static let abstractTitle: String = "추상" + static let memoTitle: String = "메모" + static let eventTitle: String = "이벤트" + } + + enum Section: Int, CaseIterable { + case color + case nature + case sensitivity + case food + case abstract + case memo + case event + } + + enum Item: Hashable { + case color(ImageUrlInfo) + case nature(ImageUrlInfo) + case sensitivity(ImageUrlInfo) + case food(ImageUrlInfo) + case abstract(ImageUrlInfo) + case memo(ImageUrlInfo) + case event(ImageUrlInfo) + case user + } + + + // MARK: Views + + private let titleLabel = UILabel().then { + $0.text = Text.title + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.caption1 + } + + private lazy var headerView = SOMSwipableTabBar().then { + $0.delegate = self + } + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .vertical + $0.minimumLineSpacing = 0 + $0.minimumInteritemSpacing = 0 + } + ).then { + $0.isScrollEnabled = false + + $0.contentInsetAdjustmentBehavior = .never + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.layer.borderColor = UIColor.som.v2.gray200.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 8 + + $0.clipsToBounds = true + + $0.register(WriteCardDefaultImageCell.self, forCellWithReuseIdentifier: WriteCardDefaultImageCell.cellIdentifier) + $0.register(WriteCardUserImageCell.self, forCellWithReuseIdentifier: WriteCardUserImageCell.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + + guard let self = self else { return nil } + + switch item { + case let .color(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case let .nature(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case let .sensitivity(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case let .food(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case let .abstract(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case let .memo(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case let .event(imageInfo): + + let cell: WriteCardDefaultImageCell = self.cellForDefault(collectionView, with: indexPath) + cell.setModel(imageInfo) + cell.isSelected = imageInfo == self.selectedImageInfo.value?.info + + return cell + case .user: + + let cell: WriteCardUserImageCell = self.cellForUser(collectionView, with: indexPath) + + return cell + } + } + + + // MARK: Variables + + private(set) var models: DefaultImages = .defaultValue + private(set) var cardType: EntranceCardType = .feed + + var selectedImageInfo = BehaviorRelay<(type: BaseCardInfo.ImageType, info: ImageUrlInfo)?>(value: nil) + var selectedUseUserImageCell = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(20) + } + + self.addSubview(self.headerView) + self.headerView.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.bottom) + $0.horizontalEdges.equalToSuperview() + } + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.top.equalTo(self.headerView.snp.bottom) + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + let width: CGFloat = (UIScreen.main.bounds.width - 16 * 2) / 4 + $0.height.equalTo(width * 2) + } + } + + + // MARK: Public func + + func setModels(_ models: DefaultImages, cardType: EntranceCardType) { + + self.models = models + self.cardType = cardType + + guard let initialImage = models.color.first else { return } + + self.selectedImageInfo.accept((.default, initialImage)) + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + var items = [ + Text.colorTitle, + Text.natureTitle, + Text.sensitivitytitle, + Text.foodTitle, + Text.abstractTitle, + Text.memoTitle, + Text.eventTitle + ] + if models.event == nil { + snapshot.deleteSections([.event]) + items.removeAll(where: { $0 == Text.eventTitle }) + } + self.headerView.items = items + + var new = models.color.map { Item.color($0) } + new.append(Item.user) + snapshot.appendItems(new, toSection: .color) + self.dataSource.apply(snapshot, animatingDifferences: true) + + var reconfigureSnapshot = self.dataSource.snapshot() + if let itemToUpdate: Item = reconfigureSnapshot.itemIdentifiers.first(where: { + switch $0 { + case let .color(imageInfo): + return imageInfo == initialImage + default: + return false + } + }) { + reconfigureSnapshot.reconfigureItems([itemToUpdate]) + self.dataSource.apply(reconfigureSnapshot, animatingDifferences: false) + } + } + + func updatedByUser() { + + var reconfigureSnapshot = self.dataSource.snapshot() + if let itemToUpdate: Item = reconfigureSnapshot.itemIdentifiers.first(where: { + switch $0 { + case let .color(imageInfo), + let .nature(imageInfo), + let .sensitivity(imageInfo), + let .food(imageInfo), + let .abstract(imageInfo), + let .memo(imageInfo), + let .event(imageInfo): + return imageInfo == self.selectedImageInfo.value?.info + case .user: + return false + } + }) { + + self.selectedImageInfo.accept((.user, .defaultValue)) + + reconfigureSnapshot.reconfigureItems([itemToUpdate]) + self.dataSource.apply(reconfigureSnapshot, animatingDifferences: false) + } + } +} + + +// MARK: Cells + +private extension WriteCardSelectImageView { + + func cellForDefault( + _ collectionView: UICollectionView, + with indexPath: IndexPath + ) -> WriteCardDefaultImageCell { + + return collectionView.dequeueReusableCell( + withReuseIdentifier: WriteCardDefaultImageCell.cellIdentifier, + for: indexPath + ) as! WriteCardDefaultImageCell + } + + func cellForUser( + _ collectionView: UICollectionView, + with indexPath: IndexPath + ) -> WriteCardUserImageCell { + return collectionView.dequeueReusableCell( + withReuseIdentifier: WriteCardUserImageCell.cellIdentifier, + for: indexPath + ) as! WriteCardUserImageCell + } +} + + +// MARK: SOMSwipeTabBarDelegate + +extension WriteCardSelectImageView: SOMSwipableTabBarDelegate { + + func tabBar(_ tabBar: SOMSwipableTabBar, didSelectTabAt index: Int) { + + var headerFilter: (section: WriteCardSelectImageView.Section, items: [WriteCardSelectImageView.Item])? { + switch index { + case 0: + let items = self.models.color.map { Item.color($0) } + return (.color, items) + case 1: + let items = self.models.nature.map { Item.nature($0) } + return (.nature, items) + case 2: + let items = self.models.sensitivity.map { Item.sensitivity($0) } + return (.sensitivity, items) + case 3: + let items = self.models.food.map { Item.food($0) } + return (.food, items) + case 4: + let items = self.models.abstract.map { Item.abstract($0) } + return (.abstract, items) + case 5: + let items = self.models.memo.map { Item.memo($0) } + return (.memo, items) + case 6: + guard let events = self.models.event else { return nil } + let items = events.map { Item.event($0) } + return (.event, items) + default: + return nil + } + } + guard var headerFilter = headerFilter else { return } + headerFilter.items.append(Item.user) + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + snapshot.appendItems(headerFilter.items, toSection: headerFilter.section) + self.dataSource.apply(snapshot, animatingDifferences: true) + + var reconfigureSnapshot = self.dataSource.snapshot() + let items = reconfigureSnapshot.itemIdentifiers(inSection: headerFilter.section) + if let itemToUpdate: Item = items.first(where: { + switch $0 { + case let .color(imageInfo), + let .nature(imageInfo), + let .sensitivity(imageInfo), + let .food(imageInfo), + let .abstract(imageInfo), + let .memo(imageInfo): + + if self.cardType == .feed { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.feedBackgroundCategory_tab_click + ) + } else { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.commentBackgroundCategory_tab_click + ) + } + + return imageInfo == self.selectedImageInfo.value?.info + case let .event(imageInfo): + + if self.cardType == .feed { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.feedBackgroundCategory_tab_click + ) + } else { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.commentBackgroundCategory_tab_click + ) + GAHelper.shared.logEvent(event: GAEvent.WriteCardView.createFeedCardEventCategory_btn_click) + } + + return imageInfo == self.selectedImageInfo.value?.info + case .user: + return false + } + }) { + reconfigureSnapshot.reconfigureItems([itemToUpdate]) + self.dataSource.apply(reconfigureSnapshot, animatingDifferences: false) + } + } +} + + +// MARK: UICollectionViewDelegate + +extension WriteCardSelectImageView: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let newItem = self.dataSource.itemIdentifier(for: indexPath) else { return } + + var reconfigureSnapshot = self.dataSource.snapshot() + var itemsToUpdate: Set = [] + if let prevItem: Item = reconfigureSnapshot.itemIdentifiers.first(where: { + switch $0 { + case let .color(imageInfo), + let .nature(imageInfo), + let .sensitivity(imageInfo), + let .food(imageInfo), + let .abstract(imageInfo), + let .memo(imageInfo), + let .event(imageInfo): + return imageInfo == self.selectedImageInfo.value?.info + case .user: + return false + } + }) { + itemsToUpdate.insert(prevItem) + } + + switch newItem { + case let .color(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case let .nature(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case let .sensitivity(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case let .food(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case let .abstract(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case let .memo(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case let .event(imageInfo): + self.selectedImageInfo.accept((.default, imageInfo)) + case .user: + self.selectedUseUserImageCell.accept(()) + } + itemsToUpdate.insert(newItem) + + reconfigureSnapshot.reconfigureItems(Array(itemsToUpdate)) + self.dataSource.apply(reconfigureSnapshot, animatingDifferences: false) + } +} + +extension WriteCardSelectImageView: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + let width: CGFloat = (UIScreen.main.bounds.width - 16 * 2) / 4 + return CGSize(width: width, height: width) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardUserImageCell.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardUserImageCell.swift new file mode 100644 index 00000000..d03027cf --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardUserImageCell.swift @@ -0,0 +1,49 @@ +// +// WriteCardUserImageCell.swift +// SOOUM +// +// Created by 오현식 on 10/10/25. +// + +import UIKit + +import SnapKit +import Then + +class WriteCardUserImageCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: WriteCardUserImageCell.self) + + + // MARK: Views + + private let cameraImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.camera)))) + $0.tintColor = .som.v2.gray400 + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.backgroundColor = .som.v2.gray100 + + self.contentView.addSubview(self.cameraImageView) + self.cameraImageView.snp.makeConstraints { + $0.center.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectOptions/SelectOptionItem.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectOptions/SelectOptionItem.swift new file mode 100644 index 00000000..0af7226f --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectOptions/SelectOptionItem.swift @@ -0,0 +1,82 @@ +// +// SelectOptionItem.swift +// SOOUM +// +// Created by 오현식 on 10/11/25. +// + +import UIKit + +import SnapKit +import Then + +class SelectOptionItem: UIView { + + enum OptionType: CaseIterable { + case distanceShare + case story + + var title: String { + switch self { + case .distanceShare: return "거리공유" + case .story: return "24시간" + } + } + } + + + // MARK: Views + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + + // MARK: Variables + + var isSelected: Bool = false { + didSet { + self.backgroundColor = self.isSelected ? .som.v2.pLight1 : .som.v2.gray100 + self.titleLabel.textColor = self.isSelected ? .som.v2.gray600 : .som.v2.gray400 + } + } + + var optionType: OptionType? + + // MARK: Initialize + + convenience init(type: OptionType) { + self.init(frame: .zero) + + self.optionType = type + self.titleLabel.text = type.title + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.gray100 + self.layer.cornerRadius = 32 * 0.5 + self.clipsToBounds = true + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(7) + $0.bottom.equalToSuperview().offset(-7) + $0.leading.equalToSuperview().offset(10) + $0.trailing.equalToSuperview().offset(-10) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectOptions/SelectOptionsView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectOptions/SelectOptionsView.swift new file mode 100644 index 00000000..e5f46dfe --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectOptions/SelectOptionsView.swift @@ -0,0 +1,110 @@ +// +// SelectOptionView.swift +// SOOUM +// +// Created by 오현식 on 10/11/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxGesture +import RxSwift + +class SelectOptionsView: UIView { + + + // MARK: Views + + private let seperator = UIView().then { + $0.backgroundColor = .som.v2.gray200 + } + + private let container = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 6 + } + + + // MARK: Variables + + var selectedOptions = BehaviorRelay<[SelectOptionItem.OptionType]?>(value: nil) + var selectOptions: [SelectOptionItem.OptionType] = [] { + didSet { + self.container.subviews.forEach { item in + guard let item = item as? SelectOptionItem else { return } + + let hasOption = self.selectOptions.contains(where: { $0 == item.optionType }) + item.isSelected = hasOption + } + + self.selectedOptions.accept(self.selectOptions) + } + } + + var items: [SelectOptionItem.OptionType] = [] { + didSet { + if self.items.isEmpty == false { + self.setupItems(self.items) + } + } + } + + private var disposeBag = DisposeBag() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: .zero) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.seperator) + self.seperator.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() + $0.height.equalTo(1) + } + + self.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalToSuperview().offset(8) + $0.bottom.equalToSuperview().offset(-8) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + } + + private func setupItems(_ items: [SelectOptionItem.OptionType]) { + + items.forEach { type in + + let item = SelectOptionItem(type: type) + self.container.addArrangedSubview(item) + + item.rx.tapGesture() + .when(.recognized) + .subscribe(with: self) { object, _ in + let hasOption = object.selectOptions.contains(where: { $0 == type }) + object.selectOptions = hasOption ? + object.selectOptions.filter { $0 != type } : + object.selectOptions + [type] + } + .disposed(by: self.disposeBag) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectTypography/SelectTypographyView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectTypography/SelectTypographyView.swift new file mode 100644 index 00000000..08a1b1f1 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectTypography/SelectTypographyView.swift @@ -0,0 +1,171 @@ +// +// SelectTypographyView.swift +// SOOUM +// +// Created by 오현식 on 10/11/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxGesture +import RxSwift + +class SelectTypographyView: UIView { + + typealias TypographyWithName = (name: String, typography: Typography) + + enum Text { + static let title: String = "글씨체" + } + + + // MARK: Views + + private let titleLabel = UILabel().then { + $0.text = Text.title + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.caption1 + } + + private let container = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 8 + } + + + // MARK: Variables + + var selectedTypography = BehaviorRelay(value: nil) + var selectTypography: BaseCardInfo.Font = .pretendard { + didSet { + + let items = self.container.arrangedSubviews + .compactMap { $0 as? UIStackView } + .flatMap { $0.arrangedSubviews } + .compactMap { $0 as? SOMButton } + + items.enumerated().forEach { index, item in + var font: BaseCardInfo.Font? { + switch index { + case 0: return .pretendard + case 1: return .ridi + case 2: return .yoonwoo + case 3: return .kkookkkook + default: return nil + } + } + item.isSelected = font == self.selectTypography + } + + self.selectedTypography.accept(self.selectTypography) + } + } + + var items: [TypographyWithName] = [] { + didSet { + if self.items.isEmpty == false { + self.setupItems(self.items) + } + } + } + + private var disposeBag = DisposeBag() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: .zero) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(20) + } + + self.addSubview(self.container) + self.container.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.bottom).offset(10) + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + } + + private func setupItems(_ items: [TypographyWithName]) { + + let firstContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 8 + } + + let secondContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 8 + } + + items.enumerated().forEach { index, value in + + let item = SOMButton().then { + $0.title = value.name + $0.typography = value.typography + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 + } + item.isSelected = index == 0 + item.rx.throttleTap + .subscribe(with: self) { object, _ in + var font: BaseCardInfo.Font? { + switch index { + case 0: return .pretendard + case 1: return .ridi + case 2: return .yoonwoo + case 3: return .kkookkkook + default: return nil + } + } + guard let font = font else { return } + + object.selectTypography = font + } + .disposed(by: self.disposeBag) + + item.snp.makeConstraints { + let width: CGFloat = (UIScreen.main.bounds.width - 16 * 2 - 8) * 0.5 + $0.width.equalTo(width) + $0.height.equalTo(48) + } + + if index < 2 { + firstContainer.addArrangedSubview(item) + } else { + secondContainer.addArrangedSubview(item) + } + } + self.container.addArrangedSubview(firstContainer) + self.container.addArrangedSubview(secondContainer) + /// 초기값 + self.selectTypography = .pretendard + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTag.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTag.swift new file mode 100644 index 00000000..f7bc054d --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTag.swift @@ -0,0 +1,114 @@ +// +// WriteCardTag.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import UIKit + +import SnapKit +import Then + +protocol WriteCardTagDelegate: AnyObject { + func tag(_ tag: WriteCardTag, didRemoveSelect model: WriteCardTagModel) +} + +class WriteCardTag: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: WriteCardTag.self) + + + // MARK: Views + + private let imageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.hash)))) + $0.tintColor = .som.v2.gray300 + } + + private let label = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.caption2 + } + + private lazy var removeButton = SOMButton().then { + $0.image = .init(.icon(.v2(.outlined(.delete)))) + $0.foregroundColor = .som.v2.gray300 + + $0.addTarget(self, action: #selector(self.remove), for: .touchUpInside) + } + + + // MARK: Variables + + private(set) var model: WriteCardTagModel? + + + // MARK: Delegate + + weak var delegate: WriteCardTagDelegate? + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.backgroundColor = .som.v2.dim + self.contentView.layer.cornerRadius = 6 + self.contentView.clipsToBounds = true + + self.contentView.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(8) + $0.size.equalTo(14) + } + + self.contentView.addSubview(self.label) + self.label.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.imageView.snp.trailing).offset(2) + } + + self.contentView.addSubview(self.removeButton) + self.removeButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.label.snp.trailing).offset(2) + $0.trailing.equalToSuperview().offset(-8) + $0.size.equalTo(16) + } + } + + + // MARK: Objc func + + @objc + private func remove(_ button: UIButton) { + guard let model = self.model else { return } + self.delegate?.tag(self, didRemoveSelect: model) + } + + + // MARK: Public func + + func setModel(_ model: WriteCardTagModel) { + + self.model = model + + self.label.text = model.originalText + self.label.typography = model.typography + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagFooter.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagFooter.swift new file mode 100644 index 00000000..826334c3 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagFooter.swift @@ -0,0 +1,188 @@ +// +// WriteCardFooter.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import UIKit + +import SnapKit +import Then + +class WriteCardTagFooter: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: WriteCardTagFooter.self) + + enum Constants { + static let maxCharacters: Int = 15 + } + + + // MARK: Views + + private let imageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.plus)))) + $0.tintColor = .som.v2.white + } + + lazy var textField = UITextField().then { + $0.typography = .som.v2.caption2 + $0.textColor = .som.v2.white + $0.tintColor = .som.v2.white + + $0.returnKeyType = .done + + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.spellCheckingType = .no + + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) + + $0.delegate = self + } + + + // MARK: Variables + + var text: String? { + set { + self.textField.text = newValue + } + get { + return self.textField.text + } + } + + var placeholder: String? { + didSet { + self.textField.text = self.placeholder + } + } + + var typography: Typography? { + didSet { + self.textField.typography = self.typography + } + } + + + // MARK: Delegate + + weak var delegate: WriteCardTagFooterDelegate? + + + // MARK: Override func + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { } + + override var isFirstResponder: Bool { + return self.textField.isFirstResponder + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + return self.textField.becomeFirstResponder() + } + + @discardableResult + override func resignFirstResponder() -> Bool { + return self.textField.resignFirstResponder() + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.dim + self.layer.cornerRadius = 6 + self.clipsToBounds = true + + self.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(8) + $0.size.equalTo(14) + } + + self.addSubview(self.textField) + self.textField.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.imageView.snp.trailing).offset(2) + $0.trailing.equalToSuperview().offset(-8) + } + } +} + + +// MARK: SendActions + +extension WriteCardTagFooter { + + func sendActionsToTextField(for controlEvents: UIControl.Event) { + self.textField.sendActions(for: controlEvents) + } + + func addTargetToTextField(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) { + self.textField.addTarget(target, action: action, for: controlEvents) + } +} + + +// MARK: UITextFieldDelegate + +extension WriteCardTagFooter: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + self.textField.text = nil + self.imageView.image = .init(.icon(.v2(.outlined(.hash)))) + self.imageView.tintColor = .som.v2.gray300 + self.delegate?.textFieldDidBeginEditing(self) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + self.imageView.image = .init(.icon(.v2(.outlined(.plus)))) + self.imageView.tintColor = .som.v2.white + self.delegate?.textFieldDidEndEditing(self) + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // 타이핑 시 공백 제거 + if string == " " && range.length == 0 { + return false + } + // 붙여넣기 공백 제거 + let isPasting: Bool = string.count > 1 || range.length > 0 + var newString: String = string + if isPasting { + newString = string.replacingOccurrences(of: " ", with: "") + if newString.isEmpty && string.contains(" ") { + return false + } + } + + return textField.shouldChangeCharactersIn( + in: range, + replacementString: newString, + maxCharacters: Constants.maxCharacters + ) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + return self.delegate?.textFieldReturnKeyClicked(self) ?? true + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagFooterDelegate.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagFooterDelegate.swift new file mode 100644 index 00000000..984ba2fe --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagFooterDelegate.swift @@ -0,0 +1,22 @@ +// +// WriteCardFooterDelegate.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Foundation + +protocol WriteCardTagFooterDelegate: AnyObject { + + func textFieldDidBeginEditing(_ textField: WriteCardTagFooter) + func textFieldDidEndEditing(_ textField: WriteCardTagFooter) + func textFieldReturnKeyClicked(_ textField: WriteCardTagFooter) -> Bool +} + +extension WriteCardTagFooterDelegate { + + func textFieldDidBeginEditing(_ textField: WriteCardTagFooter) { } + func textFieldDidEndEditing(_ textField: WriteCardTagFooter) { } + func textFieldReturnKeyClicked(_ textField: WriteCardTagFooter) -> Bool { true } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagModel.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagModel.swift new file mode 100644 index 00000000..4cd65c78 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagModel.swift @@ -0,0 +1,45 @@ +// +// WriteCardTagModel.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import Foundation + +class WriteCardTagModel { + + + // MARK: Variables + + let id: String + let originalText: String + var typography: Typography + + var identifier: AnyHashable { + self.originalText + } + + + // MARK: Initialize + + init(originalText: String, typography: Typography) { + self.id = UUID().uuidString + self.originalText = originalText + self.typography = typography + } +} + + +// MARK: Hashable + +extension WriteCardTagModel: Hashable { + + static func == (lhs: WriteCardTagModel, rhs: WriteCardTagModel) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.identifier) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags+Rx.swift new file mode 100644 index 00000000..328e6fd9 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags+Rx.swift @@ -0,0 +1,17 @@ +// +// WriteCardTags+Rx.swift +// SOOUM +// +// Created by 오현식 on 10/15/25. +// + +import RxSwift + +extension Reactive where Base: WriteCardTags { + + func models() -> Binder<[T]> { + return Binder(self.base) { tags, models in + tags.setModels(models) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift new file mode 100644 index 00000000..f0f77e13 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift @@ -0,0 +1,351 @@ +// +// WriteCardTags.swift +// SOOUM +// +// Created by 오현식 on 10/8/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa + +class WriteCardTags: UIView { + + enum Text { + static let tagPlaceholder = "태그 추가" + } + + enum Section: Int { + case main + } + + enum Item: Hashable { + case tag(WriteCardTagModel) + case footer + } + + + // MARK: Views + + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout().then { + $0.scrollDirection = .horizontal + $0.minimumInteritemSpacing = 6 + $0.minimumLineSpacing = 0 + } + ).then { + $0.backgroundColor = .clear + + $0.alwaysBounceHorizontal = true + + $0.contentInsetAdjustmentBehavior = .never + $0.contentInset = .init(top: 0, left: 16, bottom: 0, right: 16) + + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(WriteCardTag.self, forCellWithReuseIdentifier: WriteCardTag.cellIdentifier) + $0.register(WriteCardTagFooter.self, forCellWithReuseIdentifier: WriteCardTagFooter.cellIdentifier) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + guard let self = self else { return nil } + + switch item { + case let .tag(model): + + let cell: WriteCardTag = collectionView.dequeueReusableCell( + withReuseIdentifier: WriteCardTag.cellIdentifier, + for: indexPath + ) as! WriteCardTag + cell.setModel(model) + cell.delegate = self + + return cell + case .footer: + + let footer: WriteCardTagFooter = collectionView.dequeueReusableCell( + withReuseIdentifier: WriteCardTagFooter.cellIdentifier, + for: indexPath + ) as! WriteCardTagFooter + footer.placeholder = Text.tagPlaceholder + footer.typography = self.typography + footer.delegate = self + + footer.addTargetToTextField(self, action: #selector(self.textDidChanged(_:)), for: .editingChanged) + + return footer + } + } + + var updateWrittenTags = BehaviorRelay<[WriteCardTagModel]?>(value: nil) + + private(set) var models = [WriteCardTagModel]() + + private var footerText: String? = Text.tagPlaceholder + private var footerWidth: CGFloat { + guard let text = self.footerText else { return 36 } + let textWidth: CGFloat = (text as NSString).size( + withAttributes: [.font: self.typography.font] + ).width + + return max(36, 8 + 14 + 2 + ceil(textWidth) + 8) + } + + var updateFooterText: String? { + didSet { + guard let indexPath = self.dataSource.indexPath(for: .footer), + let footer: WriteCardTagFooter = self.collectionView.cellForItem( + at: indexPath + ) as? WriteCardTagFooter + else { return } + + footer.text = self.updateFooterText + footer.sendActionsToTextField(for: .editingChanged) + } + } + + var typography: Typography = .som.v2.caption2 { + didSet { + let current = self.models + current.forEach { $0.typography = self.typography } + self.models = current + + let itemsToReconfigure = self.dataSource.snapshot().itemIdentifiers + var reconfigureSnapshot = self.dataSource.snapshot() + reconfigureSnapshot.reconfigureItems(itemsToReconfigure) + self.dataSource.apply(reconfigureSnapshot, animatingDifferences: false) { [weak self] in + self?.scrollToRight() + } + } + } + + + // MARK: Delegate + + weak var delegate: WriteCardTagsDelegate? + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + private func scrollToRight(animated: Bool = false) { + + let contentWidth: CGFloat = self.collectionView.collectionViewLayout.collectionViewContentSize.width + let boundsWidth: CGFloat = self.collectionView.bounds.width + guard contentWidth >= (boundsWidth - 16 * 2) else { return } + let newOffset: CGPoint = CGPoint( + x: ceil(contentWidth - boundsWidth) + self.collectionView.contentInset.right, + y: 0 + ) + self.collectionView.setContentOffset(newOffset, animated: animated) + } + + + // MARK: Public func + + func setModels(_ models: [WriteCardTagModel]) { + + guard models.isEmpty == false else { + self.models = [] + var snapshot = Snapshot() + snapshot.appendSections([.main]) + snapshot.appendItems([.footer], toSection: .main) + self.dataSource.apply(snapshot, animatingDifferences: false) + return + } + + let current = self.models + var new = models + /// 변경사항이 없다면 종료 + guard current != new else { return } + + /// 새로운 태그가 유효한지 확인 (중복 여부 확인) + if new.count != Set(new).count { + Log.warning("중복된 태그가 존재합니다. 태그의 순서를 유지하고 중복된 태그를 제거합니다.") + new = new.removeOlderfromDuplicated() + } + + self.models = new + + var snapshot = Snapshot() + snapshot.appendSections([.main]) + + var items = new.map { Item.tag($0) } + items.append(Item.footer) + snapshot.appendItems(items, toSection: .main) + self.dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.scrollToRight() + } + } + + func isFooterFirstResponder() -> Bool { + + guard let indexPath = self.dataSource.indexPath(for: .footer), + let footer: WriteCardTagFooter = self.collectionView.cellForItem( + at: indexPath + ) as? WriteCardTagFooter + else { return false } + + return footer.isFirstResponder + } + + func footerResignFirstResponder() { + + guard let indexPath = self.dataSource.indexPath(for: .footer), + let footer: WriteCardTagFooter = self.collectionView.cellForItem( + at: indexPath + ) as? WriteCardTagFooter + else { return } + + footer.resignFirstResponder() + } + + + // MARK: Objc func + + @objc + func textDidChanged(_ textField: UITextField) { + self.footerText = textField.text + self.collectionView.collectionViewLayout.invalidateLayout() + + self.scrollToRight(animated: true) + self.delegate?.textDidChanged(textField.text) + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension WriteCardTags: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .footer: + let footer = collectionView.dequeueReusableCell( + withReuseIdentifier: WriteCardTagFooter.cellIdentifier, + for: indexPath + ) as! WriteCardTagFooter + footer.becomeFirstResponder() + default: + return + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return .zero } + + switch item { + case let .tag(model): + + var size: CGSize { + let textWidth: CGFloat = (model.originalText as NSString).size( + withAttributes: [.font: self.typography.font] + ).width + /// leading offset + hash image width + spacing + text width + spacing + remove button width + trailing offset + let tagWidth: CGFloat = 8 + 14 + 2 + ceil(textWidth) + 2 + 16 + 8 + return CGSize(width: tagWidth, height: 28) + } + + return size + case .footer: + + return CGSize(width: self.footerWidth, height: 28) + } + } +} + + +// MARK: WriteCardTagDelegate + +extension WriteCardTags: WriteCardTagDelegate { + + func tag(_ tag: WriteCardTag, didRemoveSelect model: WriteCardTagModel) { + var models = self.models + models.removeAll(where: { $0 == model }) + self.updateWrittenTags.accept(models) + } +} + + +// MARK: WriteCardFooterViewDelegate + +extension WriteCardTags: WriteCardTagFooterDelegate { + + func textFieldDidBeginEditing(_ textField: WriteCardTagFooter) { + self.footerText = textField.text + self.collectionView.collectionViewLayout.invalidateLayout() + self.delegate?.textFieldDidBeginEditing(textField) + } + + func textFieldDidEndEditing(_ textField: WriteCardTagFooter) { + if let text = textField.text, text.isEmpty == false { + let addedTag: WriteCardTagModel = .init(originalText: text, typography: self.typography) + var new = self.models + new.append(addedTag) + self.updateWrittenTags.accept(new) + } + textField.text = Text.tagPlaceholder + self.footerText = Text.tagPlaceholder + self.collectionView.collectionViewLayout.invalidateLayout() + self.scrollToRight(animated: true) + } + + func textFieldReturnKeyClicked(_ textField: WriteCardTagFooter) -> Bool { + + guard let text = textField.text, text.isEmpty == false else { + self.footerResignFirstResponder() + return false + } + + let addedTag: WriteCardTagModel = .init(originalText: text, typography: self.typography) + var new = self.models + new.append(addedTag) + self.updateWrittenTags.accept(new) + + textField.text = nil + textField.sendActionsToTextField(for: .editingChanged) + + GAHelper.shared.logEvent(event: GAEvent.WriteCardView.multipleFeedTagCreation_enter_btn_click) + + return false + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagsDelegate.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagsDelegate.swift new file mode 100644 index 00000000..f17db33b --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTagsDelegate.swift @@ -0,0 +1,20 @@ +// +// WriteCardTagsDelegate.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import Foundation + +protocol WriteCardTagsDelegate: AnyObject { + + func textFieldDidBeginEditing(_ textField: WriteCardTagFooter) + func textDidChanged(_ text: String?) +} + +extension WriteCardTagsDelegate { + + func textFieldDidBeginEditing(_ textField: WriteCardTagFooter) { } + func textDidChanged(_ text: String?) { } +} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView+Rx.swift similarity index 99% rename from SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextView+Rx.swift rename to SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView+Rx.swift index 5b8296f9..921eea00 100644 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextView+Rx.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView+Rx.swift @@ -10,7 +10,6 @@ import UIKit import RxCocoa import RxSwift - extension Reactive where Base: WriteCardTextView { var text: ControlProperty { diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift new file mode 100644 index 00000000..375373fe --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift @@ -0,0 +1,239 @@ +// +// WriteCardTextView.swift +// SOOUM +// +// Created by 오현식 on 10/18/24. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa + +class WriteCardTextView: UIView { + + enum Constants { + static let maxCharacters: Int = 500 + } + + + // MARK: Views + + private lazy var backgroundImageView = UIImageView().then { + $0.backgroundColor = .clear + $0.contentMode = .scaleAspectFill + $0.layer.borderColor = UIColor.som.v2.gray100.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 16 + $0.clipsToBounds = true + } + + private lazy var backgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.dim + $0.layer.cornerRadius = 12 + + let gestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.touch) + ) + $0.addGestureRecognizer(gestureRecognizer) + } + + lazy var textView = UITextView().then { + $0.backgroundColor = .clear + + $0.typography = .som.v2.body1 + $0.textColor = .som.v2.white + $0.tintColor = .som.v2.white + + $0.textContainerInset = .init(top: 20, left: 24, bottom: 20, right: 24) + $0.textContainer.lineFragmentPadding = 0 + $0.textContainer.lineBreakMode = .byCharWrapping + + $0.scrollIndicatorInsets = .init(top: 20, left: 0, bottom: 20, right: 0) + $0.indicatorStyle = .white + $0.isScrollEnabled = false + + $0.showsHorizontalScrollIndicator = false + + $0.returnKeyType = .default + + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.spellCheckingType = .no + + $0.delegate = self + } + + private let placeholderLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.body1 + } + + + // MARK: Variables + + var imageInfo: ImageUrlInfo? { + didSet { + guard let imageInfo = self.imageInfo else { return } + self.backgroundImageView.setImage(strUrl: imageInfo.imgUrl, with: imageInfo.imgName) + } + } + + var image: UIImage? { + didSet { + guard let image = self.image else { return } + self.backgroundImageView.image = image + } + } + + var placeholder: String? { + set { + self.placeholderLabel.text = newValue + self.placeholderLabel.typography = self.typography + } + get { + return self.placeholderLabel.text + } + } + + var text: String? { + set { + self.textView.text = newValue + self.textView.typography = self.typography + } + get { + return self.textView.text + } + } + + var typography: Typography = .som.v2.body1 { + didSet { + self.textView.typography = self.typography + self.placeholderLabel.typography = self.typography + + let limit = self.typography.lineHeight * 8 + 20 * 2 + self.backgroundDimView.snp.updateConstraints { + $0.height.lessThanOrEqualTo(limit) + } + } + } + + + // MARK: Delegate + + weak var delegate: WritrCardTextViewDelegate? + + + // MARK: Override func + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { } + + + // MARK: Objc func + + @objc + private func touch(_ recognizer: UITapGestureRecognizer) { + if self.textView.isFirstResponder == false { + self.textView.becomeFirstResponder() + } + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.backgroundImageView) + self.backgroundImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.addSubview(self.backgroundDimView) + self.backgroundDimView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(32) + $0.trailing.equalToSuperview().offset(-32) + let limit = self.typography.lineHeight * 8 + 20 * 2 + $0.height.lessThanOrEqualTo(limit) + } + + self.backgroundDimView.addSubview(self.textView) + self.textView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.textView.addSubview(self.placeholderLabel) + self.placeholderLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + private func updateTextContainerHeightLimit(_ textView: UITextView) { + + let attributedText = NSAttributedString( + string: textView.text, + attributes: self.typography.attributes + ) + + /// width 계산 시 textContainerInset 고려 + let textSize: CGSize = .init(width: textView.bounds.width - 24 * 2, height: .greatestFiniteMagnitude) + let boundingHeight = attributedText.boundingRect( + with: textSize, + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ).height + + let lines: CGFloat = boundingHeight / self.typography.lineHeight + let isScrollEnabled: Bool = lines > 8 + textView.isScrollEnabled = isScrollEnabled + } +} + +extension WriteCardTextView: UITextViewDelegate { + + func textViewDidBeginEditing(_ textView: UITextView) { + self.placeholderLabel.isHidden = true + self.delegate?.textViewDidBeginEditing(self) + } + + func textViewDidEndEditing(_ textView: UITextView) { + let trimmedText = textView.text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedText.isEmpty { + textView.text = nil + self.textViewDidChange(textView) + } + self.placeholderLabel.isHidden = trimmedText.isEmpty == false + } + + func textViewDidChange(_ textView: UITextView) { + self.updateTextContainerHeightLimit(textView) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + + // TODO: Return key 탭했을 때 동작 + // if text == "\n" { } + + return textView.shouldChangeText( + in: range, + replacementText: text, + maxCharacters: Constants.maxCharacters + ) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WritrCardTextViewDelegate.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WritrCardTextViewDelegate.swift new file mode 100644 index 00000000..464ae558 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WritrCardTextViewDelegate.swift @@ -0,0 +1,18 @@ +// +// WritrCardTextViewDelegate.swift +// SOOUM +// +// Created by 오현식 on 10/16/25. +// + +import Foundation + +protocol WritrCardTextViewDelegate: AnyObject { + + func textViewDidBeginEditing(_ textView: WriteCardTextView) +} + +extension WritrCardTextViewDelegate { + + func textViewDidBeginEditing(_ textView: WriteCardTextView) { } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/WriteCardGuideView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/WriteCardGuideView.swift new file mode 100644 index 00000000..6ba4fcf1 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/WriteCardGuideView.swift @@ -0,0 +1,54 @@ +// +// WriteCardGuideView.swift +// SOOUM +// +// Created by 오현식 on 12/5/25. +// + +import UIKit + +import SnapKit +import Then + +class WriteCardGuideView: UIView { + + + // MARK: Views + + private let imageView = UIImageView().then { + $0.image = .init(.image(.v2(.guide_write_card))) + // $0.contentMode = .scaleAspectFit + } + + let closeButton = UIButton() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.imageView) + self.imageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.addSubview(self.closeButton) + self.closeButton.snp.makeConstraints { + $0.top.equalToSuperview().offset(60) + $0.leading.equalToSuperview().offset(4) + $0.size.equalTo(48) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/WriteCardView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/WriteCardView.swift new file mode 100644 index 00000000..ffa3e46d --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/WriteCardView.swift @@ -0,0 +1,92 @@ +// +// WriteCardView.swift +// SOOUM +// +// Created by 오현식 on 10/20/24. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa + +class WriteCardView: UIView { + + enum Text { + static let writeCardPlaceholder: String = "숨에서 편하게 이야기 나눠요" + static let wirteTagPlaceholder: String = "태그 추가" + } + + + // MARK: Views + + lazy var writeCardTextView = WriteCardTextView().then { + $0.placeholder = Text.writeCardPlaceholder + $0.delegate = self + } + + lazy var writeCardTags = WriteCardTags().then { + $0.delegate = self + } + + + // MARK: Variables + + var textViewDidBeginEditing = PublishRelay() + var textFieldDidBeginEditing = PublishRelay() + var textDidChanged = BehaviorRelay(value: nil) + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.writeCardTextView) + self.writeCardTextView.snp.makeConstraints { + $0.verticalEdges.centerX.equalToSuperview() + let width: CGFloat = UIScreen.main.bounds.width - 16 * 2 + $0.size.equalTo(width) + } + + self.writeCardTextView.addSubview(self.writeCardTags) + self.writeCardTags.snp.makeConstraints { + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(1) + $0.trailing.equalToSuperview().offset(-1) + $0.height.equalTo(28) + } + } +} + +extension WriteCardView: WritrCardTextViewDelegate { + + func textViewDidBeginEditing(_ textView: WriteCardTextView) { + self.textViewDidBeginEditing.accept(()) + } +} + + +extension WriteCardView: WriteCardTagsDelegate { + + func textFieldDidBeginEditing(_ textField: WriteCardTagFooter) { + self.textFieldDidBeginEditing.accept(()) + } + + func textDidChanged(_ text: String?) { + self.textDidChanged.accept(text) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift new file mode 100644 index 00000000..08e33944 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift @@ -0,0 +1,825 @@ +// +// WriteCardViewController.swift +// SOOUM +// +// Created by 오현식 on 10/18/24. +// + +import UIKit + +import SnapKit +import Then + +import Photos +import SwiftEntryKit +import YPImagePicker + +import Clarity + +import ReactorKit +import RxCocoa +import RxKeyboard +import RxSwift + +class WriteCardViewController: BaseNavigationViewController, View { + + enum Text { + static let navigationTitle: String = "새로운 카드" + static let commentNavigationTitle: String = "댓글카드" + static let navigationWriteButtonTitle: String = "완료" + + static let pretendardTitle: String = "프리텐다드" + static let ridiBatangTitle: String = "리디바탕" + static let yoonwooTitle: String = "윤우체" + static let kkookkkookTitle: String = "꾹꾹체" + + static let locationDialogTitle: String = "위치 정보 사용 설정" + static let locationDialogMessage: String = "내 위치 확인을 위해 ‘설정 > 앱 > 숨 > 위치’에서 위치 정보 사용을 허용해 주세요." + + static let libraryDialogTitle: String = "앱 접근 권한 안내" + static let libraryDialogMessage: String = "사진첨부를 위해 접근 권한이 필요해요. [설정 > 앱 > 숨 > 사진]에서 사진 보관함 접근 권한을 허용해 주세요." + + static let inappositeDialogTitle: String = "부적절한 사진으로 보여져요" + static let inappositeDialogMessage: String = "다른 사진으로 변경하거나 기본 이미지를 사용해 주세요." + + static let banUserDialogTitle: String = "이용 제한 안내" + static let banUserDialogFirstLeadingMessage: String = "신고된 카드로 인해 " + static let banUserDialogFirstTrailingMessage: String = " 카드 추가가 제한됩니다." + static let banUserDialogSecondLeadingMessage: String = " 카드 추가는 " + static let banUserDialogSecondTrailingMessage: String = "부터 가능합니다." + + static let deletedCardDialogTitle: String = "삭제된 카드예요" + + static let cancelActionTitle: String = "취소" + static let settingActionTitle: String = "설정" + static let confirmActionTitle: String = "확인" + + static let bottomFloatEntryName: String = "SOMBottomFloatView" + static let selectLibraryButtonTitle: String = "앨범에서 사진 선택" + static let takePictureButtonTitle: String = "사진 찍기" + + static let selectPhotoFullScreenNextTitle: String = "다음" + static let selectPhotoFullScreenCancelTitle: String = "취소" + static let selectPhotoFullScreenSaveTitle: String = "저장" + static let selectPhotoFullScreenAlbumsTitle: String = "앨범" + static let selectPhotoFullScreenCameraTitle: String = "카메라" + static let selectPhotoFullScreenLibraryTitle: String = "갤러리" + static let selectPhotoFullScreenCropTitle: String = "자르기" + } + + + // MARK: Views + + private let writeCardGuideView = WriteCardGuideView() + + private let writeButton = SOMButton().then { + $0.title = Text.navigationWriteButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.black + + $0.isEnabled = false + } + + private lazy var scrollContainer = UIScrollView().then { + $0.isScrollEnabled = true + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.contentInset.top = 8 + $0.contentInset.bottom = 24 + + $0.delegate = self + } + + private let writeCardView = WriteCardView() + + private let selectImageView = WriteCardSelectImageView() + + private let selectTypographyView = SelectTypographyView().then { + $0.items = [ + (Text.pretendardTitle, .som.v2.subtitle1), + (Text.ridiBatangTitle, .som.v2.ridiButton), + (Text.yoonwooTitle, .som.v2.yoonwooButton), + (Text.kkookkkookTitle, .som.v2.kkookkkookButton) + ] + } + + private let selectOptionsView = SelectOptionsView() + + private let relatedTagsView = RelatedTagsView().then { + $0.isHidden = true + } + + + // MARK: Override variables + + override var navigationPopGestureEnabled: Bool { + false + } + + + // MARK: Variables + + private var isScrollingByFirstResponder: Bool = false + private var keyboardHeight: CGFloat = 0 + + // MARK: Constraint + + private var relatedTagsViewBottomConstraint: Constraint? + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } + + + // MARK: Override func + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { } + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = self.reactor?.entranceType == .feed ? Text.navigationTitle : Text.commentNavigationTitle + self.navigationBar.setRightButtons([self.writeButton]) + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.selectOptionsView) + self.selectOptionsView.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.horizontalEdges.equalToSuperview() + $0.height.equalTo(48) + } + + self.view.addSubview(self.scrollContainer) + self.scrollContainer.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.equalTo(self.selectOptionsView.snp.top) + $0.horizontalEdges.equalToSuperview() + } + + let container = UIStackView(arrangedSubviews: [ + self.writeCardView, + self.selectImageView, + self.selectTypographyView + ]).then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 24 + } + self.scrollContainer.addSubview(container) + container.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.view.addSubview(self.relatedTagsView) + self.relatedTagsView.snp.makeConstraints { + self.relatedTagsViewBottomConstraint = $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).constraint + $0.horizontalEdges.equalToSuperview() + } + + guard let windowScene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window: UIWindow = windowScene.windows.first(where: { $0.isKeyWindow }) + else { return } + + window.addSubview(self.writeCardGuideView) + self.writeCardGuideView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in } + + self.writeCardGuideView.isHidden = UserDefaults.showGuideView == false + } + + override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { + super.updatedKeyboard(withoutBottomSafeInset: height) + + self.keyboardHeight = height + self.relatedTagsViewBottomConstraint?.update(offset: -height) + } + + override func bind() { + + self.navigationBar.backButton.rx.tap + .subscribe(with: self) { object, _ in + + object.navigationPop { + + if case .feed = object.reactor?.entranceType { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.moveToCreateFeedCardView_cancel_btn_click + ) + } + + if case .comment = object.reactor?.entranceType { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.moveToCreateCommentCardView_cancel_btn_click + ) + } + } + } + .disposed(by: self.disposeBag) + } + + + // MARK: ReactorKit - bind + + func bind(reactor: WriteCardViewReactor) { + + self.writeCardGuideView.closeButton.rx.tap + .subscribe(with: self) { object, _ in + object.writeCardGuideView.isHidden = true + } + .disposed(by: self.disposeBag) + + var options: [SelectOptionItem.OptionType] { + if reactor.entranceType == .feed { + return [.distanceShare, .story] + } else { + return [.distanceShare] + } + } + self.selectOptionsView.items = options + + self.writeCardView.textViewDidBeginEditing + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + object.isScrollingByFirstResponder = true + + object.scrollContainer.setContentOffset(.zero, animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak object] in + object?.isScrollingByFirstResponder = false + } + } + .disposed(by: self.disposeBag) + + self.relatedTagsView.updatedContentHeight + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, updatedContentHeight in + object.isScrollingByFirstResponder = true + + if let updatedContentHeight = updatedContentHeight { + + let cardViewMaxY: CGFloat = object.writeCardView.frame.maxY + let bottomHeight: CGFloat = object.keyboardHeight + updatedContentHeight + let visibleContentHeight: CGFloat = object.scrollContainer.bounds.height - bottomHeight + let visibleAreaBottomY: CGFloat = object.scrollContainer.contentOffset.y + visibleContentHeight + if cardViewMaxY > visibleAreaBottomY { + + let offset: CGFloat = cardViewMaxY - visibleAreaBottomY + let scrollTo: CGPoint = .init(x: 0, y: object.scrollContainer.contentOffset.y + offset) + object.scrollContainer.setContentOffset(scrollTo, animated: true) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak object] in + object?.isScrollingByFirstResponder = false + } + } else { + + DispatchQueue.main.async { [weak object] in + object?.scrollContainer.setContentOffset(.zero, animated: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + object?.isScrollingByFirstResponder = false + } + } + } + } + .disposed(by: self.disposeBag) + + let writeCardtext = self.writeCardView.writeCardTextView.rx.text.orEmpty.distinctUntilChanged().share() + let selectedImageInfo = self.selectImageView.selectedImageInfo.share() + writeCardtext + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + .withLatestFrom(selectedImageInfo, resultSelector: { $0 && $1 != nil }) + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.writeButton.rx.isEnabled) + .disposed(by: self.disposeBag) + + self.writeCardView.writeCardTags.updateWrittenTags + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.instance) + .bind(to: self.writeCardView.writeCardTags.rx.models()) + .disposed(by: self.disposeBag) + + let selectedRelatedTag = self.relatedTagsView.selectedRelatedTag.filterNil().share() + selectedRelatedTag + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, _ in + object.writeCardView.writeCardTags.updateFooterText = nil + } + .disposed(by: self.disposeBag) + + selectedRelatedTag + .withUnretained(self) + .map { object, selectedRelatedTag in + var current = object.writeCardView.writeCardTags.models + let new = WriteCardTagModel( + originalText: selectedRelatedTag.originalText, + typography: current.last?.typography ?? .som.v2.caption2 + ) + current.append(new) + return current + } + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.writeCardView.writeCardTags.rx.models()) + .disposed(by: self.disposeBag) + + selectedImageInfo + .filterNil() + .filter { $0.info != .defaultValue } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.writeCardView.writeCardTextView) { writeCardTextView, selectedImageInfo in + writeCardTextView.imageInfo = selectedImageInfo.info + } + .disposed(by: self.disposeBag) + + self.selectImageView.selectedUseUserImageCell + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if status == .authorized || status == .limited { + + let actions: [SOMBottomFloatView.FloatAction] = [ + .init( + title: Text.selectLibraryButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomFloatEntryName)) { + object?.showPicker(for: .library) + } + } + ), + .init( + title: Text.takePictureButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.bottomFloatEntryName)) { + object?.showPicker(for: .photo) + } + } + ) + ] + + let bottomFloatView = SOMBottomFloatView(actions: actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomFloatEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } else { + + object.showLibraryPermissionDialog() + } + } + .disposed(by: self.disposeBag) + + let selectedTypography = self.selectTypographyView.selectedTypography + .distinctUntilChanged() + .filterNil() + .share(replay: 1) + selectedTypography + .observe(on: MainScheduler.instance) + .subscribe(with: self.writeCardView) { writeCardView, selectedTypography in + var typograhpyToTextView: Typography { + switch selectedTypography { + case .pretendard: return .som.v2.body1 + case .ridi: return .som.v2.ridiCard + case .yoonwoo: return .som.v2.yoonwooCard + case .kkookkkook: return .som.v2.kkookkkookCard + } + } + var typograhpyToTags: Typography { + switch selectedTypography { + case .pretendard: return .som.v2.caption2 + case .ridi: return .som.v2.ridiTag + case .yoonwoo: return .som.v2.yoonwooTag + case .kkookkkook: return .som.v2.kkookkkookTag + } + } + + writeCardView.writeCardTextView.typography = typograhpyToTextView + writeCardView.writeCardTags.typography = typograhpyToTags + } + .disposed(by: self.disposeBag) + + let selectedOptions = self.selectOptionsView.selectedOptions + .distinctUntilChanged() + .filterNil() + .share() + selectedOptions + .filter { $0.contains(.distanceShare) } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, options in + // 선택된 옵션 중 `거리공유` 옵션이 존재하고, 위치 권한이 허용되지 않았을 때 + guard reactor.initialState.hasPermission == false else { return } + + object.selectOptionsView.selectOptions = options.filter { $0 != .distanceShare } + object.showLocationPermissionDialog() + } + .disposed(by: self.disposeBag) + + // Action + let viewDidLoad = self.rx.viewDidLoad.share() + viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + viewDidLoad + .map { _ in return [] } + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.writeCardView.writeCardTags.rx.models()) + .disposed(by: self.disposeBag) + + // 위치 권한 유무에 따라 초기값 설정 + viewDidLoad + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.selectOptionsView) { selectOptionsView, _ in + selectOptionsView.selectOptions = reactor.initialState.hasPermission ? [.distanceShare] : [] + } + .disposed(by: self.disposeBag) + + let enteredTag = self.writeCardView.textDidChanged.share() + enteredTag + .distinctUntilChanged() + .filterNil() + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .map(Reactor.Action.relatedTags) + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let combined = Observable.combineLatest( + writeCardtext, + selectedImageInfo.filterNil(), + selectedTypography, + selectedOptions, + enteredTag.startWith(nil) + ) + self.writeButton.rx.throttleTap(.seconds(3)) + .withLatestFrom(combined) + .withUnretained(self) + .map { object, combined in + let (content, imageInfo, typography, options, enteredTag) = combined + + GAHelper.shared.logEvent(event: GAEvent.WriteCardView.createFeedCard_btn_click) + + if options.contains(.distanceShare) == false { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.createFeedCardWithoutDistanceSharedOpt_btn_click + ) + } + + var enteredTagTexts = object.writeCardView.writeCardTags.models.map { $0.originalText } + if let enteredTag = enteredTag, enteredTag.isEmpty == false { + enteredTagTexts.append(enteredTag) + } + return Reactor.Action.writeCard( + isDistanceShared: options.contains(.distanceShare), + content: content, + font: typography, + imageType: imageInfo.type, + imageName: imageInfo.info.imgName, + isStory: options.contains(.story), + tags: enteredTagTexts.reduce(into: []) { if !$0.contains($1) { $0.append($1) } } + ) + } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + reactor.state.map(\.isProcessing) + .distinctUntilChanged() + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, isProcessing in + object.view.endEditing(true) + + if isProcessing { + object.loadingIndicatorView.startAnimating() + } else { + object.loadingIndicatorView.stopAnimating() + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.writtenCardId) + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, writtenCardId in + NotificationCenter.default.post(name: .reloadHomeData, object: nil, userInfo: nil) + if reactor.entranceType == .comment { + NotificationCenter.default.post(name: .reloadDetailData, object: nil, userInfo: nil) + } + + if let navigationController = object.navigationController { + + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(with: writtenCardId) + + var viewControllers = navigationController.viewControllers + if (viewControllers.popLast() as? Self) != nil { + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .writeCard + ) + ) + + viewControllers.append(detailViewController) + navigationController.setViewControllers(viewControllers, animated: true) + } else { + object.navigationPop() + } + } else { + object.navigationPop() + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, hasErrors in + if case 422 = hasErrors { + object.showInappositeDialog() + return + } + + if case 410 = hasErrors { + object.showDeletedCardDialog() + return + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.couldPosting) + .filterNil() + .filter { $0.isBaned } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, postingPermission in + + let banEndGapToDays = postingPermission.expiredAt?.infoReadableTimeTakenFromThisForBanEndPosting(to: Date().toKorea()) + let banEndToString = postingPermission.expiredAt?.banEndDetailFormatted + + object.showWriteCardPermissionDialog(gapDays: banEndGapToDays ?? "", banEndFormatted: banEndToString ?? "") + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.defaultImages) + .distinctUntilChanged() + .filterNil() + .map { ($0, reactor.entranceType) } + .observe(on: MainScheduler.instance) + .bind(to: self.selectImageView.rx.setModels) + .disposed(by: self.disposeBag) + + reactor.state.map(\.userImage) + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.writeCardView.writeCardTextView) { writeCardTextView, userImage in + writeCardTextView.image = userImage + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.isDownloaded) + .filter { $0 == true } + .observe(on: MainScheduler.instance) + .subscribe(with: self.selectImageView) { selectImageView, _ in + selectImageView.updatedByUser() + } + .disposed(by: self.disposeBag) + + let relatedTags = reactor.state.map(\.relatedTags).distinctUntilChanged().filterNil().share() + relatedTags + .map { $0.isEmpty } + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.relatedTagsView.rx.isHidden) + .disposed(by: self.disposeBag) + + relatedTags + .map { $0.map { RelatedTagViewModel(originalText: $0.name, count: "\($0.usageCnt)") } } + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.relatedTagsView.rx.models()) + .disposed(by: self.disposeBag) + } +} + + +// MARK: Show dialog + +extension WriteCardViewController { + + func showLocationPermissionDialog() { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let settingAction = SOMDialogAction( + title: Text.settingActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } + } + } + ) + + SOMDialogViewController.show( + title: Text.locationDialogTitle, + message: Text.locationDialogMessage, + actions: [cancelAction, settingAction] + ) + } + + func showLibraryPermissionDialog() { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + SOMDialogViewController.dismiss() + } + ) + let settingAction = SOMDialogAction( + title: Text.settingActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } + } + } + ) + + SOMDialogViewController.show( + title: Text.libraryDialogTitle, + message: Text.libraryDialogMessage, + actions: [cancelAction, settingAction] + ) + } + + func showInappositeDialog() { + + let actions: [SOMDialogAction] = [ + .init( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss() + } + ) + ] + + SOMDialogViewController.show( + title: Text.inappositeDialogTitle, + message: Text.inappositeDialogMessage, + textAlignment: .left, + actions: actions + ) + } + + func showWriteCardPermissionDialog(gapDays: String, banEndFormatted: String) { + + let dialogFirstMessage = Text.banUserDialogFirstLeadingMessage + + gapDays + + Text.banUserDialogFirstTrailingMessage + let dialogSecondMessage = Text.banUserDialogSecondLeadingMessage + + banEndFormatted + + Text.banUserDialogSecondTrailingMessage + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + self.navigationPop() + } + } + ) + + SOMDialogViewController.show( + title: Text.banUserDialogTitle, + message: dialogFirstMessage + dialogSecondMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } + + func showDeletedCardDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.navigationPopToRoot() + } + } + } + ) + + SOMDialogViewController.show( + title: Text.deletedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} + + +// MARK: Show picker + +extension WriteCardViewController { + + func showPicker(for screen: YPPickerScreen) { + + var config = YPImagePickerConfiguration() + + config.library.options = nil + config.library.minWidthForItem = nil + config.showsCrop = .rectangle(ratio: 1.0) + config.showsPhotoFilters = false + config.library.preselectedItems = nil + config.screens = [screen] + config.startOnScreen = screen + config.shouldSaveNewPicturesToAlbum = false + + config.wordings.next = Text.selectPhotoFullScreenNextTitle + config.wordings.cancel = Text.selectPhotoFullScreenCancelTitle + config.wordings.save = Text.selectPhotoFullScreenSaveTitle + config.wordings.albumsTitle = Text.selectPhotoFullScreenAlbumsTitle + config.wordings.cameraTitle = Text.selectPhotoFullScreenCameraTitle + config.wordings.libraryTitle = Text.selectPhotoFullScreenLibraryTitle + config.wordings.crop = Text.selectPhotoFullScreenCropTitle + + let picker = YPImagePicker(configuration: config) + picker.didFinishPicking { [weak self, weak picker] items, cancelled in + + if cancelled { + Log.debug("Picker was canceled") + picker?.dismiss(animated: true, completion: nil) + return + } + + if let image = items.singlePhoto?.image { + self?.reactor?.action.onNext(.updateUserImage(image, true)) + } else { + self?.reactor?.action.onNext(.updateUserImage(nil, false)) + Log.error("Error occured while picking an image") + } + picker?.dismiss(animated: true) { ClaritySDK.resume() } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.present(picker, animated: true) { ClaritySDK.pause() } + } + } +} + + +// MARK: UIScrollViewDelegate + +extension WriteCardViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + guard self.isScrollingByFirstResponder == false else { return } + + self.reactor?.action.onNext(.updateRelatedTags) + self.view.endEditing(true) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift new file mode 100644 index 00000000..3436c4b1 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift @@ -0,0 +1,305 @@ +// +// WriteCardViewReactor.swift +// SOOUM +// +// Created by 오현식 on 10/20/24. +// + +import ReactorKit + + +class WriteCardViewReactor: Reactor { + + enum Action: Equatable { + case landing + case updateUserImage(UIImage?, Bool) + case writeCard( + isDistanceShared: Bool, + content: String, + font: BaseCardInfo.Font, + imageType: BaseCardInfo.ImageType, + imageName: String?, + isStory: Bool, + tags: [String] + ) + case relatedTags(keyword: String) + case updateRelatedTags + case postingPermission + } + + enum Mutation { + case defaultImages(DefaultImages) + case updateUserImage(UIImage?, Bool) + case writeCard(String?) + case relatedTags([TagInfo]) + case updatePostingPermission(PostingPermission?) + case updateIsProcessing(Bool) + case updateErrors(Int?) + } + + struct State { + fileprivate(set) var hasPermission: Bool + fileprivate(set) var shouldUseCoordinates: Bool + fileprivate(set) var defaultImages: DefaultImages? + fileprivate(set) var userImage: UIImage? + fileprivate(set) var relatedTags: [TagInfo]? + fileprivate(set) var couldPosting: PostingPermission? + fileprivate(set) var writtenCardId: String? + fileprivate(set) var isDownloaded: Bool? + fileprivate(set) var isProcessing: Bool + fileprivate(set) var hasErrors: Int? + } + + var initialState: State + + private let dependencies: AppDIContainerable + private let cardImageUseCase: CardImageUseCase + private let writeCardUseCase: WriteCardUseCase + private let fetchTagUseCase: FetchTagUseCase + private let validateUserUseCase: ValidateUserUseCase + private let locationUseCase: LocationUseCase + + let entranceType: EntranceCardType + + private let parentCardId: String? + + init( + dependencies: AppDIContainerable, + type entranceType: EntranceCardType = .feed, + parentCardId: String? = nil + ) { + self.dependencies = dependencies + self.cardImageUseCase = dependencies.rootContainer.resolve(CardImageUseCase.self) + self.writeCardUseCase = dependencies.rootContainer.resolve(WriteCardUseCase.self) + self.fetchTagUseCase = dependencies.rootContainer.resolve(FetchTagUseCase.self) + self.validateUserUseCase = dependencies.rootContainer.resolve(ValidateUserUseCase.self) + self.locationUseCase = dependencies.rootContainer.resolve(LocationUseCase.self) + + self.entranceType = entranceType + self.parentCardId = parentCardId + + self.initialState = State( + hasPermission: self.locationUseCase.hasPermission(), + shouldUseCoordinates: false, + defaultImages: nil, + userImage: nil, + relatedTags: nil, + couldPosting: nil, + writtenCardId: nil, + isDownloaded: nil, + isProcessing: false, + hasErrors: nil + ) + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return self.cardImageUseCase.defaultImages().map(Mutation.defaultImages) + + case let .updateUserImage(userImage, isDownloaded): + + return .just(.updateUserImage(userImage, isDownloaded)) + case let .writeCard( + isDistanceShared, + content, + font, + imageType, + imageName, + isStory, + tags + ): + + return .concat([ + .just(.updateIsProcessing(true)), + .just(.updateErrors(nil)), + self.writeCard( + isDistanceShared: isDistanceShared, + content: content, + font: font, + imageType: imageType, + imageName: imageName, + isStory: isStory, + tags: tags + ) + .catch(self.catchClosure) + .delay(.milliseconds(1000), scheduler: MainScheduler.instance), + .just(.updateIsProcessing(false)) + ]) + case let .relatedTags(keyword): + + return self.fetchTagUseCase.related(keyword: keyword, size: 8) + .map(Mutation.relatedTags) + case .updateRelatedTags: + + return .just(.relatedTags([])) + case .postingPermission: + + return self.validateUserUseCase.postingPermission() + .map(Mutation.updatePostingPermission) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .defaultImages(defaultImages): + newState.defaultImages = defaultImages + case let .updateUserImage(userImage, isDownloaded): + newState.userImage = userImage + newState.isDownloaded = isDownloaded + case let .writeCard(writtenCardId): + newState.writtenCardId = writtenCardId + case let .relatedTags(relatedTags): + newState.relatedTags = relatedTags + case let .updatePostingPermission(couldPosting): + newState.couldPosting = couldPosting + case let .updateIsProcessing(isProcessing): + newState.isProcessing = isProcessing + case let .updateErrors(hasErrors): + newState.hasErrors = hasErrors + } + return newState + } +} + +private extension WriteCardViewReactor { + + func uploadImage(_ image: UIImage) -> Observable { + + return self.cardImageUseCase.presignedURL() + .withUnretained(self) + .flatMapLatest { object, presignedInfo -> Observable in + if let imageData = image.jpegData(compressionQuality: 0.5), + let url = URL(string: presignedInfo.imgUrl) { + + return object.cardImageUseCase.uploadToS3(imageData, with: url) + .flatMapLatest { isSuccess -> Observable in + + let imageName = isSuccess ? presignedInfo.imgName : nil + return .just(imageName) + } + } else { + return .just(nil) + } + } + } + + func writeCard( + isDistanceShared: Bool, + content: String, + font: BaseCardInfo.Font, + imageType: BaseCardInfo.ImageType, + imageName: String?, + isStory: Bool, + tags: [String] + ) -> Observable { + + let coordinate = self.locationUseCase.coordinate() + let trimedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + + if case .default = imageType, let imageName = imageName { + + if self.entranceType == .feed { + + return self.writeCardUseCase.writeFeed( + isDistanceShared: isDistanceShared, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + content: trimedContent, + font: font.rawValue, + imgType: BaseCardInfo.ImageType.default.rawValue, + imgName: imageName, + isStory: isStory, + tags: tags + ) + .map(Mutation.writeCard) + } else { + + return self.writeCardUseCase.writeComment( + parentCardId: self.parentCardId ?? "", + isDistanceShared: isDistanceShared, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + content: trimedContent, + font: font.rawValue, + imgType: BaseCardInfo.ImageType.default.rawValue, + imgName: imageName, + tags: tags + ) + .map(Mutation.writeCard) + } + } + + if case .user = imageType, let image = self.currentState.userImage { + + return self.uploadImage(image) + .withUnretained(self) + .flatMapLatest { object, imageName -> Observable in + guard let imageName = imageName else { return .just(.writeCard(nil)) } + + if self.entranceType == .feed { + + return object.writeCardUseCase.writeFeed( + isDistanceShared: isDistanceShared, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + content: trimedContent, + font: font.rawValue, + imgType: BaseCardInfo.ImageType.user.rawValue, + imgName: imageName, + isStory: isStory, + tags: tags + ) + .map(Mutation.writeCard) + } else { + + return object.writeCardUseCase.writeComment( + parentCardId: object.parentCardId ?? "", + isDistanceShared: isDistanceShared, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + content: trimedContent, + font: font.rawValue, + imgType: BaseCardInfo.ImageType.user.rawValue, + imgName: imageName, + tags: tags + ) + .map(Mutation.writeCard) + } + } + } + + return .just(.writeCard(nil)) + } + + var catchClosure: ((Error) throws -> Observable) { + return { error in + + let nsError = error as NSError + if case 400 = nsError.code { + return .concat([ + .just(.writeCard(nil)), + .just(.updateIsProcessing(false)), + self.validateUserUseCase.postingPermission() + .map(Mutation.updatePostingPermission) + ]) + } + + return .concat([ + .just(.writeCard(nil)), + .just(.updateIsProcessing(false)), + .just(.updateErrors(nsError.code)) + ]) + } + } +} + + +extension WriteCardViewReactor { + + func reactorForDetail(with targetCardId: String) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: targetCardId) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/BottomSheetSegmentTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/BottomSheetSegmentTableViewCell.swift deleted file mode 100644 index 27f550fe..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/BottomSheetSegmentTableViewCell.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// BottomSheetSegmentTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/17/24. -// - -import UIKit - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then - -class BottomSheetSegmentTableViewCell: UITableViewCell { - - enum ImageSegment { - case defaultImage - case myImage - } - - var imageReloadButtonTapped: PublishSubject? - - var imageSegment: BehaviorRelay? - - var selectedSegment: ImageSegment = .defaultImage - - var disposeBag = DisposeBag() - - let selectModeButtonStack = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 22 - } - - let defualtImageButtonLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.textAlignment = .center - $0.textColor = .som.black - $0.text = "기본 이미지" - } - - let myImageButtonLabel = UILabel().then { - $0.typography = .som.body1WithRegular - $0.textAlignment = .center - $0.textColor = .som.gray400 - $0.text = "내 사진" - } - - let chageImageButtonStack = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 2 - } - - let chagneImageLabel = UILabel().then { - $0.typography = .som.body2WithRegular - $0.textColor = .som.gray400 - $0.text = "이미지 변경" - } - - let chageImageImageView = UIImageView().then { - $0.image = .init(systemName: "gobackward") - $0.tintColor = .som.gray400 - } - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - // MARK: - setData - func setData( - imageModeSegmentState: BehaviorRelay, - imageReloadButtonTapped: PublishSubject? - ) { - self.imageSegment = imageModeSegmentState - self.imageReloadButtonTapped = imageReloadButtonTapped - - action() - - updateImageSegment(segment: imageModeSegmentState.value, animated: false) - } - - // MARK: - action - private func action() { - defualtImageButtonLabel.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.imageSegment?.accept(.defaultImage) - object.updateImageSegment(segment: .defaultImage, animated: true) - } - .disposed(by: disposeBag) - - myImageButtonLabel.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.imageSegment?.accept(.myImage) - object.updateImageSegment(segment: .myImage, animated: true) - } - .disposed(by: disposeBag) - - chageImageButtonStack.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.imageReloadButtonTapped?.onNext(object.selectedSegment) - } - .disposed(by: disposeBag) - } - - private func updateImageSegment(segment: ImageSegment, animated: Bool) { - self.selectedSegment = segment - let duration = animated ? 0.2 : 0.0 - - UIView.transition( - with: self.defualtImageButtonLabel, - duration: duration, - options: .transitionCrossDissolve, - animations: { - self.defualtImageButtonLabel.textColor = segment == .defaultImage ? .som.black : .som.gray400 - }, - completion: nil - ) - - UIView.transition( - with: self.myImageButtonLabel, - duration: duration, - options: .transitionCrossDissolve, - animations: { - self.myImageButtonLabel.textColor = segment == .myImage ? .som.black : .som.gray400 - }, - completion: nil - ) - - self.chagneImageLabel.text = segment == .defaultImage ? "이미지 변경" : "사진 변경" - self.chageImageImageView.isHidden = segment == .myImage - } - - // MARK: - setupConstraint - private func setupConstraint() { - - selectModeButtonStack.addArrangedSubviews(defualtImageButtonLabel, myImageButtonLabel) - contentView.addSubview(selectModeButtonStack) - selectModeButtonStack.snp.makeConstraints { - $0.top.bottom.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - } - - chageImageButtonStack.addArrangedSubviews(chagneImageLabel, chageImageImageView) - contentView.addSubview(chageImageButtonStack) - chageImageButtonStack.snp.makeConstraints { - $0.top.equalToSuperview().offset(4) - $0.trailing.equalToSuperview().offset(-21) - } - chageImageImageView.snp.makeConstraints { - $0.size.equalTo(14) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/Cells/ImageCollectionViewCell.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/Cells/ImageCollectionViewCell.swift deleted file mode 100644 index 59ea8875..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/Cells/ImageCollectionViewCell.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// ImageCollectionViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import RxCocoa -import RxGesture -import RxSwift - -class ImageCollectionViewCell: UICollectionViewCell { - - /// 현재 셀 인덱스 - var idx: Int? - /// 현재 셀 이미지 - var imageWithName: ImageWithName? - /// 넘겨받은 현재선택 인덱스&이미지 - var selectedDefaultImage: BehaviorRelay<(idx: Int, imageWithName: ImageWithName?)>? - - var disposeBag = DisposeBag() - - private let imageView = UIImageView().then { - $0.backgroundColor = .som.gray200 - $0.layer.borderColor = UIColor.som.p300.cgColor - $0.layer.borderWidth = 0 - $0.contentMode = .scaleAspectFill - $0.clipsToBounds = true - } - - override init(frame: CGRect) { - super.init(frame: frame) - contentView.addSubview(imageView) - - imageView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - // MARK: - setData - func setData(idx: Int, imageWithName: ImageWithName?, selectedDefaultImage: BehaviorRelay<(idx: Int, imageWithName: ImageWithName?)>?) { - self.idx = idx - self.imageWithName = imageWithName - self.selectedDefaultImage = selectedDefaultImage - - action() - imageView.image = imageWithName?.image - applyCornerRadius(for: idx) - updateBorderColor() - } - - // MARK: - action - private func action() { - imageView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - if let idx = object.idx, let imageWithName = object.imageWithName { - object.selectedDefaultImage?.accept((idx: idx, imageWithName: imageWithName)) - object.updateBorderColor() - } - } - .disposed(by: disposeBag) - } - - /// 부분 곡률 설정 - private func applyCornerRadius(for idx: Int) { - let cornerRadius: CGFloat = 10 - - imageView.layer.cornerRadius = cornerRadius - imageView.layer.maskedCorners = [] - - switch idx { - case 0: - imageView.layer.maskedCorners = [.layerMinXMinYCorner] - case 3: - imageView.layer.maskedCorners = [.layerMaxXMinYCorner] - case 4: - imageView.layer.maskedCorners = [.layerMinXMaxYCorner] - case 7: - imageView.layer.maskedCorners = [.layerMaxXMaxYCorner] - default: - imageView.layer.maskedCorners = [] - } - } - - /// 테두리 색 업데이트 - func updateBorderColor() { - if let idx = self.idx, let selectedIdx = selectedDefaultImage?.value.idx { - imageView.layer.borderWidth = idx == selectedIdx ? 2 : 0 - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectDefaultImageTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectDefaultImageTableViewCell.swift deleted file mode 100644 index ee005eec..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectDefaultImageTableViewCell.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// SelectDefaultImageTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import SnapKit -import Then - -import RxCocoa -import RxGesture -import RxSwift - -class SelectDefaultImageTableViewCell: UITableViewCell { - - var defaultImages: [ImageWithName] = [] - var selectedDefaultImage: BehaviorRelay<(idx: Int, imageWithName: ImageWithName?)>? - - var disposeBag = DisposeBag() - - private let flowLayout = UICollectionViewFlowLayout().then { - $0.scrollDirection = .vertical - $0.minimumLineSpacing = 0 - $0.minimumInteritemSpacing = 0 - } - - // 이미지 컬렉션 뷰 설정 - lazy var imageCollectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout).then { - $0.showsHorizontalScrollIndicator = false - $0.showsVerticalScrollIndicator = false - $0.isScrollEnabled = false - $0.layer.cornerRadius = 10 - $0.clipsToBounds = true - $0.register(ImageCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ImageCollectionViewCell.self)) - $0.dataSource = self - $0.delegate = self - } - - // 초기화 코드 - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - // MARK: - setData - func setData(imageWithNames: [ImageWithName], selectedDefaultImage: BehaviorRelay<(idx: Int, imageWithName: ImageWithName?)>) { - self.defaultImages = imageWithNames - self.selectedDefaultImage = selectedDefaultImage - - bind() - - imageCollectionView.reloadData() - } - - // MARK: - setData - private func bind() { - selectedDefaultImage? - .subscribe(with: self, onNext: { object, _ in - object.imageCollectionView.reloadData() - }) - .disposed(by: disposeBag) - } - - // 뷰 설정 - private func setupConstraint() { - contentView.addSubview(imageCollectionView) - - // AutoLayout 설정 - imageCollectionView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(10) - make.bottom.equalToSuperview().offset(-18) - make.leading.equalToSuperview().offset(20) - make.trailing.equalToSuperview().offset(-20) - let numberOfRows: CGFloat = 2 // 두 줄 - let cellHeight: CGFloat = (UIScreen.main.bounds.width - 40) / 4 // 가로 4개로 나눈 셀의 높이 (셀 높이 = 셀 너비) - let totalHeight = cellHeight * numberOfRows - make.height.equalTo(totalHeight) - } - } -} - - -// MARK: - UICollectionView -extension SelectDefaultImageTableViewCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 8 - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCollectionViewCell", for: indexPath) as! ImageCollectionViewCell - let idx = indexPath.item - if defaultImages.indices.contains(idx) { - cell.setData(idx: idx, imageWithName: defaultImages[idx], selectedDefaultImage: selectedDefaultImage) - } - return cell - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let numberOfCellsPerRow: CGFloat = 4 - let width = collectionView.frame.width / numberOfCellsPerRow - return CGSize(width: width, height: width) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectFontTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectFontTableViewCell.swift deleted file mode 100644 index 6bd406ff..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectFontTableViewCell.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// SelectFontTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - - -class SelectFontTableViewCell: UITableViewCell { - - enum FontType { - case gothic - case handwriting - } - - var selectedFont: BehaviorRelay? - - var disposeBag = DisposeBag() - - let titleLabel = UILabel().then { - $0.typography = .som.body1WithRegular - $0.textAlignment = .center - $0.textColor = .som.gray700 - $0.text = "글씨체" - } - - let buttonStack = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 4 - $0.distribution = .fillEqually - } - - let gothicButtonLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.textAlignment = .center - $0.textColor = .som.white - $0.backgroundColor = .som.p300 - $0.text = "고딕체" - $0.layer.cornerRadius = 6 - $0.clipsToBounds = true - } - - let handwritingButtonLabel = UILabel().then { - $0.typography = .init( - fontContainer: BuiltInFont(type: .school, size: 18, weight: .bold), - lineHeight: 18, - letterSpacing: 0.05 - ) - $0.textAlignment = .center - $0.textColor = .som.gray600 - $0.backgroundColor = .som.gray300 - $0.text = "손글씨체" - $0.layer.cornerRadius = 6 - $0.clipsToBounds = true - } - - let seperatorView = UIView().then { - $0.backgroundColor = .som.gray200 - } - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - // MARK: - setData - func setData(selectedFont: BehaviorRelay) { - self.selectedFont = selectedFont - - action() - } - - // MARK: - action - private func action() { - gothicButtonLabel.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.updateFont(font: .gothic, animated: true) - } - .disposed(by: disposeBag) - - handwritingButtonLabel.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.updateFont(font: .handwriting, animated: true) - } - .disposed(by: disposeBag) - } - - private func updateFont(font: FontType, animated: Bool) { - self.selectedFont?.accept(font) - let duration = animated ? 0.2 : 0.0 - - UIView.animate(withDuration: duration) { - self.gothicButtonLabel.backgroundColor = font == .gothic ? .som.p300 : .som.gray300 - self.handwritingButtonLabel.backgroundColor = font == .handwriting ? .som.p300 : .som.gray300 - } - - UIView.transition( - with: self.gothicButtonLabel, - duration: duration, - options: .transitionCrossDissolve, - animations: { - self.gothicButtonLabel.textColor = font == .gothic ? .som.white : .som.gray600 - }, - completion: nil - ) - - UIView.transition( - with: self.handwritingButtonLabel, - duration: duration, - options: .transitionCrossDissolve, - animations: { - self.handwritingButtonLabel.textColor = font == .handwriting ? .som.white : .som.gray600 - }, - completion: nil - ) - } - - // MARK: - setupConstraint - private func setupConstraint() { - contentView.addSubview(titleLabel) - titleLabel.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - } - - contentView.addSubview(buttonStack) - buttonStack.addArrangedSubviews(gothicButtonLabel, handwritingButtonLabel) - buttonStack.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(4) - $0.bottom.equalToSuperview().offset(-20) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - gothicButtonLabel.snp.makeConstraints { - $0.height.equalTo(40) - } - handwritingButtonLabel.snp.makeConstraints { - $0.height.equalTo(40) - } - - contentView.addSubview(seperatorView) - seperatorView.snp.makeConstraints { - $0.bottom.leading.trailing.equalToSuperview() - $0.height.equalTo(4) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectMyImageTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectMyImageTableViewCell.swift deleted file mode 100644 index 3d3a5ac3..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/SelectMyImageTableViewCell.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// SelectMyImageTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/17/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxGesture -import RxSwift - - -class SelectMyImageTableViewCell: UITableViewCell { - - var sholdShowImagePicker: PublishSubject? - - var disposeBag = DisposeBag() - - let rootImageView = UIImageView().then { - $0.contentMode = .scaleAspectFill - $0.backgroundColor = .som.gray200 - $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - } - - let plusIconImageView = UIImageView().then { - $0.image = .init(systemName: "plus") - $0.tintColor = .som.gray500 - } - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - } - - // MARK: - setData - func setData(image: UIImage?, sholdShowImagePicker: PublishSubject) { - if let image = image { - self.rootImageView.image = image - self.plusIconImageView.isHidden = true - } else { - self.plusIconImageView.isHidden = false - } - self.sholdShowImagePicker = sholdShowImagePicker - action() - } - - // MARK: - action - private func action() { - rootImageView.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - object.sholdShowImagePicker?.onNext(()) - } - .disposed(by: disposeBag) - } - - // MARK: - setupConstraint - private func setupConstraint() { - contentView.addSubview(rootImageView) - rootImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) - $0.bottom.equalToSuperview().offset(-24) - $0.centerX.equalToSuperview() - $0.height.equalTo(108) - $0.width.equalTo(120) - } - rootImageView.addSubview(plusIconImageView) - - plusIconImageView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.size.equalTo(24) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/UploadCardSettingTableViewCell.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/UploadCardSettingTableViewCell.swift deleted file mode 100644 index 5a374683..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/UploadCardSettingTableViewCell.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// UploadCardSettingTableViewCell.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -class UploadCardSettingTableViewCell: UITableViewCell { - - /// 현재 셀 옵션 - var cellOption: UploadCardBottomSheetViewController.Section.OtherSettings = .timeLimit - /// 뷰컨으로부터 받은 전체 옵션 상태 - var globalCardOption: BehaviorRelay<[UploadCardBottomSheetViewController.Section.OtherSettings: Bool]>? - /// 현재 셀 토글 값 - let cellToggleState = BehaviorRelay(value: false) - - var disposeBag = DisposeBag() - - let titleStackContainerView = UIView().then { - $0.backgroundColor = .clear - } - - let titleStack = UIStackView().then { - $0.axis = .vertical - $0.alignment = .leading - $0.distribution = .equalSpacing - $0.spacing = 2 - } - - let titleLabel = UILabel().then { - $0.typography = .som.body1WithRegular - $0.textColor = .som.gray700 - $0.text = "시간 제한" - } - - let descLabel = UILabel().then { - $0.typography = .som.body3WithRegular - $0.textColor = .som.gray400 - $0.text = "태그를 사용할 수 없고, 24시간 뒤 모든 카드가 삭제돼요" - } - - let toggleView = ToggleView() - - // MARK: - init - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - self.selectionStyle = .none - self.contentView.autoresizingMask = .flexibleHeight - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - disposeBag = DisposeBag() - toggleView.prepareForReuse() - } - - // MARK: - setData - func setData( - cellOption: UploadCardBottomSheetViewController.Section.OtherSettings, - globalCardOptionState: BehaviorRelay<[UploadCardBottomSheetViewController.Section.OtherSettings: Bool]>, - isDeniedLocationAuthStatus: Bool - ) { - self.cellOption = cellOption - titleLabel.text = cellOption.title - descLabel.text = cellOption.description - - if cellOption == .distanceLimit, isDeniedLocationAuthStatus { - globalCardOptionState.accept([cellOption: true]) - } - self.globalCardOption = globalCardOptionState - cellToggleState.accept(globalCardOptionState.value[cellOption] ?? false) - - bind() - - toggleView.setData( - toggleState: cellToggleState, - isDeniedLocationAuthStatus: (cellOption == .distanceLimit) && isDeniedLocationAuthStatus - ) - } - - func bind() { - cellToggleState - .distinctUntilChanged() - .subscribe(with: self) { object, state in - if var updatedOptions = object.globalCardOption?.value { - updatedOptions[object.cellOption] = object.cellToggleState.value - object.globalCardOption?.accept(updatedOptions) - } - } - .disposed(by: disposeBag) - } - - // MARK: - setupConstraint - private func setupConstraint() { - contentView.addSubview(toggleView) - toggleView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview().offset(-16) - $0.height.equalTo(32) - $0.width.equalTo(48) - } - - contentView.addSubview(titleStackContainerView) - titleStackContainerView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalTo(self.toggleView.snp.leading).offset(-18) - } - - titleStackContainerView.addSubview(titleStack) - titleStack.addArrangedSubviews(titleLabel, descLabel) - titleStack.snp.makeConstraints { - $0.edges.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/Views/ToggleView.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/Views/ToggleView.swift deleted file mode 100644 index 78e35aa1..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Cells/Views/ToggleView.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// ToggleView.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then - -class ToggleView: UIView { - - enum Text { - static let dialogTitle: String = "위치 정보 사용 설정" - static let dialogMessage: String = "위치 확인을 위해 권한 설정이 필요해요" - - static let cancelActionTitle: String = "취소" - static let settingActionTitle: String = "설정" - } - - var toggleState: BehaviorRelay? - - var disposeBag = DisposeBag() - - private let backgroundView = UIView().then { - $0.backgroundColor = .som.gray400 - $0.layer.cornerRadius = 12 - } - - private let toggleCircle = UIView().then { - $0.backgroundColor = .som.white - $0.layer.cornerRadius = 10 - } - - override init(frame: CGRect) { - super.init(frame: frame) - setupConstraint() - updateToggleView(toggleState?.value ?? false, animated: false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func prepareForReuse() { - disposeBag = DisposeBag() - } - - func setData(toggleState: BehaviorRelay, isDeniedLocationAuthStatus: Bool) { - self.toggleState = toggleState - self.toggleState? - .subscribe(with: self) { object, toggleState in - object.updateToggleView(toggleState, animated: false) - } - .disposed(by: self.disposeBag) - - if isDeniedLocationAuthStatus { - showDialog() - } else { - action() - } - } - - private func setupConstraint() { - self.addSubview(backgroundView) - backgroundView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.height.equalTo(24) - $0.width.equalTo(40) - } - - backgroundView.addSubview(toggleCircle) - toggleCircle.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.size.equalTo(20) - $0.leading.equalToSuperview().offset(2) - } - } - - private func showDialog() { - self.rx.tapGesture() - .when(.recognized) - .subscribe(onNext: { _ in - let cancelAction = SOMDialogAction( - title: Text.cancelActionTitle, - style: .gray, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - let settingAction = SOMDialogAction( - title: Text.settingActionTitle, - style: .primary, - action: { - let application = UIApplication.shared - let openSettingsURLString: String = UIApplication.openSettingsURLString - if let settingsURL = URL(string: openSettingsURLString), - application.canOpenURL(settingsURL) { - application.open(settingsURL) - } - - UIApplication.topViewController?.dismiss(animated: true) - } - ) - - SOMDialogViewController.show( - title: Text.dialogTitle, - message: Text.dialogMessage, - actions: [cancelAction, settingAction] - ) - }) - .disposed(by: self.disposeBag) - } - - private func action() { - self.rx.tapGesture() - .when(.recognized) - .subscribe(with: self) { object, _ in - if let toggleState = object.toggleState { - toggleState.accept(!toggleState.value) - object.updateToggleView(toggleState.value, animated: true) - } - } - .disposed(by: disposeBag) - } - - func updateToggleView(_ state: Bool, animated: Bool) { - UIView.animate( - withDuration: animated ? 0.2 : 0.0, - delay: 0, - options: .curveEaseInOut, - animations: { - self.toggleCircle.snp.updateConstraints { - $0.leading.equalToSuperview().offset(state ? 18 : 2) - } - self.layoutIfNeeded() - self.backgroundView.backgroundColor = state ? .som.p300 : .som.gray400 - }, - completion: nil - ) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/UploadCardBottomSheetViewController.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/UploadCardBottomSheetViewController.swift deleted file mode 100644 index 136a9eb1..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/UploadCardBottomSheetViewController.swift +++ /dev/null @@ -1,436 +0,0 @@ -// -// UploadCardBottomSheetViewController.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import ReactorKit -import RxCocoa -import RxGesture -import RxSwift - -import SnapKit -import Then -import YPImagePicker - -class UploadCardBottomSheetViewController: BaseViewController, View { - - enum Section: CaseIterable { - case imageSegment - case selectImage - case selectFont - case otherSettings - - enum OtherSettings: CaseIterable { - case timeLimit - case distanceLimit - case privateCard - - var title: String { - switch self { - case .timeLimit: return "시간 제한" - case .distanceLimit: return "거리 공유 제한" - case .privateCard: return "나만 보기" - } - } - - var description: String { - switch self { - case .timeLimit: return "태그를 사용할 수 없고, 24시간 뒤 모든 카드가 삭제돼요" - case .distanceLimit: return "다른 사람이 거리 정보를 알 수 없어요" - case .privateCard: return "" - } - } - } - } - - /// 기본 서버 이미지 배열 - var defaultImages: [ImageWithName] = [] - - /// 선택된 사용자 이미지 -> bottomSheetImageSelected 로 부모 뷰컨에 전달 - var selectedMyImage = BehaviorRelay(value: nil) - /// 선택된 기본 이미지 -> bottomSheetImageSelected 로 부모 뷰컨에 전달 - var selectedDefaultImage = BehaviorRelay<(idx: Int, imageWithName: ImageWithName?)>(value: (idx: 0, imageWithName: nil)) - /// 기본이미지&내 이미지 토글 - var imageModeSegmentState = BehaviorRelay(value: .defaultImage) - - // 부모 뷰컨에 전달할 이벤트 - /// 선택한 폰트 - var bottomSheetFontState = BehaviorRelay(value: .gothic) - /// 선택된 이미지 방출 - var bottomSheetImageSelected = BehaviorRelay(value: nil) - /// 이미지 이름 방출 - var bottomSheetImageNameSeleted = PublishRelay() - /// 바텀싯 옵션 변경 방출 - var bottomSheetOptionState = BehaviorRelay<[Section.OtherSettings: Bool]>( - value: [ - .timeLimit: false, - .distanceLimit: false, - .privateCard: false - ] - ) - - /// 방출시 이미지 피커 띄움 - var sholdShowImagePicker = PublishSubject() - /// 이미지 재설정 버튼 클릭 이벤트 - let imageReloadButtonTapped = PublishSubject() - - lazy var tableView = UITableView(frame: .zero, style: .plain).then { - $0.backgroundColor = .clear - $0.indicatorStyle = .black - $0.separatorStyle = .none - $0.isScrollEnabled = false - $0.rowHeight = UITableView.automaticDimension - $0.register( - BottomSheetSegmentTableViewCell.self, - forCellReuseIdentifier: String(describing: BottomSheetSegmentTableViewCell.self) - ) - $0.register( - SelectDefaultImageTableViewCell.self, - forCellReuseIdentifier: String(describing: SelectDefaultImageTableViewCell.self) - ) - $0.register( - SelectMyImageTableViewCell.self, - forCellReuseIdentifier: String(describing: SelectMyImageTableViewCell.self) - ) - $0.register( - SelectFontTableViewCell.self, - forCellReuseIdentifier: String(describing: SelectFontTableViewCell.self) - ) - $0.register( - UploadCardSettingTableViewCell.self, - forCellReuseIdentifier: String(describing: UploadCardSettingTableViewCell.self) - ) - - $0.dataSource = self - $0.delegate = self - } - - - // MARK: Initalization - - deinit { - NotificationCenter.default.removeObserver(self, name: .changedLocationAuthorization, object: nil) - } - - - // MARK: Override func - - override func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver( - self, - selector: #selector(self.changedLocationAuthorization), - name: .changedLocationAuthorization, - object: nil - ) - } - - override func setupConstraints() { - super.setupConstraints() - - self.view.backgroundColor = .som.white - - let handle = UIView().then { - $0.backgroundColor = UIColor(hex: "#B4B4B4") - $0.layer.cornerRadius = 8 - } - self.view.addSubview(handle) - handle.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) - $0.centerX.equalToSuperview() - $0.width.equalTo(68) - $0.height.equalTo(2) - } - - self.view.addSubview(tableView) - tableView.snp.makeConstraints { - $0.top.equalTo(handle.snp.bottom).offset(28) - $0.bottom.leading.trailing.equalToSuperview() - } - } - - func bind(reactor: UploadCardBottomSheetViewReactor) { - - self.rx.viewWillAppear - .take(1) - .map({ _ in - return Reactor.Action.fetchNewDefaultImage - }) - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - sholdShowImagePicker - .subscribe(with: self) { object, _ in - object.presentPicker() - } - .disposed(by: self.disposeBag) - - imageModeSegmentState - .subscribe(with: self) { object, segment in - object.tableView.reloadSections(IndexSet([1]), with: .automatic) - switch segment { - case .defaultImage: - object.bottomSheetImageNameSeleted.accept(object.selectedDefaultImage.value.imageWithName?.name ?? "") - object.bottomSheetImageSelected.accept(object.selectedDefaultImage.value.imageWithName?.image) - case .myImage: - if let selectedMyImage = object.selectedMyImage.value { - object.bottomSheetImageNameSeleted.accept(selectedMyImage.name) - object.bottomSheetImageSelected.accept(selectedMyImage.image) - } - } - } - .disposed(by: self.disposeBag) - - // 기본 이미지 선택 시 이미지 선택 이벤트 방출 - selectedDefaultImage - .compactMap(\.imageWithName?.image) - .bind(to: bottomSheetImageSelected) - .disposed(by: self.disposeBag) - - // 기본 이미지 선택 시 이름 이벤트 방출 - selectedDefaultImage - .compactMap { - return $0.imageWithName?.name - } - .bind(to: bottomSheetImageNameSeleted) - .disposed(by: self.disposeBag) - - imageReloadButtonTapped - .filter { $0 == .defaultImage } - .map { _ in Reactor.Action.fetchNewDefaultImage } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - selectedMyImage - .distinctUntilChanged({ $0?.name == $01?.name }) - .subscribe(with: self, onNext: { object, imageWithName in - if let name = imageWithName?.name, let image = imageWithName?.image { - object.bottomSheetImageSelected.accept(image) - object.bottomSheetImageNameSeleted.accept(name) - } - }) - .disposed(by: self.disposeBag) - - // MARK: - state - reactor.state.map(\.defaultImages) - .subscribe(with: self) { object, imageWithNames in - let idx = object.selectedDefaultImage.value.idx - if imageWithNames.indices.contains(idx) { - object.selectedDefaultImage.accept((idx: idx, imageWithName: imageWithNames[idx])) - } - object.defaultImages = imageWithNames - object.tableView.reloadSections(IndexSet([1]), with: .automatic) - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.myImageName) - .subscribe(with: self) { object, imageName in - if let imageName = imageName, !imageName.isEmpty { - if let image = object.selectedMyImage.value?.image { - object.selectedMyImage.accept(.init(name: imageName, image: image)) - } - } else { - return - } - } - .disposed(by: self.disposeBag) - } - - - // MARK: Objc func - - @objc - private func changedLocationAuthorization(_ notification: Notification) { - - self.tableView.reloadSections(IndexSet(3...3), with: .automatic) - } -} - -// MARK: - UITableVie -extension UploadCardBottomSheetViewController: UITableViewDataSource, UITableViewDelegate { - - func numberOfSections(in tableView: UITableView) -> Int { - Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch Section.allCases[section] { - case .imageSegment, .selectImage, .selectFont: - 1 - - case .otherSettings: - self.reactor?.requestType == .card ? Section.OtherSettings.allCases.count : 1 - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Section.allCases[indexPath.section] { - case .imageSegment: - return createBottomSheetSegmentTableViewCell(indexPath: indexPath) - - case .selectImage: - switch imageModeSegmentState.value { - case .defaultImage: - return createSelectDefaultImageTableViewCell(indexPath: indexPath) - case .myImage: - return createSelectMyImageTableViewCell(indexPath: indexPath) - } - - case .selectFont: - return createSelectFontTableViewCell(indexPath: indexPath) - - case .otherSettings: - return createUploadCardSettingTableViewCell(indexPath: indexPath) - } - } - - private func createBottomSheetSegmentTableViewCell(indexPath: IndexPath) -> BottomSheetSegmentTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: - String( - describing: BottomSheetSegmentTableViewCell.self - ), - for: indexPath - ) as! BottomSheetSegmentTableViewCell - cell.setData(imageModeSegmentState: imageModeSegmentState, imageReloadButtonTapped: imageReloadButtonTapped) - return cell - } - - private func createSelectDefaultImageTableViewCell(indexPath: IndexPath) -> SelectDefaultImageTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: - String( - describing: SelectDefaultImageTableViewCell.self - ), - for: indexPath - ) as! SelectDefaultImageTableViewCell - cell.setData(imageWithNames: defaultImages, selectedDefaultImage: selectedDefaultImage) - return cell - } - - private func createSelectMyImageTableViewCell(indexPath: IndexPath) -> SelectMyImageTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: - String( - describing: SelectMyImageTableViewCell.self - ), - for: indexPath - ) as! SelectMyImageTableViewCell - cell.setData(image: self.selectedMyImage.value?.image, sholdShowImagePicker: sholdShowImagePicker) - return cell - } - - private func createSelectFontTableViewCell(indexPath: IndexPath) -> SelectFontTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: - String( - describing: SelectFontTableViewCell.self - ), - for: indexPath - ) as! SelectFontTableViewCell - cell.setData(selectedFont: self.bottomSheetFontState) - return cell - } - - private func createUploadCardSettingTableViewCell(indexPath: IndexPath) -> UploadCardSettingTableViewCell { - let cell = tableView.dequeueReusableCell( - withIdentifier: - String( - describing: UploadCardSettingTableViewCell.self - ), - for: indexPath - ) as! UploadCardSettingTableViewCell - - let isDeniedLocationAuthStatus = self.reactor?.provider.locationManager.checkLocationAuthStatus() == .denied - if self.reactor?.requestType == .card { - cell.setData( - cellOption: Section.OtherSettings.allCases[indexPath.item], - globalCardOptionState: bottomSheetOptionState, - isDeniedLocationAuthStatus: isDeniedLocationAuthStatus - ) - } else { - cell.setData( - cellOption: Section.OtherSettings.distanceLimit, - globalCardOptionState: bottomSheetOptionState, - isDeniedLocationAuthStatus: isDeniedLocationAuthStatus - ) - } - - return cell - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - switch Section.allCases[indexPath.section] { - case .imageSegment: - return 24 - - case .selectImage: - switch self.imageModeSegmentState.value { - case .defaultImage: - return ((UIScreen.main.bounds.width - 40) / 2) + 28 - case .myImage: - return 140 - } - - case .selectFont: - return 92 - - case .otherSettings: - return 74 - } - } -} - -// MARK: - YPImagePicker -extension UploadCardBottomSheetViewController { - func presentPicker() { - var config = YPImagePickerConfiguration() - - config.library.options = nil - config.library.onlySquare = false - config.library.isSquareByDefault = true - config.library.minWidthForItem = nil - config.library.mediaType = YPlibraryMediaType.photo - config.library.defaultMultipleSelection = false - config.library.maxNumberOfItems = 1 - config.library.minNumberOfItems = 1 - config.library.numberOfItemsInRow = 4 - config.library.spacingBetweenItems = 1.0 - config.showsCrop = .rectangle(ratio: 1.0) - config.showsPhotoFilters = false - config.library.skipSelectionsGallery = false - config.library.preselectedItems = nil - config.library.preSelectItemOnMultipleSelection = true - config.startOnScreen = .library - config.shouldSaveNewPicturesToAlbum = false - - config.wordings.next = "다음" - config.wordings.cancel = "취소" - config.wordings.save = "저장" - config.wordings.albumsTitle = "앨범" - config.wordings.cameraTitle = "카메라" - config.wordings.libraryTitle = "갤러리" - config.wordings.crop = "자르기" - - let picker = YPImagePicker(configuration: config) - picker.didFinishPicking { [weak self] items, _ in - guard let image = items.singlePhoto?.image else { - picker.dismiss(animated: true, completion: nil) - return - } - self?.selectedMyImage.accept(.init(name: "", image: image)) - picker.dismiss(animated: true, completion: nil) - // 이미지 업로드 - if let reactor = self?.reactor { - reactor.action.onNext(.seleteMyImage(image)) - } - self?.tableView.reloadSections(IndexSet([1]), with: .automatic) - } - present(picker, animated: true, completion: nil) - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/UploadCardBottomSheetViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/UploadCardBottomSheetViewReactor.swift deleted file mode 100644 index 83be395c..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/UploadCardBottomSheetViewReactor.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// UploadCardBottomSheetViewReactor.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import Alamofire -import Kingfisher -import ReactorKit - -struct ImageURLWithName { - var name: String - var urlString: String -} - -struct ImageWithName { - var name: String - var image: UIImage -} - -class UploadCardBottomSheetViewReactor: Reactor { - - enum RequestType { - case card - case comment - } - - enum Action: Equatable { - /// 처음, 이미지 변경 눌렀을때 호출 - case fetchNewDefaultImage - /// 이미지 업로드용 url&이름 fetch후 이미지 업로드 까지 이미지 선택 완료시 호출 - case seleteMyImage(UIImage) - } - - enum Mutation { - /// 기본 이미지 fetch 결과 - case defaultImages([ImageWithName]) - /// 내 이미지 이름 fetch, 이미지 put 까지 완료 하고 이미지 이름 반환 - case myImageName(String) - } - - struct State { - /// 선택한 이미지 이름 - var myImageName: String? - var defaultImages: [ImageWithName] - } - - var initialState: State = .init( - myImageName: nil, - defaultImages: [] - ) - - let provider: ManagerProviderType - let requestType: RequestType - - init(provider: ManagerProviderType, type requestType: RequestType) { - self.provider = provider - self.requestType = requestType - } - - func mutate(action: Action) -> Observable { - switch action { - case .fetchNewDefaultImage: - return fetchDefaultImages() - case let .seleteMyImage(myImage): - return uploadMyImage(myImage: myImage) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state = state - switch mutation { - case let .defaultImages(images): - state.defaultImages = images - - case let .myImageName(imageName): - state.myImageName = imageName - } - return state - } - - func fetchDefaultImages() -> Observable { - - let request: UploadRequest = .defaultImages - - return self.provider.networkManager.request(DefaultCardImageResponse.self, request: request) - .map(\.embedded.imgURLInfoList) - .flatMap { imageInfoList -> Observable in - let imageURLWithNames: [ImageURLWithName] = imageInfoList.map { - ImageURLWithName(name: $0.imgName, urlString: $0.url.href) - } - return Observable.from(imageURLWithNames) - .withUnretained(self) - .flatMap { object, imageURLWithName -> Observable in - object.downloadImage(imageURLWithName: imageURLWithName) - } // 각 ImageURLWithName를 옵저버블로 바꾼 후 - .compactMap { $0 } // nil 값 제거 - .toArray() - .asObservable() - .map { imagesWithNames in - return Mutation.defaultImages(imagesWithNames) - } - } - } - - private func downloadImage(imageURLWithName: ImageURLWithName) -> Observable { - guard let url = URL(string: imageURLWithName.urlString) else { - return Observable.just(nil) - } - - return Observable.create { observer in - // Kingfisher를 사용한 이미지 다운로드 - let task = KingfisherManager.shared.retrieveImage(with: url) { result in - switch result { - case .success(let value): - let imageWithName = ImageWithName(name: imageURLWithName.name, image: value.image) - observer.onNext(imageWithName) // 성공 시 이미지 방출 - case .failure: - observer.onNext(nil) // 실패 시 nil 방출 - } - observer.onCompleted() // 작업 완료 - } - - // 반환된 작업을 사용해 Kingfisher의 취소 작업을 처리 - return Disposables.create { - task?.cancel() - } - } - } - - func uploadMyImage(myImage: UIImage) -> Observable { - - let presignedURLRequest: UploadRequest = .presignedURL - - return self.provider.networkManager.request(PresignedStorageResponse.self, request: presignedURLRequest) - .flatMap { [weak self] presignedResponse -> Observable in - guard let self = self else { return Observable.just(Mutation.myImageName("")) } - - // 2. presigned URL을 통해 이미지를 업로드합니다. - guard let url = URL(string: presignedResponse.url.url) else { - return Observable.just(Mutation.myImageName("")) - } - - return Observable.create { observer in - self.uploadImageToURL(image: myImage, url: url) { result in - switch result { - case .success: - observer.onNext(Mutation.myImageName(presignedResponse.imgName)) - observer.onCompleted() - case .failure(let error): - // 4. 실패 시 에러 방출 - observer.onError(error) - } - } - - return Disposables.create() - } - } - } - - func uploadImageToURL(image: UIImage, url: URL, completion: @escaping (Result) -> Void) { - - // 이미지를 JPEG로 변환 - guard let imageData = image.jpegData(compressionQuality: 0.5) else { - completion(.failure(NSError(domain: "ImageConversionError", code: 0, userInfo: nil))) - return - } - - AF.upload(imageData, to: url, method: .put) - .validate(statusCode: 200..<300) - .response { response in - switch response.result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Views/UploadCardBottomSheetSegmentView.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Views/UploadCardBottomSheetSegmentView.swift deleted file mode 100644 index 778ce9be..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/UploadCard/Views/UploadCardBottomSheetSegmentView.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// UploadCardBottomSheetSegmentView.swift -// SOOUM -// -// Created by JDeoks on 10/16/24. -// - -import UIKit - -import ReactorKit -import RxGesture -import RxSwift - -import SnapKit -import Then - -class UploadCardBottomSheetSegmentView: UIView { - let rootStack = UIStackView().then { - $0.axis = .horizontal - $0.spacing = 0 - $0.isLayoutMarginsRelativeArrangement = true - } - - let selectModeButtonStack = UIStackView().then { - $0.axis = .horizontal - } - - let defualtImageButtonLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.textAlignment = .center - $0.textColor = .som.black - $0.text = "기본 이미지" - } - - let myImageButtonLabel = UILabel().then { - $0.typography = .som.body1WithBold - $0.textAlignment = .center - $0.textColor = .som.gray400 - $0.text = "내 사진" - } - - let chageImageButtonStack = UIStackView().then { - $0.alignment = .center - $0.axis = .horizontal - $0.spacing = 2 - $0.layoutMargins = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) - $0.isLayoutMarginsRelativeArrangement = true - } - - let chagneImageLabel = UILabel().then { - $0.typography = .som.body2WithRegular - $0.textColor = .som.gray400 - $0.text = "이미지 변경" - } - - let chageImageImageView = UIImageView().then { - $0.image = .init(systemName: "gobackward") - $0.tintColor = .som.gray400 - } - - // MARK: - init - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - setupConstraint - private func setupConstraint() { - self.addSubview(rootStack) - rootStack.snp.makeConstraints { - $0.leading.top.trailing.bottom.equalToSuperview() - } - - rootStack.addArrangedSubviews(selectModeButtonStack, UIView(), chageImageButtonStack) - selectModeButtonStack.addArrangedSubviews(defualtImageButtonLabel, myImageButtonLabel) - defualtImageButtonLabel.snp.makeConstraints { - $0.height.equalTo(32) - $0.width.equalTo(94) - } - myImageButtonLabel.snp.makeConstraints { - $0.height.equalTo(32) - $0.width.equalTo(70) - } - - chageImageButtonStack.addArrangedSubviews(chagneImageLabel, chageImageImageView) - chagneImageLabel.snp.makeConstraints { - $0.height.equalTo(14) - $0.width.equalTo(65) - } - chageImageImageView.snp.makeConstraints { - $0.height.equalTo(14) - $0.width.equalTo(14) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/PungTimeView.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/PungTimeView.swift deleted file mode 100644 index cb403ed3..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/PungTimeView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// PungTimeView.swift -// SOOUM -// -// Created by 오현식 on 12/30/24. -// - -import UIKit - -import SnapKit -import Then - - -class PungTimeView: UIView { - - enum Text { - static let pungTimeGuideMessage: String = "이후에 카드가 삭제될 예정이에요" - } - - private let backgroundView = UIView().then { - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - private let pungTimeLabel = UILabel().then { - $0.textColor = .som.white - $0.typography = .som.body2WithBold - } - - private let pungTimeGuideLabel = UILabel().then { - $0.text = Text.pungTimeGuideMessage - $0.textColor = .som.gray700 - $0.typography = .som.body2WithBold - } - - var text: String? { - set { - self.pungTimeLabel.text = newValue - } - get { - return self.pungTimeLabel.text - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.addSubview(self.backgroundView) - self.backgroundView.snp.makeConstraints { - $0.top.bottom.leading.equalToSuperview() - } - - self.backgroundView.addSubview(self.pungTimeLabel) - self.pungTimeLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(10) - $0.trailing.equalToSuperview().offset(-10) - } - - self.addSubview(self.pungTimeGuideLabel) - self.pungTimeGuideLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.backgroundView.snp.trailing).offset(3) - $0.trailing.equalToSuperview() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextView.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextView.swift deleted file mode 100644 index 5e1aca19..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextView.swift +++ /dev/null @@ -1,299 +0,0 @@ -// -// WriteCardTextView.swift -// SOOUM -// -// Created by 오현식 on 10/18/24. -// - -import UIKit - -import SnapKit -import Then - - -class WriteCardTextView: UIView { - - private lazy var backgroundImageView = UIImageView().then { - $0.backgroundColor = .clear - } - - private lazy var backgroundDimView = UIView().then { - $0.backgroundColor = .som.black.withAlphaComponent(0.5) - - let gestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(self.touch) - ) - $0.addGestureRecognizer(gestureRecognizer) - } - - lazy var textView = UITextView().then { - $0.keyboardAppearance = .light - $0.backgroundColor = .clear - - let paragraphStyle = NSMutableParagraphStyle() - $0.typingAttributes[.paragraphStyle] = paragraphStyle - $0.typingAttributes[.foregroundColor] = UIColor.som.white - $0.typingAttributes[.font] = Typography.som.body1WithBold.font - $0.tintColor = .som.p300 - - $0.textAlignment = .center - - let verticalInset = (self.width * 0.9 - 40 * 2) * 0.5 - let horizontalInset = (self.width - 40 * 2) * 0.5 - $0.textContainerInset = .init( - top: verticalInset, - left: horizontalInset, - bottom: verticalInset, - right: horizontalInset - ) - $0.textContainer.lineFragmentPadding = 0 - - $0.scrollIndicatorInsets = .init(top: 4, left: 0, bottom: 4, right: 0) - $0.indicatorStyle = .white - $0.isScrollEnabled = true - - $0.enablesReturnKeyAutomatically = true - $0.returnKeyType = .go - - $0.autocapitalizationType = .none - $0.autocorrectionType = .no - $0.spellCheckingType = .no - - $0.delegate = self - } - - private let placeholderLabel = UILabel().then { - $0.textColor = .som.white - $0.typography = .som.body1WithBold - } - - private let characterLabel = UILabel().then { - $0.textColor = .init(hex: "#D6D6D6") - // 고정된 타이포그래피가 없음 - $0.typography = .init( - fontContainer: BuiltInFont(size: 14, weight: .semibold), - lineHeight: 17, - letterSpacing: -0.04 - ) - } - - weak var delegate: WriteCardTextViewDelegate? - - var image: UIImage? { - didSet { - self.backgroundImageView.image = self.image - } - } - - var placeholder: String? { - set { - self.placeholderLabel.text = newValue - } - get { - return self.placeholderLabel.text - } - } - - var text: String? { - set { - self.textView.text = newValue - } - get { - return self.textView.text - } - } - - // TODO: 임시로 typography가 변경되면 텍스트 대치 - var typography: Typography = .som.body1WithBold { - didSet { - self.textView.typography = self.typography - self.textView.text = self.text - self.textView.textColor = .som.white - } - } - - var maxCharacter: Int? { - didSet { - self.characterLabel.isHidden = (maxCharacter == nil) - - guard let max = self.maxCharacter else { return } - self.characterLabel.text = "0/" + max.description + "자" - } - } - - override var isFirstResponder: Bool { - return self.textView.isFirstResponder - } - - override var canBecomeFirstResponder: Bool { - return self.textView.canBecomeFirstResponder - } - - override var canResignFirstResponder: Bool { - return self.textView.canResignFirstResponder - } - - @discardableResult - override func becomeFirstResponder() -> Bool { - return self.textView.becomeFirstResponder() - } - - @discardableResult - override func resignFirstResponder() -> Bool { - return self.textView.resignFirstResponder() - } - - let width: CGFloat = UIScreen.main.bounds.width - 20 * 2 - - @objc - private func touch(_ recognizer: UITapGestureRecognizer) { - if !self.textView.isFirstResponder { - self.textView.becomeFirstResponder() - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { } - - private func setupConstraints() { - - self.layer.cornerRadius = 40 - self.clipsToBounds = true - - self.addSubview(self.backgroundImageView) - self.backgroundImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - /// 가로 : 세로 = 10 : 9 - $0.width.equalTo(self.width) - $0.height.equalTo(self.width).multipliedBy(0.9) - } - - self.addSubview(self.backgroundDimView) - self.backgroundDimView.snp.makeConstraints { - $0.edges.equalTo(self.backgroundImageView) - } - - self.addSubview(self.textView) - self.textView.snp.makeConstraints { - $0.center.equalToSuperview() - let width: CGFloat = self.width - 40 * 2 - let height: CGFloat = self.width * 0.9 - 40 * 2 - $0.width.equalTo(width) - $0.height.equalTo(height) - } - - self.textView.addSubview(self.placeholderLabel) - self.placeholderLabel.snp.makeConstraints { - $0.center.equalToSuperview() - } - - self.addSubview(self.characterLabel) - self.characterLabel.snp.makeConstraints { - $0.bottom.equalToSuperview().offset(-12) - $0.centerX.equalToSuperview() - } - } - - private func updateTextContainerInset(_ textView: UITextView) { - - let min: UIEdgeInsets = .init(top: 12, left: 16, bottom: 12, right: 16) - - let attributedText = NSAttributedString( - string: textView.text, - attributes: [.font: self.typography.font] - ) - - let size: CGSize = .init(width: textView.bounds.width, height: .greatestFiniteMagnitude) - let textSize: CGSize = textView.sizeThatFits(size) - let boundingRect = attributedText.boundingRect( - with: textSize, - options: [.usesLineFragmentOrigin, .usesFontLeading], - context: nil - ) - - let verticalInset = max(min.top, (textView.bounds.height - boundingRect.height) * 0.5) - let horizontalInset = max(min.left, (textView.bounds.width - boundingRect.width) * 0.5) - - textView.textContainerInset = .init( - top: verticalInset, - left: horizontalInset, - bottom: verticalInset, - right: horizontalInset - ) - } -} - -extension WriteCardTextView: UITextViewDelegate { - - func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { - let shouldBeginEditing = self.delegate?.textViewShouldBeginEditing(self) ?? true - - guard let max = self.maxCharacter else { return shouldBeginEditing } - let characterText = textView.text.count.description + "/" + max.description + "자" - let attributedString = NSMutableAttributedString(string: characterText).then { - let textColor = UIColor.som.white - let range = (characterText as NSString).range(of: textView.text.count.description) - $0.addAttribute(.foregroundColor, value: textColor, range: range) - } - self.characterLabel.attributedText = attributedString - - return shouldBeginEditing - } - - func textViewDidBeginEditing(_ textView: UITextView) { - self.delegate?.textViewDidBeginEditing(self) - - self.placeholderLabel.isHidden = true - } - - func textViewShouldEndEditing(_ textView: UITextView) -> Bool { - let shouldEndEditing = self.delegate?.textViewShouldEndEditing(self) ?? true - return shouldEndEditing - } - - func textViewDidEndEditing(_ textView: UITextView) { - self.delegate?.textViewDidEndEditing(self) - - let isHidden = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - placeholderLabel.isHidden = isHidden - } - - func textViewDidChange(_ textView: UITextView) { - self.delegate?.textViewDidChange(self) - - self.updateTextContainerInset(textView) - - guard let max = self.maxCharacter else { return } - let characterText = textView.text.count.description + "/" + max.description + "자" - let attributedString = NSMutableAttributedString(string: characterText).then { - let textColor = UIColor.som.white - let range = (characterText as NSString).range(of: textView.text.count.description) - $0.addAttribute(.foregroundColor, value: textColor, range: range) - } - self.characterLabel.attributedText = attributedString - } - - // return key did tap - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - - if text == "\n" { self.delegate?.textViewReturnKeyClicked(self) } - - let currentText: String = textView.text ?? "" - guard let textRange = Range(range, in: currentText) else { return false } - let newText: String = currentText.replacingCharacters(in: textRange, with: text) - - let shouldChangeText: Bool = (self.delegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true) - - return (newText.count <= (self.maxCharacter ?? Int.max)) && shouldChangeText - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextViewDelegate.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextViewDelegate.swift deleted file mode 100644 index 49f00a37..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/TextView/WriteCardTextViewDelegate.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// WriteCardTextViewDelegate.swift -// SOOUM -// -// Created by 오현식 on 10/18/24. -// - -import Foundation - - -/// 각 메서드의 동작은 `UIKit/UITextView/UITextViewDelegate` 를 참고하십시오. -protocol WriteCardTextViewDelegate: AnyObject { - - func textViewShouldBeginEditing(_ textView: WriteCardTextView) -> Bool - func textViewDidBeginEditing(_ textView: WriteCardTextView) - - func textViewShouldEndEditing(_ textView: WriteCardTextView) -> Bool - func textViewDidEndEditing(_ textView: WriteCardTextView) - - func textViewDidChange(_ textView: WriteCardTextView) - func textView(_ textView: WriteCardTextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool - - func textViewReturnKeyClicked(_ textView: WriteCardTextView) -} - -extension WriteCardTextViewDelegate { - - func textViewShouldBeginEditing(_ textView: WriteCardTextView) -> Bool { true } - func textViewDidBeginEditing(_ textView: WriteCardTextView) { } - - func textViewShouldEndEditing(_ textView: WriteCardTextView) -> Bool { true } - func textViewDidEndEditing(_ textView: WriteCardTextView) { } - - func textViewDidChange(_ textView: WriteCardTextView) { } - func textView(_ textView: WriteCardTextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { true } - - func textViewReturnKeyClicked(_ textView: WriteCardTextView) { } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/WriteCardView.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/WriteCardView.swift deleted file mode 100644 index 5b61b972..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/Views/WriteCardView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// WriteCardView.swift -// SOOUM -// -// Created by 오현식 on 10/20/24. -// - -import UIKit - -import SnapKit -import Then - - -class WriteCardView: UIView { - - enum Text { - static let writeCardPlaceholder: String = "이곳에 글을 적어주세요." - static let wirteTagPlaceholder: String = "#태그를 입력해주세요!" - static let relatedTagsTitle: String = "#관련태그" - } - - lazy var writeCardTextView = WriteCardTextView().then { - $0.maxCharacter = 1000 - $0.placeholder = Text.writeCardPlaceholder - } - - lazy var writeTagTextField = WriteTagTextField().then { - $0.placeholder = Text.wirteTagPlaceholder - } - - let pungTimeView = PungTimeView().then { - $0.isHidden = true - } - - let writtenTags = SOMTags(configure: .horizontalWithRemove).then { - $0.tag = 0 - } - - let relatedTagsBackgroundView = UIView().then { - $0.isHidden = true - } - let relatedTags = SOMTags(configure: .verticalWithoutRemove).then { - $0.tag = 1 - } - - var writtenTagsHeightConstraint: Constraint? - - override init(frame: CGRect) { - super.init(frame: frame) - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.addSubview(self.writeCardTextView) - self.writeCardTextView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - self.addSubview(self.writtenTags) - self.writtenTags.snp.makeConstraints { - $0.top.equalTo(self.writeCardTextView.snp.bottom) - $0.leading.trailing.equalToSuperview() - self.writtenTagsHeightConstraint = $0.height.equalTo(12).constraint - } - - self.addSubview(self.writeTagTextField) - self.writeTagTextField.snp.makeConstraints { - $0.top.equalTo(self.writtenTags.snp.bottom) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - self.addSubview(self.pungTimeView) - self.pungTimeView.snp.makeConstraints { - $0.top.equalTo(self.writtenTags.snp.bottom).offset(18) - $0.centerX.equalToSuperview() - $0.height.equalTo(25) - } - - self.addSubview(self.relatedTagsBackgroundView) - self.relatedTagsBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.writeTagTextField.snp.bottom).offset(12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - let relatedTagsLabel = UILabel().then { - $0.text = Text.relatedTagsTitle - $0.textColor = UIColor(hex: "#303030") - $0.textAlignment = .center - $0.typography = .som.body2WithBold - } - self.relatedTagsBackgroundView.addSubview(relatedTagsLabel) - relatedTagsLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview() - } - - self.relatedTagsBackgroundView.addSubview(self.relatedTags) - self.relatedTags.snp.makeConstraints { - $0.top.equalTo(relatedTagsLabel.snp.bottom).offset(8) - $0.bottom.leading.trailing.equalToSuperview() - let height: CGFloat = 32 * 3 + 12 * 2 - $0.height.equalTo(height) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/WriteCardViewController.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/WriteCardViewController.swift deleted file mode 100644 index 07376578..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/WriteCardViewController.swift +++ /dev/null @@ -1,630 +0,0 @@ -// -// WriteCardViewController.swift -// SOOUM -// -// Created by 오현식 on 10/18/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxKeyboard -import RxSwift - - -class WriteCardViewController: BaseNavigationViewController, View { - - enum Text { - static let timeLimitLabelText: String = "시간제한 카드" - static let wirteButtonTitle: String = "작성하기" - - static let writeDialogTitle: String = "카드를 작성할까요?" - static let writeDialogMessage: String = "추가한 카드는 수정할 수 없어요" - - static let unusablePhotoDialogTitle: String = "부적절한 사진으로 보여져요" - static let unusablePhotoDialogMessage: String = "적절한 사진으로 바꾸거나\n기본 이미지를 사용해주세요" - - static let donotWirteDialogTitle: String = "카드를 작성할 수 없어요" - static let donotWirteDialogTopMessage: String = "지속적인 신고 접수로 인해" - static let donotWirteDialogBottomMessage: String = "까지\n카드를 작성할 수 없어요" - - static let deletedCardDialogTitle: String = "카드가 삭제되었어요" - static let deletedCardDialogMessage: String = "삭제된 카드에는 답카드를 작성할 수 없어요" - - static let cancelActionTitle: String = "취소" - static let addCardActionTitle: String = "카드추가" - static let confirmActionTitle: String = "확인" - } - - enum ConstValue { - static let maxCharacterForTag: Int = 15 - } - - - // MARK: Views - - private let timeLimitBackgroundView = UIView().then { - $0.backgroundColor = .som.p200 - $0.layer.cornerRadius = 22 * 0.5 - $0.clipsToBounds = true - $0.isHidden = true - } - - private let timeLimitLabel = UILabel().then { - $0.text = Text.timeLimitLabelText - $0.textColor = .som.black - $0.typography = .som.body2WithBold - } - - private let writeButton = SOMButton().then { - $0.title = Text.wirteButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.gray700 - - $0.isEnabled = false - } - - private lazy var writeCardView = WriteCardView().then { - $0.writeCardTextView.delegate = self - $0.writeTagTextField.delegate = self - $0.writtenTags.delegate = self - $0.relatedTags.delegate = self - } - - private let uploadCardBottomSheetViewController = UploadCardBottomSheetViewController() - - private var writtenTagModels = [SOMTagModel]() - - - // MARK: Override variables - - override var navigationBarHeight: CGFloat { - 58 - } - - override var navigationPopGestureEnabled: Bool { - false - } - - - // MARK: Variables - - private var keyboardHeight: CGFloat = 0 - - private let initalHeight: CGFloat = 38 + 24 + ((UIScreen.main.bounds.width - 40) * 0.5) + 28 - private var maxHeight: CGFloat = 38 + 24 + ((UIScreen.main.bounds.width - 40) * 0.5) + 28 + 92 + (74 * 3) + 19 - - // 펑 이벤트 처리 위해 추가 - private var serialTimer: Disposable? - - - // MARK: Override func - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.setupNaviBar() - - guard self.presentedViewController == nil else { return } - self.showBottomSheet( - presented: self.uploadCardBottomSheetViewController, - dismissWhenScreenDidTap: true, - isHandleBar: true, - neverDismiss: true, - maxHeight: self.maxHeight, - initalHeight: self.initalHeight - ) - } - - override func setupNaviBar() { - super.setupNaviBar() - - self.timeLimitBackgroundView.snp.makeConstraints { - $0.width.equalTo(93) - $0.height.equalTo(22) - } - - self.timeLimitBackgroundView.addSubview(self.timeLimitLabel) - self.timeLimitLabel.snp.makeConstraints { - $0.center.equalToSuperview() - } - - self.navigationBar.titleView = self.timeLimitBackgroundView - self.navigationBar.setRightButtons([self.writeButton]) - } - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.writeCardView) - self.writeCardView.snp.makeConstraints { - $0.edges.equalTo(self.view.safeAreaLayoutGuide.snp.edges) - } - } - - override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { - super.updatedKeyboard(withoutBottomSafeInset: height) - - self.keyboardHeight = height == 0 ? self.keyboardHeight : height - - let isTextFieldFirstResponder = self.writeCardView.writeTagTextField.isFirstResponder - self.writeCardView.snp.updateConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(isTextFieldFirstResponder ? -height : 0) - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(isTextFieldFirstResponder ? -height : 0) - } - - UIView.performWithoutAnimation { - self.view.layoutIfNeeded() - } - } - - override func bind() { - super.bind() - - self.uploadCardBottomSheetViewController.reactor = self.reactor?.reactorForUploadCard() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - self.serialTimer?.dispose() - - self.presentedViewController?.dismiss(animated: false) - } - - - // MARK: ReactorKit - bind - - func bind(reactor: WriteCardViewReactor) { - - // landing - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // 피드 및 답글 작성 시 바텀 시트 높이 및 태그 입력 영역 숨김 처리 - if reactor.requestType == .comment { - self.maxHeight = 38 + 24 + ((UIScreen.main.bounds.width - 40) * 0.5) + 28 + 92 + 74 + 19 - - self.writeCardView.writeTagTextField.isHidden = reactor.parentPungTime != nil - self.writeCardView.pungTimeView.isHidden = reactor.parentPungTime == nil - - if reactor.parentPungTime != nil { - self.subscribePungTime() - } - } - - // Keyboard, bottomSheet interaction - RxKeyboard.instance.isHidden - .drive(with: self) { object, isHidden in - - // 부모 뷰로 돌아갈 때, 아래 조건 무시 - guard object.isMovingFromParent == false else { return } - - if isHidden { - - // 현재 present 된 viewController가 없을 때 표시 - guard object.presentedViewController == nil else { return } - - object.showBottomSheet( - presented: object.uploadCardBottomSheetViewController, - dismissWhenScreenDidTap: true, - isHandleBar: true, - neverDismiss: true, - maxHeight: object.maxHeight, - initalHeight: object.initalHeight - ) - } else { - - // 현재 present 된 viewController가 있을 때 dismiss - guard object.presentedViewController != nil else { return } - - object.dismissBottomSheet() - } - } - .disposed(by: self.disposeBag) - - // Update image for textView - let bottomSheetImageSelected = self.uploadCardBottomSheetViewController.bottomSheetImageSelected.distinctUntilChanged().share() - bottomSheetImageSelected - .filterNil() - .bind(to: self.writeCardView.writeCardTextView.rx.image) - .disposed(by: self.disposeBag) - - // Set tags - let writtenTagText = self.writeCardView.writeTagTextField.rx.text.orEmpty.distinctUntilChanged().share() - self.writeCardView.writeTagTextField.addTagButton.rx.throttleTap(.seconds(3)) - .withLatestFrom(writtenTagText) - .filter { $0.isEmpty == false } - .withUnretained(self) - .do(onNext: { object, writtenTagText in - object.writeCardView.writeTagTextField.text = nil - object.writeCardView.writeTagTextField.sendActionsToTextField(for: .editingChanged) - }) - .map { object, writtenTagText in - let toModel: SOMTagModel = .init( - id: UUID().uuidString, - originalText: writtenTagText, - isRemovable: true - ) - - guard object.writtenTagModels.contains(toModel) == false else { return object.writtenTagModels } - - object.writtenTagModels.append(toModel) - object.writeCardView.writtenTagsHeightConstraint?.deactivate() - object.writeCardView.writtenTags.snp.makeConstraints { - object.writeCardView.writtenTagsHeightConstraint = $0.height.equalTo(58).constraint - } - - UIView.performWithoutAnimation { - object.view.layoutIfNeeded() - } - - return object.writtenTagModels - } - .bind(to: self.writeCardView.writtenTags.rx.models()) - .disposed(by: self.disposeBag) - - // Action - writtenTagText - .filter { $0.isEmpty == false } - .debounce(.seconds(1), scheduler: MainScheduler.instance) - .map(Reactor.Action.relatedTags) - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - let optionState = self.uploadCardBottomSheetViewController.bottomSheetOptionState - .distinctUntilChanged() - .share(replay: 1, scope: .whileConnected) - let imageName = self.uploadCardBottomSheetViewController.bottomSheetImageNameSeleted - .distinctUntilChanged() - .share(replay: 1, scope: .whileConnected) - let imageType = imageName.map { $0.count < 14 ? "DEFAULT" : "USER" } - let font = self.uploadCardBottomSheetViewController.bottomSheetFontState - .distinctUntilChanged() - .map { $0 == .gothic ? Font.pretendard : Font.school } - .share(replay: 1, scope: .whileConnected) - let content = self.writeCardView.writeCardTextView.rx.text.orEmpty - .distinctUntilChanged() - .share(replay: 1, scope: .whileConnected) - - // 네비게이션 바 작성하기 버튼 attributes 설정 - Observable.combineLatest( - content, - bottomSheetImageSelected, - resultSelector: { $0.isEmpty == false && $1 != nil } - ) - .subscribe(with: self) { object, isEnabled in - - object.writeButton.isEnabled = isEnabled - object.writeButton.foregroundColor = isEnabled ? .som.p300 : .som.gray700 - } - .disposed(by: self.disposeBag) - - // 시간제한 카드 뷰 및 태그 표시 - optionState - .skip(1) - .compactMap { $0[.timeLimit] } - .subscribe(with: self) { object, isTimeLimit in - - object.timeLimitBackgroundView.isHidden = isTimeLimit == false - - object.writeCardView.relatedTagsBackgroundView.isHidden = isTimeLimit - - object.writeCardView.writeTagTextField.isHidden = isTimeLimit - object.writeCardView.writtenTagsHeightConstraint?.deactivate() - object.writeCardView.writtenTags.snp.makeConstraints { - let height = object.writtenTagModels.isEmpty ? 12 : 58 - let constraint = isTimeLimit ? $0.height.equalTo(0).constraint : $0.height.equalTo(height).constraint - object.writeCardView.writtenTagsHeightConstraint = constraint - } - } - .disposed(by: self.disposeBag) - - font - .subscribe(with: self.writeCardView) { writeCardView, font in - let isChange = font == .school - writeCardView.writeCardTextView.typography = isChange ? .som.schoolBody1WithBold : .som.body1WithBold - } - .disposed(by: self.disposeBag) - - let combined = Observable.combineLatest(optionState, imageName, imageType, font, content) - self.writeButton.rx.throttleTap(.seconds(3)) - .withLatestFrom(combined) - .filter { $4.isEmpty == false } - .subscribe(with: self) { object, combine in - let (optionState, imageName, imageType, font, content) = combine - - let cancelAction = SOMDialogAction( - title: Text.cancelActionTitle, - style: .gray, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - let addCardAction = SOMDialogAction( - title: Text.addCardActionTitle, - style: .primary, - action: { - let feedTags = object.writtenTagModels.map { $0.originalText } - if reactor.requestType == .card { - GAManager.shared.logEvent( - event: SOMEvent.Comment.add_comment( - comment_length: content.count, - parent_post_id: reactor.parentCardId ?? "", - image_attached: imageType == "USER" - ) - ) - reactor.action.onNext( - .writeCard( - isDistanceShared: optionState[.distanceLimit] ?? false, - isPublic: optionState[.privateCard] ?? false, - isStory: optionState[.timeLimit] ?? false, - content: content, - font: font.rawValue, - imgType: imageType, - imgName: imageName, - feedTags: feedTags - ) - ) - } else { - - reactor.action.onNext( - .writeComment( - isDistanceShared: optionState[.distanceLimit] ?? false, - content: content, - font: font.rawValue, - imgType: imageType, - imgName: imageName, - commentTags: feedTags - ) - ) - } - - UIApplication.topViewController?.dismiss(animated: true) - } - ) - - SOMDialogViewController.show( - title: Text.writeDialogTitle, - message: Text.writeDialogMessage, - actions: [cancelAction, addCardAction] - ) - } - .disposed(by: self.disposeBag) - - // State - reactor.state.map(\.banEndAt) - .filterNil() - .subscribe(with: self) { object, banEndAt in - let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) { - object.navigationPop() - } - } - ) - - SOMDialogViewController.show( - title: Text.donotWirteDialogTitle, - message: "\(Text.donotWirteDialogTopMessage)\n\(banEndAt.banEndDetailFormatted)\(Text.donotWirteDialogBottomMessage)", - actions: [confirmAction] - ) - } - .disposed(by: self.disposeBag) - - let relatedTags = reactor.state.map(\.relatedTags).distinctUntilChanged().share() - writtenTagText - .map { $0.isEmpty } - .bind(to: self.writeCardView.relatedTagsBackgroundView.rx.isHidden) - .disposed(by: self.disposeBag) - relatedTags - .map { relatedTags in - let toModels: [SOMTagModel] = relatedTags.map { relatedTag in - let toModel: SOMTagModel = .init( - id: UUID().uuidString, - originalText: relatedTag.content, - count: "0\(relatedTag.count)", - isSelectable: true, - isRemovable: false - ) - return toModel - } - return toModels - } - .bind(to: self.writeCardView.relatedTags.rx.models()) - .disposed(by: self.disposeBag) - - reactor.state.map(\.isWrite) - .filterNil() - .filter { $0 == true } - .delay(.seconds(1), scheduler: MainScheduler.instance) - .subscribe(with: self) { object, isWrite in - - // GA 태그 추적용 - let tagStrs = object.writtenTagModels.map { $0.originalText } - GAManager.shared.logEvent( - event: SOMEvent.WriteCard.add_tag(tag_count: tagStrs.count, tag_texts: tagStrs) - ) - // 글추가 성공 - // 키보드가 표시되어 있을 때, 이전 화면으로 전환 - if object.presentedViewController == nil { - - object.navigationPop() - } else { - // 바텀싯이 표시되어 있을 때, 바텀싯 제거 후 이전 화면으로 전환 - object.dismissBottomSheet(completion: { - object.navigationPop() - }) - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.errorCode) - .filterNil() - .delay(.seconds(1), scheduler: MainScheduler.instance) - .subscribe(with: self) { object, errorCode in - // GA 태그 추적용 - let tagStrs = object.writtenTagModels.map { $0.originalText } - GAManager.shared.logEvent( - event: SOMEvent.WriteCard.dismiss_with_tag(tag_count: tagStrs.count, tag_texts: tagStrs) - ) - // 글추가 실패, 실패 다이얼로그 표시 - let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - - let failedWriteDialogTitle = errorCode == 402 ? Text.deletedCardDialogTitle : Text.unusablePhotoDialogTitle - let failedWriteDialogMessage = errorCode == 402 ? Text.deletedCardDialogMessage : Text.unusablePhotoDialogMessage - SOMDialogViewController.show( - title: failedWriteDialogTitle, - message: failedWriteDialogMessage, - actions: [confirmAction] - ) - } - .disposed(by: self.disposeBag) - } - - - // MARK: Private func - - // 펑 이벤트 구독 - private func subscribePungTime() { - self.serialTimer?.dispose() - self.serialTimer = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) - .withUnretained(self) - .startWith((self, 0)) - .map { object, _ in - guard let pungTime = object.reactor?.parentPungTime else { - object.serialTimer?.dispose() - return "00 : 00 : 00" - } - - let currentDate = Date() - let remainingTime = currentDate.infoReadableTimeTakenFromThisForPung(to: pungTime) - if remainingTime == "00 : 00 : 00" { - object.serialTimer?.dispose() - } - - return remainingTime - } - .bind(to: self.writeCardView.pungTimeView.rx.text) - } -} - - -// MARK: WriteCardTextViewDelegate - -extension WriteCardViewController: WriteCardTextViewDelegate { - - func textViewDidBeginEditing(_ textView: WriteCardTextView) { - - if self.writeCardView.writeCardTextView.isFirstResponder { - - RxKeyboard.instance.isHidden - .filter { $0 == false } - .drive(with: self) { object, _ in - object.updatedKeyboard(withoutBottomSafeInset: object.keyboardHeight) - } - .disposed(by: self.disposeBag) - } - } -} - - -// MARK: WriteTagTextFieldDelegate - -extension WriteCardViewController: WriteTagTextFieldDelegate { - - func textFieldDidBeginEditing(_ textField: WriteTagTextField) { - - if self.writeCardView.writeTagTextField.isFirstResponder { - - RxKeyboard.instance.isHidden - .filter { $0 == false } - .drive(with: self) { object, _ in - object.updatedKeyboard(withoutBottomSafeInset: object.keyboardHeight) - } - .disposed(by: self.disposeBag) - } - } - - func textField( - _ textField: WriteTagTextField, - shouldChangeTextIn range: NSRange, - replacementText string: String - ) -> Bool { - - let nsString: NSString? = textField.text as NSString? - let newString: String = nsString?.replacingCharacters(in: range, with: string) ?? "" - - return newString.count < ConstValue.maxCharacterForTag + 1 - } - - func textFieldReturnKeyClicked(_ textField: WriteTagTextField) -> Bool { - - if self.writeCardView.writeTagTextField.isFirstResponder { - self.writeCardView.writeTagTextField.addTagButton.sendActions(for: .touchUpInside) - return false - } - - return true - } -} - - -// MARK: SOMTagsDelegate - -extension WriteCardViewController: SOMTagsDelegate { - - // writtenTags.tag == 0 - // relatedTags.tag == 1 - func tags(_ tags: SOMTags, didRemove model: SOMTagModel) { - - if tags.tag == 0 { - self.writtenTagModels.removeAll(where: { $0 == model }) - if self.writtenTagModels.isEmpty { - self.writeCardView.writtenTagsHeightConstraint?.deactivate() - self.writeCardView.writtenTags.snp.makeConstraints { - self.writeCardView.writtenTagsHeightConstraint = $0.height.equalTo(12).constraint - } - } - } - } - - func tags(_ tags: SOMTags, didTouch model: SOMTagModel) { - - if tags.tag == 1 { - guard self.writtenTagModels.contains(model) == false else { return } - - let toModel: SOMTagModel = .init( - id: model.id, - originalText: model.originalText, - isRemovable: true - ) - - self.writtenTagModels.append(toModel) - self.writeCardView.writtenTagsHeightConstraint?.deactivate() - self.writeCardView.writtenTags.snp.makeConstraints { - self.writeCardView.writtenTagsHeightConstraint = $0.height.equalTo(58).constraint - } - - UIView.performWithoutAnimation { - self.view.layoutIfNeeded() - } - - self.writeCardView.writtenTags.setModels(self.writtenTagModels) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/WriteCard/WriteCardViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/WriteCard/WriteCardViewReactor.swift deleted file mode 100644 index a38fe76e..00000000 --- a/SOOUM/SOOUM/Presentations/Main/WriteCard/WriteCardViewReactor.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// WriteCardViewReactor.swift -// SOOUM -// -// Created by 오현식 on 10/20/24. -// - -import ReactorKit - - -class WriteCardViewReactor: Reactor { - - enum RequestType { - case card - case comment - } - - enum Action: Equatable { - case landing - case writeCard( - isDistanceShared: Bool, - isPublic: Bool, - isStory: Bool, - content: String, - font: String, - imgType: String, - imgName: String, - feedTags: [String] - ) - case writeComment( - isDistanceShared: Bool, - content: String, - font: String, - imgType: String, - imgName: String, - commentTags: [String] - ) - case relatedTags(keyword: String) - } - - enum Mutation { - case updateBanEndAt(Date?) - case writeCard(Bool) - case relatedTags([RelatedTag]) - case updateError(Int?) - } - - struct State { - var banEndAt: Date? - var isWrite: Bool? - var relatedTags: [RelatedTag] - var errorCode: Int? - } - - var initialState: State = .init( - banEndAt: nil, - isWrite: nil, - relatedTags: [], - errorCode: nil - ) - - let provider: ManagerProviderType - - let requestType: RequestType - - let parentCardId: String? - let parentPungTime: Date? - - init( - provider: ManagerProviderType, - type requestType: RequestType, - parentCardId: String? = nil, - parentPungTime: Date? = nil - ) { - self.provider = provider - self.requestType = requestType - self.parentCardId = parentCardId - self.parentPungTime = parentPungTime - } - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - - return self.provider.networkManager.request(SettingsResponse.self, request: SettingsRequest.activate) - .flatMapLatest { response -> Observable in - return .just(.updateBanEndAt(response.banEndAt)) - } - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - case let .writeCard( - isDistanceShared, - isPublic, - isStory, - content, - font, - imgType, - imgName, - feedTags - ): - let coordinate = self.provider.locationManager.coordinate - let trimedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) - - let request: CardRequest = .writeCard( - isDistanceShared: !isDistanceShared, - latitude: coordinate.latitude, - longitude: coordinate.longitude, - isPublic: !isPublic, - isStory: isStory, - content: trimedContent, - font: font, - imgType: imgType, - imgName: imgName, - feedTags: feedTags - ) - - return self.provider.networkManager.request(Status.self, request: request) - .map { .writeCard($0.httpCode == 201) } - .catch(self.catchClosure) - case let .writeComment( - isDistanceShared, - content, - font, - imgType, - imgName, - commentTags - ): - let coordinate = self.provider.locationManager.coordinate - let trimedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) - - let request: CardRequest = .writeComment( - id: self.parentCardId ?? "", - isDistanceShared: !isDistanceShared, - latitude: coordinate.latitude, - longitude: coordinate.longitude, - content: trimedContent, - font: font, - imgType: imgType, - imgName: imgName, - commentTags: commentTags - ) - - return self.provider.networkManager.request(Status.self, request: request) - .map { .writeCard($0.httpCode == 201) } - .catch(self.catchClosure) - case let .relatedTags(keyword): - - let request: CardRequest = .relatedTag(keyword: keyword, size: 5) - return self.provider.networkManager.request(RelatedTagResponse.self, request: request) - .map(\.embedded.relatedTags) - .map { .relatedTags($0) } - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state: State = state - switch mutation { - case let .updateBanEndAt(banEndAt): - state.banEndAt = banEndAt - case let .writeCard(isWrite): - state.isWrite = isWrite - case let .relatedTags(relatedTags): - state.relatedTags = relatedTags - case let .updateError(errorCode): - state.errorCode = errorCode - } - return state - } -} - -extension WriteCardViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { error in - - let nsError = error as NSError - return .just(.updateError(nsError.code)) - } - } -} - -extension WriteCardViewReactor { - - func reactorForUploadCard() -> UploadCardBottomSheetViewReactor { - UploadCardBottomSheetViewReactor.init( - provider: self.provider, - type: self.requestType == .card ? .card : .comment - ) - } -} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/AuthRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/AuthRequest.swift deleted file mode 100644 index 72a417c7..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/AuthRequest.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// AuthRequest.swift -// SOOUM -// -// Created by 오현식 on 10/26/24. -// - -import Foundation - -import Alamofire - - -enum AuthRequest: BaseRequest { - - /// RSA 공개 키 요청 - case getPublicKey - /// 로그인 - case login(encryptedDeviceId: String) - /// 회원가입 - case signUp( - encryptedDeviceId: String, - isAllowNotify: Bool, - isAllowTermOne: Bool, - isAllowTermTwo: Bool, - isAllowTermThree: Bool - ) - /// 재인증 - case reAuthenticationWithRefreshSession - /// fcm 업데이트 - case updateFCM(fcmToken: String) - /// version 검사 - case updateCheck - - var path: String { - switch self { - case .getPublicKey: - return "/users/key" - case .login: - return "/users/login" - case .signUp: - return "/users/sign-up" - case .reAuthenticationWithRefreshSession: - return "/users/token" - case .updateFCM: - return "/members/fcm" - case .updateCheck: - return "/app/version/ios" - } - } - - var method: HTTPMethod { - switch self { - case .getPublicKey, .updateCheck: - return .get - case .login, .signUp, .reAuthenticationWithRefreshSession: - return .post - case .updateFCM: - return .patch - } - } - - var parameters: Parameters { - switch self { - case let .login(encryptedDeviceId): - return ["encryptedDeviceId": encryptedDeviceId] - case let .signUp( - encryptedDeviceId, - isAllowNotify, - isAllowTermOne, - isAllowTermTwo, - isAllowTermThree - ): - return [ - "memberInfo": [ - "encryptedDeviceId": encryptedDeviceId, - "deviceType": "IOS", - "isAllowNotify": isAllowNotify - ] as [String: Any], - "policy": [ - "isAllowTermOne": isAllowTermOne, - "isAllowTermTwo": isAllowTermTwo, - "isAllowTermThree": isAllowTermThree - ] as [String: Any] - ] - case let .updateFCM(fcmToken): - return ["fcmToken": fcmToken] - case .updateCheck: - return ["version": Version.thisAppVersion] - default: - return [:] - } - } - - var encoding: ParameterEncoding { - switch self { - case .login, .signUp, .updateFCM: - return JSONEncoding.default - default: - return URLEncoding.default - } - } - - var authorizationType: AuthorizationType { - switch self { - case .updateFCM: - return .access - case .reAuthenticationWithRefreshSession: - return .refresh - default: - return .none - } - } - - var version: APIVersion { - switch self { - case .updateCheck: - return .v2 - default: - return .v1 - } - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - - let encoded = try self.encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/JoinRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/JoinRequest.swift deleted file mode 100644 index 804f5d1b..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/JoinRequest.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// JoinRequest.swift -// SOOUM -// -// Created by JDeoks on 11/7/24. -// - -import Foundation - -import Alamofire - -enum JoinRequest: BaseRequest { - - case suspension(encryptedDeviceId: String) - case validateNickname(nickname: String) - case profileImagePresignedURL - case registerUser(userName: String, imageName: String?) - - var path: String { - switch self { - case .suspension: - return "/members/suspension" - case .validateNickname: - return "/profiles/nickname/available" - case .profileImagePresignedURL: - return "/imgs/profiles/upload" - case .registerUser: - return "/profiles" - } - } - - var method: HTTPMethod { - switch self { - case .suspension, .validateNickname: - return .post - case .registerUser: - return .patch - default: - return .get - } - } - - var parameters: Parameters { - switch self { - case let .suspension(encryptedDeviceId): - return ["encryptedDeviceId": encryptedDeviceId] - case let .validateNickname(nickname): - return ["nickname": nickname] - case .profileImagePresignedURL: - return ["extension": "JPEG"] - case .registerUser(let nickname, let profileImg): - if let profileImg = profileImg { - return ["nickname": nickname, "profileImg": profileImg] - } else { - return ["nickname": nickname] - } - } - } - - var encoding: ParameterEncoding { - switch self { - case .suspension, .registerUser, .validateNickname: - return JSONEncoding.default - default: - return URLEncoding.queryString - } - } - - var authorizationType: AuthorizationType { - switch self { - case .suspension, .validateNickname: - return .none - default: - return .access - } - } - - var version: APIVersion { - return .v1 - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - - let encoded = try self.encoding.encode(request, with: self.parameters) - - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/NotificationRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/NotificationRequest.swift deleted file mode 100644 index 3a626d08..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/NotificationRequest.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// NotificationRequest.swift -// SOOUM -// -// Created by 오현식 on 12/23/24. -// - -import Foundation - -import Alamofire - - -enum NotificationRequest: BaseRequest { - - case totalWithoutRead(lastId: String?) - case totalRead(lastId: String?) - case totalWithoutReadCount - case commentWithoutRead(lastId: String?) - case commentRead(lastId: String?) - case commentWithoutReadCount - case likeWithoutRead(lastId: String?) - case likeRead(lastId: String?) - case likeWihoutReadCount - case requestRead(notificationId: String) - - var path: String { - switch self { - case let .totalWithoutRead(lastId): - if let lastId = lastId { - return "/notifications/unread/\(lastId)" - } else { - return "/notifications/unread" - } - - case let .totalRead(lastId): - if let lastId = lastId { - return "/notifications/read/\(lastId)" - } else { - return "/notifications/read" - } - - case .totalWithoutReadCount: - return "/notifications/unread-cnt" - - case let .commentWithoutRead(lastId): - if let lastId = lastId { - return "/notifications/card/unread/\(lastId)" - } else { - return "/notifications/card/unread" - } - - case let .commentRead(lastId): - if let lastId = lastId { - return "/notifications/card/read/\(lastId)" - } else { - return "/notifications/card/read" - } - - case .commentWithoutReadCount: - return "/notifications/card/unread-cnt" - - case let .likeWithoutRead(lastId): - if let lastId = lastId { - return "/notifications/like/unread/\(lastId)" - } else { - return "/notifications/like/unread" - } - - case let .likeRead(lastId): - if let lastId = lastId { - return "/notifications/like/read/\(lastId)" - } else { - return "/notifications/like/read" - } - - case .likeWihoutReadCount: - return "/notifications/like/unread-cnt" - - case let .requestRead(notificationId): - return "/notifications/\(notificationId)/read" - } - } - - var method: HTTPMethod { - switch self { - case .requestRead: - return .patch - default: - return .get - } - } - - var parameters: Parameters { - return [:] - } - - var encoding: ParameterEncoding { - return URLEncoding.default - } - - var authorizationType: AuthorizationType { - return .access - } - - var version: APIVersion { - return .v1 - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - - let encoded = try encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/ProfileRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/ProfileRequest.swift deleted file mode 100644 index 2b99f616..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/ProfileRequest.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// ProfileRequest.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import Foundation - -import Alamofire - - -enum ProfileRequest: BaseRequest { - - case myProfile - case otherProfile(memberId: String) - case updateProfile(nickname: String, profileImg: String?) - case myCards(lastId: String?) - case otherCards(memberId: String, lastId: String?) - case myFollowing(lastId: String?) - case otherFollowing(memberId: String, lastId: String?) - case myFollower(lastId: String?) - case otherFollower(memberId: String, lastId: String?) - case requestFollow(memberId: String) - case cancelFollow(memberId: String) - - - var path: String { - switch self { - case .myProfile: - return "/profiles/my" - case let .otherProfile(memberId): - return "/profiles/\(memberId)" - case .updateProfile: - return "/profiles" - case .myCards: - return "/members/feed-cards" - case let .otherCards(memberId, _): - return "/members/\(memberId)/feed-cards" - case .myFollowing: - return "/profiles/following" - case let .otherFollowing(memberId, _): - return "/profiles/\(memberId)/following" - case .myFollower: - return "/profiles/follower" - case let .otherFollower(memberId, _): - return "/profiles/\(memberId)/follower" - case .requestFollow: - return "/followers" - case let .cancelFollow(memberId): - return "/followers/\(memberId)" - } - } - - var method: HTTPMethod { - switch self { - case .updateProfile: - return .patch - case .requestFollow: - return .post - case .cancelFollow: - return .delete - default: - return .get - } - } - - var parameters: Parameters { - switch self { - case let .updateProfile(nickname, profileImg): - if let profileImg = profileImg { - return ["nickname": nickname, "profileImg": profileImg] - } else { - return ["nickname": nickname] - } - case let .myCards(lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .otherCards(_, lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .myFollowing(lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .otherFollowing(_, lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .myFollower(lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .otherFollower(_, lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .requestFollow(memberId): - return ["userId": memberId] - default: - return [:] - } - } - - var encoding: ParameterEncoding { - switch self { - case .updateProfile, .requestFollow: - return JSONEncoding.default - default: - return URLEncoding.default - } - } - - var authorizationType: AuthorizationType { - return .access - } - - var version: APIVersion { - return .v1 - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - let encoded = try encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} - diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/ReportRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/ReportRequest.swift deleted file mode 100644 index 903ae912..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/ReportRequest.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// ReportRequest.swift -// SOOUM -// -// Created by JDeoks on 10/15/24. -// - -import Foundation - -import Alamofire - - -enum ReportRequest: BaseRequest { - - case reportCard(id: String, reportType: ReportViewReactor.ReportType) - case blockMember(id: String) - case cancelBlockMember(id: String) - - - var path: String { - switch self { - case let .reportCard(id, _): - return "/report/cards/\(id)" - case .blockMember: - return "/blocks" - case let .cancelBlockMember(id): - return "/blocks/\(id)" - } - } - - var method: HTTPMethod { - switch self { - case .cancelBlockMember: - return .delete - default: - return .post - } - } - - var parameters: Parameters { - switch self { - case let .reportCard(_, reportType): - return ["reportType": reportType.rawValue] - case let .blockMember(id): - return ["toMemberId": id] - case .cancelBlockMember: - return [:] - } - } - - var encoding: ParameterEncoding { - switch self { - case .cancelBlockMember: - return URLEncoding.default - default: - return JSONEncoding.default - } - } - - var authorizationType: AuthorizationType { - return .access - } - - var version: APIVersion { - return .v1 - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - let encoded = try encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/SettingsRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/SettingsRequest.swift deleted file mode 100644 index 4b5ba23c..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/SettingsRequest.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// SettingsRequest.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import Foundation - -import Alamofire - - -enum SettingsRequest: BaseRequest { - - case activate - case notificationAllow(isAllowNotify: Bool?) - case commentHistory(lastId: String?) - case transferCode(isUpdate: Bool) - case transferMember(transferId: String, encryptedDeviceId: String) - case resign(token: Token) - case announcement - - - var path: String { - switch self { - case .activate: - return "/settings/status" - case .notificationAllow: - return "/members/notify" - case .commentHistory: - return "/members/comment-cards" - case .resign: - return "/members" - case .announcement: - return "/notices" - default: - return "/settings/transfer" - } - } - - var method: HTTPMethod { - switch self { - case let .notificationAllow(isAllowNotify): - return isAllowNotify == nil ? .get : .patch - case let .transferCode(isUpdate): - return isUpdate ? .patch : .get - case .transferMember: - return .post - case .resign: - return .delete - default: - return .get - } - } - - var parameters: Parameters { - switch self { - case let .notificationAllow(isAllowNotify): - if let isAllowNotify = isAllowNotify { - return ["isAllowNotify": isAllowNotify] - } else { - return [:] - } - case let .transferMember(transferId, encryptedDeviceId): - return [ - "deviceType": "IOS", - "transferId": transferId, - "encryptedDeviceId": encryptedDeviceId - ] - case let .commentHistory(lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .resign(token): - return ["accessToken": token.accessToken, "refreshToken": token.refreshToken] - default: - return [:] - } - } - - var encoding: ParameterEncoding { - switch self { - case let .notificationAllow(isAllowNotify): - return isAllowNotify == nil ? URLEncoding.default : JSONEncoding.default - case .transferMember, .resign: - return JSONEncoding.default - default: - return URLEncoding.default - } - } - - var authorizationType: AuthorizationType { - return .access - } - - var version: APIVersion { - return .v1 - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - let encoded = try encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} - diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/UploadRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/UploadRequest.swift deleted file mode 100644 index 1d630034..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/UploadRequest.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// UploadRequest.swift -// SOOUM -// -// Created by JDeoks on 10/23/24. -// - -import UIKit - -import Alamofire - -enum UploadRequest: BaseRequest { - - case defaultImages - /// 이미지 업로드할 url, 이미지 이름 fetch - case presignedURL - /// 이미지를 URL로 업로드 - case uploadMyImage(image: UIImage, presignedURL: URL) - - var path: String { - switch self { - case .defaultImages: - return "/imgs/default" - case .presignedURL: - return "/imgs/cards/upload" - case .uploadMyImage: - return "" // presignedURL로 직접 요청 - } - } - - var method: HTTPMethod { - switch self { - case .uploadMyImage: - return .put - case .defaultImages, .presignedURL: - return .get - } - } - - var parameters: Parameters { - switch self { - case .defaultImages, .uploadMyImage: - return [:] - case .presignedURL: - return ["extension": "jpeg"] - } - } - - var encoding: ParameterEncoding { - return URLEncoding.queryString - } - - var authorizationType: AuthorizationType { - return .access - } - - var version: APIVersion { - return .v1 - } - - func asURLRequest() throws -> URLRequest { - - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - - let encoded = try encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } - -} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift new file mode 100644 index 00000000..25b9775c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift @@ -0,0 +1,159 @@ +// +// AuthRequest.swift +// SOOUM +// +// Created by 오현식 on 10/26/24. +// + +import Alamofire + +enum AuthRequest: BaseRequest { + + /// RSA 공개 키 요청 + case publicKey + /// 회원가입 + case signUp( + encryptedDeviceId: String, + isNotificationAgreed: Bool, + nickname: String, + profileImageName: String? + ) + /// 로그인 + case login(encryptedDeviceId: String) + /// 재인증 + case reAuthenticationWithRefreshSession(token: Token) + /// 회원탈퇴 + case withdraw(token: Token, reason: String) + + var path: String { + switch self { + case .publicKey: + return "/api/rsa/public-key" + case .signUp: + return "/api/auth/sign-up" + case .login: + return "/api/auth/login" + case .reAuthenticationWithRefreshSession: + return "/api/auth/token/reissue" + case .withdraw: + return "/api/auth/withdrawal" + } + } + + var method: HTTPMethod { + switch self { + case .publicKey: + return .get + case .login, .signUp, .reAuthenticationWithRefreshSession: + return .post + case .withdraw: + return .delete + } + } + + var parameters: Parameters { + switch self { + case let .signUp(encryptedDeviceId, isNotificationAgreed, nickname, profileImageName): + var memberInfo: [String: Any] + if let profileImageName = profileImageName { + memberInfo = [ + "encryptedDeviceId": encryptedDeviceId, + "deviceType": "IOS", + "deviceModel": Info.deviceModel, + "deviceOsVersion": Info.iOSVersion, + "isNotificationAgreed": isNotificationAgreed, + "nickname": nickname, + "profileImage": profileImageName + ] + } else { + memberInfo = [ + "encryptedDeviceId": encryptedDeviceId, + "deviceType": "IOS", + "deviceModel": Info.deviceModel, + "deviceOsVersion": Info.iOSVersion, + "isNotificationAgreed": isNotificationAgreed, + "nickname": nickname + ] + } + return [ + "memberInfo": memberInfo, + "policy": [ + "agreedToTermsOfService": true, + "agreedToLocationTerms": true, + "agreedToPrivacyPolicy": true + ] as [String: Any] + ] + case let .login(encryptedDeviceId): + return [ + "encryptedDeviceId": encryptedDeviceId, + "deviceType": "IOS", + "deviceModel": Info.deviceModel, + "deviceOsVersion": Info.iOSVersion + ] + case let .reAuthenticationWithRefreshSession(token): + return [ + "accessToken": token.accessToken, + "refreshToken": token.refreshToken + ] + case let .withdraw(token, reaseon): + return [ + "accessToken": token.accessToken, + "refreshToken": token.refreshToken, + "reason": reaseon + ] + default: + return [:] + } + } + + var encoding: ParameterEncoding { + switch self { + case .publicKey: + return URLEncoding.default + default: + return JSONEncoding.default + } + } + + var authorizationType: AuthorizationType { + switch self { + case .withdraw: + return .access + default: + return .none + } + } + + var serverEndpoint: String { + #if DEVELOP + return Constants.endpoint + #elseif PRODUCTION + return UserDefaults.standard.bool(forKey: "AppFlag") ? "https://test-core.sooum.org:555" : Constants.endpoint + #endif + } + + func asURLRequest() throws -> URLRequest { + + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { + var request = URLRequest(url: url) + request.method = self.method + + request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue + ) + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue + ) + + let encoded = try self.encoding.encode(request, with: self.parameters) + return encoded + } else { + return .init(url: URL(string: "")!) + } + } +} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/CardRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift similarity index 68% rename from SOOUM/SOOUM/Resources/Alamofire/Request/CardRequest.swift rename to SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift index 3b7f5a60..3cef0d35 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/CardRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift @@ -5,99 +5,121 @@ // Created by 오현식 on 9/26/24. // -import Foundation - import Alamofire - enum CardRequest: BaseRequest { + + // MARK: Home + /// 최신순 case latestCard(lastId: String?, latitude: String?, longitude: String?) /// 인기순 case popularCard(latitude: String?, longitude: String?) /// 거리순 case distancCard(lastId: String?, latitude: String, longitude: String, distanceFilter: String) + + + // MARK: Detail + /// 상세보기 case detailCard(id: String, latitude: String?, longitude: String?) + /// 상세보기 - 삭제 여부 + case isCardDeleted(id: String) /// 상세보기 - 답카드 case commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) - /// 상세보기 - 답카드 좋아요 정보 - case cardSummary(id: String) /// 상세보기 - 카드 삭제 case deleteCard(id: String) /// 상세보기 - 좋아요 업데이트 case updateLike(id: String, isLike: Bool) + /// 상세보기 - 신고 + case reportCard(id: String, reportType: String) + + // MARK: Write + + /// 기본 이미지 조회 + case defaultImages + /// 프로필 이미지 업로드할 공간 조회 + case presignedURL /// 글추가 case writeCard( isDistanceShared: Bool, - latitude: String, - longitude: String, - isPublic: Bool, - isStory: Bool, + latitude: String?, + longitude: String?, content: String, font: String, imgType: String, imgName: String, - feedTags: [String] + isStory: Bool, + tags: [String] ) /// 답카드 추가 case writeComment( id: String, isDistanceShared: Bool, - latitude: String, - longitude: String, + latitude: String?, + longitude: String?, content: String, font: String, imgType: String, imgName: String, - commentTags: [String] + tags: [String] ) - /// 글추가 - 관련 태그 조회 - case relatedTag(keyword: String, size: Int) var path: String { switch self { case let .latestCard(lastId, _, _): + if let lastId = lastId { - return "/cards/home/latest/\(lastId)" + return "/api/cards/feeds/latest/\(lastId)" } else { - return "/cards/home/latest" + return "/api/cards/feeds/latest" } - case .popularCard: - return "/cards/home/popular" + return "/api/cards/feeds/popular" case let .distancCard(lastId, _, _, _): + if let lastId = lastId{ - return "/cards/home/distance/\(lastId)" + return "/api/cards/feeds/distance/\(lastId)" } else { - return "/cards/home/distance" + return "/api/cards/feeds/distance" } - + case let .detailCard(id, _, _): - return "/cards/\(id)/detail" - - case let .commentCard(id, _, _, _): - return "/comments/current/\(id)" + return "/api/cards/\(id)" - case let .cardSummary(id): - return "/cards/current/\(id)/summary" + case let .commentCard(id, lastId, _, _): + if let lastId = lastId { + return "/api/cards/\(id)/comments/\(lastId)" + } else { + return "/api/cards/\(id)/comments" + } case let .deleteCard(id): - return "/cards/\(id)" + return "/api/cards/\(id)" + case let .isCardDeleted(id): + + return "/api/cards/\(id)/delete-check" case let .updateLike(id, _): - return "/cards/\(id)/like" + return "/api/cards/\(id)/like" + case let .reportCard(id, _): + + return "/api/reports/cards/\(id)" + case .defaultImages: + + return "/api/images/defaults" + case .presignedURL: + + return "/api/images/card-img" case .writeCard: - return "/cards" + return "/api/cards" case let .writeComment(id, _, _, _, _, _, _, _, _): - return "/cards/\(id)" - case .relatedTag: - return "/tags/search" + return "/api/cards/\(id)" } } @@ -105,7 +127,7 @@ enum CardRequest: BaseRequest { switch self { case .deleteCard: return .delete - case .writeCard, .writeComment: + case .reportCard, .writeCard, .writeComment: return .post case let .updateLike(_, isLike): return isLike ? .post : .delete @@ -117,73 +139,69 @@ enum CardRequest: BaseRequest { var parameters: Parameters { switch self { case let .latestCard(_, latitude, longitude): + if let latitude = latitude, let longitude = longitude { return ["latitude": latitude, "longitude": longitude] } else { return [:] } - case let .popularCard(latitude, longitude): + if let latitude = latitude, let longitude = longitude { return ["latitude": latitude, "longitude": longitude] } else { return [:] } - case let .distancCard(_, latitude, longitude, distanceFilter): - return ["latitude": latitude, "longitude": longitude, "distanceFilter": distanceFilter] + + return ["latitude": latitude, "longitude": longitude, "distance": distanceFilter] case let .detailCard(_, latitude, longitude): + if let latitude = latitude, let longitude = longitude { return ["latitude": latitude, "longitude": longitude] } else { return [:] } - - case let .commentCard(_, lastId, latitude, longitude): - var params: Parameters = [:] - if let lastId = lastId { - params.updateValue(lastId, forKey: "lastId") - } + case let .commentCard(_, _, latitude, longitude): + if let latitude = latitude, let longitude = longitude { - params.updateValue(latitude, forKey: "latitude") - params.updateValue(longitude, forKey: "longitude") + return ["latitude": latitude, "longitude": longitude] + } else { + return [:] } - return params + case let .reportCard(_, reportType): + + return ["reportType": reportType] case let .writeCard( isDistanceShared, latitude, longitude, - isPublic, - isStory, content, font, imgType, imgName, - feedTags + isStory, + tags ): + var parameters: [String: Any] = [ "isDistanceShared": isDistanceShared, - "isPublic": isPublic, - "isStory": isStory, "content": content, "font": font, "imgType": imgType, "imgName": imgName, + "isStory": isStory, + "tags": tags ] - if isDistanceShared { + if isDistanceShared, let latitude = latitude, let longitude = longitude { parameters.updateValue(latitude, forKey: "latitude") parameters.updateValue(longitude, forKey: "longitude") } - if isStory == false { - parameters.updateValue(feedTags, forKey: "feedTags") - } - return parameters - case let .writeComment( _, isDistanceShared, @@ -193,30 +211,24 @@ enum CardRequest: BaseRequest { font, imgType, imgName, - commentTags + tags ): + var parameters: [String: Any] = [ "isDistanceShared": isDistanceShared, "content": content, "font": font, "imgType": imgType, "imgName": imgName, + "tags": tags ] - if isDistanceShared { + if isDistanceShared, let latitude = latitude, let longitude = longitude { parameters.updateValue(latitude, forKey: "latitude") parameters.updateValue(longitude, forKey: "longitude") } - if commentTags.isEmpty == false { - parameters.updateValue(commentTags, forKey: "commentTags") - } - return parameters - - case let .relatedTag(keyword, size): - return ["keyword": keyword, "size": size] - default: return [:] } @@ -224,7 +236,7 @@ enum CardRequest: BaseRequest { var encoding: ParameterEncoding { switch self { - case .updateLike, .writeCard, .writeComment: + case .reportCard, .writeCard, .writeComment: return JSONEncoding.default default: return URLEncoding.default @@ -235,14 +247,19 @@ enum CardRequest: BaseRequest { return .access } - var version: APIVersion { - return .v1 + var serverEndpoint: String { + #if DEVELOP + return Constants.endpoint + #elseif PRODUCTION + return UserDefaults.standard.bool(forKey: "AppFlag") ? "https://test-core.sooum.org:555" : Constants.endpoint + #endif } func asURLRequest() throws -> URLRequest { - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { var request = URLRequest(url: url) request.method = self.method diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift new file mode 100644 index 00000000..c3b6b8dc --- /dev/null +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift @@ -0,0 +1,119 @@ +// +// NotificationRequest.swift +// SOOUM +// +// Created by 오현식 on 12/23/24. +// + +import Alamofire + +enum NotificationRequest: BaseRequest { + + enum RequestType: String { + case notification = "NOTIFICATION" + case settings = "SETTINGS" + } + + /// 읽지 않은 알림 전체 조회 + case unreadNotifications(lastId: String?) + /// 읽은 알림 전체 조회 + case readNotifications(lastId: String?) + /// 알림 읽음 요청 + case requestRead(notificationId: String) + /// 공지 조회 + case notices(lastId: String?, size: Int?, requestType: RequestType) + + var path: String { + switch self { + case let .unreadNotifications(lastId): + if let lastId = lastId { + return "/api/notifications/unread/\(lastId)" + } else { + return "/api/notifications/unread" + } + + case let .readNotifications(lastId): + if let lastId = lastId { + return "/api/notifications/read/\(lastId)" + } else { + return "/api/notifications/read" + } + + case let .requestRead(notificationId): + return "/api/notifications/\(notificationId)/read" + + case let .notices(lastId, _, _): + if let lastId = lastId { + return "/api/notices/\(lastId)" + } else { + return "/api/notices" + } + } + } + + var method: HTTPMethod { + switch self { + case .requestRead: + return .patch + default: + return .get + } + } + + var parameters: Parameters { + switch self { + case let .notices(_, size, requestType): + if let size = size { + return [ + "pageSize": size, + "source": requestType.rawValue + ] + } else { + return ["source": requestType.rawValue] + } + default: + return [:] + } + } + + var encoding: ParameterEncoding { + return URLEncoding.default + } + + var authorizationType: AuthorizationType { + return .access + } + + var serverEndpoint: String { + #if DEVELOP + return Constants.endpoint + #elseif PRODUCTION + return UserDefaults.standard.bool(forKey: "AppFlag") ? "https://test-core.sooum.org:555" : Constants.endpoint + #endif + } + + func asURLRequest() throws -> URLRequest { + + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { + var request = URLRequest(url: url) + request.method = self.method + + request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue + ) + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue + ) + + let encoded = try encoding.encode(request, with: self.parameters) + return encoded + } else { + return .init(url: URL(string: "")!) + } + } +} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/SettingsRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/SettingsRequest.swift new file mode 100644 index 00000000..ee2e5339 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/SettingsRequest.swift @@ -0,0 +1,108 @@ +// +// SettingsRequest.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Alamofire + +enum SettingsRequest: BaseRequest { + + case rejoinableDate + case transferIssue + case transferEnter(code: String, encryptedDeviceId: String) + case transferUpdate + case blockUsers(lastId: String?) + + var path: String { + switch self { + case .rejoinableDate: + return "/api/members/rejoinable-date" + case .transferIssue: + return "/api/members/account/transfer-code" + case .transferEnter: + return "/api/members/account/transfer" + case .transferUpdate: + return "/api/members/account/transfer-code" + case let .blockUsers(lastId): + if let lastId = lastId { + return "/api/blocks/\(lastId)" + } else { + return "/api/blocks" + } + } + } + + var method: HTTPMethod { + switch self { + case .rejoinableDate, .transferIssue, .blockUsers: + return .get + case .transferEnter: + return .post + case .transferUpdate: + return .patch + } + } + + var parameters: Parameters { + switch self { + case let .transferEnter(code, encryptedDeviceId): + return [ + "transferCode": code, + "encryptedDeviceId": encryptedDeviceId, + "deviceType": "IOS", + "deviceModel": Info.deviceModel, + "deviceOsVersion": Info.iOSVersion + ] + default: + return [:] + } + } + + var encoding: ParameterEncoding { + switch self { + case .transferEnter: + return JSONEncoding.default + default: + return URLEncoding.default + } + } + + var authorizationType: AuthorizationType { + return .access + } + + var serverEndpoint: String { + #if DEVELOP + return Constants.endpoint + #elseif PRODUCTION + return UserDefaults.standard.bool(forKey: "AppFlag") ? "https://test-core.sooum.org:555" : Constants.endpoint + #endif + } + + func asURLRequest() throws -> URLRequest { + + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { + var request = URLRequest(url: url) + request.method = self.method + + request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue + ) + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue + ) + + let encoded = try self.encoding.encode(request, with: self.parameters) + return encoded + } else { + return .init(url: URL(string: "")!) + } + } +} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/TagRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/TagRequest.swift similarity index 50% rename from SOOUM/SOOUM/Resources/Alamofire/Request/TagRequest.swift rename to SOOUM/SOOUM/Resources/Alamofire/Request/V2/TagRequest.swift index 0420e1f6..451c0f18 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/TagRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/TagRequest.swift @@ -5,57 +5,48 @@ // Created by JDeoks on 12/4/24. // -import Foundation - import Alamofire enum TagRequest: BaseRequest { - case favorite(last: String?) - case recommend - case search(keyword: String) - case tagInfo(tagID: String) - case tagCard(tagID: String) - case addFavorite(tagID: String) - case deleteFavorite(tagID: String) + // 연관 태그 + case related(keyword: String, size: Int) + + case favorites + case updateFavorite(tagId: String, isFavorite: Bool) + case ranked + case tagCards(tagId: String, lastId: String?) var path: String { switch self { - case let .favorite(last): - if let last = last { - return "/tags/favorites/\(last)" - } else { - return "/tags/favorites" - } + case let .related(_, size): + return "/api/tags/related/\(size)" - case .recommend: - return "/tags/recommendation" + case .favorites: - case .search: - return "/tags/search" + return "/api/tags/favorite" + case let .updateFavorite(tagId, _): - case let .tagInfo(tagID): - return "/tags/\(tagID)/summary" + return "/api/tags/\(tagId)/favorite" + case .ranked: - case let .tagCard(tagID): - return "/cards/tags/\(tagID)" + return "/api/tags/rank" + case let .tagCards(tagId, lastId): - case let .addFavorite(tagID): - return "/tags/\(tagID)/favorite" - - case let .deleteFavorite(tagID): - return "/tags/\(tagID)/favorite" + if let lastId = lastId { + return "/api/tags/\(tagId)/cards/\(lastId)" + } else { + return "/api/tags/\(tagId)/cards" + } } } var method: HTTPMethod { switch self { - case .addFavorite(_): + case .related: return .post - - case .deleteFavorite(_): - return .delete - + case let .updateFavorite(_, isFavorite): + return isFavorite ? .post : .delete default: return .get } @@ -63,37 +54,39 @@ enum TagRequest: BaseRequest { var parameters: Parameters { switch self { - case let .favorite(lastId): - if let lastId = lastId { - return ["last": lastId] - } else { - return [:] - } - - case let .search(keyword): - return ["keyword": keyword, "size": 20] - + case let .related(keyword, _): + return ["tag": keyword] default: return [:] } } var encoding: ParameterEncoding { - return URLEncoding.queryString + switch self { + case .related: + return JSONEncoding.default + default: + return URLEncoding.default + } } var authorizationType: AuthorizationType { return .access } - var version: APIVersion { - return .v1 + var serverEndpoint: String { + #if DEVELOP + return Constants.endpoint + #elseif PRODUCTION + return UserDefaults.standard.bool(forKey: "AppFlag") ? "https://test-core.sooum.org:555" : Constants.endpoint + #endif } func asURLRequest() throws -> URLRequest { - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { var request = URLRequest(url: url) request.method = self.method diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift new file mode 100644 index 00000000..9adb41bc --- /dev/null +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift @@ -0,0 +1,225 @@ +// +// UserRequest.swift +// SOOUM +// +// Created by 오현식 on 9/16/25. +// + +import Alamofire + +enum UserRequest: BaseRequest { + + /// 가입 가능 여부 확인 + case checkAvailable(encryptedDeviceId: String) + /// 추천 닉네임 조회 + case nickname + /// 닉네임 유효성 검사 + case validateNickname(nickname: String) + /// 닉네임 업데이트 + case updateNickname(nickname: String) + /// 프로필 이미지 업로드할 공간 조회 + case presignedURL + /// 이미지 업데이트 + case updateImage(imageName: String) + /// fcmToken 업데이트 + case updateFCMToken(fcmToken: String) + /// 카드추가 가능 여부 확인 + case postingPermission + /// 프로필 조회 + case profile(userId: String?) + /// 나의 프로필 업데이트 + case updateMyProfile(nickname: String?, imageName: String?) + /// 나의 피드 카드 조회 + case feedCards(userId: String, lastId: String?) + /// 나의 답카드 조회 + case myCommentCards(lastId: String?) + /// 팔로워 조회 + case followers(userId: String, lastId: String?) + /// 팔로우 조회 + case followings(userId: String, lastId: String?) + /// 팔로우 요청 및 취소 + case updateFollowing(userId: String, isFollow: Bool) + /// 상대방 차단 + case updateBlocked(id: String, isBlocked: Bool) + /// 푸시 알림 여부 요청 + case updateNotify(isAllowNotify: Bool) + + var path: String { + switch self { + case .checkAvailable: + + return "/api/members/check-available" + case .nickname: + + return "/api/members/generate-nickname" + case .validateNickname: + + return "/api/members/validate-nickname" + case .updateNickname: + + return "/api/members/nickname" + case .presignedURL: + + return "/api/images/profile" + case .updateImage: + + return "/api/members/profile-img" + case .updateFCMToken: + + return "/api/members/fcm" + case .postingPermission: + + return "/api/members/permissions/posting" + case let .profile(userId): + + if let userId = userId { + return "/api/members/profile/info/\(userId)" + } else { + return "/api/members/profile/info/me" + } + case .updateMyProfile: + + return "/api/members/profile/info/me" + case let .feedCards(userId, lastId): + + if let lastId = lastId { + return "/api/members/\(userId)/cards/feed/\(lastId)" + } else { + return "/api/members/\(userId)/cards/feed" + } + case let .myCommentCards(lastId): + + if let lastId = lastId { + return "/api/members/me/cards/comment/\(lastId)" + } else { + return "/api/members/me/cards/comment" + } + case let .followers(userId, lastId): + + if let lastId = lastId { + return "/api/members/\(userId)/followers/\(lastId)" + } else { + return "/api/members/\(userId)/followers" + } + case let .followings(userId, lastId): + + if let lastId = lastId { + return "/api/members/\(userId)/following/\(lastId)" + } else { + return "/api/members/\(userId)/following" + } + case let .updateFollowing(userId, isFollow): + + if isFollow { + return "/api/members/follow" + } else { + return "/api/members/\(userId)/unfollow" + } + case let .updateBlocked(id, _): + + return "/api/blocks/\(id)" + case .updateNotify: + + return "/api/members/notify" + } + } + + var method: HTTPMethod { + switch self { + case .checkAvailable, .validateNickname: + return .post + case .updateNickname, .updateImage, .updateFCMToken, .updateMyProfile, .updateNotify: + return .patch + case let .updateFollowing(_, isFollow): + return isFollow ? .post : .delete + case let .updateBlocked(_, isBlocked): + return isBlocked ? .post : .delete + default: + return .get + } + } + + var parameters: Parameters { + switch self { + case let .checkAvailable(encryptedDeviceId): + return ["encryptedDeviceId": encryptedDeviceId] + case let .updateNickname(nickname): + return ["nickname": nickname] + case let .validateNickname(nickname): + return ["nickname": nickname] + case let .updateImage(imageName): + return ["name": imageName] + case let .updateFCMToken(fcmToken): + return ["fcmToken": fcmToken] + case let .updateMyProfile(nickname, imageName): + var dictionary: [String: Any] = [:] + if let nickname = nickname { + dictionary["nickname"] = nickname + } + if let imageName = imageName { + dictionary["profileImgName"] = imageName + } + return dictionary + case let .updateFollowing(userId, isFollow): + return isFollow ? ["userId": userId] : [:] + case let .updateNotify(isAllowNotify): + return ["isAllowNotify": isAllowNotify] + default: + return [:] + } + } + + var encoding: ParameterEncoding { + switch self { + case .checkAvailable, + .updateNickname, + .validateNickname, + .updateImage, + .updateFCMToken, + .updateMyProfile, + .updateNotify: + return JSONEncoding.default + case .updateFollowing: + return URLEncoding.queryString + default: + return URLEncoding.default + } + } + + var authorizationType: AuthorizationType { + return .access + } + + var serverEndpoint: String { + #if DEVELOP + return Constants.endpoint + #elseif PRODUCTION + return UserDefaults.standard.bool(forKey: "AppFlag") ? "https://test-core.sooum.org:555" : Constants.endpoint + #endif + } + + func asURLRequest() throws -> URLRequest { + + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { + var request = URLRequest(url: url) + request.method = self.method + + request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue + ) + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue + ) + + let encoded = try self.encoding.encode(request, with: self.parameters) + return encoded + } else { + return .init(url: URL(string: "")!) + } + } +} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/ConfigureRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift similarity index 52% rename from SOOUM/SOOUM/Resources/Alamofire/Request/ConfigureRequest.swift rename to SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift index 07420437..77f597ea 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/ConfigureRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift @@ -1,59 +1,45 @@ // -// ConfigureRequest.swift +// VersionRequest.swift // SOOUM // -// Created by JDeoks on 1/26/25. +// Created by 오현식 on 9/16/25. // -import Foundation - import Alamofire - -enum ConfigureRequest: BaseRequest { - - case appFlag - +enum VersionRequest: BaseRequest { + + case version + var path: String { - switch self { - case .appFlag: - "/app/version/flag" - } + return "/api/version/IOS" } - + var method: HTTPMethod { - switch self { - case .appFlag: - return .get - } + return .get } - + var parameters: Parameters { - switch self { - case .appFlag: - return [:] - } + return ["version": Info.appVersion] } - + var encoding: ParameterEncoding { - switch self { - case .appFlag: - return URLEncoding.default - } + return URLEncoding.default } var authorizationType: AuthorizationType { - return .access + return .none } - var version: APIVersion { - return .v1 + var serverEndpoint: String { + return "https://test-core.sooum.org:555" } - + func asURLRequest() throws -> URLRequest { - let pathWithAPIVersion = self.path + self.version.rawValue - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(pathWithAPIVersion) { + // TODO: 앱 심사 중 사용할 url + // if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + if let url = URL(string: self.serverEndpoint)?.appendingPathComponent(self.path) { var request = URLRequest(url: url) request.method = self.method @@ -66,10 +52,11 @@ enum ConfigureRequest: BaseRequest { Constants.ContentType.json.rawValue, forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue ) - let encoded = try encoding.encode(request, with: self.parameters) + + let encoded = try self.encoding.encode(request, with: self.parameters) return encoded } else { - return URLRequest(url: URL(string: "")!) + return .init(url: URL(string: "")!) } } } diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/100.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/100.png index 4a35df96..44d76cd6 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/100.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/100.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/1024.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/1024.png index e2b1b5b9..8e5ab540 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/1024.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/1024.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/114.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/114.png index 1b95e1a9..efa3aab9 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/114.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/114.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/120.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/120.png index 9c700c6f..7292ffa4 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/120.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/120.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/144.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/144.png index c5c24c75..0272ad78 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/144.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/144.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/152.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/152.png index 4dfab833..9af43eb6 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/152.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/152.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/167.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/167.png index be1df57c..3f0db64f 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/167.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/167.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/180.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/180.png index 16b832d1..d0df52c7 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/180.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/180.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/20.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/20.png index f8922382..74e1923e 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/20.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/20.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/29.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/29.png index 4bf513c4..de76cd56 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/29.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/29.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/40.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/40.png index 69566787..d074c7a5 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/40.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/40.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/50.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/50.png index 7217a060..3ff7c180 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/50.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/50.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/57.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/57.png index cfa791f7..d0c93c9c 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/57.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/57.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/58.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/58.png index 1b55b774..68b30de5 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/58.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/58.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/60.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/60.png index 9cf48da5..09c208d6 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/60.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/60.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/72.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/72.png index 3c46f748..1a077882 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/72.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/72.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/76.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/76.png index bda251ef..04731a3a 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/76.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/76.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/80.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/80.png index 5602cf8c..2e8c162f 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/80.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/80.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/87.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/87.png index 7239401d..946147a1 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/87.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon-Dev.appiconset/87.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/100.png index 4a35df96..44d76cd6 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/100.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/1024.png index e2b1b5b9..8e5ab540 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/1024.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/114.png index 1b95e1a9..efa3aab9 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/114.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/120.png index 9c700c6f..7292ffa4 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/120.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/144.png index c5c24c75..0272ad78 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/144.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/152.png index 4dfab833..9af43eb6 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/152.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/167.png index be1df57c..3f0db64f 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/167.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/180.png index 16b832d1..d0df52c7 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/180.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/20.png index f8922382..74e1923e 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/20.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/29.png index 4bf513c4..de76cd56 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/29.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/40.png index 69566787..d074c7a5 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/40.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/50.png index 7217a060..3ff7c180 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/50.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/57.png index cfa791f7..d0c93c9c 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/57.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/58.png index 1b55b774..68b30de5 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/58.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/60.png index 9cf48da5..09c208d6 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/60.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/72.png index 3c46f748..1a077882 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/72.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/76.png index bda251ef..04731a3a 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/76.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/80.png index 5602cf8c..2e8c162f 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/80.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/87.png index 7239401d..946147a1 100644 Binary files a/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/87.png and b/SOOUM/SOOUM/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bell_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bell_filled.imageset/Contents.json new file mode 100644 index 00000000..4c288709 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bell_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "bell_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bell_filled.imageset/bell_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bell_filled.imageset/bell_filled.svg new file mode 100644 index 00000000..09495b53 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bell_filled.imageset/bell_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bomb_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bomb_filled.imageset/Contents.json new file mode 100644 index 00000000..175093d5 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bomb_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_bomb_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bomb_filled.imageset/v2_bomb_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bomb_filled.imageset/v2_bomb_filled.svg new file mode 100644 index 00000000..598fed4b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_bomb_filled.imageset/v2_bomb_filled.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_camera_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_camera_filled.imageset/Contents.json new file mode 100644 index 00000000..9f1e6b16 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_camera_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "camera_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_camera_filled.imageset/camera_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_camera_filled.imageset/camera_filled.svg new file mode 100644 index 00000000..f3e7e38f --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_camera_filled.imageset/camera_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_card_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_card_filled.imageset/Contents.json new file mode 100644 index 00000000..4b20aff6 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_card_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_card_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_card_filled.imageset/v2_card_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_card_filled.imageset/v2_card_filled.svg new file mode 100644 index 00000000..d7957eaa --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_card_filled.imageset/v2_card_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_danger_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_danger_filled.imageset/Contents.json new file mode 100644 index 00000000..80030d64 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_danger_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "danger_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_danger_filled.imageset/danger_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_danger_filled.imageset/danger_filled.svg new file mode 100644 index 00000000..ab0afdff --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_danger_filled.imageset/danger_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_headset_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_headset_filled.imageset/Contents.json new file mode 100644 index 00000000..82cc5f6c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_headset_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_headset_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_headset_filled.imageset/v2_headset_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_headset_filled.imageset/v2_headset_outlined.svg new file mode 100644 index 00000000..e9df593a --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_headset_filled.imageset/v2_headset_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_heart_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_heart_filled.imageset/Contents.json new file mode 100644 index 00000000..0788c549 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_heart_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "heart_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_heart_filled.imageset/heart_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_heart_filled.imageset/heart_filled.svg new file mode 100644 index 00000000..70efc3b8 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_heart_filled.imageset/heart_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/Contents.json new file mode 100644 index 00000000..e099b949 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_hide_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/v2_hide_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/v2_hide_filled.svg new file mode 100644 index 00000000..db8d044c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/v2_hide_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_home_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_home_filled.imageset/Contents.json new file mode 100644 index 00000000..2da6cbcb --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_home_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "home_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_home_filled.imageset/home_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_home_filled.imageset/home_filled.svg new file mode 100644 index 00000000..a9a8d688 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_home_filled.imageset/home_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_image_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_image_filled.imageset/Contents.json new file mode 100644 index 00000000..d5813415 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_image_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "image_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_image_filled.imageset/image_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_image_filled.imageset/image_filled.svg new file mode 100644 index 00000000..666130cf --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_image_filled.imageset/image_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_info_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_info_filled.imageset/Contents.json new file mode 100644 index 00000000..29b2fb8e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_info_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Info_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_info_filled.imageset/Info_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_info_filled.imageset/Info_filled.svg new file mode 100644 index 00000000..2b426491 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_info_filled.imageset/Info_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_location_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_location_filled.imageset/Contents.json new file mode 100644 index 00000000..3fc0069d --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_location_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "location_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_location_filled.imageset/location_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_location_filled.imageset/location_filled.svg new file mode 100644 index 00000000..e32e9ba2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_location_filled.imageset/location_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_lock_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_lock_filled.imageset/Contents.json new file mode 100644 index 00000000..8b2af3a6 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_lock_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_lock_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_lock_filled.imageset/v2_lock_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_lock_filled.imageset/v2_lock_filled.svg new file mode 100644 index 00000000..1d0b392c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_lock_filled.imageset/v2_lock_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_mail_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_mail_filled.imageset/Contents.json new file mode 100644 index 00000000..d0d11681 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_mail_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_mail_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_mail_filled.imageset/v2_mail_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_mail_filled.imageset/v2_mail_filled.svg new file mode 100644 index 00000000..9d46a7de --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_mail_filled.imageset/v2_mail_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_circle_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_circle_filled.imageset/Contents.json new file mode 100644 index 00000000..93b3da70 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_circle_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "message_circle_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_circle_filled.imageset/message_circle_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_circle_filled.imageset/message_circle_filled.svg new file mode 100644 index 00000000..8d30ff97 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_circle_filled.imageset/message_circle_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_square_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_square_filled.imageset/Contents.json new file mode 100644 index 00000000..6d1069e1 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_square_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "message_square_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_square_filled.imageset/message_square_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_square_filled.imageset/message_square_filled.svg new file mode 100644 index 00000000..f0b849d4 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_message_square_filled.imageset/message_square_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_notice_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_notice_filled.imageset/Contents.json new file mode 100644 index 00000000..3546ec6e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_notice_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_notice_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_notice_filled.imageset/v2_notice_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_notice_filled.imageset/v2_notice_filled.svg new file mode 100644 index 00000000..b3e8dfd4 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_notice_filled.imageset/v2_notice_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_official_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_official_filled.imageset/Contents.json new file mode 100644 index 00000000..c600a588 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_official_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_official_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_official_filled.imageset/v2_official_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_official_filled.imageset/v2_official_filled.svg new file mode 100644 index 00000000..d48dff46 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_official_filled.imageset/v2_official_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_settings_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_settings_filled.imageset/Contents.json new file mode 100644 index 00000000..671f5aed --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_settings_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "settings_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_settings_filled.imageset/settings_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_settings_filled.imageset/settings_filled.svg new file mode 100644 index 00000000..0c58b0ee --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_settings_filled.imageset/settings_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_star_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_star_filled.imageset/Contents.json new file mode 100644 index 00000000..9ea53472 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_star_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "star_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_star_filled.imageset/star_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_star_filled.imageset/star_filled.svg new file mode 100644 index 00000000..deee1483 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_star_filled.imageset/star_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tag_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tag_filled.imageset/Contents.json new file mode 100644 index 00000000..ac1f327b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tag_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_tag_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tag_filled.imageset/v2_tag_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tag_filled.imageset/v2_tag_filled.svg new file mode 100644 index 00000000..fad666d7 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tag_filled.imageset/v2_tag_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_time_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_time_filled.imageset/Contents.json new file mode 100644 index 00000000..4160db24 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_time_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "time_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_time_filled.imageset/time_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_time_filled.imageset/time_filled.svg new file mode 100644 index 00000000..d7352164 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_time_filled.imageset/time_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tool_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tool_filled.imageset/Contents.json new file mode 100644 index 00000000..5690862e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tool_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_tool_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tool_filled.imageset/v2_tool_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tool_filled.imageset/v2_tool_filled.svg new file mode 100644 index 00000000..5adc5336 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_tool_filled.imageset/v2_tool_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_trash_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_trash_filled.imageset/Contents.json new file mode 100644 index 00000000..c5bad132 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_trash_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "trash_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_trash_filled.imageset/trash_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_trash_filled.imageset/trash_filled.svg new file mode 100644 index 00000000..e030089b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_trash_filled.imageset/trash_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_user_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_user_filled.imageset/Contents.json new file mode 100644 index 00000000..032e3249 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_user_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "user_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_user_filled.imageset/user_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_user_filled.imageset/user_filled.svg new file mode 100644 index 00000000..72e3cee5 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_user_filled.imageset/user_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_users_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_users_filled.imageset/Contents.json new file mode 100644 index 00000000..a8f88f06 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_users_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_users_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_users_filled.imageset/v2_users_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_users_filled.imageset/v2_users_filled.svg new file mode 100644 index 00000000..32cfd80d --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_users_filled.imageset/v2_users_filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_write_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_write_filled.imageset/Contents.json new file mode 100644 index 00000000..233021fa --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_write_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_write_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_write_filled.imageset/v2_write_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_write_filled.imageset/v2_write_filled.svg new file mode 100644 index 00000000..5e9faec4 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_write_filled.imageset/v2_write_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_bell_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_bell_outlined.imageset/Contents.json new file mode 100644 index 00000000..ffad4de9 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_bell_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "bell_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_bell_outlined.imageset/bell_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_bell_outlined.imageset/bell_outlined.svg new file mode 100644 index 00000000..586c2bba --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_bell_outlined.imageset/bell_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_camera_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_camera_outlined.imageset/Contents.json new file mode 100644 index 00000000..9d466ff4 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_camera_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "camera_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_camera_outlined.imageset/camera_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_camera_outlined.imageset/camera_outlined.svg new file mode 100644 index 00000000..73afffa0 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_camera_outlined.imageset/camera_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_check_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_check_outlined.imageset/Contents.json new file mode 100644 index 00000000..8ea481f6 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_check_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "check_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_check_outlined.imageset/check_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_check_outlined.imageset/check_outlined.svg new file mode 100644 index 00000000..d940ee8e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_check_outlined.imageset/check_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_danger_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_danger_outlined.imageset/Contents.json new file mode 100644 index 00000000..76f2f52d --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_danger_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "danger_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_danger_outlined.imageset/danger_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_danger_outlined.imageset/danger_outlined.svg new file mode 100644 index 00000000..e6022bed --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_danger_outlined.imageset/danger_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_full_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_full_outlined.imageset/Contents.json new file mode 100644 index 00000000..64a963a9 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_full_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_delete_full_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_full_outlined.imageset/v2_delete_full_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_full_outlined.imageset/v2_delete_full_outlined.svg new file mode 100644 index 00000000..5751f166 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_full_outlined.imageset/v2_delete_full_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_outlined.imageset/Contents.json new file mode 100644 index 00000000..20ea4b4c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_delete_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_outlined.imageset/v2_delete_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_outlined.imageset/v2_delete_outlined.svg new file mode 100644 index 00000000..7ec96ff0 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_delete_outlined.imageset/v2_delete_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_down_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_down_outlined.imageset/Contents.json new file mode 100644 index 00000000..13f6239e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_down_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "down_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_down_outlined.imageset/down_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_down_outlined.imageset/down_outlined.svg new file mode 100644 index 00000000..989a805f --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_down_outlined.imageset/down_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_error_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_error_outlined.imageset/Contents.json new file mode 100644 index 00000000..c7cae5ea --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_error_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "error_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_error_outlined.imageset/error_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_error_outlined.imageset/error_outlined.svg new file mode 100644 index 00000000..5bd5874a --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_error_outlined.imageset/error_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_eye_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_eye_outlined.imageset/Contents.json new file mode 100644 index 00000000..de4398ae --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_eye_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_eye_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_eye_outlined.imageset/v2_eye_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_eye_outlined.imageset/v2_eye_outlined.svg new file mode 100644 index 00000000..9d2ecb9e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_eye_outlined.imageset/v2_eye_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_flag_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_flag_outlined.imageset/Contents.json new file mode 100644 index 00000000..9c3b26bd --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_flag_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_flag_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_flag_outlined.imageset/v2_flag_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_flag_outlined.imageset/v2_flag_outlined.svg new file mode 100644 index 00000000..a34d660f --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_flag_outlined.imageset/v2_flag_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hash_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hash_outlined.imageset/Contents.json new file mode 100644 index 00000000..ba573410 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hash_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_hash_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hash_outlined.imageset/v2_hash_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hash_outlined.imageset/v2_hash_outlined.svg new file mode 100644 index 00000000..df14108c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hash_outlined.imageset/v2_hash_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_heart_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_heart_outlined.imageset/Contents.json new file mode 100644 index 00000000..cf085279 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_heart_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "heart_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_heart_outlined.imageset/heart_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_heart_outlined.imageset/heart_outlined.svg new file mode 100644 index 00000000..693e0556 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_heart_outlined.imageset/heart_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hide_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hide_outlined.imageset/Contents.json new file mode 100644 index 00000000..fc81bb7b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hide_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_hide_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hide_outlined.imageset/v2_hide_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hide_outlined.imageset/v2_hide_outlined.svg new file mode 100644 index 00000000..c7afbf8f --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_hide_outlined.imageset/v2_hide_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_home_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_home_outlined.imageset/Contents.json new file mode 100644 index 00000000..858581e7 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_home_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "home_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_home_outlined.imageset/home_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_home_outlined.imageset/home_outlined.svg new file mode 100644 index 00000000..8c2152ac --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_home_outlined.imageset/home_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_image_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_image_outlined.imageset/Contents.json new file mode 100644 index 00000000..09d24c07 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_image_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "image_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_image_outlined.imageset/image_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_image_outlined.imageset/image_outlined.svg new file mode 100644 index 00000000..e13eb67c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_image_outlined.imageset/image_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_left_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_left_outlined.imageset/Contents.json new file mode 100644 index 00000000..9dbd3c2c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_left_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "left_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_left_outlined.imageset/left_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_left_outlined.imageset/left_outlined.svg new file mode 100644 index 00000000..abe68d69 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_left_outlined.imageset/left_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_location_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_location_outlined.imageset/Contents.json new file mode 100644 index 00000000..29d82ed7 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_location_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "location_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_location_outlined.imageset/location_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_location_outlined.imageset/location_outlined.svg new file mode 100644 index 00000000..37fe871e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_location_outlined.imageset/location_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_circle_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_circle_outlined.imageset/Contents.json new file mode 100644 index 00000000..465a538f --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_circle_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "message_circle_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_circle_outlined.imageset/message_circle_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_circle_outlined.imageset/message_circle_outlined.svg new file mode 100644 index 00000000..4d32a4fc --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_circle_outlined.imageset/message_circle_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_square_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_square_outlined.imageset/Contents.json new file mode 100644 index 00000000..4edcf321 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_square_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "message_square_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_square_outlined.imageset/message_square_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_square_outlined.imageset/message_square_outlined.svg new file mode 100644 index 00000000..8cd29b43 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_message_square_outlined.imageset/message_square_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_more_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_more_outlined.imageset/Contents.json new file mode 100644 index 00000000..86ea4115 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_more_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_more_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_more_outlined.imageset/v2_more_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_more_outlined.imageset/v2_more_outlined.svg new file mode 100644 index 00000000..77befe28 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_more_outlined.imageset/v2_more_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_plus_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_plus_outlined.imageset/Contents.json new file mode 100644 index 00000000..3f77fb15 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_plus_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "plus_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_plus_outlined.imageset/plus_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_plus_outlined.imageset/plus_outlined.svg new file mode 100644 index 00000000..f28f1c62 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_plus_outlined.imageset/plus_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_right_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_right_outlined.imageset/Contents.json new file mode 100644 index 00000000..711cb869 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_right_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "right_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_right_outlined.imageset/right_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_right_outlined.imageset/right_outlined.svg new file mode 100644 index 00000000..5acef760 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_right_outlined.imageset/right_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_search_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_search_outlined.imageset/Contents.json new file mode 100644 index 00000000..f667080b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_search_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "search_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_search_outlined.imageset/search_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_search_outlined.imageset/search_outlined.svg new file mode 100644 index 00000000..79467171 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_search_outlined.imageset/search_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_settings_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_settings_outlined.imageset/Contents.json new file mode 100644 index 00000000..3023f1a2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_settings_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "settings_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_settings_outlined.imageset/settings_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_settings_outlined.imageset/settings_outlined.svg new file mode 100644 index 00000000..655a31e2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_settings_outlined.imageset/settings_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_star_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_star_outlined.imageset/Contents.json new file mode 100644 index 00000000..6ef99b3b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_star_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "star_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_star_outlined.imageset/star_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_star_outlined.imageset/star_outlined.svg new file mode 100644 index 00000000..e1d88da9 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_star_outlined.imageset/star_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_swap_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_swap_outlined.imageset/Contents.json new file mode 100644 index 00000000..861d0d59 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_swap_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "swap_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_swap_outlined.imageset/swap_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_swap_outlined.imageset/swap_outlined.svg new file mode 100644 index 00000000..61b64fcc --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_swap_outlined.imageset/swap_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_tag_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_tag_outlined.imageset/Contents.json new file mode 100644 index 00000000..0760205b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_tag_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tag_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_tag_outlined.imageset/tag_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_tag_outlined.imageset/tag_outlined.svg new file mode 100644 index 00000000..7d69d7d0 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_tag_outlined.imageset/tag_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_time_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_time_outlined.imageset/Contents.json new file mode 100644 index 00000000..e6d5843a --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_time_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "time_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_time_outlined.imageset/time_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_time_outlined.imageset/time_outlined.svg new file mode 100644 index 00000000..e7b1e124 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_time_outlined.imageset/time_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/Contents.json new file mode 100644 index 00000000..0d3a2317 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_timer_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/v2_timer_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/v2_timer_outlined.svg new file mode 100644 index 00000000..cd0f74a2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/v2_timer_outlined.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_trash_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_trash_outlined.imageset/Contents.json new file mode 100644 index 00000000..f7490657 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_trash_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "trash_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_trash_outlined.imageset/trash_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_trash_outlined.imageset/trash_outlined.svg new file mode 100644 index 00000000..8e652075 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_trash_outlined.imageset/trash_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_up_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_up_outlined.imageset/Contents.json new file mode 100644 index 00000000..a379f110 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_up_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "up_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_up_outlined.imageset/up_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_up_outlined.imageset/up_outlined.svg new file mode 100644 index 00000000..b1e4f4e3 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_up_outlined.imageset/up_outlined.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_user_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_user_outlined.imageset/Contents.json new file mode 100644 index 00000000..32ae4010 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_user_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "user_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_user_outlined.imageset/user_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_user_outlined.imageset/user_outlined.svg new file mode 100644 index 00000000..58bfe2d2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_user_outlined.imageset/user_outlined.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_write_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_write_outlined.imageset/Contents.json new file mode 100644 index 00000000..50bf40af --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_write_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "write_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_write_outlined.imageset/write_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_write_outlined.imageset/write_outlined.svg new file mode 100644 index 00000000..a33731db --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_write_outlined.imageset/write_outlined.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_check_square_light.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_check_square_light.imageset/Contents.json new file mode 100644 index 00000000..455aa1f2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_check_square_light.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "check_square_light.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_check_square_light.imageset/check_square_light.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_check_square_light.imageset/check_square_light.svg new file mode 100644 index 00000000..71c2689f --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_check_square_light.imageset/check_square_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_detail_delete_card.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_detail_delete_card.imageset/Contents.json new file mode 100644 index 00000000..5e67ee0b --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_detail_delete_card.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_detail_delete_card.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_detail_delete_card.imageset/v2_detail_delete_card.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_detail_delete_card.imageset/v2_detail_delete_card.svg new file mode 100644 index 00000000..bbe83a8c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_detail_delete_card.imageset/v2_detail_delete_card.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/Contents.json new file mode 100644 index 00000000..5949e4d6 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/Contents.json @@ -0,0 +1,27 @@ +{ + "images" : [ + { + "filename" : "v2_guide_write_card.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "v2_guide_write_card_2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "v2_guide_write_card_3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card.png b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card.png new file mode 100644 index 00000000..964ecd04 Binary files /dev/null and b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card_2x.png b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card_2x.png new file mode 100644 index 00000000..21847b8b Binary files /dev/null and b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card_2x.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card_3x.png b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card_3x.png new file mode 100644 index 00000000..f63b1dc1 Binary files /dev/null and b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_guide_write_card.imageset/v2_guide_write_card_3x.png differ diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_message_tail.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_message_tail.imageset/Contents.json new file mode 100644 index 00000000..12d9bff3 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_message_tail.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_message_tail.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_message_tail.imageset/v2_message_tail.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_message_tail.imageset/v2_message_tail.svg new file mode 100644 index 00000000..1799927d --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_message_tail.imageset/v2_message_tail.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding.imageset/Contents.json new file mode 100644 index 00000000..b4297bf9 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_onboarding.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding.imageset/v2_onboarding.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding.imageset/v2_onboarding.svg new file mode 100644 index 00000000..9ce0de0c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding.imageset/v2_onboarding.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding_finish.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding_finish.imageset/Contents.json new file mode 100644 index 00000000..1ef0aae8 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding_finish.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "onboarding_finish.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding_finish.imageset/onboarding_finish.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding_finish.imageset/onboarding_finish.svg new file mode 100644 index 00000000..eecb62f3 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_onboarding_finish.imageset/onboarding_finish.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_home.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_home.imageset/Contents.json new file mode 100644 index 00000000..386dd753 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_home.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_placeholder.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_home.imageset/v2_placeholder.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_home.imageset/v2_placeholder.svg new file mode 100644 index 00000000..d6642707 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_home.imageset/v2_placeholder.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_notification.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_notification.imageset/Contents.json new file mode 100644 index 00000000..d8ef08aa --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_notification.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_placeholder_notification.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_notification.imageset/v2_placeholder_notification.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_notification.imageset/v2_placeholder_notification.svg new file mode 100644 index 00000000..42bbd340 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_placeholder_notification.imageset/v2_placeholder_notification.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_prev_card_button.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_prev_card_button.imageset/Contents.json new file mode 100644 index 00000000..45914047 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_prev_card_button.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_prev_card_button.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_prev_card_button.imageset/v2_prev_card_button.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_prev_card_button.imageset/v2_prev_card_button.svg new file mode 100644 index 00000000..b7427f5e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_prev_card_button.imageset/v2_prev_card_button.svg @@ -0,0 +1,3 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_large.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_large.imageset/Contents.json new file mode 100644 index 00000000..76a87433 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_large.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_profile_large.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_large.imageset/v2_profile_large.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_large.imageset/v2_profile_large.svg new file mode 100644 index 00000000..3d173cab --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_large.imageset/v2_profile_large.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/Contents.json new file mode 100644 index 00000000..51caedca --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_profile_medium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/v2_profile_medium.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/v2_profile_medium.svg new file mode 100644 index 00000000..d721e427 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/v2_profile_medium.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_small.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_small.imageset/Contents.json new file mode 100644 index 00000000..60e6962e --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_small.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_profile_small.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_small.imageset/v2_profile_small.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_small.imageset/v2_profile_small.svg new file mode 100644 index 00000000..376f6140 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_small.imageset/v2_profile_small.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_black.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_black.imageset/Contents.json new file mode 100644 index 00000000..6cf38bbf --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_black.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_logo_black.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_black.imageset/v2_logo_black.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_black.imageset/v2_logo_black.svg new file mode 100644 index 00000000..bdeabc05 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_black.imageset/v2_logo_black.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_white.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_white.imageset/Contents.json new file mode 100644 index 00000000..c25e3ab6 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_white.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "logo_white.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_white.imageset/logo_white.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_white.imageset/logo_white.svg new file mode 100644 index 00000000..e9ed230c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Logos/v2_logo_white.imageset/logo_white.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Resources/Base.lproj/LaunchScreen.storyboard b/SOOUM/SOOUM/Resources/Base.lproj/LaunchScreen.storyboard index 865e9329..bb416cf3 100644 --- a/SOOUM/SOOUM/Resources/Base.lproj/LaunchScreen.storyboard +++ b/SOOUM/SOOUM/Resources/Base.lproj/LaunchScreen.storyboard @@ -1,7 +1,9 @@ - - + + + - + + @@ -11,10 +13,23 @@ - + - + + + + + + + + + + + + + + @@ -22,4 +37,7 @@ + + + diff --git a/SOOUM/SOOUM/Resources/Develop/Info-dev.plist b/SOOUM/SOOUM/Resources/Develop/Info-dev.plist index 1d47e318..45391eec 100644 --- a/SOOUM/SOOUM/Resources/Develop/Info-dev.plist +++ b/SOOUM/SOOUM/Resources/Develop/Info-dev.plist @@ -2,33 +2,17 @@ - NSAppTransportSecurity - - NSExceptionDomains - - ec2-52-79-234-222.ap-northeast-2.compute.amazonaws.com - - NSExceptionAllowsInsecureHTTPLoads - - NSExceptionRequiresForwardSecrecy - - NSIncludesSubdomains - - NSTemporaryExceptionMinimumTLSVersion - TLSv1.2 - - - - ClarityId - $(SOOUM_CLARITY_ID) AppId $(SOOUM_APP_ID) + ClarityId + $(SOOUM_CLARITY_ID) ServerEndpoint $(SOOUM_SERVER_ENDPOINT) UIAppFonts - Hakgyoansim-Bold.ttf - Hakgyoansim-Light.ttf + RIDIBatang.otf + Yoonwoo.ttf + Kkukkkuk.ttf PretendardVariable.ttf UIApplicationSceneManifest diff --git a/SOOUM/SOOUM/Resources/Font/Kkukkkuk.ttf b/SOOUM/SOOUM/Resources/Font/Kkukkkuk.ttf new file mode 100644 index 00000000..2a3fd19a Binary files /dev/null and b/SOOUM/SOOUM/Resources/Font/Kkukkkuk.ttf differ diff --git a/SOOUM/SOOUM/Resources/PretendardVariable.ttf b/SOOUM/SOOUM/Resources/Font/PretendardVariable.ttf similarity index 100% rename from SOOUM/SOOUM/Resources/PretendardVariable.ttf rename to SOOUM/SOOUM/Resources/Font/PretendardVariable.ttf diff --git a/SOOUM/SOOUM/Resources/Font/RIDIBatang.otf b/SOOUM/SOOUM/Resources/Font/RIDIBatang.otf new file mode 100644 index 00000000..a213e491 Binary files /dev/null and b/SOOUM/SOOUM/Resources/Font/RIDIBatang.otf differ diff --git a/SOOUM/SOOUM/Resources/Font/Yoonwoo.ttf b/SOOUM/SOOUM/Resources/Font/Yoonwoo.ttf new file mode 100644 index 00000000..8c70ec51 Binary files /dev/null and b/SOOUM/SOOUM/Resources/Font/Yoonwoo.ttf differ diff --git a/SOOUM/SOOUM/Resources/Hakgyoansim-Bold.ttf b/SOOUM/SOOUM/Resources/Hakgyoansim-Bold.ttf deleted file mode 100644 index b4e71735..00000000 Binary files a/SOOUM/SOOUM/Resources/Hakgyoansim-Bold.ttf and /dev/null differ diff --git a/SOOUM/SOOUM/Resources/Hakgyoansim-Light.ttf b/SOOUM/SOOUM/Resources/Hakgyoansim-Light.ttf deleted file mode 100644 index 19628851..00000000 Binary files a/SOOUM/SOOUM/Resources/Hakgyoansim-Light.ttf and /dev/null differ diff --git a/SOOUM/SOOUM/Resources/loading_indicator_lottie.json b/SOOUM/SOOUM/Resources/loading_indicator_lottie.json new file mode 100644 index 00000000..ee0160b0 --- /dev/null +++ b/SOOUM/SOOUM/Resources/loading_indicator_lottie.json @@ -0,0 +1 @@ +{"nm":"Main Scene","ddd":0,"h":60,"w":60,"meta":{"g":"@lottiefiles/creator 1.47.3"},"layers":[{"ty":4,"nm":"Shape Layer 1","sr":1,"st":1,"op":33,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[39.30069732666017,-18.2509729862213]},"s":{"a":0,"k":[40,40]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[45.720278930664065,22.69961080551148]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Ellipse Path 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":1,"k":[{"o":{"x":0.561,"y":0.016},"i":{"x":0.439,"y":1.017},"s":[100],"t":-1.00000004073083},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1],"t":1},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[15],"t":6},{"s":[100],"t":28}],"ix":2},"o":{"a":0,"k":0,"ix":3},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.68,"y":0.19},"s":[0],"t":0},{"o":{"x":0.55,"y":0.06},"i":{"x":1,"y":1},"s":[0],"t":6},{"o":{"x":0,"y":0},"i":{"x":0.36,"y":1},"s":[90],"t":28},{"s":[99],"t":33}],"ix":1},"m":1},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":16},"c":{"a":0,"k":[0.1255,0.7765,0.9255]}}],"ind":1}],"v":"5.7.0","fr":29.9700012207031,"op":33,"ip":0,"assets":[]} \ No newline at end of file diff --git a/SOOUM/SOOUM/Resources/refrech_control_lottie.json b/SOOUM/SOOUM/Resources/refrech_control_lottie.json new file mode 100644 index 00000000..2ac24e1b --- /dev/null +++ b/SOOUM/SOOUM/Resources/refrech_control_lottie.json @@ -0,0 +1 @@ +{"nm":"Main Scene","ddd":0,"h":44,"w":44,"meta":{"g":"@lottiefiles/creator 1.47.3"},"layers":[{"ty":4,"nm":"Shape Layer 1","sr":1,"st":1,"op":33,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[19.33671903610231,19.528985500335693]},"s":{"a":0,"k":[40,40]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[29.734687614440922,29.811594200134277]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Ellipse Path 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]}},{"ty":"tm","bm":0,"hd":false,"mn":"ADBE Vector Filter - Trim","nm":"Trim Paths 1","ix":2,"e":{"a":1,"k":[{"o":{"x":0.561,"y":0.016},"i":{"x":0.439,"y":1.017},"s":[100],"t":-1.00000004073083},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[1],"t":1},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[15],"t":6},{"s":[100],"t":28}],"ix":2},"o":{"a":0,"k":0,"ix":3},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.68,"y":0.19},"s":[0],"t":0},{"o":{"x":0.55,"y":0.06},"i":{"x":1,"y":1},"s":[0],"t":6},{"o":{"x":0,"y":0},"i":{"x":0.36,"y":1},"s":[90],"t":28},{"s":[99],"t":33}],"ix":1},"m":1},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.1255,0.7765,0.9255]}}],"ind":1},{"ty":0,"nm":"Nested Scene 1","sr":1,"st":0,"op":33,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[30,30]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[22,22]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"w":60,"h":60,"refId":"precomp_newScene_1beb0f4e-74e1-40f2-b77d-ab5a4d6c6cb4","ind":2}],"v":"5.7.0","fr":29.9700012207031,"op":33,"ip":0,"assets":[{"nm":"Nested Scene 1","id":"precomp_newScene_1beb0f4e-74e1-40f2-b77d-ab5a4d6c6cb4","fr":29.9700012207031,"layers":[{"ty":4,"nm":"Ellipse 1","sr":1,"st":0,"op":33,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[40,40]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[30,30]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":20}},"shapes":[{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Path 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.1255,0.7765,0.9255]}}],"ind":1}]}]} \ No newline at end of file diff --git a/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_Request.swift b/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_Request.swift index f333b542..c9a2a7d3 100644 --- a/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_Request.swift +++ b/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_Request.swift @@ -17,12 +17,9 @@ protocol BaseRequest: URLRequestConvertible { var parameters: Parameters { get } var encoding: ParameterEncoding { get } var authorizationType: AuthorizationType { get } - var version: APIVersion { get } -} - -enum APIVersion: String { - case v1 = "" - case v2 = "/v2" + + // TODO: 앱 심사 중 사용할 endpoint + var serverEndpoint: String { get } } enum AuthorizationType: String { diff --git a/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_constants.swift b/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_constants.swift index 06e12a11..5bc67984 100644 --- a/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_constants.swift +++ b/SOOUM/SOOUM/Utilities/Alamofire/Alamofire_constants.swift @@ -15,11 +15,7 @@ struct Constants { } static var endpoint: String { - #if DEVELOP - return self.serverEndpoint(scheme: "http://") - #elseif PRODUCTION return self.serverEndpoint(scheme: "https://") - #endif } enum HTTPHeader: String { diff --git a/SOOUM/SOOUM/Utilities/AuthKeyChain.swift b/SOOUM/SOOUM/Utilities/AuthKeyChain.swift index 30e5adfa..47dfdcf1 100644 --- a/SOOUM/SOOUM/Utilities/AuthKeyChain.swift +++ b/SOOUM/SOOUM/Utilities/AuthKeyChain.swift @@ -56,10 +56,25 @@ class AuthKeyChain { ] var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess - - if status, let data = result as? Data { return data } - return nil + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecSuccess { + if let data = result as? Data { + return data + } else { + Log.error("Keychain Load Success, but Data Casting Failed for: \(tokenType.rawValue). Result type: \(type(of: result))") + return nil + } + } else { + let errorDescription = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown error" + Log.error(""" + Keychain Load Failed! + TokenType: \(tokenType.rawValue) + Status Code (OSStatus): \(status) + Error Description: \(errorDescription) + Query Service: \(self.service) + """) + return nil + } } /// 키체인에서 특정 토큰을 삭제 diff --git a/SOOUM/SOOUM/Utilities/GAHelper/AnalyticsEventProtocol.swift b/SOOUM/SOOUM/Utilities/GAHelper/AnalyticsEventProtocol.swift new file mode 100644 index 00000000..ff472345 --- /dev/null +++ b/SOOUM/SOOUM/Utilities/GAHelper/AnalyticsEventProtocol.swift @@ -0,0 +1,56 @@ +// +// AnalyticsEventProtocol.swift +// SOOUM +// +// Created by JDeoks on 3/11/25. +// + +protocol AnalyticsEventProtocol { + var eventName: String { get } + var parameters: [String: FirebaseLoggable]? { get } +} + +extension AnalyticsEventProtocol { + + var eventName: String { + let components = String(describing: type(of: self)).split(separator: ".").map { String($0) } + let enumName = components.last ?? "" + + let caseName = "\(self)".components(separatedBy: "(").first ?? "" + + return "\(enumName)_\(caseName)" + } + + var parameters: [String: FirebaseLoggable]? { + let mirror = Mirror(reflecting: self) + + guard let child = mirror.children.first else { return nil } + + // 튜플의 경우 + if Mirror(reflecting: child.value).displayStyle == .tuple { + + var dict = [String: FirebaseLoggable]() + let tupleMirror = Mirror(reflecting: child.value) + + // 튜플 안의 각 파라미터를 순회 + for tupleChild in tupleMirror.children { + guard let paramLabel = tupleChild.label else { continue } + // FirebaseLoggable 타입 검사 + if let loggableValue = tupleChild.value as? any FirebaseLoggable { + dict[paramLabel] = loggableValue + } + } + return dict.isEmpty ? nil : dict + // 단일 파라미터인 경우 + } else { + + guard let label = child.label else { return nil } + // FirebaseLoggable 타입 검사 + if let loggableValue = child.value as? any FirebaseLoggable { + return [label: loggableValue] + } + } + + return nil + } +} diff --git a/SOOUM/SOOUM/Managers/GAManager/FirebaseLoggable.swift b/SOOUM/SOOUM/Utilities/GAHelper/FirebaseLoggable.swift similarity index 79% rename from SOOUM/SOOUM/Managers/GAManager/FirebaseLoggable.swift rename to SOOUM/SOOUM/Utilities/GAHelper/FirebaseLoggable.swift index 2c106c1f..d97174e5 100644 --- a/SOOUM/SOOUM/Managers/GAManager/FirebaseLoggable.swift +++ b/SOOUM/SOOUM/Utilities/GAHelper/FirebaseLoggable.swift @@ -12,4 +12,5 @@ extension String: FirebaseLoggable {} extension Int: FirebaseLoggable {} extension Double: FirebaseLoggable {} extension Bool: FirebaseLoggable {} -extension Array: FirebaseLoggable where Element == String {} // [String]만 허용 +/// [String]만 허용 +extension Array: FirebaseLoggable where Element == String {} diff --git a/SOOUM/SOOUM/Utilities/GAHelper/GAHelper.swift b/SOOUM/SOOUM/Utilities/GAHelper/GAHelper.swift new file mode 100644 index 00000000..a9e5dfdd --- /dev/null +++ b/SOOUM/SOOUM/Utilities/GAHelper/GAHelper.swift @@ -0,0 +1,18 @@ +// +// GAHelper.swift +// SOOUM +// +// Created by JDeoks on 3/11/25. +// + +import FirebaseAnalytics + +final class GAHelper { + + static let shared = GAHelper() + private init() { } + + func logEvent(event: AnalyticsEventProtocol) { + Analytics.logEvent(event.eventName, parameters: event.parameters) + } +} diff --git a/SOOUM/SOOUM/Utilities/Info.swift b/SOOUM/SOOUM/Utilities/Info.swift index 644d5533..1cc74e6e 100644 --- a/SOOUM/SOOUM/Utilities/Info.swift +++ b/SOOUM/SOOUM/Utilities/Info.swift @@ -5,8 +5,7 @@ // Created by 오현식 on 1/6/25. // -import Foundation - +import UIKit enum Info { @@ -25,4 +24,12 @@ enum Info { static var clarityId: String { return self["ClarityId"]! } + + static var iOSVersion: String { + return UIDevice.current.systemVersion + } + + static var deviceModel: String { + return UIDevice.current.name + } } diff --git a/SOOUM/SOOUM/Utilities/SimpleCache.swift b/SOOUM/SOOUM/Utilities/SimpleCache.swift deleted file mode 100644 index 55ba000c..00000000 --- a/SOOUM/SOOUM/Utilities/SimpleCache.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// SimpleCache.swift -// SOOUM -// -// Created by 오현식 on 12/17/24. -// - -import Foundation - - -class SimpleCache { - - enum CardType: String { - case latest - case popular - case distance - } - - static let shared = SimpleCache() - - private let cache = NSCache() - - - // MARK: Main home - - private let mainHomeKey: String = "com.sooum.main.home" - - func loadMainHomeCards(type cardType: CardType) -> [Card]? { - let key: String = "\(self.mainHomeKey).\(cardType.rawValue)" - return self.cache.object(forKey: key as NSString) as? [Card] - } - - func saveMainHomeCards(type cardType: CardType, datas cards: [Card]) { - let key: String = "\(self.mainHomeKey).\(cardType.rawValue)" - self.cache.setObject(cards as NSArray, forKey: key as NSString) - } - - func isEmpty(type cardType: CardType) -> Bool { - return self.loadMainHomeCards(type: cardType) == nil - } - - func clear(type cardType: CardType) { - self.cache.removeAllObjects() - } -} diff --git a/SOOUM/SOOUM/Utilities/SimpleReachability.swift b/SOOUM/SOOUM/Utilities/SimpleReachability.swift new file mode 100644 index 00000000..6463c137 --- /dev/null +++ b/SOOUM/SOOUM/Utilities/SimpleReachability.swift @@ -0,0 +1,45 @@ +// +// SimpleReachability.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Network + +import RxCocoa +import RxSwift + +final class SimpleReachability { + + enum Text { + static let networkMoniterQueueLabel: String = "com.sooum.network.monitor.queue" + } + + static let shared = SimpleReachability() + + private let monitor = NWPathMonitor() + private let isConnect = BehaviorRelay(value: false) + + lazy var isConnected: Observable = { + return self.isConnect + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .distinctUntilChanged() + .asObservable() + .share(replay: 1, scope: .forever) + }() + + private init() { + + self.monitor.pathUpdateHandler = { [weak self] path in + let isAvailable = path.status == .satisfied + Log.info("Network is \(isAvailable ? "available" : "unavailable")") + self?.isConnect.accept(isAvailable) + } + self.monitor.start(queue: DispatchQueue(label: Text.networkMoniterQueueLabel, qos: .background)) + } + + deinit { + self.monitor.cancel() + } +} diff --git a/SOOUM/SOOUM/Utilities/SwiftEntryKit/SwiftEntryKit.swift b/SOOUM/SOOUM/Utilities/SwiftEntryKit/SwiftEntryKit.swift new file mode 100644 index 00000000..3c010817 --- /dev/null +++ b/SOOUM/SOOUM/Utilities/SwiftEntryKit/SwiftEntryKit.swift @@ -0,0 +1,36 @@ +// +// SwiftEntryKit.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import SwiftEntryKit + +protocol SwiftEntryKitExtension { + + var entryName: String? { get } + + /// *주의 : 반드시 display 직전에 올바른 entryName을 지정해야 함 + func show(with attributes: EKAttributes) + + func dismiss(_ completion: (() -> Void)?) + static func dismiss(_ completion: (() -> Void)?) +} + +extension SwiftEntryKitExtension { + + func dismiss(_ completion: (() -> Void)? = nil) { + if let entryName = self.entryName { + SwiftEntryKit.dismiss(.specific(entryName: entryName), with: completion) + } else { + Self.dismiss(completion) + } + } + + static func dismiss(_ completion: (() -> Void)? = nil) { + SwiftEntryKit.dismiss(.displayed, with: completion) + } +} diff --git a/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift b/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift new file mode 100644 index 00000000..fdd3d30c --- /dev/null +++ b/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift @@ -0,0 +1,126 @@ +// +// View+SwiftEntryKit.swift +// SOOUM +// +// Created by 오현식 on 9/12/25. +// + +import UIKit + +import SwiftEntryKit + +protocol SwiftEntryKitViewExtension: SwiftEntryKitExtension { + var afterView: () -> UIView? { get } +} + +protocol SwiftEntryKitViewBridge { + associatedtype Base + var sek: SwiftEntryKitViewWrapper { get } +} + +struct SwiftEntryKitViewWrapper: SwiftEntryKitViewExtension { + + var afterView: () -> UIView? + var entryName: String? + + init(closure: @escaping () -> UIView?) { + self.afterView = closure + } +} + +extension SwiftEntryKitViewExtension { + + func show(with attributes: EKAttributes) { + guard let view = self.afterView() else { return } + var attributes: EKAttributes = attributes + attributes.name = self.entryName + DispatchQueue.main.async { + SwiftEntryKit.display(entry: view, using: attributes) + } + } +} + +extension UIView: SwiftEntryKitViewBridge { + + var sek: SwiftEntryKitViewWrapper { + return .init { [weak self] in self } + } +} + +extension SwiftEntryKitViewWrapper where Base == UIView { + + func showBottomFloat( + screenColor: UIColor? = .som.v2.dim, + screenInteraction: EKAttributes.UserInteraction, + useSafeArea: Bool = true, + hasHandleBar: Bool = true, + workAtWillAppear: (() -> Void)? = nil, + completion: (() -> Void)? = nil + ) { + var attributes: EKAttributes = .bottomFloat + + if useSafeArea { + attributes.positionConstraints.safeArea = .overridden + } + + if let screenColor: UIColor = screenColor { + attributes.screenBackground = .color(color: .init(screenColor)) + } else { + attributes.screenBackground = .clear + } + + if hasHandleBar { + attributes.scroll = .edgeCrossingDisabled(swipeable: true) + } + + attributes.roundCorners = .all(radius: 20) + attributes.positionConstraints.verticalOffset = 34 + + attributes.entryBackground = .color(color: .init(.som.v2.white)) + + attributes.displayDuration = .infinity + attributes.entranceAnimation = .init(translate: .init(duration: 0.25)) + attributes.exitAnimation = .init(translate: .init(duration: 0.25)) + + attributes.entryInteraction = .forward + attributes.screenInteraction = screenInteraction + + attributes.lifecycleEvents.willAppear = workAtWillAppear + attributes.lifecycleEvents.willDisappear = completion + + self.show(with: attributes) + } + + func showBottomToast( + verticalOffset: CGFloat, + displayDuration: CGFloat = 7, + useSafeArea: Bool = true, + workAtWillAppear: (() -> Void)? = nil, + completion: (() -> Void)? = nil + ) { + var attributes: EKAttributes = .bottomToast + + if useSafeArea { + attributes.positionConstraints.safeArea = .overridden + } + + attributes.screenBackground = .clear + attributes.scroll = .edgeCrossingDisabled(swipeable: true) + + attributes.roundCorners = .all(radius: 6) + attributes.positionConstraints.verticalOffset = verticalOffset + + attributes.entryBackground = .color(color: .init(.som.v2.gray500)) + + attributes.displayDuration = displayDuration + attributes.entranceAnimation = .init(translate: .init(duration: 0.25)) + attributes.exitAnimation = .init(translate: .init(duration: 0.25)) + + attributes.entryInteraction = .forward + + attributes.lifecycleEvents.willAppear = workAtWillAppear + attributes.lifecycleEvents.willDisappear = completion + + self.show(with: attributes) + } +} diff --git a/SOOUM/SOOUM/Utilities/Typography/Typography.swift b/SOOUM/SOOUM/Utilities/Typography/Typography.swift index b297aa83..b771d91c 100644 --- a/SOOUM/SOOUM/Utilities/Typography/Typography.swift +++ b/SOOUM/SOOUM/Utilities/Typography/Typography.swift @@ -23,7 +23,7 @@ class Typography: NSObject, NSCopying { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = self.lineHeight paragraph.maximumLineHeight = self.lineHeight - /// Set alignment == center + /// initial alignment == center paragraph.alignment = self.alignment return paragraph } @@ -31,8 +31,7 @@ class Typography: NSObject, NSCopying { var attributes: [NSAttributedString.Key: Any] { let fontLineHeight = self.font.lineHeight let lineHeight = self.lineHeight - let adjustment: CGFloat = lineHeight > fontLineHeight ? 2.0 : 1.0 - let baselineOffset: CGFloat = (lineHeight - fontLineHeight) / 2.0 / adjustment + let baselineOffset: CGFloat = (lineHeight - fontLineHeight) / 2.0 return [ .paragraphStyle: self.paragraphStyle, .kern: self.letterSpacing, diff --git a/SOOUM/SOOUM/Utilities/Typography/UILabel+Typography.swift b/SOOUM/SOOUM/Utilities/Typography/UILabel+Typography.swift index 63fd6bc5..798f0e9b 100644 --- a/SOOUM/SOOUM/Utilities/Typography/UILabel+Typography.swift +++ b/SOOUM/SOOUM/Utilities/Typography/UILabel+Typography.swift @@ -10,40 +10,16 @@ import UIKit /// https://github.com/Geri-Borbas/iOS.Blog.UILabel_Typography_Extensions extension UILabel { - - fileprivate struct Keys { - static var kUILabelTypography: String = "kUILabelTypography" - static var kUILabelTextObserver: String = "kUILabelTextObserver" - - static func setObjctForTypo(_ typography: Typography) { - withUnsafePointer(to: Self.kUILabelTypography) { - objc_setAssociatedObject(self, $0, typography, .OBJC_ASSOCIATION_RETAIN) - } - } - static func getObjectForTypo() -> Typography? { - withUnsafePointer(to: Self.kUILabelTypography) { - objc_getAssociatedObject(self, $0) as? Typography - } - } - - static func setObjectForObserver(_ textObserver: TextObserver?) { - withUnsafePointer(to: Self.kUILabelTextObserver) { - objc_setAssociatedObject(self, $0, textObserver, .OBJC_ASSOCIATION_RETAIN) - } - } - static func getObjectForObserver() -> TextObserver? { - withUnsafePointer(to: Self.kUILabelTextObserver) { - objc_getAssociatedObject(self, $0) as? TextObserver - } - } - } + + private static var kUILabelTypography: UInt8 = 0 + private static var kUILabelTextObserver: UInt8 = 0 func setTypography( _ typography: Typography, with closure: ((NSMutableAttributedString) -> Void)? = nil ) { - Keys.setObjctForTypo(typography) + objc_setAssociatedObject(self, &Self.kUILabelTypography, typography, .OBJC_ASSOCIATION_RETAIN) self.font = typography.font @@ -73,6 +49,7 @@ extension UILabel { } } + /// When self.text == nil, must set typography when input text var typography: Typography? { set { if let typography: Typography = newValue { @@ -80,7 +57,7 @@ extension UILabel { } } get { - return Keys.getObjectForTypo() + return objc_getAssociatedObject(self, &Self.kUILabelTypography) as? Typography } } } @@ -92,11 +69,11 @@ extension UILabel { typealias TextChangeAction = (_ oldValue: String?, _ newValue: String?) -> Void fileprivate var observer: TextObserver? { - get { - Keys.getObjectForObserver() - } set { - Keys.setObjectForObserver(newValue) + objc_setAssociatedObject(self, &Self.kUILabelTextObserver, newValue, .OBJC_ASSOCIATION_RETAIN) + } + get { + return objc_getAssociatedObject(self, &Self.kUILabelTextObserver) as? TextObserver } } diff --git a/SOOUM/SOOUM/Utilities/Typography/UITextField+Typography.swift b/SOOUM/SOOUM/Utilities/Typography/UITextField+Typography.swift index af06beb0..38c9798a 100644 --- a/SOOUM/SOOUM/Utilities/Typography/UITextField+Typography.swift +++ b/SOOUM/SOOUM/Utilities/Typography/UITextField+Typography.swift @@ -9,47 +9,22 @@ import UIKit extension UITextField { - fileprivate struct Keys { - static var UITextFieldTypography: String = "UITextFieldTypography" - static var kUITextFieldConstraint: String = "kUITextFieldConstraint" - - static func setObjctForTypo(_ typography: Typography) { - withUnsafePointer(to: Self.UITextFieldTypography) { - objc_setAssociatedObject(self, $0, typography, .OBJC_ASSOCIATION_RETAIN) - } - } - static func getObjectForTypo() -> Typography? { - withUnsafePointer(to: Self.UITextFieldTypography) { - objc_getAssociatedObject(self, $0) as? Typography - } - } - - static func setObjectForConstraint(_ constraint: NSLayoutConstraint?) { - withUnsafePointer(to: Self.kUITextFieldConstraint) { - objc_setAssociatedObject(self, $0, constraint, .OBJC_ASSOCIATION_RETAIN) - } - } - - static func getObjectForConstraint() -> NSLayoutConstraint? { - withUnsafePointer(to: Self.kUITextFieldConstraint) { - objc_getAssociatedObject(self, $0) as? NSLayoutConstraint - } - } - } + private static var KUITextFieldTypography: UInt8 = 0 + private static var kUITextFieldConstraint: UInt8 = 0 func setTypography( _ typography: Typography, with closure: ((inout [NSAttributedString.Key: Any]) -> Void)? = nil ) { - Keys.setObjctForTypo(typography) + objc_setAssociatedObject(self, &Self.KUITextFieldTypography, typography, .OBJC_ASSOCIATION_RETAIN) if let constraint = self.constraint { constraint.constant = typography.lineHeight } else { - self.translatesAutoresizingMaskIntoConstraints = true + self.translatesAutoresizingMaskIntoConstraints = false let heightConstraint = self.heightAnchor.constraint(equalToConstant: typography.lineHeight) - heightConstraint.priority = .defaultHigh + heightConstraint.priority = .required heightConstraint.isActive = true self.constraint = heightConstraint } @@ -57,10 +32,12 @@ extension UITextField { var attributes: [NSAttributedString.Key: Any] = typography.attributes attributes.removeValue(forKey: .paragraphStyle) attributes[.font] = typography.font + attributes[.foregroundColor] = self.textColor closure?(&attributes) self.defaultTextAttributes = attributes } + /// When self.text == nil, must set typography when input text var typography: Typography? { set { if let typography: Typography = newValue { @@ -68,21 +45,16 @@ extension UITextField { } } get { - return Keys.getObjectForTypo() + return objc_getAssociatedObject(self, &Self.KUITextFieldTypography) as? Typography } } -} - -extension UITextField { - - static var kUITextFieldConstraint: String = "kUITextFieldConstraint" - - fileprivate var constraint: NSLayoutConstraint? { - get { - return Keys.getObjectForConstraint() - } + + private var constraint: NSLayoutConstraint? { set { - Keys.setObjectForConstraint(newValue) + objc_setAssociatedObject(self, &Self.kUITextFieldConstraint, newValue, .OBJC_ASSOCIATION_RETAIN) + } + get { + return objc_getAssociatedObject(self, &Self.kUITextFieldConstraint) as? NSLayoutConstraint } } } diff --git a/SOOUM/SOOUM/Utilities/Typography/UITextView+Typography.swift b/SOOUM/SOOUM/Utilities/Typography/UITextView+Typography.swift index 64e42b7a..87c22929 100644 --- a/SOOUM/SOOUM/Utilities/Typography/UITextView+Typography.swift +++ b/SOOUM/SOOUM/Utilities/Typography/UITextView+Typography.swift @@ -7,37 +7,38 @@ import UIKit - extension UITextView { - - fileprivate struct Keys { - static var kUITextViewTypography: String = "kUITextViewTypography" - - static func setObjctForTypo(_ typography: Typography) { - withUnsafePointer(to: Self.kUITextViewTypography) { - objc_setAssociatedObject(self, $0, typography, .OBJC_ASSOCIATION_RETAIN) - } - } - static func getObjectForTypo() -> Typography? { - withUnsafePointer(to: Self.kUITextViewTypography) { - objc_getAssociatedObject(self, $0) as? Typography - } - } - } + + private static var kUITextViewTypography: UInt8 = 0 func setTypography( _ typography: Typography, with closure: ((inout [NSAttributedString.Key: Any]) -> Void)? = nil ) { - Keys.setObjctForTypo(typography) + objc_setAssociatedObject(self, &Self.kUITextViewTypography, typography, .OBJC_ASSOCIATION_RETAIN) var attributes: [NSAttributedString.Key: Any] = typography.attributes + var baselineOffset = attributes[.baselineOffset] as! CGFloat + baselineOffset -= abs(typography.font.descender) + attributes.removeValue(forKey: .baselineOffset) + + attributes[.baselineOffset] = baselineOffset attributes[.font] = typography.font + attributes[.foregroundColor] = self.textColor closure?(&attributes) self.typingAttributes = attributes + + if let text = self.text, text.isEmpty == false { + let selectedRange = self.selectedRange + + let attributedText = NSMutableAttributedString(string: text, attributes: attributes) + self.attributedText = attributedText + self.selectedRange = selectedRange + } } + /// When self.text == nil, must set typography when input text var typography: Typography? { set { if let typography: Typography = newValue { @@ -45,7 +46,7 @@ extension UITextView { } } get { - return Keys.getObjectForTypo() + return objc_getAssociatedObject(self, &Self.kUITextViewTypography) as? Typography } } }