diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 32be413b..e834b177 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,8 +30,32 @@ Prefix [#이슈번호] 작업 설명 ## 📚 참고자료 -## 👀 기타 더 이야기해볼 점 +## 👀 리뷰어에게 전달할 사항 +## ✅ 이번 PR에서 이런 부분을 중점적으로 체크해주세요! + + + + +
+잠깐 확인하고 갈까요? + +- 들여쓰기를 5번 이하로 준수했는지, 코드 가독성이 적절한지 확인해주세요. +- 한 줄당 120자 제한을 준수했는지 확인해주세요. +- MARK 주석이 정해진 순서와 형식에 맞게 작성되었는지 확인해주세요. + +- 반복되는 상수 값이 있는지, 있다면 Constant enum으로 분리되어 있는지 확인해주세요. +- 삼항 연산자가 길어질 경우 적절히 개행되어 있는지 확인해주세요. +- 조건문에서 중괄호가 올바르게 사용되었는지 확인해주세요. +- 라이브러리 import가 퍼스트파티와 서드파티로 구분되고 알파벳순으로 정렬되었는지 확인해주세요. + +- 용량이 큰 리소스나 호출되지 않을 가능성이 있는 프로퍼티에 lazy var가 적절히 사용되었는지 확인해주세요. +- 메모리 누수 방지를 위한 weak 참조가 필요한 곳에 적용되었는지 확인해주세요. +- 도메인 로직과 UI 로직이 적절히 분리되어 있는지 확인해주세요. + +
+ + ## 🔗 연결된 이슈 - Resolved: #이슈번호 diff --git a/README.md b/README.md index 6271c94e..c66861db 100644 --- a/README.md +++ b/README.md @@ -201,106 +201,228 @@ print("와블 iOS 레포지토리입니다 🚀") ![Coding Convention](https://github.com/user-attachments/assets/14b15313-a25a-4ef9-a76a-0851eb9e55ea) -
-

인터페이스(프로토콜)와 실구현체

-- 프로토콜의 네이밍: 구현하고자 하는 객체 이름 -- 실 구현체의 네이밍: 프로토콜 네이밍 + `Impl` +## 📝 기본 규칙 + +### 코드 작성 규칙 +- **한 줄 당 최대 글자 수**는 120자로 제한한다. +- **들여쓰기 제한**은 최대 5번까지만 허용한다. 6번 이상 들여쓰기가 필요할 경우 메서드 분리로 최대한 가독성을 지켜 작성한다. +- **자동 포매팅(`Control` + `M`)**을 적극적으로 활용한다. + +### 라이브러리 선언 +퍼스트 파티와 서드 파티를 구분하여 각각 알파벳 순으로 정렬한다. 퍼스트 파티를 먼저 작성한다. ```swift -protocol UserRepository {} -final class UserRepositoryImpl {} +import Combine +import Foundation + +import CombineMoya +import Moya ``` -
- -
-

함수명

- -- 조회: `fetch` -- 수정: `update` -- 삭제: `delete` -- 생성: `create` -- 초기 상태 설정: `configure` -- 액션 메서드: `~DidTap` - -
- -
-

UseCase

- -- 단일 메서드일 경우, 메서드 명은 `execute`로 한다. - -
- -
-

Setup Method

+ +--- + +## 🏗️ 네이밍 규칙 + +### 인터페이스(프로토콜)와 실구현체 네이밍 규칙 +- **프로토콜**: 구현하고자 하는 객체 이름으로 네이밍한다. +- **실구현체**: 프로토콜 이름 + `Impl` 접미사 형태로 네이밍한다. ```swift -func setupNavigationBar() -func setupView() -func setupConstraints() -func setupAction() -func setupDelegate() -func setupDataSource() -func setupBinding() +protocol UserRepository {} +final class UserRepositoryImpl: UserRepository {} ``` -
+### 메서드 네이밍 규칙 + +메서드의 역할에 따라 일관된 접두사를 사용한다. + +- **조회**: `fetch` +- **수정**: `update` +- **삭제**: `delete` +- **생성**: `create` +- **초기 상태 설정**: `configure` +- **액션 메서드**: `~DidTap` 형식 + +--- + +## 🔧 Setup Method 규칙 + +`Setup` 관련 메서드는 다음 명명 규칙과 역할에 따라 구분하여 작성한다. +아래 순서대로 차례대로 작성한다. + +
+ +| 메서드명 | 역할 및 담당 업무 | +|---|---| +| `setupNavigationBar()` | 네비게이션 바 설정 및 구성 | +| `setupView()` | 해당 클래스의 프로퍼티 관리, 기본 뷰 설정 | +| `setupConstraints()` | addSubviews, SnapKit 등 오토레이아웃 관련 코드 | +| `setupAction()` | 액션 이벤트 관련 설정 | +| `setupDelegate()` | Delegate, DataSource 관련 코드 | +| `setupDataSource()` | 데이터 소스 초기화 및 설정 | +| `setupBinding()` | 데이터 바인딩 및 리액티브 프로그래밍 관련 코드 | + +
+ +--- + +## 📚 MARK 주석 작성 규칙 -
-

MARK 주석

-- **위, 아래로 한 줄 씩 공백**을 두고 작성합니다. +MARK 주석은 위아래로 한 줄씩 공백을 두고 작성하며, 되도록이면 아래 순서를 준수한다. + +### 클래스 내부 구조 ```swift +// MARK: - Section & Item + +// MARK: - Typealias + // MARK: - Property -// MARK: - Initializer + +// MARK: - UIComponent + // MARK: - Life Cycle +``` + +### Extension 구조 + +Extension은 public으로 선언하고 내부에서 필요한 부분만 private으로 선언한다. 되도록이면 아래 순서를 준수한다. + +```swift +// MARK: - Delegate 관련 Extension들 + +// MARK: - Computed Property + // MARK: - Setup Method -// MARK: - UICollectionViewDelegate (ex) -// MARK: - Private Method + +// MARK: - Helper Method (재사용성을 위한 private 메서드) + +// MARK: - Action Method (@objc 메서드) + +// MARK: - UICollectionViewCompositionalLayout (CollectionView 레이아웃 관련 코드) + +// MARK: - Constant ``` -
+--- -
-

Mapper

+## 🎨 코드 포매팅 규칙 -> DTO → Entity -> -- `enum`의 `static` method로 구현 -- 메서드 네이밍: `map(with dto:)` +### 삼항 연산자 +120자를 초과하는 경우 다음과 같이 개행한다. -
+```swift +let result = someCondition + ? valueWhenTrue + : valueWhenFalse +``` -
-

라이브러리 선언

- -- 퍼스트 파티와 서드 파티를 분리 -- 순서는 무조건 알파벳순 +### 조건문 (if-else) +반드시 아래와 같은 양식으로 개행해 작성한다. ```swift -import Combine -import Foundation +if condition { + // 실행 코드 +} else { + // 실행 코드 +} +``` -import CombineMoya -import Moya +### Guard Let 구문 +120자 이하인 경우 한 줄로 작성 가능하며, 초과 시 개행한다. + +```swift +// 120자 이하인 경우 +guard let self = self, let profile = sessionProfile else { return } + +// 120자 초과인 경우 +guard let self = self, + let profile = sessionProfile, + let userData = profile.userData +else { + return +} ``` -
-
-

반복되는 숫자, 문자열 등 선언

- -- 객체에서 반복되는 숫자, 문자열 등에 대해서 중첩 타입으로 Constant를 정의하고 타입 프로퍼티로 선언한다. +### 클로저 +한 줄로 작성 가능하고 120자 이하인 경우 개행하지 않는다. ```swift -final class CustomView: UIView {} +// 한 줄 작성 가능 +items.map { $0.name } + +// 복잡하거나 120자보다 긴 경우 개행 +items.compactMap { item in + return item.isValid ? item.processedValue : nil +} +``` + +--- + +## 🔄 지연 저장 프로퍼티 사용 규칙 + +다음 조건에 해당하는 경우 `lazy var`를 사용한다. + +- 호출되지 않을 가능성이 있는 프로퍼티 +- 용량이 큰 리소스 (특히 애셋 파일이나 화면을 꽉 채우는 이미지) + +```swift +private lazy var backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(named: "large-background-image") + return imageView +}() +``` + +--- + +## 🔧 Mapper 규칙 + +`DTO`에서 `Entity로` 변환하는 `Mapper는` 다음 규칙을 따른다. + +- `enum`의 `static` 메서드로 구현 +- 메서드명: `map(with dto:)` + +```swift +enum UserMapper { + static func map(with dto: UserDTO) -> User { + return User( + id: dto.id, + name: dto.name, + email: dto.email + ) + } +} +``` + +--- + +## 📊 상수 선언 규칙 + +객체에서 반복되는 숫자나 문자열은 중첩 타입으로 `Constant`를 정의하여 관리한다. + +```swift +final class CustomView: UIView { + // 구현 코드 +} // MARK: - Constant private extension CustomView { - enum Constant { - static let padding: CGFloat = 16 - } + enum Constant { + static let padding: CGFloat = 16 + static let cornerRadius: CGFloat = 8 + static let animationDuration: TimeInterval = 0.3 + } } ``` -
+--- + +## 🚀 성능 최적화 가이드라인 + +성능 테스트와 관련된 지침은 추후 업데이트 예정이며 현재는 다음 기본 원칙을 준수한다. + +- 메모리 누수 방지를 위한 적절한 `weak` 참조 사용 +- 불필요한 연산 최소화 +- 적절한 캐싱 전략 적용 diff --git a/Wable-iOS.xcodeproj/project.pbxproj b/Wable-iOS.xcodeproj/project.pbxproj index c04af475..c045d5ef 100644 --- a/Wable-iOS.xcodeproj/project.pbxproj +++ b/Wable-iOS.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ DD005BAB2D80071E00B1661F /* CommentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD005BAA2D80071E00B1661F /* CommentButton.swift */; }; DD1F30452D9D71DE003DB820 /* CreateContentLikedUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1F30442D9D71C7003DB820 /* CreateContentLikedUseCase.swift */; }; DD1F30472D9D71E1003DB820 /* DeleteContentLikedUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1F30462D9D71E1003DB820 /* DeleteContentLikedUseCase.swift */; }; + DD214B582E028FB80065D995 /* ProfileEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD214B572E028FB80065D995 /* ProfileEditView.swift */; }; + DD214B5A2E029E480065D995 /* TeamCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD214B592E029E430065D995 /* TeamCollectionView.swift */; }; + DD214B5C2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD214B5B2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift */; }; DD2967F92D6DAC4800143851 /* AnyPublisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29675E2D6DAC3100143851 /* AnyPublisher+.swift */; }; DD2967FA2D6DAC4800143851 /* Publisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2967602D6DAC3100143851 /* Publisher+.swift */; }; DD2967FB2D6DAC4800143851 /* DIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29675B2D6DAC3100143851 /* DIContainer.swift */; }; @@ -140,6 +143,7 @@ DD29687C2D6DB27F00143851 /* OAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29687B2D6DB27200143851 /* OAuthenticator.swift */; }; DD29687E2D6DC70700143851 /* OAuthCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD29687D2D6DC70100143851 /* OAuthCredential.swift */; }; DD3935B62DDDB9F700B6CA94 /* MyProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3935B42DDDB9F700B6CA94 /* MyProfileView.swift */; }; + DD431E1A2E136C8100BA55FB /* UpdateFCMTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD431E192E136C8100BA55FB /* UpdateFCMTokenUseCase.swift */; }; DD4AFD452D8FF19F00D56B94 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFD442D8FF19B00D56B94 /* LoginViewModel.swift */; }; DD4AFD472D90264500D56B94 /* OAuthEventManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4AFD462D90263400D56B94 /* OAuthEventManager.swift */; }; DD50980C2DA2C1A300F666DE /* FetchContentCommentListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50980B2DA2C18F00F666DE /* FetchContentCommentListUseCase.swift */; }; @@ -152,7 +156,6 @@ DD514D0A2D8C958B0095021D /* AgreementItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD514D092D8C957E0095021D /* AgreementItemView.swift */; }; DD514D0E2D8C99DC0095021D /* AgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD514D0D2D8C99DC0095021D /* AgreementView.swift */; }; DD514D102D8CA3780095021D /* AttributedString+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD514D0F2D8CA36D0095021D /* AttributedString+.swift */; }; - DD51A43B2DD3BE10004295B6 /* UpdateFCMTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD51A43A2DD3BE08004295B6 /* UpdateFCMTokenUseCase.swift */; }; DD51A44C2DD458A8004295B6 /* ProfileEditViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD51A44B2DD458A8004295B6 /* ProfileEditViewController.swift */; }; DD547AB92D7E43A600B8BA5A /* GhostButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD547AB82D7E439E00B8BA5A /* GhostButton.swift */; }; DD69C5902D71A3BE000A3349 /* OAuthTokenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD69C58F2D71A3B6000A3349 /* OAuthTokenProvider.swift */; }; @@ -201,7 +204,6 @@ DDED595E2D78C3E000A0BEF1 /* KakaoAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED595D2D78C3D900A0BEF1 /* KakaoAuthProvider.swift */; }; DDED59752D78F16400A0BEF1 /* FetchNicknameDuplicationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59742D78F15000A0BEF1 /* FetchNicknameDuplicationUseCase.swift */; }; DDED59782D78F1E500A0BEF1 /* UserProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59772D78F1DA00A0BEF1 /* UserProfileUseCase.swift */; }; - DDED597C2D794F0D00A0BEF1 /* FetchUserAuthUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED597B2D794F0600A0BEF1 /* FetchUserAuthUseCase.swift */; }; DDED597E2D79578C00A0BEF1 /* AuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED597D2D79578800A0BEF1 /* AuthProvider.swift */; }; DDED59862D7965E900A0BEF1 /* OverviewUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59852D7965DB00A0BEF1 /* OverviewUseCase.swift */; }; DDED59882D7965F100A0BEF1 /* NotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED59872D7965EC00A0BEF1 /* NotificationUseCase.swift */; }; @@ -239,7 +241,6 @@ DDFB223B2D8B9F5100FEA24A /* LCKYearCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB223A2D8B9F3F00FEA24A /* LCKYearCollectionViewCell.swift */; }; DDFB22402D8BA68900FEA24A /* LCKTeamCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB223F2D8BA67D00FEA24A /* LCKTeamCollectionViewCell.swift */; }; DDFB22422D8BA9D200FEA24A /* LCKTeamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22412D8BA9C800FEA24A /* LCKTeamViewController.swift */; }; - DDFB22442D8BB8D500FEA24A /* LCKTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22432D8BB8D100FEA24A /* LCKTeamView.swift */; }; DDFB22462D8BBB8600FEA24A /* ProfileRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22452D8BBB7900FEA24A /* ProfileRegisterViewController.swift */; }; DDFB22482D8BBCB300FEA24A /* ProfileRegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFB22472D8BBCB000FEA24A /* ProfileRegisterView.swift */; }; DDFF126F2D7EE39500980BA7 /* UIColor+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF126E2D7EE39200980BA7 /* UIColor+.swift */; }; @@ -248,10 +249,11 @@ DE0BFB602DA4F63800F06656 /* CommunityInviteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0BFB5F2DA4F63800F06656 /* CommunityInviteCell.swift */; }; DE0BFB632DA4F7FA00F06656 /* CommunityCellBaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0BFB622DA4F7FA00F06656 /* CommunityCellBaseView.swift */; }; DE0D0C9C2DD1E35B00FB64DC /* UserRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0C9B2DD1E35B00FB64DC /* UserRole.swift */; }; - DE0D0CA22DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CA12DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift */; }; DE0D0CA52DD2013A00FB64DC /* CheckUserRoleUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CA42DD2013A00FB64DC /* CheckUserRoleUseCase.swift */; }; DE0D0CAA2DD2041100FB64DC /* LikeViewitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CA92DD2041100FB64DC /* LikeViewitUseCase.swift */; }; DE0D0CAC2DD204D000FB64DC /* ReportViewitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0D0CAB2DD204D000FB64DC /* ReportViewitUseCase.swift */; }; + DE0DC22B2E0AEB9600FF061E /* GhostInfoTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0DC22A2E0AEB9600FF061E /* GhostInfoTooltipView.swift */; }; + DE0DC22F2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0DC22E2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift */; }; DE202FF62DE784FB00B04CAA /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE202FF52DE784FB00B04CAA /* AppVersion.swift */; }; DE202FF82DE7856E00B04CAA /* UpdateRequirement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE202FF72DE7856E00B04CAA /* UpdateRequirement.swift */; }; DE202FFA2DE785E800B04CAA /* AppVersionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE202FF92DE785E800B04CAA /* AppVersionRepository.swift */; }; @@ -355,6 +357,8 @@ DEB60AF62DD210E600FE8BFD /* AccountInfoCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB60AF52DD210E600FE8BFD /* AccountInfoCellItem.swift */; }; DEB60AF92DD2158A00FE8BFD /* AccountInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB60AF82DD2158A00FE8BFD /* AccountInfoViewModel.swift */; }; DEB60AFF2DD238AF00FE8BFD /* ViewitListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB60AFE2DD238AF00FE8BFD /* ViewitListCell.swift */; }; + DEC5268A2E0837DA000F7900 /* WableTextSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC526892E0837DA000F7900 /* WableTextSheetViewController.swift */; }; + DEC5268C2E085AAA000F7900 /* WableTextSheetShowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC5268B2E085AAA000F7900 /* WableTextSheetShowable.swift */; }; DECC36DA2DC3A4D800336B7B /* CreateViewitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DECC36D92DC3A4D800336B7B /* CreateViewitViewModel.swift */; }; DECC36DF2DC3B24000336B7B /* CreateViewitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DECC36DE2DC3B24000336B7B /* CreateViewitViewController.swift */; }; DED1344E2DCF6C73008463BD /* ViewitListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED1344D2DCF6C73008463BD /* ViewitListViewModel.swift */; }; @@ -372,7 +376,6 @@ DEE5D61A2D91537C009E5A25 /* GameScheduleViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE5D6192D91537C009E5A25 /* GameScheduleViewItem.swift */; }; DEF08E232DDB46170024B33C /* MyProfileEmptyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF08E222DDB46170024B33C /* MyProfileEmptyCell.swift */; }; DEF08E292DDB73200024B33C /* ProfileEmptyCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF08E282DDB73200024B33C /* ProfileEmptyCellItem.swift */; }; - DEF148402DA7B586003B2AD8 /* CommunityUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF1483F2DA7B586003B2AD8 /* CommunityUseCase.swift */; }; DEF148422DA7B7E5003B2AD8 /* IsUserRegistered.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF148412DA7B7E5003B2AD8 /* IsUserRegistered.swift */; }; DEF148442DA7B966003B2AD8 /* RegisterResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF148432DA7B966003B2AD8 /* RegisterResult.swift */; }; DEF148542DA7EF7B003B2AD8 /* CommunityRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF148532DA7EF7B003B2AD8 /* CommunityRegistration.swift */; }; @@ -395,6 +398,9 @@ DD005BAA2D80071E00B1661F /* CommentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentButton.swift; sourceTree = ""; }; DD1F30442D9D71C7003DB820 /* CreateContentLikedUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateContentLikedUseCase.swift; sourceTree = ""; }; DD1F30462D9D71E1003DB820 /* DeleteContentLikedUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteContentLikedUseCase.swift; sourceTree = ""; }; + DD214B572E028FB80065D995 /* ProfileEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = ""; }; + DD214B592E029E430065D995 /* TeamCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamCollectionView.swift; sourceTree = ""; }; + DD214B5B2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+KeyboardHandling.swift"; sourceTree = ""; }; DD29675B2D6DAC3100143851 /* DIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIContainer.swift; sourceTree = ""; }; DD29675C2D6DAC3100143851 /* Injected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Injected.swift; sourceTree = ""; }; DD29675E2D6DAC3100143851 /* AnyPublisher+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+.swift"; sourceTree = ""; }; @@ -514,6 +520,7 @@ DD29687B2D6DB27200143851 /* OAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthenticator.swift; sourceTree = ""; }; DD29687D2D6DC70100143851 /* OAuthCredential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthCredential.swift; sourceTree = ""; }; DD3935B42DDDB9F700B6CA94 /* MyProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileView.swift; sourceTree = ""; }; + DD431E192E136C8100BA55FB /* UpdateFCMTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFCMTokenUseCase.swift; sourceTree = ""; }; DD4AFD442D8FF19B00D56B94 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; DD4AFD462D90263400D56B94 /* OAuthEventManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthEventManager.swift; sourceTree = ""; }; DD50980B2DA2C18F00F666DE /* FetchContentCommentListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchContentCommentListUseCase.swift; sourceTree = ""; }; @@ -526,7 +533,6 @@ DD514D092D8C957E0095021D /* AgreementItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementItemView.swift; sourceTree = ""; }; DD514D0D2D8C99DC0095021D /* AgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementView.swift; sourceTree = ""; }; DD514D0F2D8CA36D0095021D /* AttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+.swift"; sourceTree = ""; }; - DD51A43A2DD3BE08004295B6 /* UpdateFCMTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFCMTokenUseCase.swift; sourceTree = ""; }; DD51A44B2DD458A8004295B6 /* ProfileEditViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditViewController.swift; sourceTree = ""; }; DD547AB82D7E439E00B8BA5A /* GhostButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostButton.swift; sourceTree = ""; }; DD69C58F2D71A3B6000A3349 /* OAuthTokenProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenProvider.swift; sourceTree = ""; }; @@ -582,7 +588,6 @@ DDED595D2D78C3D900A0BEF1 /* KakaoAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KakaoAuthProvider.swift; sourceTree = ""; }; DDED59742D78F15000A0BEF1 /* FetchNicknameDuplicationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchNicknameDuplicationUseCase.swift; sourceTree = ""; }; DDED59772D78F1DA00A0BEF1 /* UserProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileUseCase.swift; sourceTree = ""; }; - DDED597B2D794F0600A0BEF1 /* FetchUserAuthUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchUserAuthUseCase.swift; sourceTree = ""; }; DDED597D2D79578800A0BEF1 /* AuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthProvider.swift; sourceTree = ""; }; DDED59852D7965DB00A0BEF1 /* OverviewUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewUseCase.swift; sourceTree = ""; }; DDED59872D7965EC00A0BEF1 /* NotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUseCase.swift; sourceTree = ""; }; @@ -608,7 +613,6 @@ DDFB223A2D8B9F3F00FEA24A /* LCKYearCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKYearCollectionViewCell.swift; sourceTree = ""; }; DDFB223F2D8BA67D00FEA24A /* LCKTeamCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKTeamCollectionViewCell.swift; sourceTree = ""; }; DDFB22412D8BA9C800FEA24A /* LCKTeamViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKTeamViewController.swift; sourceTree = ""; }; - DDFB22432D8BB8D100FEA24A /* LCKTeamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LCKTeamView.swift; sourceTree = ""; }; DDFB22452D8BBB7900FEA24A /* ProfileRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRegisterViewController.swift; sourceTree = ""; }; DDFB22472D8BBCB000FEA24A /* ProfileRegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRegisterView.swift; sourceTree = ""; }; DDFF126E2D7EE39200980BA7 /* UIColor+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+.swift"; sourceTree = ""; }; @@ -617,10 +621,11 @@ DE0BFB5F2DA4F63800F06656 /* CommunityInviteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityInviteCell.swift; sourceTree = ""; }; DE0BFB622DA4F7FA00F06656 /* CommunityCellBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityCellBaseView.swift; sourceTree = ""; }; DE0D0C9B2DD1E35B00FB64DC /* UserRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRole.swift; sourceTree = ""; }; - DE0D0CA12DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewitBottomSheetActionKind.swift; sourceTree = ""; }; DE0D0CA42DD2013A00FB64DC /* CheckUserRoleUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckUserRoleUseCase.swift; sourceTree = ""; }; DE0D0CA92DD2041100FB64DC /* LikeViewitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeViewitUseCase.swift; sourceTree = ""; }; DE0D0CAB2DD204D000FB64DC /* ReportViewitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewitUseCase.swift; sourceTree = ""; }; + DE0DC22A2E0AEB9600FF061E /* GhostInfoTooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostInfoTooltipView.swift; sourceTree = ""; }; + DE0DC22E2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StringLiterals+PhotoDetail.swift"; sourceTree = ""; }; DE202FF52DE784FB00B04CAA /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; DE202FF72DE7856E00B04CAA /* UpdateRequirement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRequirement.swift; sourceTree = ""; }; DE202FF92DE785E800B04CAA /* AppVersionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionRepository.swift; sourceTree = ""; }; @@ -722,6 +727,8 @@ DEB60AF52DD210E600FE8BFD /* AccountInfoCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfoCellItem.swift; sourceTree = ""; }; DEB60AF82DD2158A00FE8BFD /* AccountInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInfoViewModel.swift; sourceTree = ""; }; DEB60AFE2DD238AF00FE8BFD /* ViewitListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewitListCell.swift; sourceTree = ""; }; + DEC526892E0837DA000F7900 /* WableTextSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WableTextSheetViewController.swift; sourceTree = ""; }; + DEC5268B2E085AAA000F7900 /* WableTextSheetShowable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WableTextSheetShowable.swift; sourceTree = ""; }; DECC36D92DC3A4D800336B7B /* CreateViewitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateViewitViewModel.swift; sourceTree = ""; }; DECC36DE2DC3B24000336B7B /* CreateViewitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateViewitViewController.swift; sourceTree = ""; }; DED1344D2DCF6C73008463BD /* ViewitListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewitListViewModel.swift; sourceTree = ""; }; @@ -740,7 +747,6 @@ DEE5D6192D91537C009E5A25 /* GameScheduleViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameScheduleViewItem.swift; sourceTree = ""; }; DEF08E222DDB46170024B33C /* MyProfileEmptyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileEmptyCell.swift; sourceTree = ""; }; DEF08E282DDB73200024B33C /* ProfileEmptyCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEmptyCellItem.swift; sourceTree = ""; }; - DEF1483F2DA7B586003B2AD8 /* CommunityUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityUseCase.swift; sourceTree = ""; }; DEF148412DA7B7E5003B2AD8 /* IsUserRegistered.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsUserRegistered.swift; sourceTree = ""; }; DEF148432DA7B966003B2AD8 /* RegisterResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterResult.swift; sourceTree = ""; }; DEF148532DA7EF7B003B2AD8 /* CommunityRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityRegistration.swift; sourceTree = ""; }; @@ -1416,6 +1422,7 @@ DDAD41A52D873E3600EF312C /* View */ = { isa = PBXGroup; children = ( + DD214B592E029E430065D995 /* TeamCollectionView.swift */, DD9980DE2D834F4E00EBBFBC /* PostUserInfoView.swift */, DDCD67042D7E271C00EF4C28 /* ToastView.swift */, ); @@ -1478,11 +1485,9 @@ isa = PBXGroup; children = ( DE202FFC2DE7865300B04CAA /* AppVersion */, - DDED59552D78BF5800A0BEF1 /* Login */, DE0D0CA32DD2012900FB64DC /* UserRole */, DDED59732D78EFCF00A0BEF1 /* Onboarding */, DD9EAE202D99C58600803A1A /* Home */, - DDED596E2D78EB9200A0BEF1 /* Community */, DDED59822D79614E00A0BEF1 /* Overview */, DDED59812D79612600A0BEF1 /* Notification */, DDED59802D79611F00A0BEF1 /* Profile */, @@ -1491,14 +1496,6 @@ path = UseCase; sourceTree = ""; }; - DDED59552D78BF5800A0BEF1 /* Login */ = { - isa = PBXGroup; - children = ( - DDED597B2D794F0600A0BEF1 /* FetchUserAuthUseCase.swift */, - ); - path = Login; - sourceTree = ""; - }; DDED59582D78C31B00A0BEF1 /* Auth */ = { isa = PBXGroup; children = ( @@ -1508,20 +1505,12 @@ path = Auth; sourceTree = ""; }; - DDED596E2D78EB9200A0BEF1 /* Community */ = { - isa = PBXGroup; - children = ( - DEF1483F2DA7B586003B2AD8 /* CommunityUseCase.swift */, - ); - path = Community; - sourceTree = ""; - }; DDED59732D78EFCF00A0BEF1 /* Onboarding */ = { isa = PBXGroup; children = ( DDED59772D78F1DA00A0BEF1 /* UserProfileUseCase.swift */, DDED59742D78F15000A0BEF1 /* FetchNicknameDuplicationUseCase.swift */, - DD51A43A2DD3BE08004295B6 /* UpdateFCMTokenUseCase.swift */, + DD431E192E136C8100BA55FB /* UpdateFCMTokenUseCase.swift */, ); path = Onboarding; sourceTree = ""; @@ -1588,6 +1577,7 @@ DDF97CB12DE4EAC7009FC612 /* StringLiterals+URL.swift */, DDF97CB22DE4EAC7009FC612 /* StringLiterals+Viewit.swift */, DDF97CB32DE4EAC7009FC612 /* StringLiterals+Write.swift */, + DE0DC22E2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift */, ); path = String; sourceTree = ""; @@ -1605,7 +1595,6 @@ isa = PBXGroup; children = ( DDCA70922D88315C00A988B8 /* LCKYearView.swift */, - DDFB22432D8BB8D100FEA24A /* LCKTeamView.swift */, DDFB22472D8BBCB000FEA24A /* ProfileRegisterView.swift */, DD514D0D2D8C99DC0095021D /* AgreementView.swift */, DD514D092D8C957E0095021D /* AgreementItemView.swift */, @@ -1662,14 +1651,6 @@ path = Subview; sourceTree = ""; }; - DE0D0CA02DD1EF7900FB64DC /* Model */ = { - isa = PBXGroup; - children = ( - DE0D0CA12DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift */, - ); - path = Model; - sourceTree = ""; - }; DE0D0CA32DD2012900FB64DC /* UserRole */ = { isa = PBXGroup; children = ( @@ -1781,6 +1762,7 @@ DE4EFCCF2DE0060700E4146C /* Edit */ = { isa = PBXGroup; children = ( + DD214B572E028FB80065D995 /* ProfileEditView.swift */, DD51A44B2DD458A8004295B6 /* ProfileEditViewController.swift */, ); path = Edit; @@ -1867,6 +1849,7 @@ DE54B03D2D82CD82009A7C34 /* NotFoundViewController.swift */, DE54B03F2D832E78009A7C34 /* LoadingViewController.swift */, DE54B0412D833B0B009A7C34 /* WableSheetViewController.swift */, + DEC526892E0837DA000F7900 /* WableTextSheetViewController.swift */, DE8D12692D859FB000D02993 /* WableBottomSheetController.swift */, ); path = ViewController; @@ -2042,6 +2025,7 @@ DE83891A2D906C0C00C089E6 /* Types.swift */, DE7C53172D761F3A00076E5D /* ReuseIdentifiable.swift */, DE4AE84E2DE8B645003E20AB /* WableSheetShowable.swift */, + DEC5268B2E085AAA000F7900 /* WableTextSheetShowable.swift */, DE4AE8502DE8B6FF003E20AB /* WableBottomSheetShowable.swift */, DE4AE8522DE8BA18003E20AB /* UIAlertShowable.swift */, DE8E86402D91A092000A4292 /* Extension */, @@ -2071,6 +2055,7 @@ DE8E86402D91A092000A4292 /* Extension */ = { isa = PBXGroup; children = ( + DD214B5B2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift */, DD8B42A32DA15DA9007DC1CA /* UITextView+.swift */, DE7C530F2D761E3300076E5D /* String+.swift */, DE9C3BFB2D9AEC0600A5AFEC /* NSMutableAttributedString+.swift */, @@ -2362,7 +2347,6 @@ DECC36DC2DC3B20600336B7B /* List */ = { isa = PBXGroup; children = ( - DE0D0CA02DD1EF7900FB64DC /* Model */, DED1344C2DCF6B4B008463BD /* ViewModel */, DEF6A3B92DA97F6E0044ACAB /* View */, ); @@ -2482,6 +2466,7 @@ children = ( DEA10B5B2DD3F21F001EBBA9 /* ProfileSegmentedHeaderView.swift */, DEA10B502DD3D995001EBBA9 /* ProfileInfoCell.swift */, + DE0DC22A2E0AEB9600FF061E /* GhostInfoTooltipView.swift */, ); path = Component; sourceTree = ""; @@ -2672,6 +2657,7 @@ DDF97CC02DE4EAC7009FC612 /* StringLiterals+URL.swift in Sources */, DDF97CC12DE4EAC7009FC612 /* StringLiterals+Ban.swift in Sources */, DDF97CC22DE4EAC7009FC612 /* StringLiterals+Exit.swift in Sources */, + DEC5268C2E085AAA000F7900 /* WableTextSheetShowable.swift in Sources */, DDF97CC32DE4EAC7009FC612 /* StringLiterals+Detail.swift in Sources */, DDF97CC42DE4EAC7009FC612 /* StringLiterals+Viewit.swift in Sources */, DDF97CC52DE4EAC7009FC612 /* StringLiterals+ProfileDelete.swift in Sources */, @@ -2692,7 +2678,6 @@ DD2968102D6DACF700143851 /* Notification.swift in Sources */, DD514D0A2D8C958B0095021D /* AgreementItemView.swift in Sources */, DEE5D6112D9121E5009E5A25 /* OverviewPageView.swift in Sources */, - DDFB22442D8BB8D500FEA24A /* LCKTeamView.swift in Sources */, DD2968112D6DACF700143851 /* Viewit.swift in Sources */, DDCD66F52D7DF5B700EF4C28 /* CommunityViewController.swift in Sources */, DD514D082D8C83560095021D /* AgreementViewController.swift in Sources */, @@ -2702,6 +2687,7 @@ DE4AE8532DE8BA18003E20AB /* UIAlertShowable.swift in Sources */, DD2968132D6DACF700143851 /* TriggerType.swift in Sources */, DD9980DF2D834F4E00EBBFBC /* CommentCollectionViewCell.swift in Sources */, + DE0DC22F2E0B008F00FF061E /* StringLiterals+PhotoDetail.swift in Sources */, DD9980E02D834F4E00EBBFBC /* PostUserInfoView.swift in Sources */, DD9980E12D834F4E00EBBFBC /* ContentCollectionViewCell.swift in Sources */, DD2968142D6DACF700143851 /* AccountInfo.swift in Sources */, @@ -2737,6 +2723,7 @@ DEE5D61A2D91537C009E5A25 /* GameScheduleViewItem.swift in Sources */, DE72BB102D93CC68008E5774 /* NotificationPageView.swift in Sources */, DDFB22462D8BBB8600FEA24A /* ProfileRegisterViewController.swift in Sources */, + DD214B582E028FB80065D995 /* ProfileEditView.swift in Sources */, DE833BA22DE22D4600812A80 /* Publishers+UIGesture.swift in Sources */, DD29685B2D6DAD4400143851 /* CreateAccountRequest.swift in Sources */, DE9B94762D927F140060FF92 /* NoticeViewModel.swift in Sources */, @@ -2804,6 +2791,7 @@ DD2968432D6DAD2F00143851 /* FetchGameSchedules.swift in Sources */, DE3CA2832DD4464E00BF5E87 /* FetchUserCommentListUseCase.swift in Sources */, DEB60AF92DD2158A00FE8BFD /* AccountInfoViewModel.swift in Sources */, + DEC5268A2E0837DA000F7900 /* WableTextSheetViewController.swift in Sources */, DE6459D52D97B8C2005569B8 /* InformationNotificationViewController.swift in Sources */, DD8326962D95836F0014B62D /* HomeViewModel.swift in Sources */, DD2968442D6DAD2F00143851 /* FetchUserProfile.swift in Sources */, @@ -2875,6 +2863,7 @@ DD29686C2D6DAD5500143851 /* ProfileTargetType.swift in Sources */, DD29686D2D6DAD5500143851 /* AccountTargetType.swift in Sources */, DD514D102D8CA3780095021D /* AttributedString+.swift in Sources */, + DD214B5A2E029E480065D995 /* TeamCollectionView.swift in Sources */, DD2968392D6DAD1700143851 /* LoginRepositoryImpl.swift in Sources */, DEDC29182D720D030073D512 /* PostStatus.swift in Sources */, DEB60AF22DD20C2D00FE8BFD /* AccountInfoCell.swift in Sources */, @@ -2909,6 +2898,7 @@ DEAFE05B2D8C4E930097E91C /* GameScheduleHeaderView.swift in Sources */, DE7C53182D761F3A00076E5D /* ReuseIdentifiable.swift in Sources */, DECC36DA2DC3A4D800336B7B /* CreateViewitViewModel.swift in Sources */, + DD214B5C2E02C6D40065D995 /* UIViewController+KeyboardHandling.swift in Sources */, DDFB22402D8BA68900FEA24A /* LCKTeamCollectionViewCell.swift in Sources */, DDCA70972D88797200A988B8 /* AuthorType.swift in Sources */, DD2967FC2D6DAC4800143851 /* Injected.swift in Sources */, @@ -2931,11 +2921,8 @@ DD2968182D6DAD0200143851 /* CommunityRepository.swift in Sources */, DD4AFD472D90264500D56B94 /* OAuthEventManager.swift in Sources */, DDED59862D7965E900A0BEF1 /* OverviewUseCase.swift in Sources */, - DEF148402DA7B586003B2AD8 /* CommunityUseCase.swift in Sources */, DE202FF62DE784FB00B04CAA /* AppVersion.swift in Sources */, - DDED597C2D794F0D00A0BEF1 /* FetchUserAuthUseCase.swift in Sources */, DE7C53102D761E3300076E5D /* String+.swift in Sources */, - DD51A43B2DD3BE10004295B6 /* UpdateFCMTokenUseCase.swift in Sources */, DD9EAE222D99C59900803A1A /* FetchContentListUseCase.swift in Sources */, DD2968192D6DAD0200143851 /* ReportRepository.swift in Sources */, DE6C3E3E2D91A8E00046DB30 /* NewsViewModel.swift in Sources */, @@ -2945,6 +2932,7 @@ DDFB22422D8BA9D200FEA24A /* LCKTeamViewController.swift in Sources */, DD765D9D2D8747950029A317 /* SplashViewController.swift in Sources */, DD29681B2D6DAD0200143851 /* ProfileRepository.swift in Sources */, + DD431E1A2E136C8100BA55FB /* UpdateFCMTokenUseCase.swift in Sources */, DD1F30472D9D71E1003DB820 /* DeleteContentLikedUseCase.swift in Sources */, DD29681C2D6DAD0200143851 /* InformationRepository.swift in Sources */, DD69C5902D71A3BE000A3349 /* OAuthTokenProvider.swift in Sources */, @@ -2973,6 +2961,7 @@ DD2968252D6DAD0B00143851 /* ViewitMapper.swift in Sources */, DEA10B4C2DD3C504001EBBA9 /* WithdrawalGuideViewModel.swift in Sources */, DEA10B2B2DD399F4001EBBA9 /* WithdrawalReasonViewController.swift in Sources */, + DE0DC22B2E0AEB9600FF061E /* GhostInfoTooltipView.swift in Sources */, DD2968262D6DAD0B00143851 /* CommunityMapper.swift in Sources */, DE7004842D8E87DC00B7AB71 /* RankListViewController.swift in Sources */, DD2968272D6DAD0B00143851 /* LoginMapper.swift in Sources */, @@ -2997,7 +2986,6 @@ DE0BFB632DA4F7FA00F06656 /* CommunityCellBaseView.swift in Sources */, DD29682D2D6DAD0B00143851 /* NotificationMapper.swift in Sources */, DE3390302D8EC6C400638BB4 /* NoticeViewController.swift in Sources */, - DE0D0CA22DD1EF8B00FB64DC /* ViewitBottomSheetActionKind.swift in Sources */, DE4AE8512DE8B6FF003E20AB /* WableBottomSheetShowable.swift in Sources */, DD2968232D6DAD0200143851 /* NotificationRepository.swift in Sources */, DD2968002D6DAC8900143851 /* AppDelegate.swift in Sources */, @@ -3147,7 +3135,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = DD8CEF3F2D6A007900DBE580 /* Development.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon_Dev; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Wable-iOS/Wable-iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; @@ -3158,7 +3146,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = HGVD26K7DP; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Wable-iOS/Resource/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "와블"; + INFOPLIST_KEY_CFBundleDisplayName = "술먹고미친와블"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "게시글이나 프로필을 변경할 때 사용합니다."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -3170,7 +3158,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = "com.wable.Wable-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3212,7 +3200,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = "com.wable.Wable-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-Dev.xcscheme b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-Dev.xcscheme new file mode 100644 index 00000000..01aba8d5 --- /dev/null +++ b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-Dev.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme index 39297408..452e3149 100644 --- a/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme +++ b/Wable-iOS.xcodeproj/xcshareddata/xcschemes/Wable-iOS.xcscheme @@ -24,14 +24,14 @@ + buildConfiguration = "Release"> diff --git a/Wable-iOS/App/AppDelegate+InjectDependency.swift b/Wable-iOS/App/AppDelegate+InjectDependency.swift index c6cd15c0..ace0b7d6 100644 --- a/Wable-iOS/App/AppDelegate+InjectDependency.swift +++ b/Wable-iOS/App/AppDelegate+InjectDependency.swift @@ -12,6 +12,18 @@ extension AppDelegate { func injectDependency() { + // MARK: - UserSession + + diContainer.register( + for: UserSessionRepository.self, + object: UserSessionRepositoryImpl(userDefaults: UserDefaultsStorage()) + ) + + // MARK: - Login + + diContainer.register(for: LoginRepository.self, object: LoginRepositoryImpl()) + diContainer.register(for: TokenStorage.self, object: TokenStorage(keyChainStorage: KeychainStorage())) + // MARK: - Overview diContainer.register(for: InformationRepository.self, object: InformationRepositoryImpl()) @@ -21,10 +33,8 @@ extension AppDelegate { diContainer.register(for: ReportRepository.self) { env in switch env { - case .mock: - return MockReportRepository() - case .production: - return ReportRepositoryImpl() + case .mock: MockReportRepository() + case .production: ReportRepositoryImpl() } } @@ -32,10 +42,8 @@ extension AppDelegate { diContainer.register(for: ViewitRepository.self) { env in switch env { - case .mock: - return MockViewitRepository() - case .production: - return ViewitRepositoryImpl() + case .mock: MockViewitRepository() + case .production: ViewitRepositoryImpl() } } @@ -45,10 +53,8 @@ extension AppDelegate { diContainer.register(for: CommentRepository.self) { env in switch env { - case .mock: - return MockCommentRepository() - case .production: - return CommentRepositoryImpl() + case .mock: MockCommentRepository() + case .production: CommentRepositoryImpl() } } diContainer.register(for: CommentLikedRepository.self, object: CommentLikedRepositoryImpl()) @@ -72,19 +78,24 @@ extension AppDelegate { diContainer.register(for: AppVersionRepository.self) { env in switch env { - case .mock: - return MockAppVersionRepository() - case .production: - return AppVersionRepositoryImpl() + case .mock: MockAppVersionRepository() + case .production: AppVersionRepositoryImpl() } } diContainer.register(for: UpdateAlertPolicyRepository.self) { env in switch env { - case .mock: - return MockUpdateAlertPolicyRepository() - case .production: - return UpdateAlertPolicyRepositoryImpl() + case .mock: MockUpdateAlertPolicyRepository() + case .production: UpdateAlertPolicyRepositoryImpl() + } + } + + // MARK: - Community + + diContainer.register(for: CommunityRepository.self) { env in + switch env { + case .mock: MockCommunityRepository() + case .production: CommunityRepositoryImpl() } } } diff --git a/Wable-iOS/App/SceneDelegate.swift b/Wable-iOS/App/SceneDelegate.swift index a4230f35..208fe7cb 100644 --- a/Wable-iOS/App/SceneDelegate.swift +++ b/Wable-iOS/App/SceneDelegate.swift @@ -20,13 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private let loginRepository = LoginRepositoryImpl() private let profileRepository = ProfileRepositoryImpl() - private let userSessionRepository = UserSessionRepositoryImpl( - userDefaults: UserDefaultsStorage( - userDefaults: UserDefaults.standard, - jsonEncoder: JSONEncoder(), - jsonDecoder: JSONDecoder() - ) - ) + private let userSessionRepository = UserSessionRepositoryImpl(userDefaults: UserDefaultsStorage()) private let checkAppUpdateRequirementUseCase = CheckAppUpdateRequirementUseCaseImpl() // MARK: - UIComponent @@ -66,19 +60,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private extension SceneDelegate { func configureLoginScreen() { - self.window?.rootViewController = LoginViewController( - viewModel: LoginViewModel( - updateFCMTokenUseCase: UpdateFCMTokenUseCase( - repository: ProfileRepositoryImpl() - ), - fetchUserAuthUseCase: FetchUserAuthUseCase( - loginRepository: loginRepository, - userSessionRepository: userSessionRepository - ), - updateUserSessionUseCase: FetchUserInformationUseCase(repository: userSessionRepository), - userProfileUseCase: UserProfileUseCase(repository: ProfileRepositoryImpl()) - ) - ) + self.window?.rootViewController = LoginViewController(viewModel: LoginViewModel()) } func configureMainScreen() { diff --git a/Wable-iOS/Core/Amplitude/AmplitudeManager.swift b/Wable-iOS/Core/Amplitude/AmplitudeManager.swift index c93a773c..300c6619 100644 --- a/Wable-iOS/Core/Amplitude/AmplitudeManager.swift +++ b/Wable-iOS/Core/Amplitude/AmplitudeManager.swift @@ -74,6 +74,7 @@ extension AmplitudeManager { case clickNextDeleteguide case clickDoneDeleteaccount case clickGobackHome + case clickDownloadPhoto var value: String { switch self { @@ -123,6 +124,7 @@ extension AmplitudeManager { case .clickNextDeleteguide: return "click_next_deleteguide" case .clickDoneDeleteaccount: return "click_done_deleteaccount" case .clickGobackHome: return "click_goback_home" + case .clickDownloadPhoto: return "click_download_photo" } } } diff --git a/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift b/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift index c8d9122a..962e2734 100644 --- a/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift +++ b/Wable-iOS/Core/Literals/String/StringLiterals+Ghost.swift @@ -14,6 +14,11 @@ extension StringLiterals { enum Ghost { static let sheetTitle = "와블의 온화한 문화를 해치는\n누군가를 발견하신 건가요?" + static let sheetPlaceholder = """ + 투명도를 내리려는 이유가 무엇인가요? + ex. 선수를 비하했어요 + """ static let completeToast = "덕분에 와블이 더 온화해지고 있어요!" + static let tooltip = "투명도란? 와블의 건강하고 유쾌한 문화를 유지하기 위한 최소한의 장치입니다. 과도한 혐오나 비방, 조롱을 보게 된다면 투명도를 내려주세요! 클린 LCK 문화 함께 만들어가요." } } diff --git a/Wable-iOS/Core/Literals/String/StringLiterals+PhotoDetail.swift b/Wable-iOS/Core/Literals/String/StringLiterals+PhotoDetail.swift new file mode 100644 index 00000000..029cccc4 --- /dev/null +++ b/Wable-iOS/Core/Literals/String/StringLiterals+PhotoDetail.swift @@ -0,0 +1,18 @@ +// +// StringLiterals+PhotoDetail.swift +// Wable-iOS +// +// Created by 김진웅 on 6/25/25. +// + +import Foundation + +extension StringLiterals { + + // MARK: - PhotoDetail + + enum PhotoDetail { + static let successMessage = "사진을 앨범에 저장했습니다." + static let errorMessage = "사진 저장에 실패했습니다.\n잠시 후 다시 시도해 주세요." + } +} diff --git a/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift b/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift index ad1e1341..f46530a6 100644 --- a/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift +++ b/Wable-iOS/Core/Literals/String/StringLiterals+Report.swift @@ -13,8 +13,12 @@ extension StringLiterals { // MARK: - Report enum Report { - static let sheetTitle = "신고하시겠어요?" - static let sheetMessage = "해당 유저 혹은 게시글을 신고하시려면 신고하기 버튼을 눌러주세요" + static let sheetTitle = "신고하기" + static let sheetPlaceholder = """ + 신고하시려는 사유를 알려주시면 더 건강한 + 문화를 만드는 것에 도움돼요. + 사유는 생략 가능해요. + """ static let completeToast = "신고 접수가 완료되었어요.\n24시간 이내에 조치할 예정이예요." } } diff --git a/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift b/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift index 7633ca0f..7d7a8c5d 100644 --- a/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift +++ b/Wable-iOS/Data/RepositoryImpl/AppVersionRepositoryImpl.swift @@ -31,7 +31,7 @@ final class AppVersionRepositoryImpl: AppVersionRepository { } return AppVersion(from: appStoreVersion) - } catch let error as URLError { + } catch _ as URLError { throw WableError.networkError } catch { throw error diff --git a/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift b/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift index e103a511..f9e0d505 100644 --- a/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift +++ b/Wable-iOS/Data/RepositoryImpl/CommunityRepositoryImpl.swift @@ -17,7 +17,7 @@ final class CommunityRepositoryImpl { } extension CommunityRepositoryImpl: CommunityRepository { - func updateRegister(communityName: String) -> AnyPublisher { + func updateRegistration(communityName: String) -> AnyPublisher { return provider.request( .updateRegister( request: DTO.Request.UpdateRegister( @@ -39,9 +39,45 @@ extension CommunityRepositoryImpl: CommunityRepository { .mapWableError() } - func isUserRegistered() -> AnyPublisher { + func checkUserRegistration() -> AnyPublisher { return provider.request(.isUserRegistered, for: DTO.Response.IsUserRegistered.self) .map(CommunityMapper.toDomain) .mapWableError() } } + +struct MockCommunityRepository: CommunityRepository { + private var randomDelay: TimeInterval { Double.random(in: 0.7...1.3) } + + func updateRegistration(communityName: String) -> AnyPublisher { + return .just(88.0) + .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func fetchCommunityList() -> AnyPublisher<[Community], WableError> { + let communityMockData: [Community] = [ + Community(team: .t1, registrationRate: 0.91), + Community(team: .gen, registrationRate: 0.88), + Community(team: .hle, registrationRate: 0.72), + Community(team: .dk, registrationRate: 0.79), + Community(team: .kt, registrationRate: 0.65), + Community(team: .ns, registrationRate: 0.54), + Community(team: .drx, registrationRate: 0.49), + Community(team: .bro, registrationRate: 0.37), + Community(team: .bfx, registrationRate: 0.42), + Community(team: .dnf, registrationRate: 0.33) + ] + + return .just(communityMockData) + .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + + func checkUserRegistration() -> AnyPublisher { + let registration = CommunityRegistration(team: nil, hasRegisteredTeam: false) + return .just(registration) + .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift b/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift index 74db404c..2667b07f 100644 --- a/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift +++ b/Wable-iOS/Data/RepositoryImpl/ProfileRepositoryImpl.swift @@ -89,12 +89,12 @@ extension ProfileRepositoryImpl: ProfileRepository { } func updateUserProfile( - profile: UserProfile? = nil, - isPushAlarmAllowed: Bool? = nil, - isAlarmAllowed: Bool? = nil, - image: UIImage? = nil, - fcmToken: String? = nil, - defaultProfileType: String? = nil + profile: UserProfile?, + isPushAlarmAllowed: Bool?, + isAlarmAllowed: Bool?, + image: UIImage?, + fcmToken: String?, + defaultProfileType: String? ) -> AnyPublisher { return provider.request( .updateUserProfile( diff --git a/Wable-iOS/Domain/Error/WableError.swift b/Wable-iOS/Domain/Error/WableError.swift index 5ffe1194..ef195328 100644 --- a/Wable-iOS/Domain/Error/WableError.swift +++ b/Wable-iOS/Domain/Error/WableError.swift @@ -34,8 +34,8 @@ enum WableError: String, Error { case notFoundComment = "해당하는 답글이 없습니다." case unauthorizedMember = "권한이 없는 유저입니다." case unauthorizedToken = "유효하지 않은 토큰입니다." - case kakaoUnauthorizedUser = "카카오 로그인 실패. 만료되었거나 잘못된 카카오 토큰입니다." - case failedToValidateAppleLogin = "애플 로그인 실패. 만료되었거나 잘못된 애플 토큰입니다." + case failedToKakaoLogin = "카카오 로그인 실패. 작업이 취소되었거나 토큰 오류입니다." + case failedToAppleLogin = "애플 로그인 실패. 작업이 취소되었거나 토큰 오류입니다." case signinRequired = "access, refreshToken 모두 만료되었습니다. 재로그인이 필요합니다." case validAccessToken = "아직 유효한 accessToken 입니다." diff --git a/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift b/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift index e33c16e8..c3a52e48 100644 --- a/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift +++ b/Wable-iOS/Domain/RepositoryInterface/CommunityRepository.swift @@ -10,7 +10,7 @@ import Combine import Foundation protocol CommunityRepository { - func updateRegister(communityName: String) -> AnyPublisher + func updateRegistration(communityName: String) -> AnyPublisher func fetchCommunityList() -> AnyPublisher<[Community], WableError> - func isUserRegistered() -> AnyPublisher + func checkUserRegistration() -> AnyPublisher } diff --git a/Wable-iOS/Domain/UseCase/Community/CommunityUseCase.swift b/Wable-iOS/Domain/UseCase/Community/CommunityUseCase.swift deleted file mode 100644 index d9acfd5f..00000000 --- a/Wable-iOS/Domain/UseCase/Community/CommunityUseCase.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// CommunityUseCase.swift -// Wable-iOS -// -// Created by 김진웅 on 4/10/25. -// - -import Combine -import Foundation - -protocol CommunityUseCase { - func isUserRegistered() -> AnyPublisher - func fetchCommunityList() -> AnyPublisher<[Community], WableError> - func register(for communityTeam: LCKTeam) -> AnyPublisher -} - -final class CommunityUseCaseImpl: CommunityUseCase { - private let repository: CommunityRepository - - init(repository: CommunityRepository) { - self.repository = repository - } - - func isUserRegistered() -> AnyPublisher { - return repository.isUserRegistered() - } - - func fetchCommunityList() -> AnyPublisher<[Community], WableError> { - return repository.fetchCommunityList() - } - - func register(for communityTeam: LCKTeam) -> AnyPublisher { - return repository.updateRegister(communityName: communityTeam.rawValue) - } -} - -final class MockCommunityUseCaseImpl: CommunityUseCase { - private var randomDelay: TimeInterval { Double.random(in: 0.7...1.3) } - - func isUserRegistered() -> AnyPublisher { - let registration = CommunityRegistration(team: nil, hasRegisteredTeam: false) - return .just(registration) - .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } - - func fetchCommunityList() -> AnyPublisher<[Community], WableError> { - let communityMockData: [Community] = [ - Community(team: .t1, registrationRate: 0.91), - Community(team: .gen, registrationRate: 0.88), - Community(team: .hle, registrationRate: 0.72), - Community(team: .dk, registrationRate: 0.79), - Community(team: .kt, registrationRate: 0.65), - Community(team: .ns, registrationRate: 0.54), - Community(team: .drx, registrationRate: 0.49), - Community(team: .bro, registrationRate: 0.37), - Community(team: .bfx, registrationRate: 0.42), - Community(team: .dnf, registrationRate: 0.33) - ] - - return .just(communityMockData) - .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } - - func register(for communityTeam: LCKTeam) -> AnyPublisher { - return .just(0.92) - .delay(for: .seconds(randomDelay), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } -} diff --git a/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift b/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift index e8aac0cd..d88b06a8 100644 --- a/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift +++ b/Wable-iOS/Domain/UseCase/Home/FetchGhostUseCase.swift @@ -20,12 +20,12 @@ final class FetchGhostUseCase { // MARK: - Extension extension FetchGhostUseCase { - func execute(type: PostType, targetID: Int, userID: Int) -> AnyPublisher { + func execute(type: PostType, targetID: Int, userID: Int, reason: String?) -> AnyPublisher { return repository.postGhostReduction( alarmTriggerType: type == .comment ? "commentGhost" : "contentGhost", alarmTriggerID: targetID, targetMemberID: userID, - reason: "" + reason: reason ?? "" ) } } diff --git a/Wable-iOS/Domain/UseCase/Login/FetchUserAuthUseCase.swift b/Wable-iOS/Domain/UseCase/Login/FetchUserAuthUseCase.swift deleted file mode 100644 index 562aeb01..00000000 --- a/Wable-iOS/Domain/UseCase/Login/FetchUserAuthUseCase.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// FetchUserAuthUseCase.swift -// Wable-iOS -// -// Created by YOUJIM on 3/6/25. -// - - -import Combine - -final class FetchUserAuthUseCase { - private let loginRepository: LoginRepository - private let userSessionRepository: UserSessionRepository - private let tokenStorage = TokenStorage(keyChainStorage: KeychainStorage()) - - init(loginRepository: LoginRepository, userSessionRepository: UserSessionRepository) { - self.loginRepository = loginRepository - self.userSessionRepository = userSessionRepository - } -} - -// MARK: - Extension - -extension FetchUserAuthUseCase { - func execute(platform: SocialPlatform) -> AnyPublisher { - return loginRepository.fetchUserAuth(platform: platform, userName: nil) - .handleEvents( - receiveOutput: { account in - do { - try self.tokenStorage.save(account.token.accessToken, for: .wableAccessToken) - - WableLogger.log("액세스 토큰 저장 성공: \(account.token.accessToken)", for: .debug) - try self.tokenStorage.save(account.token.refreshToken, for: .wableRefreshToken) - - WableLogger.log("리프레시 토큰 저장 성공: \(account.token.refreshToken)", for: .debug) - } catch { - WableLogger.log("토큰 저장 실패: \(error)", for: .debug) - } - - self.userSessionRepository.updateUserSession( - userID: account.user.id, - nickname: account.user.nickname, - profileURL: account.user.profileURL, - isPushAlarmAllowed: account.isPushAlarmAllowed ?? false, - isAdmin: account.isAdmin, - isAutoLoginEnabled: true, - notificationBadgeCount: nil - ) - - self.userSessionRepository.updateActiveUserID(account.user.id) - }) - .eraseToAnyPublisher() - } -} diff --git a/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift b/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift index b7efa50c..fe540cde 100644 --- a/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift +++ b/Wable-iOS/Domain/UseCase/Onboarding/UpdateFCMTokenUseCase.swift @@ -20,10 +20,7 @@ final class UpdateFCMTokenUseCase { extension UpdateFCMTokenUseCase { func execute(nickname: String) -> AnyPublisher { - guard let token = repository.fetchFCMToken() else { - return .fail(WableError.noToken) - } - + guard let token = repository.fetchFCMToken() else { return .fail(WableError.noToken) } return repository.updateUserProfile(nickname: nickname, fcmToken: token) } } diff --git a/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift b/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift index 87205d44..c63ccc88 100644 --- a/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift +++ b/Wable-iOS/Domain/UseCase/Onboarding/UserProfileUseCase.swift @@ -18,7 +18,13 @@ final class UserProfileUseCase { } extension UserProfileUseCase { - func execute(profile: UserProfile? = nil, isPushAlarmAllowed: Bool? = nil, isAlarmAllowed: Bool? = nil, image: UIImage? = nil, defaultProfileType: String? = nil) -> AnyPublisher { + func updateProfile( + profile: UserProfile?, + isPushAlarmAllowed: Bool? = nil, + isAlarmAllowed: Bool? = nil, + image: UIImage? = nil, + defaultProfileType: String? = nil + ) -> AnyPublisher { return repository.updateUserProfile( profile: profile, isPushAlarmAllowed: isPushAlarmAllowed, @@ -29,7 +35,30 @@ extension UserProfileUseCase { ) } - func execute(userID: Int) -> AnyPublisher { + func fetchProfile(userID: Int) -> AnyPublisher { return repository.fetchUserProfile(memberID: userID) } + + func updateProfileWithUserID( + userID: Int, + isPushAlarmAllowed: Bool? = nil, + isAlarmAllowed: Bool? = nil, + image: UIImage? = nil, + defaultProfileType: String? = nil + ) -> AnyPublisher { + let fetchProfile = repository.fetchUserProfile(memberID: userID) + let updateProfile = { [weak self] (profile: UserProfile) -> AnyPublisher in + guard let self = self else { return Fail(error: WableError.unknownError).eraseToAnyPublisher() } + + return self.updateProfile( + profile: profile, + isPushAlarmAllowed: isPushAlarmAllowed, + isAlarmAllowed: isAlarmAllowed, + image: image, + defaultProfileType: defaultProfileType + ) + } + + return fetchProfile.flatMap(updateProfile).eraseToAnyPublisher() + } } diff --git a/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift b/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift index 48aeff78..5b8bb426 100644 --- a/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift +++ b/Wable-iOS/Domain/UseCase/Viewit/ReportViewitUseCase.swift @@ -9,15 +9,16 @@ import Combine import Foundation protocol ReportViewitUseCase { - func report(viewit: Viewit) -> AnyPublisher + func report(viewit: Viewit, message: String) -> AnyPublisher func ban(viewit: Viewit) -> AnyPublisher } final class ReportViewitUseCaseImpl: ReportViewitUseCase { @Injected private var repository: ReportRepository - func report(viewit: Viewit) -> AnyPublisher { - return repository.createReport(nickname: viewit.userNickname, text: viewit.text) + func report(viewit: Viewit, message: String) -> AnyPublisher { + let text = message.isEmpty ? viewit.text : message + return repository.createReport(nickname: viewit.userNickname, text: text) .map { viewit } .eraseToAnyPublisher() } diff --git a/Wable-iOS/Infra/Auth/AppleAuthProvider.swift b/Wable-iOS/Infra/Auth/AppleAuthProvider.swift index b31f13bc..7de8ad1e 100644 --- a/Wable-iOS/Infra/Auth/AppleAuthProvider.swift +++ b/Wable-iOS/Infra/Auth/AppleAuthProvider.swift @@ -57,7 +57,7 @@ extension AppleAuthProvider: ASAuthorizationControllerDelegate { func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { guard let promise = self.promise else { return } - promise(.failure(.failedToValidateAppleLogin)) + promise(.failure(.failedToAppleLogin)) } } diff --git a/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift b/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift index 833bb827..8d4ff705 100644 --- a/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift +++ b/Wable-iOS/Infra/Auth/KakaoAuthProvider.swift @@ -41,12 +41,12 @@ private extension KakaoAuthProvider { promise: @escaping (Result) -> Void ) { if error != nil { - promise(.failure(.kakaoUnauthorizedUser)) + promise(.failure(.failedToKakaoLogin)) return } guard let token = oauthToken?.accessToken else { - promise(.failure(.kakaoUnauthorizedUser)) + promise(.failure(.failedToKakaoLogin)) return } diff --git a/Wable-iOS/Infra/Local/UserDefaultsStorage.swift b/Wable-iOS/Infra/Local/UserDefaultsStorage.swift index 7f19c430..7f2662f1 100644 --- a/Wable-iOS/Infra/Local/UserDefaultsStorage.swift +++ b/Wable-iOS/Infra/Local/UserDefaultsStorage.swift @@ -13,7 +13,11 @@ struct UserDefaultsStorage { private let jsonEncoder: JSONEncoder private let jsonDecoder: JSONDecoder - init(userDefaults: UserDefaults = .standard, jsonEncoder: JSONEncoder, jsonDecoder: JSONDecoder) { + init( + userDefaults: UserDefaults = .standard, + jsonEncoder: JSONEncoder = JSONEncoder(), + jsonDecoder: JSONDecoder = JSONDecoder() + ) { self.userDefaults = userDefaults self.jsonEncoder = jsonEncoder self.jsonDecoder = jsonDecoder diff --git a/Wable-iOS/Presentation/Community/View/CommunityViewController.swift b/Wable-iOS/Presentation/Community/View/CommunityViewController.swift index 5727880f..c6ce859c 100644 --- a/Wable-iOS/Presentation/Community/View/CommunityViewController.swift +++ b/Wable-iOS/Presentation/Community/View/CommunityViewController.swift @@ -5,6 +5,7 @@ // Created by 김진웅 on 4/8/25. // +import Combine import UIKit import SafariServices @@ -31,11 +32,8 @@ final class CommunityViewController: UIViewController { private var dataSource: DataSource? private let viewModel: ViewModel - private let viewDidLoadRelay = PassthroughRelay() - private let viewDidRefreshRelay = PassthroughRelay() - private let registerRelay = PassthroughRelay() - private let copyLinkRelay = PassthroughRelay() - private let checkNotificationAuthorizationRelay = PassthroughRelay() + private let registerSubject = PassthroughSubject() + private let checkNotificationAuthorizationSubject = PassthroughSubject() private let cancelBag = CancelBag() private let rootView = CommunityView() @@ -65,18 +63,20 @@ final class CommunityViewController: UIViewController { setupNavigationBar() setupDataSource() setupBinding() - - viewDidLoadRelay.send() } } -// MARK: - Setup Method - private extension CommunityViewController { + + // MARK: - Setup Method + func setupAction() { - askButton.addTarget(self, action: #selector(askButtonDidTap), for: .touchUpInside) - - refreshControl?.addTarget(self, action: #selector(collectionViewDidRefresh), for: .valueChanged) + rootView.askDidTap + .compactMap { URL(string: StringLiterals.URL.feedbackForm) } + .sink { [weak self] url in + self?.present(SFSafariViewController(url: url), animated: true) + } + .store(in: cancelBag) } func setupNavigationBar() { @@ -102,7 +102,6 @@ private extension CommunityViewController { let teamName = item.community.team?.rawValue ?? "" let progress = Float(item.community.registrationRate) / Float(100) - WableLogger.log("프로그레스 값은: \(progress)", for: .debug) cell.configure( image: UIImage(named: teamName.lowercased()), @@ -120,7 +119,7 @@ private extension CommunityViewController { let headerKind = UICollectionView.elementKindSectionHeader let headerRegistration = SupplementaryRegistration(elementKind: headerKind) { _, _, _ in } - dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, item in + dataSource = DataSource(collectionView: rootView.collectionView) { collectionView, indexPath, item in if item.hasRegisteredCommunity, item.isRegistered { return collectionView.dequeueConfiguredReusableCell( using: inviteCellRegistration, @@ -150,48 +149,38 @@ private extension CommunityViewController { func setupBinding() { let input = ViewModel.Input( - viewDidLoad: viewDidLoadRelay.eraseToAnyPublisher(), - viewDidRefresh: viewDidRefreshRelay.eraseToAnyPublisher(), - register: registerRelay.eraseToAnyPublisher(), - checkNotificationAuthorization: checkNotificationAuthorizationRelay.eraseToAnyPublisher() + refresh: rootView.didRefresh, + register: registerSubject.eraseToAnyPublisher(), + checkNotificationAuthorization: checkNotificationAuthorizationSubject.eraseToAnyPublisher() ) let output = viewModel.transform(input: input, cancelBag: cancelBag) output.communityItems - .removeDuplicates() - .sink { [weak self] communityItems in - self?.applySnapshot(items: communityItems) - } + .sink { [weak self] in self?.applySnapshot(items: $0) } .store(in: cancelBag) output.isLoading .filter { !$0 } - .sink { [weak self] _ in - self?.refreshControl?.endRefreshing() - } + .sink { [weak self] _ in self?.rootView.refreshControl.endRefreshing() } .store(in: cancelBag) - output.completeRegistration + output.registrationCompleted .compactMap { $0 } .sink { [weak self] team in - self?.scrollToTopItem() + self?.scrollToTop() self?.showCompleteSheet(for: team.rawValue) } .store(in: cancelBag) output.isNotificationAuthorized .filter { !$0 } - .sink { [weak self] _ in - self?.showAlarmSettingSheet() - } + .sink { [weak self] _ in self?.showAlarmSettingSheet() } .store(in: cancelBag) } -} - -// MARK: - Helper Method + + // MARK: - Helper Method -private extension CommunityViewController { func applySnapshot(items: [Item]) { var snapshot = Snapshot() snapshot.appendSections([.main]) @@ -209,7 +198,7 @@ private extension CommunityViewController { let registerAction = WableSheetAction(title: "신청하기", style: .primary) { [weak self] in AmplitudeManager.shared.trackEvent(tag: .clickApplyTeamzone) - self?.registerRelay.send(item) + self?.registerSubject.send(item) } wableSheet.addActions(cancelAction, registerAction) present(wableSheet, animated: true) @@ -221,7 +210,7 @@ private extension CommunityViewController { DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { completeSheet.dismiss(animated: true) { [weak self] in - self?.checkNotificationAuthorizationRelay.send() + self?.checkNotificationAuthorizationSubject.send() } } } @@ -262,37 +251,7 @@ private extension CommunityViewController { UIPasteboard.general.string = StringLiterals.URL.littly } - func scrollToTopItem() { - guard collectionView.numberOfSections > 0, - collectionView.numberOfItems(inSection: 0) > 0 - else { - return - } - - let indexPath = IndexPath(item: 0, section: 0) - collectionView.scrollToItem(at: indexPath, at: .top, animated: true) - } -} - -// MARK: - Action Method - -private extension CommunityViewController { - @objc func askButtonDidTap() { - guard let url = URL(string: StringLiterals.URL.feedbackForm) else { return } - - let safariController = SFSafariViewController(url: url) - present(safariController, animated: true) - } - - @objc func collectionViewDidRefresh() { - viewDidRefreshRelay.send() + func scrollToTop() { + rootView.collectionView.setContentOffset(.zero, animated: true) } } - -// MARK: - Computed Property - -private extension CommunityViewController { - var collectionView: UICollectionView { rootView.collectionView } - var refreshControl: UIRefreshControl? { rootView.collectionView.refreshControl } - var askButton: UIButton { rootView.askButton } -} diff --git a/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift b/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift index 90f0c3db..cd096d6d 100644 --- a/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift +++ b/Wable-iOS/Presentation/Community/View/Subview/CommunityView.swift @@ -5,6 +5,7 @@ // Created by 김진웅 on 4/11/25. // +import Combine import UIKit import SnapKit @@ -14,22 +15,29 @@ final class CommunityView: UIView { // MARK: - UIComponent + private let statusBarBackgroundView = UIView(backgroundColor: .wableBlack) + + private let navigationView = NavigationView(type: .hub(title: "커뮤니티", isBeta: true)) + lazy var collectionView = UICollectionView( frame: .zero, collectionViewLayout: collectionViewLayout ).then { - $0.refreshControl = UIRefreshControl() $0.alwaysBounceVertical = true } + let refreshControl = UIRefreshControl() + let askButton = WableButton(style: .black).then { var config = $0.configuration - config?.attributedTitle = Constant.askButtonTitle + config?.attributedTitle = StringLiterals.Community.askButtonTitle .pretendardString(with: .body3) .highlight(textColor: .sky50, to: "요청하기") $0.configuration = config } + private let underlineView = UIView(backgroundColor: .gray200) + // MARK: - Initializer override init(frame: CGRect) { @@ -44,19 +52,19 @@ final class CommunityView: UIView { } } -// MARK: - Setup Method +extension CommunityView { + var askDidTap: AnyPublisher { askButton.publisher(for: .touchUpInside).eraseToAnyPublisher() } + var didRefresh: AnyPublisher { refreshControl.publisher(for: .valueChanged).eraseToAnyPublisher() } +} private extension CommunityView { + + // MARK: - Setup Method + func setupView() { backgroundColor = .wableWhite - let statusBarBackgroundView = UIView(backgroundColor: .wableBlack) - - let navigationView = NavigationView(type: .hub(title: "커뮤니티", isBeta: true)).then { - $0.configureView() - } - - let underlineView = UIView(backgroundColor: .gray200) + collectionView.refreshControl = refreshControl addSubviews( statusBarBackgroundView, @@ -93,11 +101,9 @@ private extension CommunityView { make.height.equalTo(1) } } -} - -// MARK: - Computed Property + + // MARK: - CollectionViewLayout -private extension CommunityView { var collectionViewLayout: UICollectionViewCompositionalLayout { let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), @@ -128,11 +134,3 @@ private extension CommunityView { return UICollectionViewCompositionalLayout(section: section) } } - -// MARK: - Constant - -private extension CommunityView { - enum Constant { - static let askButtonTitle = "더 추가하고 싶은 게시판이 있다면? 요청하기" - } -} diff --git a/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift b/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift index 1ba90ebb..218210a9 100644 --- a/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift +++ b/Wable-iOS/Presentation/Community/ViewModel/CommunityViewModel.swift @@ -10,17 +10,18 @@ import Foundation import UserNotifications final class CommunityViewModel { - private let useCase: CommunityUseCase + @Injected private var repository: CommunityRepository - init(useCase: CommunityUseCase) { - self.useCase = useCase - } + private var userRegistrationState = CommunityRegistration.initialState() + + private let communityListSubject = CurrentValueSubject<[Community], Never>([]) + private let loadingStateSubject = CurrentValueSubject(false) + private let registrationCompletedSubject = CurrentValueSubject(nil) } extension CommunityViewModel: ViewModelType { struct Input { - let viewDidLoad: Driver - let viewDidRefresh: Driver + let refresh: Driver let register: Driver let checkNotificationAuthorization: Driver } @@ -28,97 +29,91 @@ extension CommunityViewModel: ViewModelType { struct Output { let communityItems: Driver<[CommunityItem]> let isLoading: Driver - let completeRegistration: Driver + let registrationCompleted: Driver let isNotificationAuthorized: Driver } func transform(input: Input, cancelBag: CancelBag) -> Output { - let registrationRelay = CurrentValueRelay(.initialState()) - let communityListRelay = CurrentValueRelay<[Community]>([]) - let isLoadingRelay = CurrentValueRelay(false) - let completeRegistrationRelay = CurrentValueRelay(nil) + bindInitialLoad(cancelBag: cancelBag) + bindRefresh(input: input, cancelBag: cancelBag) + bindRegister(input: input, cancelBag: cancelBag) - useCase.isUserRegistered() + return Output( + communityItems: createItemsPublisher(), + isLoading: loadingStateSubject.removeDuplicates().asDriver(), + registrationCompleted: registrationCompletedSubject.asDriver(), + isNotificationAuthorized: createIsNotificationAuthorizedPublisher(input: input) + ) + } +} + +private extension CommunityViewModel { + func bindInitialLoad(cancelBag: CancelBag) { + repository.checkUserRegistration() .catch { error -> AnyPublisher in WableLogger.log("\(error.localizedDescription)", for: .error) return .just(.initialState()) } - .sink { status in - registrationRelay.send(status) - } - .store(in: cancelBag) - - input.viewDidLoad + .handleEvents(receiveOutput: { [weak self] in self?.userRegistrationState = $0 }) .withUnretained(self) - .flatMap { owner, _ -> AnyPublisher<[Community], Never> in - owner.useCase.fetchCommunityList() - .catch { error -> AnyPublisher<[Community], Never> in - WableLogger.log("\(error.localizedDescription)", for: .error) - return .just([]) - } - .eraseToAnyPublisher() + .flatMap(maxPublishers: .max(1)) { owner, _ -> AnyPublisher<[Community], Never> in + owner.fetchCommunityList() } - .filter { !$0.isEmpty } - .sink { communityListRelay.send($0) } + .sink { [weak self] in self?.communityListSubject.send($0) } .store(in: cancelBag) - - let viewDidRefresh = input.viewDidRefresh - .handleEvents(receiveOutput: { _ in - isLoadingRelay.send(true) - }) - - Publishers.Merge(input.viewDidLoad, viewDidRefresh) + } + + func bindRefresh(input: Input, cancelBag: CancelBag) { + input.refresh + .handleEvents(receiveOutput: { [weak self] in self?.loadingStateSubject.send(true) }) .withUnretained(self) - .flatMap { owner, _ -> AnyPublisher<[Community], Never> in - owner.useCase.fetchCommunityList() - .catch { error -> AnyPublisher<[Community], Never> in - WableLogger.log("\(error.localizedDescription)", for: .error) - return .just([]) - } - .eraseToAnyPublisher() + .flatMap { owner, _ -> AnyPublisher<[Community], Never> in + owner.fetchCommunityList() } - .filter { !$0.isEmpty } - .sink { communityListRelay.send($0) } + .sink { [weak self] in self?.communityListSubject.send($0) } .store(in: cancelBag) - - input.register - .compactMap { communityListRelay.value[$0].team } - .handleEvents(receiveOutput: { team in - registrationRelay.send(.init(team: team, hasRegisteredTeam: true)) + } + + func bindRegister(input: Input, cancelBag: CancelBag) { + let teamPublisher: AnyPublisher = input.register + .compactMap { [weak self] in self?.communityListSubject.value[$0].team } + .handleEvents(receiveOutput: { [weak self] team in + self?.userRegistrationState = CommunityRegistration(team: team, hasRegisteredTeam: true) }) + .eraseToAnyPublisher() + + teamPublisher .withUnretained(self) - .flatMap { owner, team -> AnyPublisher in - return owner.useCase.register(for: team) - .map { value -> Double? in - return value - } + .flatMap { owner, team -> AnyPublisher<(LCKTeam, Double), Never> in + return owner.repository.updateRegistration(communityName: team.rawValue) + .map { Optional.some($0) } .catch { error -> AnyPublisher in WableLogger.log("\(error.localizedDescription)", for: .error) return .just(nil) } .compactMap { $0 } - .handleEvents(receiveOutput: { _ in - completeRegistrationRelay.send(team) - }) + .handleEvents(receiveOutput: { [weak self] _ in self?.registrationCompletedSubject.send(team) }) + .map { (team, $0) } .eraseToAnyPublisher() } - .sink { updatedRate in - guard let team = registrationRelay.value.team, - let index = communityListRelay.value.firstIndex(where: { $0.team == team }) - else { - WableLogger.log("팀을 찾을 수 없습니다.", for: .debug) - return + .sink { [weak self] team, updatedRate in + guard let index = self?.communityListSubject.value.firstIndex(where: { $0.team == team }) else { + return WableLogger.log("팀을 찾을 수 없습니다.", for: .debug) } - communityListRelay.value[index].registrationRate = updatedRate + self?.communityListSubject.value[index].registrationRate = updatedRate } .store(in: cancelBag) - - let communityItems = communityListRelay - .map { communityList in - let registration = registrationRelay.value + } + + func createItemsPublisher() -> AnyPublisher<[CommunityItem], Never> { + return communityListSubject + .map { [weak self] list in + guard let registration = self?.userRegistrationState else { + return [] + } - return communityList.map { + return list.map { let isRegistered = registration.hasRegisteredTeam ? $0.team == registration.team : false @@ -131,27 +126,29 @@ extension CommunityViewModel: ViewModelType { } .sorted { $0.isRegistered && !$1.isRegistered } } - .handleEvents(receiveOutput: { _ in - isLoadingRelay.send(false) - }) + .handleEvents(receiveOutput: { [weak self] _ in self?.loadingStateSubject.send(false) }) + .removeDuplicates() .asDriver() - - let isNotificationAuthorized = input.checkNotificationAuthorization - .flatMap { _ -> AnyPublisher in - Future { promise in + } + + func createIsNotificationAuthorizedPublisher(input: Input) -> AnyPublisher { + return input.checkNotificationAuthorization + .flatMap { _ in + return Future { promise in UNUserNotificationCenter.current().getNotificationSettings { settings in promise(.success(settings.authorizationStatus == .authorized)) } } - .eraseToAnyPublisher() } .asDriver() - - return Output( - communityItems: communityItems, - isLoading: isLoadingRelay.asDriver(), - completeRegistration: completeRegistrationRelay.asDriver(), - isNotificationAuthorized: isNotificationAuthorized - ) + } + + func fetchCommunityList() -> AnyPublisher<[Community], Never> { + return repository.fetchCommunityList() + .catch { error -> AnyPublisher<[Community], Never> in + WableLogger.log("\(error.localizedDescription)", for: .error) + return .just([]) + } + .eraseToAnyPublisher() } } diff --git a/Wable-iOS/Presentation/Enum/DefaultProfileType.swift b/Wable-iOS/Presentation/Enum/DefaultProfileType.swift index 077ebbcd..e8d107ff 100644 --- a/Wable-iOS/Presentation/Enum/DefaultProfileType.swift +++ b/Wable-iOS/Presentation/Enum/DefaultProfileType.swift @@ -8,7 +8,7 @@ import Foundation -enum DefaultProfileType: String { +enum DefaultProfileType: String, CaseIterable { case green = "img_profile_green" case blue = "img_profile_blue" case purple = "img_profile_purple" diff --git a/Wable-iOS/Presentation/Helper/Extension/UIViewController+KeyboardHandling.swift b/Wable-iOS/Presentation/Helper/Extension/UIViewController+KeyboardHandling.swift new file mode 100644 index 00000000..20642d1b --- /dev/null +++ b/Wable-iOS/Presentation/Helper/Extension/UIViewController+KeyboardHandling.swift @@ -0,0 +1,93 @@ +// +// UIViewController+KeyboardHandling.swift +// Wable-iOS +// +// Created by YOUJIM on 6/18/25. +// + + +import UIKit + +// MARK: - Keyboard Handling Extension + +extension UIViewController { + func enableKeyboardHandling() { + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleKeyboardShow(notification: notification) + } + + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleKeyboardHide(notification: notification) + } + } + + func disableKeyboardHandling() { + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - Helper Method + +private extension UIViewController { + private func handleKeyboardShow(notification: Notification) { + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue, + let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, + let animationCurve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { + return + } + + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + + animateViewTransform( + window: window, + yOffset: -(keyboardFrame.cgRectValue.height - (window?.safeAreaInsets.bottom ?? 0)), + duration: animationDuration, + curve: animationCurve + ) + } + + private func handleKeyboardHide(notification: Notification) { + guard let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, + let animationCurve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt + else { + return + } + + animateViewTransform( + window: UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow }, + yOffset: 0, + duration: animationDuration, + curve: animationCurve + ) + } + + private func animateViewTransform(window: UIWindow?, yOffset: CGFloat, duration: Double, curve: UInt) { + guard let window = window else { return } + + let animationOptions = UIView.AnimationOptions(rawValue: curve << 16) + + UIView.animate( + withDuration: duration, + delay: 0, + options: [animationOptions, .beginFromCurrentState], + animations: { + window.transform = CGAffineTransform(translationX: 0, y: yOffset) + }, + completion: nil + ) + } +} diff --git a/Wable-iOS/Presentation/Helper/UIAlertShowable.swift b/Wable-iOS/Presentation/Helper/UIAlertShowable.swift index 384dcf66..38b71c58 100644 --- a/Wable-iOS/Presentation/Helper/UIAlertShowable.swift +++ b/Wable-iOS/Presentation/Helper/UIAlertShowable.swift @@ -11,6 +11,7 @@ import UIKit protocol UIAlertShowable: AnyObject { func showAlert(title: String, message: String?, actions: UIAlertAction...) + func showAlert(title: String, message: String?, actions: [UIAlertAction]) } extension UIAlertShowable where Self: UIViewController { @@ -20,6 +21,12 @@ extension UIAlertShowable where Self: UIViewController { present(alert, animated: true) } + func showAlert(title: String, message: String?, actions: [UIAlertAction]) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + actions.forEach { alert.addAction($0) } + present(alert, animated: true) + } + func showAlertWithCancel(title: String, message: String? = nil, action: UIAlertAction) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let cancelAction = UIAlertAction(title: "취소", style: .cancel) diff --git a/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift b/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift index 66244756..022c95da 100644 --- a/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift +++ b/Wable-iOS/Presentation/Helper/WableBottomSheetShowable.swift @@ -11,6 +11,7 @@ import UIKit protocol WableBottomSheetShowable: AnyObject { func showBottomSheet(actions: WableBottomSheetAction...) + func showBottomSheet(actions: [WableBottomSheetAction]) } extension WableBottomSheetShowable where Self: UIViewController { @@ -19,6 +20,12 @@ extension WableBottomSheetShowable where Self: UIViewController { actions.forEach { wableBottomSheet.addActions($0) } present(wableBottomSheet, animated: true) } + + func showBottomSheet(actions: [WableBottomSheetAction]) { + let wableBottomSheet = WableBottomSheetController() + wableBottomSheet.addActions(actions) + present(wableBottomSheet, animated: true) + } } // MARK: - UIViewController Extension diff --git a/Wable-iOS/Presentation/Helper/WableSheetShowable.swift b/Wable-iOS/Presentation/Helper/WableSheetShowable.swift index 70aba696..7219563c 100644 --- a/Wable-iOS/Presentation/Helper/WableSheetShowable.swift +++ b/Wable-iOS/Presentation/Helper/WableSheetShowable.swift @@ -11,6 +11,7 @@ import UIKit protocol WableSheetShowable { func showWableSheet(title: String, message: String?, actions: WableSheetAction...) + func showWableSheet(title: String, message: String?, actions: [WableSheetAction]) } extension WableSheetShowable where Self: UIViewController { @@ -20,6 +21,12 @@ extension WableSheetShowable where Self: UIViewController { present(wableSheet, animated: true) } + func showWableSheet(title: String, message: String?, actions: [WableSheetAction]) { + let wableSheet = WableSheetViewController(title: title, message: message) + wableSheet.addActions(actions) + present(wableSheet, animated: true) + } + func showWableSheetWithCancel(title: String, message: String? = nil, action: WableSheetAction) { let wableSheet = WableSheetViewController(title: title, message: message) let cancelAction = WableSheetAction(title: "취소", style: .gray) diff --git a/Wable-iOS/Presentation/Helper/WableTextSheetShowable.swift b/Wable-iOS/Presentation/Helper/WableTextSheetShowable.swift new file mode 100644 index 00000000..a1ee1370 --- /dev/null +++ b/Wable-iOS/Presentation/Helper/WableTextSheetShowable.swift @@ -0,0 +1,55 @@ +// +// WableTextSheetShowable.swift +// Wable-iOS +// +// Created by 김진웅 on 6/23/25. +// + +import UIKit + +// MARK: - WableTextSheetShowable + +protocol WableTextSheetShowable { + func showWableTextSheet(title: String, placeholder: String, actions: WableTextSheetAction...) + func showWableTextSheet(title: String, placeholder: String, actions: [WableTextSheetAction]) +} + +extension WableTextSheetShowable where Self: UIViewController { + func showWableTextSheet(title: String, placeholder: String, actions: WableTextSheetAction...) { + let wableTextSheet = WableTextSheetViewController(title: title, placeholder: placeholder) + actions.forEach { wableTextSheet.addAction($0) } + present(wableTextSheet, animated: true) + } + + func showWableTextSheet(title: String, placeholder: String, actions: [WableTextSheetAction]) { + let wableTextSheet = WableTextSheetViewController(title: title, placeholder: placeholder) + wableTextSheet.addActions(actions) + present(wableTextSheet, animated: true) + } + + func showGhostSheet(onCancel cancelAction: (() -> Void)?, onPrimary primaryAction: @escaping (String?) -> Void) { + let wableTextSheet = WableTextSheetViewController( + title: StringLiterals.Ghost.sheetTitle, + placeholder: StringLiterals.Ghost.sheetPlaceholder + ) + let cancel = WableTextSheetAction(title: "고민할게요", style: .gray, handler: { _ in cancelAction?() }) + let confirm = WableTextSheetAction(title: "투명도 내리기", style: .primary, handler: primaryAction) + wableTextSheet.addActions(cancel, confirm) + present(wableTextSheet, animated: true) + } + + func showReportSheet(onPrimary handler: @escaping (String?) -> Void) { + let wableTextSheet = WableTextSheetViewController( + title: StringLiterals.Report.sheetTitle, + placeholder: StringLiterals.Report.sheetPlaceholder + ) + let cancel = WableTextSheetAction(title: "고민할게요", style: .gray) + let confirm = WableTextSheetAction(title: "신고하기", style: .primary, handler: handler) + wableTextSheet.addActions(cancel, confirm) + present(wableTextSheet, animated: true) + } +} + +// MARK: - UIViewController Extension + +extension UIViewController: WableTextSheetShowable {} diff --git a/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift b/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift index d3f7e89d..cc357817 100644 --- a/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift +++ b/Wable-iOS/Presentation/Home/View/HomeDetailViewController.swift @@ -40,7 +40,7 @@ final class HomeDetailViewController: NavigationViewController { private let didCommentHeartTappedSubject = PassthroughSubject<(Bool, ContentComment), Never>() private let didReplyTappedSubject = PassthroughSubject<(Int, Int), Never>() private let didCommentTappedSubject = PassthroughSubject() - private let didGhostTappedSubject = PassthroughSubject<(Int, Int, PostType), Never>() + private let didGhostTappedSubject = PassthroughSubject<(Int, Int, String?, PostType), Never>() private let didDeleteTappedSubject = PassthroughSubject<(Int, PostType), Never>() private let didBannedTappedSubject = PassthroughSubject<(Int, Int, TriggerType.Ban), Never>() private let didReportTappedSubject = PassthroughSubject<(String, String), Never>() @@ -203,7 +203,10 @@ private extension HomeDetailViewController { let contentCellRegistration = UICollectionView.CellRegistration < ContentCollectionViewCell, Content - > { [weak self] cell, indexPath, item in + > { + [weak self] cell, + indexPath, + item in guard let self = self else { return } cell.divideView.snp.updateConstraints { make in @@ -217,7 +220,8 @@ private extension HomeDetailViewController { contentImageViewTapHandler: { guard let image = cell.contentImageView.image else { return } - self.present(PhotoDetailViewController(image: image), animated: true) + let photoDetailViewController = PhotoDetailViewController(image: image) + self.navigationController?.pushViewController(photoDetailViewController, animated: true) }, likeButtonTapHandler: { AmplitudeManager.shared.trackEvent(tag: .clickLikeComment) @@ -249,50 +253,46 @@ private extension HomeDetailViewController { }) })) } else if self.isActiveUserAdmin ?? false { - viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: { - viewController.dismiss(animated: true, completion: { - let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle) - - viewController.addActions( - WableSheetAction(title: "취소", style: .gray), - WableSheetAction( - title: "신고하기", - style: .primary, - handler: { - viewController.dismiss(animated: true, completion: { - self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text)) - }) - } - ) - ) - - self.present(viewController, animated: true) - }) - }), WableBottomSheetAction(title: "밴하기", handler: { - self.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id, .content)) - }) - ) + let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in + self?.showReportSheet( + onPrimary: { message in + viewController.dismiss( + animated: true, + completion: { + self?.didReportTappedSubject.send( + ( + item.content.contentInfo.author.nickname, + message ?? item.content.contentInfo.text + ) + ) + }) + }) + } + let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in + self?.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id, .content)) + } + self.showBottomSheet(actions: reportAction, banAction) } else { - viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: { - viewController.dismiss(animated: true, completion: { - let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle) - - viewController.addActions( - WableSheetAction(title: "취소", style: .gray), - WableSheetAction( - title: "신고하기", - style: .primary, - handler: { - viewController.dismiss(animated: true, completion: { - self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text)) + let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in + self?.showReportSheet( + onPrimary: { message in + viewController.dismiss( + animated: true, + completion: { + viewController.dismiss( + animated: true, + completion: { + self?.didReportTappedSubject.send( + ( + item.content.contentInfo.author.nickname, + message ?? item.content.contentInfo.text + ) + ) }) - } - ) - ) - - self.present(viewController, animated: true) - }) - })) + }) + }) + } + self.showBottomSheet(actions: reportAction) } self.present(viewController, animated: true) @@ -321,30 +321,13 @@ private extension HomeDetailViewController { }, ghostButtonTapHandler: { AmplitudeManager.shared.trackEvent(tag: .clickGhostPost) - - let viewController = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle) - - viewController.addActions( - WableSheetAction( - title: "고민할게요", - style: .gray, - handler: { - AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) - }), - WableSheetAction( - title: "네 맞아요", - style: .primary, - handler: { - AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) - - viewController.dismiss(animated: true, completion: { - self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id, .content)) - }) - } - ) - ) - - self.present(viewController, animated: true) + self.showGhostSheet(onCancel: { + AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) + }, onPrimary: { message in + AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) + + self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id, message, .content)) + }) } ) @@ -372,7 +355,10 @@ private extension HomeDetailViewController { let commentCellRegistration = UICollectionView.CellRegistration < CommentCollectionViewCell, ContentComment - > { [weak self] cell, indexPath, item in + > { + [weak self] cell, + indexPath, + item in guard let self = self else { return } cell.configureCell( @@ -413,62 +399,37 @@ private extension HomeDetailViewController { }) })) } else if self.isActiveUserAdmin ?? false { - viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: { - viewController.dismiss(animated: true, completion: { - let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle) - - viewController.addActions( - WableSheetAction( - title: "취소", - style: .gray, - handler: { - viewController.dismiss(animated: true) - } - ), - WableSheetAction( - title: "신고하기", - style: .primary, - handler: { - viewController.dismiss(animated: true, completion: { - self.didReportTappedSubject.send((item.comment.author.nickname, item.comment.text)) - }) - } - ) - ) - - self.present(viewController, animated: true) - }) - }), WableBottomSheetAction(title: "밴하기", handler: { - self.didBannedTappedSubject.send((item.comment.author.id, item.comment.id, .comment)) - }) - ) + let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in + self?.showReportSheet( + onPrimary: { message in + viewController.dismiss( + animated: true, + completion: { + self?.didReportTappedSubject.send((item.comment.author.nickname, message ?? item.comment.text)) + }) + }) + } + let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in + self?.didBannedTappedSubject.send((item.comment.author.id, item.comment.id, .comment)) + } + self.showBottomSheet(actions: reportAction, banAction) } else { - viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: { - viewController.dismiss(animated: true, completion: { - let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle) - - viewController.addActions( - WableSheetAction( - title: "취소", - style: .gray, - handler: { - viewController.dismiss(animated: true) - } - ), - WableSheetAction( - title: "신고하기", - style: .primary, - handler: { - viewController.dismiss(animated: true, completion: { - self.didReportTappedSubject.send((item.comment.author.nickname, item.comment.text)) - }) - } - ) - ) - - self.present(viewController, animated: true) - }) - })) + let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in + self?.showReportSheet( + onPrimary: { message in + viewController.dismiss( + animated: true, + completion: { + self?.didReportTappedSubject.send( + ( + item.comment.author.nickname, + message ?? item.comment.text + ) + ) + }) + }) + } + self.showBottomSheet(actions: reportAction) } self.present(viewController, animated: true) @@ -497,33 +458,13 @@ private extension HomeDetailViewController { }, ghostButtonTapHandler: { AmplitudeManager.shared.trackEvent(tag: .clickGhostComment) - - let viewController = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle) - - viewController.addActions( - WableSheetAction( - title: "고민할게요", - style: .gray, - handler: { - AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) - - viewController.dismiss(animated: true) - } - ), - WableSheetAction( - title: "네 맞아요", - style: .primary, - handler: { - AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) - - viewController.dismiss(animated: true, completion: { - self.didGhostTappedSubject.send((item.comment.id, item.comment.author.id, .comment)) - }) - } - ) - ) - - self.present(viewController, animated: true) + self.showGhostSheet(onCancel: { + AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) + }, onPrimary: { message in + AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) + + self.didGhostTappedSubject.send((item.comment.id, item.comment.author.id, message, .comment)) + }) }, replyButtonTapHandler: { AmplitudeManager.shared.trackEvent(tag: .clickWriteRecomment) @@ -650,6 +591,12 @@ private extension HomeDetailViewController { } .store(in: cancelBag) + output.contentNotFound + .sink { [weak self] _ in + self?.showNotFoundViewController() + } + .store(in: cancelBag) + output.comments .receive(on: DispatchQueue.main) .withUnretained(self) @@ -857,6 +804,15 @@ extension HomeDetailViewController { } } } + + func showNotFoundViewController() { + let tabBar = navigationController?.tabBarController + let notFoundViewController = NotFoundViewController { [weak self] in + self?.navigationController?.popToRootViewController(animated: false) + tabBar?.selectedIndex = 0 + } + present(notFoundViewController, animated: true) + } } // MARK: - UITextViewDelegate diff --git a/Wable-iOS/Presentation/Home/View/HomeViewController.swift b/Wable-iOS/Presentation/Home/View/HomeViewController.swift index 4e67a0e4..e6cf0ea7 100644 --- a/Wable-iOS/Presentation/Home/View/HomeViewController.swift +++ b/Wable-iOS/Presentation/Home/View/HomeViewController.swift @@ -33,7 +33,7 @@ final class HomeViewController: NavigationViewController { private let didRefreshSubject = PassthroughSubject() private let didSelectedSubject = PassthroughSubject() private let didHeartTappedSubject = PassthroughSubject<(Int, Bool), Never>() - private let didGhostTappedSubject = PassthroughSubject<(Int, Int), Never>() + private let didGhostTappedSubject = PassthroughSubject<(Int, Int, String?), Never>() private let didDeleteTappedSubject = PassthroughSubject() private let didBannedTappedSubject = PassthroughSubject<(Int, Int), Never>() private let didReportTappedSubject = PassthroughSubject<(String, String), Never>() @@ -186,7 +186,7 @@ private extension HomeViewController { return } - self.present(PhotoDetailViewController(image: image), animated: true) + self.navigationController?.pushViewController(PhotoDetailViewController(image: image), animated: true) }, likeButtonTapHandler: { AmplitudeManager.shared.trackEvent(tag: .clickLikePost) @@ -220,50 +220,42 @@ private extension HomeViewController { }) })) } else if self.isActiveUserAdmin ?? false { - viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: { - viewController.dismiss(animated: true, completion: { - let viewController = WableSheetViewController(title: StringLiterals.Report.sheetTitle) - - viewController.addActions( - WableSheetAction(title: "취소", style: .gray), - WableSheetAction( - title: "신고하기", - style: .primary, - handler: { - viewController.dismiss(animated: true, completion: { - self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text)) - }) - } - ) - ) - - self.present(viewController, animated: true) - }) - }), WableBottomSheetAction(title: "밴하기", handler: { - self.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id)) - }) - ) + let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in + self?.showReportSheet( + onPrimary: { message in + viewController.dismiss( + animated: true, + completion: { + self?.didReportTappedSubject.send( + ( + item.content.contentInfo.author.nickname, + message ?? item.content.contentInfo.text + ) + ) + }) + }) + } + let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in + self?.didBannedTappedSubject.send((item.content.contentInfo.author.id, item.content.id)) + } + self.showBottomSheet(actions: reportAction, banAction) } else { - viewController.addActions(WableBottomSheetAction(title: "신고하기", handler: { - viewController.dismiss(animated: true, completion: { - let viewController = WableSheetViewController(title: "신고하시겠어요?") - - viewController.addActions( - WableSheetAction(title: "취소", style: .gray), - WableSheetAction( - title: "신고하기", - style: .primary, - handler: { - viewController.dismiss(animated: true, completion: { - self.didReportTappedSubject.send((item.content.contentInfo.author.nickname, item.content.contentInfo.text)) - }) - } - ) - ) - - self.present(viewController, animated: true) - }) - })) + let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in + self?.showReportSheet( + onPrimary: { message in + viewController.dismiss( + animated: true, + completion: { + self?.didReportTappedSubject.send( + ( + item.content.contentInfo.author.nickname, + message ?? item.content.contentInfo.text + ) + ) + }) + }) + } + self.showBottomSheet(actions: reportAction) } self.present(viewController, animated: true) @@ -295,31 +287,13 @@ private extension HomeViewController { }, ghostButtonTapHandler: { AmplitudeManager.shared.trackEvent(tag: .clickGhostPost) - - let viewController = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle) - - viewController.addActions( - WableSheetAction( - title: "고민할게요", - style: .gray, - handler: { - AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) - } - ), - WableSheetAction( - title: "네 맞아요", - style: .primary, - handler: { - AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) - - viewController.dismiss(animated: true, completion: { - self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id)) - }) - } - ) - ) - - self.present(viewController, animated: true) + self.showGhostSheet(onCancel: { + AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) + }, onPrimary: { message in + AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) + + self.didGhostTappedSubject.send((item.content.id, item.content.contentInfo.author.id, message)) + }) } ) } @@ -508,6 +482,7 @@ private extension HomeViewController { ) ) ) + viewController.onPostCompleted = { [weak self] in self?.scrollToTop() } diff --git a/Wable-iOS/Presentation/Home/View/WritePostViewController.swift b/Wable-iOS/Presentation/Home/View/WritePostViewController.swift index 14b05d66..99c2a55f 100644 --- a/Wable-iOS/Presentation/Home/View/WritePostViewController.swift +++ b/Wable-iOS/Presentation/Home/View/WritePostViewController.swift @@ -208,6 +208,8 @@ private extension WritePostViewController { self.isPosting = false self.updatePostButtonState(isLoading: false) + self.onPostCompleted?() + if self.navigationController?.topViewController == self { let toast = ToastView(status: .complete, message: "게시물이 작성되었습니다") toast.show() diff --git a/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift b/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift index 690aa864..cdc1f8d3 100644 --- a/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift +++ b/Wable-iOS/Presentation/Home/ViewModel/HomeDetailViewModel.swift @@ -67,7 +67,7 @@ extension HomeDetailViewModel: ViewModelType { let didCommentTappedItem: AnyPublisher let didReplyTappedItem: AnyPublisher<(Int, Int), Never> let didCreateTappedItem: AnyPublisher - let didGhostTappedItem: AnyPublisher<(Int, Int, PostType), Never> + let didGhostTappedItem: AnyPublisher<(Int, Int, String?, PostType), Never> let didDeleteTappedItem: AnyPublisher<(Int, PostType), Never> let didBannedTappedItem: AnyPublisher<(Int, Int, TriggerType.Ban), Never> let didReportTappedItem: AnyPublisher<(String, String), Never> @@ -78,6 +78,7 @@ extension HomeDetailViewModel: ViewModelType { let activeUserID: AnyPublisher let isAdmin: AnyPublisher let content: AnyPublisher + let contentNotFound: AnyPublisher let comments: AnyPublisher<[ContentComment], Never> let isLoading: AnyPublisher let isLoadingMore: AnyPublisher @@ -89,6 +90,7 @@ extension HomeDetailViewModel: ViewModelType { func transform(input: Input, cancelBag: CancelBag) -> Output { let contentSubject = CurrentValueSubject(nil) + let contentNotFoundSubject = PassthroughSubject() let commentsSubject = CurrentValueSubject<[ContentComment], Never>([]) let isLoadingSubject = CurrentValueSubject(false) let isLoadingMoreSubject = CurrentValueSubject(false) @@ -142,6 +144,13 @@ extension HomeDetailViewModel: ViewModelType { .map { contentInfo -> ContentInfo? in return contentInfo } + .catch { error -> AnyPublisher in + WableLogger.log("\(error.localizedDescription)", for: .error) + if case WableError.notFoundContent = error { + contentNotFoundSubject.send() + } + return .just(nil) + } .replaceError(with: nil) let commentsPublisher = owner.fetchContentCommentListUseCase.execute( @@ -303,7 +312,7 @@ extension HomeDetailViewModel: ViewModelType { input.didGhostTappedItem .withUnretained(self) .flatMap { owner, input -> AnyPublisher in - return owner.fetchGhostUseCase.execute(type: input.2, targetID: input.0, userID: input.1) + return owner.fetchGhostUseCase.execute(type: input.3, targetID: input.0, userID: input.1, reason: input.2) .map { _ in input.1 } .asDriver(onErrorJustReturn: input.1) } @@ -487,6 +496,7 @@ extension HomeDetailViewModel: ViewModelType { activeUserID: activeUserIDSubject.eraseToAnyPublisher(), isAdmin: isAdminSubject.eraseToAnyPublisher(), content: contentSubject.eraseToAnyPublisher(), + contentNotFound: contentNotFoundSubject.asDriver(), comments: commentsSubject.eraseToAnyPublisher(), isLoading: isLoadingSubject.eraseToAnyPublisher(), isLoadingMore: isLoadingMoreSubject.eraseToAnyPublisher(), diff --git a/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift b/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift index 021700d2..891fdfdc 100644 --- a/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift +++ b/Wable-iOS/Presentation/Home/ViewModel/HomeViewModel.swift @@ -46,7 +46,7 @@ extension HomeViewModel: ViewModelType { let viewDidRefresh: AnyPublisher let didSelectedItem: AnyPublisher let didHeartTappedItem: AnyPublisher<(Int, Bool), Never> - let didGhostTappedItem: AnyPublisher<(Int, Int), Never> + let didGhostTappedItem: AnyPublisher<(Int, Int, String?), Never> let didDeleteTappedItem: AnyPublisher let didBannedTappedItem: AnyPublisher<(Int, Int), Never> let didReportTappedItem: AnyPublisher<(String, String), Never> @@ -197,7 +197,7 @@ extension HomeViewModel: ViewModelType { input.didGhostTappedItem .withUnretained(self) .flatMap { owner, id -> AnyPublisher in - return owner.fetchGhostUseCase.execute(type: .content, targetID: id.0, userID: id.1) + return owner.fetchGhostUseCase.execute(type: .content, targetID: id.0, userID: id.1, reason: id.2) .map { _ in id.1 } .asDriver(onErrorJustReturn: id.1) } diff --git a/Wable-iOS/Presentation/Login/LoginViewController.swift b/Wable-iOS/Presentation/Login/LoginViewController.swift index 1469be9f..08a283f5 100644 --- a/Wable-iOS/Presentation/Login/LoginViewController.swift +++ b/Wable-iOS/Presentation/Login/LoginViewController.swift @@ -32,33 +32,33 @@ final class LoginViewController: UIViewController { } private let titleLabel: UILabel = UILabel().then { - $0.attributedText = StringLiterals.Login.title.pretendardString(with: .head0) - $0.textAlignment = .center $0.numberOfLines = 0 - $0.textColor = .black + $0.textColor = .wableBlack + $0.textAlignment = .center + $0.attributedText = StringLiterals.Login.title.pretendardString(with: .head0) } private lazy var kakaoButton: UIButton = UIButton().then { - $0.setImage(.btnKakao, for: .normal) - $0.imageView?.contentMode = .scaleAspectFit + $0.clipsToBounds = true $0.contentVerticalAlignment = .fill + $0.setImage(.btnKakao, for: .normal) $0.contentHorizontalAlignment = .fill $0.backgroundColor = UIColor("FEE500") $0.layer.cornerRadius = 6.adjustedHeight - $0.clipsToBounds = true + $0.imageView?.contentMode = .scaleAspectFit } private lazy var appleButton: UIButton = UIButton().then { - $0.setImage(.btnApple, for: .normal) - $0.imageView?.contentMode = .scaleAspectFit + $0.clipsToBounds = true + $0.backgroundColor = .wableBlack $0.contentVerticalAlignment = .fill + $0.setImage(.btnApple, for: .normal) $0.contentHorizontalAlignment = .fill - $0.backgroundColor = .wableBlack $0.layer.cornerRadius = 6.adjustedHeight - $0.clipsToBounds = true + $0.imageView?.contentMode = .scaleAspectFit } - // MARK: - LifeCycle + // MARK: - Life Cycle init(viewModel: LoginViewModel) { self.viewModel = viewModel @@ -66,6 +66,7 @@ final class LoginViewController: UIViewController { super.init(nibName: nil, bundle: nil) } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -73,19 +74,15 @@ final class LoginViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - setupView() setupConstraint() setupBinding() } } -// MARK: - Private Extension +// MARK: - Setup Method private extension LoginViewController { - - // MARK: - Setup - - func setupView() { + func setupConstraint() { view.addSubviews( backgroundImageView, logoImageView, @@ -94,40 +91,38 @@ private extension LoginViewController { kakaoButton, appleButton ) - } - - func setupConstraint() { + backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() } logoImageView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).offset(44) - $0.centerX.equalToSuperview() $0.adjustedWidthEqualTo(104) $0.adjustedHeightEqualTo(34) + $0.centerX.equalToSuperview() + $0.top.equalTo(view.safeAreaLayoutGuide).offset(44) } titleLabel.snp.makeConstraints { - $0.top.equalTo(logoImageView.snp.bottom).offset(26) $0.centerX.equalToSuperview() + $0.top.equalTo(logoImageView.snp.bottom).offset(26) } loginImageView.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(42) $0.horizontalEdges.equalToSuperview() + $0.top.equalTo(titleLabel.snp.bottom).offset(42) } appleButton.snp.makeConstraints { - $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(56) - $0.horizontalEdges.equalToSuperview().inset(16) $0.adjustedHeightEqualTo(50) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(56) } kakaoButton.snp.makeConstraints { - $0.bottom.equalTo(appleButton.snp.top).offset(-18) - $0.horizontalEdges.equalToSuperview().inset(16) $0.adjustedHeightEqualTo(50) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.bottom.equalTo(appleButton.snp.top).offset(-18) } } @@ -156,16 +151,31 @@ private extension LoginViewController { .sink { owner, sessionInfo in let condition = sessionInfo.isNewUser || sessionInfo.user.nickname == "" - if condition { - AmplitudeManager.shared.trackEvent(tag: .clickAgreePopupSignup) - } - + if condition { AmplitudeManager.shared.trackEvent(tag: .clickAgreePopupSignup) } condition ? owner.navigateToOnboarding() : owner.navigateToHome() } .store(in: cancelBag) + + output.error + .receive(on: DispatchQueue.main) + .withUnretained(self) + .sink { owner, error in + let toast = WableSheetViewController( + title: "로그인 중 오류가 발생했어요", + message: "\(error.localizedDescription)\n다시 시도해주세요." + ) + + toast.addAction(.init(title: "확인", style: .primary)) + owner.present(toast, animated: true) + } + .store(in: cancelBag) } - - private func navigateToOnboarding() { +} + +// MARK: - Helper Method + +private extension LoginViewController { + func navigateToOnboarding() { let noticeViewController = WableSheetViewController( title: "앗 잠깐!", message: StringLiterals.Onboarding.enterSheetTitle @@ -183,9 +193,10 @@ private extension LoginViewController { present(noticeViewController, animated: true) } - private func navigateToHome() { + func navigateToHome() { let tabBarController = TabBarController() present(tabBarController, animated: true) } } + diff --git a/Wable-iOS/Presentation/Login/LoginViewModel.swift b/Wable-iOS/Presentation/Login/LoginViewModel.swift index 1a68dd04..25a4b9b1 100644 --- a/Wable-iOS/Presentation/Login/LoginViewModel.swift +++ b/Wable-iOS/Presentation/Login/LoginViewModel.swift @@ -14,30 +14,15 @@ final class LoginViewModel { // MARK: Property - private let updateFCMTokenUseCase: UpdateFCMTokenUseCase - private let fetchUserAuthUseCase: FetchUserAuthUseCase - private let updateUserSessionUseCase: FetchUserInformationUseCase - private let userProfileUseCase: UserProfileUseCase private let loginSuccessSubject = PassthroughSubject() private let loginErrorSubject = PassthroughSubject() - // MARK: - LifeCycle - - init( - updateFCMTokenUseCase: UpdateFCMTokenUseCase, - fetchUserAuthUseCase: FetchUserAuthUseCase, - updateUserSessionUseCase: FetchUserInformationUseCase, - userProfileUseCase: UserProfileUseCase - ) { - self.updateFCMTokenUseCase = updateFCMTokenUseCase - self.fetchUserAuthUseCase = fetchUserAuthUseCase - self.updateUserSessionUseCase = updateUserSessionUseCase - self.userProfileUseCase = userProfileUseCase - } + @Injected private var tokenStorage: TokenStorage + @Injected private var loginRepository: LoginRepository + @Injected private var profileRepository: ProfileRepository + @Injected private var userSessionRepository: UserSessionRepository } -// MARK: - Extension - extension LoginViewModel: ViewModelType { struct Input { let kakaoLoginTrigger: AnyPublisher @@ -45,92 +30,110 @@ extension LoginViewModel: ViewModelType { } struct Output { + let error: AnyPublisher let account: AnyPublisher } func transform(input: Input, cancelBag: CancelBag) -> Output { - let kakaoLoginTrigger = input.kakaoLoginTrigger - .map { SocialPlatform.kakao } - let appleLoginTrigger = input.appleLoginTrigger - .map { SocialPlatform.apple } + let kakaoLoginTrigger = input.kakaoLoginTrigger.map { SocialPlatform.kakao } + let appleLoginTrigger = input.appleLoginTrigger.map { SocialPlatform.apple } Publishers.Merge(appleLoginTrigger, kakaoLoginTrigger) .withUnretained(self) - .flatMap { owner, flatform -> AnyPublisher in - return owner.fetchUserAuthUseCase.execute(platform: flatform) - .handleEvents(receiveCompletion: { completion in - if case .failure(let error) = completion { - WableLogger.log("로그인 중 오류 발생: \(error)", for: .error) - } - }) - .catch { error -> AnyPublisher in - return Empty().eraseToAnyPublisher() - } - .eraseToAnyPublisher() + .flatMap { owner, platform -> AnyPublisher in + return owner.fetchUserAuth(platform: platform) } - .sink( - receiveCompletion: { completion in - WableLogger.log("로그인 작업 완료", for: .debug) - }, - receiveValue: { [weak self] account in - guard let self = self else { return } - - self.updateFCMTokenUseCase.execute(nickname: account.user.nickname) - .sink { completion in - if case .failure(let error) = completion { - WableLogger.log("FCM 토큰 저장 중 에러 발생: \(error)", for: .error) - } else { - WableLogger.log("FCM 토큰 저장 성공", for: .network) - } - } receiveValue: { () in - } - .store(in: cancelBag) - - Task { - let isAuthorized = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus == .authorized - - WableLogger.log("\(isAuthorized)", for: .debug) - - self.updateUserSessionUseCase.updateUserSession( - userID: account.user.id, - nickname: account.user.nickname, - profileURL: account.user.profileURL, - isPushAlarmAllowed: isAuthorized, - isAdmin: account.isAdmin, - isAutoLoginEnabled: true - ) - .sink(receiveCompletion: { _ in - }, receiveValue: { _ in - WableLogger.log("로컬에 세션 저장 완료", for: .debug) - }) - .store(in: cancelBag) - - self.userProfileUseCase.execute(userID: account.user.id) - .sink { _ in - } receiveValue: { profile in - self.userProfileUseCase.execute(profile: profile, isPushAlarmAllowed: isAuthorized) - .sink { completion in - switch completion { - case .failure(let error): - WableLogger.log("서버로 프로필 업데이트 중 오류 발생: \(error)", for: .error) - default: - WableLogger.log("로그인 및 서버로 프로필 업데이트 완료", for: .debug) - } - } receiveValue: { _ in - - } - .store(in: cancelBag) - } - .store(in: cancelBag) - } - - self.loginSuccessSubject.send(account) - } - ) + .sink(receiveValue: { [weak self] account in + guard let self = self else { return } + + self.updateFCMToken(account: account, cancelBag: cancelBag) + self.updateUserProfile(userID: account.user.id, cancelBag: cancelBag) + self.loginSuccessSubject.send(account) + }) .store(in: cancelBag) return Output( + error: loginErrorSubject.eraseToAnyPublisher(), account: loginSuccessSubject.eraseToAnyPublisher() ) } } + +// MARK: - Helper Method + +private extension LoginViewModel { + func fetchUserAuth(platform: SocialPlatform) -> AnyPublisher { + return loginRepository.fetchUserAuth(platform: platform, userName: nil) + .handleEvents(receiveOutput: { [weak self] account in + guard let self = self else { return } + + self.updateToken(accessToken: account.token.accessToken, refreshToken: account.token.refreshToken) + self.updateUserSession(account: account) + }) + .catch { [weak self] error -> AnyPublisher in + self?.loginErrorSubject.send(error) + return Empty().eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func updateToken(accessToken: String, refreshToken: String) { + do { + try self.tokenStorage.save(accessToken, for: .wableAccessToken) + try self.tokenStorage.save(refreshToken, for: .wableRefreshToken) + } catch let error as WableError { + loginErrorSubject.send(error) + } catch { + loginErrorSubject.send(.unknownError) + } + } + + func updateUserSession(account: Account) { + userSessionRepository.updateUserSession( + userID: account.user.id, + nickname: account.user.nickname, + profileURL: account.user.profileURL, + isPushAlarmAllowed: account.isPushAlarmAllowed ?? false, + isAdmin: account.isAdmin, + isAutoLoginEnabled: true, + notificationBadgeCount: nil + ) + + userSessionRepository.updateActiveUserID(account.user.id) + } + + func updateFCMToken(account: Account, cancelBag: CancelBag) { + guard let token = profileRepository.fetchFCMToken() else { return } + + profileRepository.updateUserProfile(nickname: account.user.nickname, fcmToken: token) + .catch { [weak self] error -> AnyPublisher in + self?.loginErrorSubject.send(error) + return .just(()) + } + .sink(receiveValue: {}) + .store(in: cancelBag) + } + + func updateUserProfile(userID: Int, cancelBag: CancelBag) { + Task { + let authorizedStatus = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + let isAuthorized = authorizedStatus == .authorized + let profile = try await profileRepository.fetchUserProfile(memberID: userID) + + profileRepository.updateUserProfile( + profile: profile, + isPushAlarmAllowed: isAuthorized, + isAlarmAllowed: nil, + image: nil, + fcmToken: nil, + defaultProfileType: nil + ) + .catch { [weak self] error -> AnyPublisher in + self?.loginErrorSubject.send(error) + return .just(()) + } + .sink(receiveValue: {}) + .store(in: cancelBag) + } + } +} diff --git a/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift b/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift index 4e566ec4..0c6761b1 100644 --- a/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift +++ b/Wable-iOS/Presentation/Notification/Information/View/InformationNotificationViewController.swift @@ -192,37 +192,12 @@ private extension InformationNotificationViewController { } .store(in: cancelBag) + let tabBar = navigationController?.tabBarController output.selectedNotification .receive(on: DispatchQueue.main) - .sink { [weak self] item in - let viewController = HomeDetailViewController( - viewModel: HomeDetailViewModel( - contentID: item.id, - fetchContentInfoUseCase: FetchContentInfoUseCase(repository: ContentRepositoryImpl()), - fetchContentCommentListUseCase: FetchContentCommentListUseCase(repository: CommentRepositoryImpl()), - createCommentUseCase: CreateCommentUseCase(repository: CommentRepositoryImpl()), - deleteCommentUseCase: DeleteCommentUseCase(repository: CommentRepositoryImpl()), - createContentLikedUseCase: CreateContentLikedUseCase(repository: ContentLikedRepositoryImpl()), - deleteContentLikedUseCase: DeleteContentLikedUseCase(repository: ContentLikedRepositoryImpl()), - createCommentLikedUseCase: CreateCommentLikedUseCase(repository: CommentLikedRepositoryImpl()), - deleteCommentLikedUseCase: DeleteCommentLikedUseCase(repository: CommentLikedRepositoryImpl()), - fetchUserInformationUseCase: FetchUserInformationUseCase( - repository: UserSessionRepositoryImpl( - userDefaults: UserDefaultsStorage( - jsonEncoder: JSONEncoder(), - jsonDecoder: JSONDecoder() - ) - ) - ), - fetchGhostUseCase: FetchGhostUseCase(repository: GhostRepositoryImpl()), - createReportUseCase: CreateReportUseCase(repository: ReportRepositoryImpl()), - createBannedUseCase: CreateBannedUseCase(repository: ReportRepositoryImpl()), - deleteContentUseCase: DeleteContentUseCase(repository: ContentRepositoryImpl()) - ), - cancelBag: CancelBag() - ) - - self?.navigationController?.pushViewController(viewController, animated: true) + .sink { [weak self] _ in + self?.navigationController?.popToRootViewController(animated: false) + tabBar?.selectedIndex = 2 } .store(in: cancelBag) diff --git a/Wable-iOS/Presentation/Onboarding/View/LCKTeamView.swift b/Wable-iOS/Presentation/Onboarding/View/LCKTeamView.swift deleted file mode 100644 index 138ecf27..00000000 --- a/Wable-iOS/Presentation/Onboarding/View/LCKTeamView.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// LCKTeamView.swift -// Wable-iOS -// -// Created by YOUJIM on 3/20/25. -// - - -import UIKit - -final class LCKTeamView: UIView { - - // MARK: - UIComponent - - private let titleLabel: UILabel = UILabel().then { - $0.attributedText = StringLiterals.Onboarding.teamSheetTitle.pretendardString(with: .head0) - $0.textColor = .wableBlack - } - - private let descriptionLabel: UILabel = UILabel().then { - $0.attributedText = StringLiterals.Onboarding.teamSheetMessage.pretendardString(with: .body2) - $0.textColor = .gray600 - $0.numberOfLines = 2 - } - - lazy var teamCollectionView: UICollectionView = UICollectionView( - frame: .zero, - collectionViewLayout: UICollectionViewFlowLayout().then { - $0.itemSize = .init(width: 166.adjustedWidth, height: 64.adjustedHeight) - $0.minimumInteritemSpacing = 11 - $0.minimumLineSpacing = 12 - }).then { - $0.register( - LCKTeamCollectionViewCell.self, - forCellWithReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier - ) - $0.isScrollEnabled = false - } - - lazy var skipButton: UIButton = UIButton(configuration: .plain()).then { - $0.configuration?.attributedTitle = StringLiterals.Onboarding.teamEmptyButtonTitle.pretendardString(with: .body2) - $0.configuration?.baseForegroundColor = .gray600 - } - - lazy var nextButton: WableButton = WableButton(style: .gray).then { - $0.configuration?.attributedTitle = "다음으로".pretendardString(with: .head2) - $0.isUserInteractionEnabled = false - } - - // MARK: - LifeCycle - - override init(frame: CGRect) { - super.init(frame: frame) - - setupView() - setupConstraint() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// MARK: - Private Extension - -private extension LCKTeamView { - - // MARK: Setup Method - - func setupView() { - addSubviews( - titleLabel, - descriptionLabel, - teamCollectionView, - skipButton, - nextButton - ) - } - - func setupConstraint() { - titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(10) - $0.leading.equalToSuperview().offset(16) - } - - descriptionLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(6) - $0.leading.equalToSuperview().offset(16) - } - - teamCollectionView.snp.makeConstraints { - $0.top.equalTo(descriptionLabel.snp.bottom).offset(22) - $0.horizontalEdges.equalToSuperview().inset(16) - $0.adjustedHeightEqualTo(368) - } - - nextButton.snp.makeConstraints { - $0.top.equalTo(skipButton.snp.bottom).offset(12) - $0.horizontalEdges.equalToSuperview().inset(16) - $0.bottom.equalToSuperview().inset(64) - $0.adjustedHeightEqualTo(56) - } - - skipButton.snp.makeConstraints { - $0.bottom.equalTo(nextButton.snp.top).offset(-12) - $0.horizontalEdges.equalToSuperview().inset(48) - $0.adjustedHeightEqualTo(48) - } - } -} diff --git a/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift b/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift index 6b171cfb..949966cc 100644 --- a/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift +++ b/Wable-iOS/Presentation/Onboarding/ViewController/AgreementViewController.swift @@ -154,7 +154,7 @@ private extension AgreementViewController { userSession in guard let userSession = userSession else { return } - owner.profileUseCase.execute( + owner.profileUseCase.updateProfile( profile: UserProfile( user: User( id: userSession.id, diff --git a/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift b/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift index 2fdd7324..7a8be786 100644 --- a/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift +++ b/Wable-iOS/Presentation/Onboarding/ViewController/LCKTeamViewController.swift @@ -11,15 +11,40 @@ import UIKit final class LCKTeamViewController: NavigationViewController { // MARK: - Property - // TODO: 유즈케이스 리팩 후에 뷰모델 만들어 넘기기 private let lckYear: Int - private let randomTeamList: [LCKTeam] = [.t1, .gen, .bro, .drx, .dk, .kt, .ns, .bfx, .hle, .dnf].shuffled() private var lckTeam = "LCK" // MARK: - UIComponent - private let rootView = LCKTeamView() + private let titleLabel: UILabel = UILabel().then { + $0.attributedText = StringLiterals.Onboarding.teamSheetTitle.pretendardString(with: .head0) + $0.textColor = .wableBlack + } + + private let descriptionLabel: UILabel = UILabel().then { + $0.attributedText = StringLiterals.Onboarding.teamSheetMessage.pretendardString(with: .body2) + $0.textColor = .gray600 + $0.numberOfLines = 2 + } + + lazy var teamCollectionView: TeamCollectionView = TeamCollectionView(cellDidTapped: { [weak self] selectedTeam in + guard let self = self else { return } + + self.lckTeam = selectedTeam + self.nextButton.updateStyle(.primary) + self.nextButton.isUserInteractionEnabled = true + }) + + lazy var skipButton: UIButton = UIButton(configuration: .plain()).then { + $0.configuration?.attributedTitle = StringLiterals.Onboarding.teamEmptyButtonTitle.pretendardString(with: .body2) + $0.configuration?.baseForegroundColor = .gray600 + } + + lazy var nextButton: WableButton = WableButton(style: .gray).then { + $0.configuration?.attributedTitle = "다음으로".pretendardString(with: .head2) + $0.isUserInteractionEnabled = false + } // MARK: - LifeCycle @@ -38,7 +63,6 @@ final class LCKTeamViewController: NavigationViewController { setupView() setupConstraint() - setupDelegate() setupAction() } } @@ -49,24 +73,49 @@ private extension LCKTeamViewController { func setupView() { navigationController?.interactivePopGestureRecognizer?.isEnabled = true - view.addSubview(rootView) + view.addSubviews( + titleLabel, + descriptionLabel, + teamCollectionView, + skipButton, + nextButton + ) } func setupConstraint() { - rootView.snp.makeConstraints { - $0.top.equalTo(navigationView.snp.bottom) - $0.horizontalEdges.bottom.equalToSuperview() + titleLabel.snp.makeConstraints { + $0.top.equalTo(navigationView.snp.bottom).offset(10) + $0.leading.equalToSuperview().offset(16) + } + + descriptionLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(6) + $0.leading.equalToSuperview().offset(16) + } + + teamCollectionView.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(22) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.adjustedHeightEqualTo(368) + } + + nextButton.snp.makeConstraints { + $0.top.equalTo(skipButton.snp.bottom).offset(12) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.bottom.equalToSuperview().inset(64) + $0.adjustedHeightEqualTo(56) + } + + skipButton.snp.makeConstraints { + $0.bottom.equalTo(nextButton.snp.top).offset(-12) + $0.horizontalEdges.equalToSuperview().inset(48) + $0.adjustedHeightEqualTo(48) } - } - - func setupDelegate() { - rootView.teamCollectionView.delegate = self - rootView.teamCollectionView.dataSource = self } func setupAction() { - rootView.skipButton.addTarget(self, action: #selector(skipButtonDidTap), for: .touchUpInside) - rootView.nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside) + skipButton.addTarget(self, action: #selector(skipButtonDidTap), for: .touchUpInside) + nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside) } // MARK: - @objc Method @@ -95,49 +144,3 @@ private extension LCKTeamViewController { ) } } - -// MARK: - UICollectionViewDelegate - -extension LCKTeamViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - for cell in collectionView.visibleCells { - guard let cell = cell as? LCKTeamCollectionViewCell else { return } - - cell.layer.borderColor = UIColor.gray300.cgColor - cell.teamLabel.textColor = .gray700 - } - - lckTeam = randomTeamList[indexPath.row].rawValue - - guard let cell = collectionView.cellForItem(at: indexPath) as? LCKTeamCollectionViewCell else { return } - - cell.layer.borderColor = UIColor.purple50.cgColor - cell.teamLabel.textColor = .wableBlack - - rootView.nextButton.updateStyle(.primary) - rootView.nextButton.isUserInteractionEnabled = true - } -} - -// MARK: - UICollectionViewDataSource - -extension LCKTeamViewController: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 10 - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier, - for: indexPath - ) as? LCKTeamCollectionViewCell else { - return UICollectionViewCell() - } - - cell.teamLabel.text = randomTeamList[indexPath.row].rawValue - cell.teamLabel.textColor = .gray700 - cell.teamImageView.image = UIImage(named: randomTeamList[indexPath.row].rawValue.lowercased()) - - return cell - } -} diff --git a/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift b/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift index d2b98f81..11ed6422 100644 --- a/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift +++ b/Wable-iOS/Presentation/Overview/Detail/View/AnnouncementDetailViewController.swift @@ -112,7 +112,7 @@ private extension AnnouncementDetailViewController { guard let image = imageView.image else { return } let photoDetailViewController = PhotoDetailViewController(image: image) - present(photoDetailViewController, animated: true) + navigationController?.pushViewController(photoDetailViewController, animated: true) } @objc func submitButtonDidTap() { diff --git a/Wable-iOS/Presentation/Profile/Component/GhostInfoTooltipView.swift b/Wable-iOS/Presentation/Profile/Component/GhostInfoTooltipView.swift new file mode 100644 index 00000000..1549bb30 --- /dev/null +++ b/Wable-iOS/Presentation/Profile/Component/GhostInfoTooltipView.swift @@ -0,0 +1,56 @@ +// +// GhostInfoTooltipView.swift +// Wable-iOS +// +// Created by 김진웅 on 6/24/25. +// + +import UIKit + +import SnapKit +import Then + +final class GhostInfoTooltipView: UIView { + private let tooltipImageView = UIImageView(image: .imgGhostTooltip).then { + $0.contentMode = .scaleAspectFill + } + + private let label = UILabel().then { + $0.textColor = .wableWhite + $0.attributedText = StringLiterals.Ghost.tooltip + .pretendardString(with: .caption3) + .highlight(textColor: .sky50, to: "투명도란?") + $0.numberOfLines = 0 + } + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + setupConstraint() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension GhostInfoTooltipView { + func setupView() { + addSubviews(tooltipImageView, label) + } + + func setupConstraint() { + tooltipImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + label.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.horizontalEdges.equalToSuperview().inset(12) + make.bottom.equalToSuperview().offset(-8) + } + } +} + diff --git a/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift b/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift index af7759c8..88a4c5d4 100644 --- a/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift +++ b/Wable-iOS/Presentation/Profile/Component/ProfileInfoCell.swift @@ -5,6 +5,7 @@ // Created by 김진웅 on 5/14/25. // +import Combine import UIKit import SnapKit @@ -57,14 +58,22 @@ final class ProfileInfoCell: UICollectionViewCell { $0.numberOfLines = 0 } - // MARK: - Ghost UIComponent + // MARK: - Footer UIComponent - private let ghostView = UIView() + private let footerView = UIView() private let ghostTitleLabel = UILabel().then { $0.attributedText = "투명도".pretendardString(with: .caption1) } + private let ghostInfoButton = UIButton().then { + $0.setImage(.icGhostInfo, for: .normal) + } + + private let ghostInfoTooltip = GhostInfoTooltipView().then { + $0.isHidden = true + } + private let ghostImageView = UIImageView(image: .icPurpleGhost) private let ghostValueLabel = UILabel().then { @@ -79,10 +88,6 @@ final class ProfileInfoCell: UICollectionViewCell { $0.clipsToBounds = true } - // MARK: - Badge UIComponent - - private let badgeView = UIView() - private let badgeTitleLabel = UILabel().then { $0.attributedText = "뱃지".pretendardString(with: .caption1) } @@ -90,6 +95,12 @@ final class ProfileInfoCell: UICollectionViewCell { private let defaultBadgeImageView = UIImageView(image: .imgBadge).then { $0.contentMode = .scaleAspectFit } + + // MARK: - Property + + private var tooltipTimer: AnyCancellable? + + private let cancelBag = CancelBag() // MARK: - Initializer @@ -99,8 +110,7 @@ final class ProfileInfoCell: UICollectionViewCell { setupView() setupHeaderView() setupIntroductionView() - setupGhostView() - setupBadgeView() + setupFooterView() setupAction() } @@ -157,7 +167,7 @@ final class ProfileInfoCell: UICollectionViewCell { private extension ProfileInfoCell { func setupView() { - contentView.addSubviews(headerView, introductionView, ghostView, badgeView) + contentView.addSubviews(headerView, introductionView, footerView) headerView.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) @@ -167,17 +177,12 @@ private extension ProfileInfoCell { introductionView.snp.makeConstraints { make in make.horizontalEdges.equalTo(headerView) - make.bottom.equalTo(ghostView.snp.top).offset(-12) + make.bottom.equalTo(footerView.snp.top).offset(-12) } - ghostView.snp.makeConstraints { make in + footerView.snp.makeConstraints { make in make.horizontalEdges.equalTo(headerView) - make.bottom.equalTo(badgeView.snp.top).offset(-12) - } - - badgeView.snp.makeConstraints { make in - make.horizontalEdges.equalTo(headerView) - make.bottom.equalToSuperview().offset(-16) + make.bottom.equalToSuperview().offset(-12) } } @@ -220,13 +225,30 @@ private extension ProfileInfoCell { } } - func setupGhostView() { - ghostView.addSubviews(ghostTitleLabel, ghostImageView, ghostValueLabel, ghostProgressBar) + func setupFooterView() { + footerView.addSubviews( + ghostTitleLabel, + ghostInfoButton, + ghostImageView, + ghostValueLabel, + ghostProgressBar, + ghostInfoTooltip, + badgeTitleLabel, + defaultBadgeImageView + ) + + footerView.bringSubviewToFront(ghostInfoTooltip) ghostTitleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() } + ghostInfoButton.snp.makeConstraints { make in + make.centerY.equalTo(ghostTitleLabel) + make.leading.equalTo(ghostTitleLabel.snp.trailing).offset(4) + make.size.equalTo(12) + } + ghostImageView.snp.makeConstraints { make in make.centerY.equalTo(ghostValueLabel) make.trailing.equalTo(ghostValueLabel.snp.leading).offset(-8) @@ -239,32 +261,93 @@ private extension ProfileInfoCell { } ghostProgressBar.snp.makeConstraints { make in - make.horizontalEdges.bottom.equalToSuperview() + make.horizontalEdges.equalToSuperview() make.adjustedHeightEqualTo(12) } - } - - func setupBadgeView() { - badgeView.addSubviews(badgeTitleLabel, defaultBadgeImageView) + + ghostInfoTooltip.snp.makeConstraints { make in + make.top.equalTo(ghostProgressBar.snp.bottom).offset(4) + make.leading.equalTo(ghostProgressBar) + make.trailing.equalToSuperview() + } badgeTitleLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview() + make.top.equalTo(ghostProgressBar.snp.bottom).offset(12) + make.leading.equalTo(ghostInfoTooltip) make.bottom.equalTo(defaultBadgeImageView.snp.top).offset(-4) } defaultBadgeImageView.snp.makeConstraints { make in make.leading.equalTo(badgeTitleLabel) make.adjustedHeightEqualTo(Constant.badgeImageViewHeight) + make.bottom.equalToSuperview() } } func setupAction() { - editButton.addAction( - UIAction(handler: { [weak self] _ in self?.editButtonTapHandler?() }), - for: .touchUpInside - ) + editButton.publisher(for: .touchUpInside) + .sink { [weak self] _ in + self?.editButtonTapHandler?() + } + .store(in: cancelBag) + + let onGhostTitleTap = ghostTitleLabel.gesture().asVoid() + let onGhostInfoTap = ghostInfoButton.publisher(for: .touchUpInside) + Publishers.Merge(onGhostTitleTap, onGhostInfoTap) + .sink { [weak self] _ in + self?.toggleTooltip() + } + .store(in: cancelBag) } + // MARK: - Helper Method + + func toggleTooltip() { + tooltipTimer?.cancel() + + if ghostInfoTooltip.isHidden { + showTooltipWithAnimation() + + tooltipTimer = Just(()) + .delay(for: .seconds(5), scheduler: DispatchQueue.main) + .sink { [weak self] _ in self?.hideTooltipWithAnimation() } + } else { + hideTooltipWithAnimation() + } + } + + func showTooltipWithAnimation() { + ghostInfoTooltip.alpha = 0 + ghostInfoTooltip.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + ghostInfoTooltip.isHidden = false + + UIView.animate( + withDuration: 0.3, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.2 + ) { + self.ghostInfoTooltip.alpha = 1 + self.ghostInfoTooltip.transform = .identity + } + } + + func hideTooltipWithAnimation() { + UIView.animate( + withDuration: 0.25, + delay: 0, + options: [.curveEaseInOut] + ) { + self.ghostInfoTooltip.alpha = 0 + self.ghostInfoTooltip.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + } completion: { _ in + self.ghostInfoTooltip.isHidden = true + self.ghostInfoTooltip.transform = .identity + } + } + + // MARK: - Constant + enum Constant { static let profileImageViewSize: CGFloat = 80 static let editButtonSize: CGFloat = 48 diff --git a/Wable-iOS/Presentation/Profile/Edit/ProfileEditView.swift b/Wable-iOS/Presentation/Profile/Edit/ProfileEditView.swift new file mode 100644 index 00000000..b69a7877 --- /dev/null +++ b/Wable-iOS/Presentation/Profile/Edit/ProfileEditView.swift @@ -0,0 +1,217 @@ +// +// ProfileEditView.swift +// Wable-iOS +// +// Created by YOUJIM on 6/18/25. +// + + +import UIKit + +import Kingfisher + +final class ProfileEditView: UIView { + + // MARK: Property + + var defaultImageList = DefaultProfileType.allCases + + private let cellTapped: ((String) -> Void)? + + // MARK: - UIComponent + + let profileImageView: UIImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.layer.cornerRadius = (112 / 2).adjustedWidth + $0.clipsToBounds = true + } + + let nickNameTextField: UITextField = UITextField( + pretendard: .body2, + placeholder: "예) 중꺾마" + ).then { + $0.textColor = .wableBlack + $0.layer.cornerRadius = 6.adjustedWidth + $0.backgroundColor = .gray200 + $0.addPadding(left: 16) + } + + let duplicationCheckButton: UIButton = UIButton(configuration: .filled()).then { + $0.configuration?.attributedTitle = "중복확인".pretendardString(with: .body3) + $0.configuration?.baseForegroundColor = .gray600 + $0.configuration?.baseBackgroundColor = .gray200 + $0.isUserInteractionEnabled = false + } + + let conditionLabel: UILabel = UILabel().then { + $0.attributedText = StringLiterals.ProfileSetting.checkDefaultMessage.pretendardString(with: .caption2) + $0.textColor = .gray600 + } + + let myTeamLabel: UILabel = UILabel().then { + $0.attributedText = " ".pretendardString(with: .body3) + $0.textColor = .purple50 + } + + lazy var switchButton: UIButton = UIButton(configuration: .plain()).then { + $0.configuration?.image = .icChange + } + + lazy var addButton: UIButton = UIButton(configuration: .plain()).then { + $0.configuration?.image = .icProfileplus + } + + lazy var teamCollectionView: TeamCollectionView = { + return TeamCollectionView(cellDidTapped: { [weak self] selectedTeam in + guard let self = self else { return } + + myTeamLabel.text = selectedTeam + cellTapped?(selectedTeam) + }) + }() + + private let nicknameDiscriptionLabel: UILabel = UILabel().then { + $0.attributedText = "닉네임".pretendardString(with: .body3) + $0.textColor = .wableBlack + } + + private let teamDescriptionLabel: UILabel = UILabel().then { + $0.attributedText = "응원팀".pretendardString(with: .body3) + $0.textColor = .wableBlack + } + + // MARK: - LifeCycle + + init(cellTapped: ((String) -> Void)?) { + self.cellTapped = cellTapped + + super.init(frame: .zero) + + setupView() + setupConstraint() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: Public Extension + +extension ProfileEditView { + func configureDefaultImage() { + defaultImageList.shuffle() + + profileImageView.image = UIImage(named: defaultImageList[0].rawValue) + } +} + +// MARK: - Private Extension + +private extension ProfileEditView { + + // MARK: Setup Method + + func setupView() { + addSubviews( + profileImageView, + switchButton, + addButton, + nicknameDiscriptionLabel, + nickNameTextField, + conditionLabel, + duplicationCheckButton, + teamDescriptionLabel, + myTeamLabel, + teamCollectionView + ) + } + + func setupConstraint() { + profileImageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(24) + $0.centerX.equalToSuperview() + $0.size.equalTo(112.adjustedWidth) + } + + switchButton.snp.makeConstraints { + $0.leading.equalTo(profileImageView.snp.leading).offset(-17) + $0.bottom.equalTo(profileImageView.snp.bottom).offset(6) + $0.size.equalTo(48.adjustedWidth) + } + + addButton.snp.makeConstraints { + $0.trailing.equalTo(profileImageView.snp.trailing).offset(19) + $0.bottom.equalTo(profileImageView.snp.bottom).offset(6) + $0.size.equalTo(48.adjustedWidth) + } + + nicknameDiscriptionLabel.snp.makeConstraints { + $0.top.equalTo(profileImageView.snp.bottom).offset(6) + $0.leading.equalToSuperview().offset(16) + } + + nickNameTextField.snp.makeConstraints { + $0.top.equalTo(nicknameDiscriptionLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalTo(duplicationCheckButton.snp.leading).offset(-8) + $0.adjustedHeightEqualTo(48) + } + + duplicationCheckButton.snp.makeConstraints { + $0.centerY.equalTo(nickNameTextField) + $0.trailing.equalToSuperview().inset(16) + $0.adjustedWidthEqualTo(94) + $0.adjustedHeightEqualTo(48) + } + + conditionLabel.snp.makeConstraints { + $0.top.equalTo(nickNameTextField.snp.bottom).offset(9) + $0.leading.equalToSuperview().inset(16) + } + + teamDescriptionLabel.snp.makeConstraints { + $0.top.equalTo(conditionLabel.snp.bottom).offset(24) + $0.leading.equalToSuperview().offset(16) + } + + myTeamLabel.snp.makeConstraints { + $0.centerY.equalTo(teamDescriptionLabel) + $0.leading.equalTo(teamDescriptionLabel.snp.trailing).offset(4) + } + + teamCollectionView.snp.makeConstraints { + $0.top.equalTo(teamDescriptionLabel.snp.bottom).offset(4) + $0.horizontalEdges.equalToSuperview().inset(16) + $0.adjustedHeightEqualTo(368) + } + } +} + +extension ProfileEditView { + + // MARK: Configure Method + + func configureView(profileImageURL: URL? = .none, team: LCKTeam?) { + myTeamLabel.text = team?.rawValue ?? "LCK" + teamCollectionView.selectInitialTeam(team: team) + + if let profileImageURL = profileImageURL { + switch profileImageURL.absoluteString { + case "PURPLE": + profileImageView.image = .imgProfilePurple + case "GREEN": + profileImageView.image = .imgProfileGreen + case "BLUE": + profileImageView.image = .imgProfileBlue + default: + profileImageView.kf.setImage( + with: profileImageURL, + placeholder: [UIImage.imgProfilePurple, UIImage.imgProfileBlue, UIImage.imgProfileGreen].randomElement() + ) + } + } else { + configureDefaultImage() + } + } +} diff --git a/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift b/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift index f2151577..d6190ea9 100644 --- a/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift +++ b/Wable-iOS/Presentation/Profile/Edit/ProfileEditViewController.swift @@ -26,18 +26,25 @@ final class ProfileEditViewController: NavigationViewController { ) private let cancelBag = CancelBag() + private var lckTeam = "LCK" private var sessionProfile: UserProfile? = nil private var defaultImage: String? = nil private var hasUserSelectedImage = false // MARK: - UIComponent - private let rootView = ProfileRegisterView() + private lazy var rootView = ProfileEditView(cellTapped: { [weak self] selectedTeam in + guard let self = self else { return } + + lckTeam = selectedTeam + }) // MARK: - LifeCycle init() { - super.init(type: .page(type: .detail, title: "프로필 편집")) + super.init(type: .page(type: .profileEdit, title: "프로필 편집")) + + hidesBottomBarWhenPushed = true } required init?(coder: NSCoder) { @@ -92,12 +99,56 @@ private extension ProfileEditViewController { rootView.switchButton.addTarget(self, action: #selector(switchButtonDidTap), for: .touchUpInside) rootView.addButton.addTarget(self, action: #selector(addButtonDidTap), for: .touchUpInside) rootView.duplicationCheckButton.addTarget(self, action: #selector(duplicationCheckButtonDidTap), for: .touchUpInside) - rootView.nextButton.addTarget(self, action: #selector(nextButtonDidTap), for: .touchUpInside) + navigationView.doneButton + .publisher(for: .touchUpInside) + .sink { [weak self] _ in + guard let self = self, + let profile = sessionProfile + else { + return + } + + let nicknameText = rootView.nickNameTextField.text ?? "" + let hasNicknameChanged = !nicknameText.isEmpty && nicknameText != profile.user.nickname + let hasImageChanged = defaultImage != nil || hasUserSelectedImage + let hasTeamChanged = lckTeam != (profile.user.fanTeam?.rawValue ?? "LCK") + + if hasNicknameChanged || hasImageChanged || hasTeamChanged { + let finalNickname = hasNicknameChanged ? nicknameText : profile.user.nickname + + profileUseCase.updateProfile( + profile: UserProfile( + user: User( + id: profile.user.id, + nickname: finalNickname, + profileURL: profile.user.profileURL, + fanTeam: LCKTeam(rawValue: lckTeam) + ), + introduction: profile.introduction, + ghostCount: profile.ghostCount, + lckYears: profile.lckYears, + userLevel: profile.userLevel + ), + image: defaultImage == nil ? rootView.profileImageView.image : nil, + defaultProfileType: defaultImage + ) + .replaceError(with: ()) + .withUnretained(self) + .sink(receiveValue: { owner, _ in + owner.navigationController?.popViewController(animated: true) + }) + .store(in: cancelBag) + } else { + navigationController?.popViewController(animated: true) + } + } + .store(in: cancelBag) } private func setupTapGesture() { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tapGesture.cancelsTouchesInView = false view.addGestureRecognizer(tapGesture) } @@ -107,11 +158,49 @@ private extension ProfileEditViewController { view.endEditing(true) } + @objc func doneButtonDidTap() { + guard let profile = sessionProfile else { return } + + let nicknameText = rootView.nickNameTextField.text ?? "" + let hasNicknameChanged = !nicknameText.isEmpty && nicknameText != profile.user.nickname + let hasImageChanged = defaultImage != nil || hasUserSelectedImage + let hasTeamChanged = lckTeam != (profile.user.fanTeam?.rawValue ?? "LCK") + + if hasNicknameChanged || hasImageChanged || hasTeamChanged { + let finalNickname = hasNicknameChanged ? nicknameText : profile.user.nickname + + profileUseCase.updateProfile( + profile: UserProfile( + user: User( + id: profile.user.id, + nickname: finalNickname, + profileURL: profile.user.profileURL, + fanTeam: LCKTeam(rawValue: lckTeam) + ), + introduction: profile.introduction, + ghostCount: profile.ghostCount, + lckYears: profile.lckYears, + userLevel: profile.userLevel + ), + image: defaultImage == nil ? rootView.profileImageView.image : nil, + defaultProfileType: defaultImage + ) + .replaceError(with: ()) + .withUnretained(self) + .sink(receiveValue: { owner, _ in + owner.navigationController?.popViewController(animated: true) + }) + .store(in: cancelBag) + } else { + navigationController?.popViewController(animated: true) + } + } + @objc func switchButtonDidTap() { rootView.configureDefaultImage() defaultImage = rootView.defaultImageList[0].uppercased hasUserSelectedImage = false - updateNextButtonState() + updateDoneButtonState() } @objc func addButtonDidTap() { @@ -141,52 +230,15 @@ private extension ProfileEditViewController { .sink(receiveCompletion: { [weak self] completion in let condition = completion == .finished - self?.rootView.conditiionLabel.text = condition ? StringLiterals.ProfileSetting.checkVaildMessage : StringLiterals.ProfileSetting.checkDuplicateError - self?.rootView.conditiionLabel.textColor = condition ? .success : .error - self?.rootView.nextButton.isUserInteractionEnabled = condition - self?.rootView.nextButton.updateStyle(condition ? .primary : .gray) + self?.rootView.conditionLabel.text = condition ? StringLiterals.ProfileSetting.checkVaildMessage : StringLiterals.ProfileSetting.checkDuplicateError + self?.rootView.conditionLabel.textColor = condition ? .success : .error + self?.navigationView.doneButton.isUserInteractionEnabled = condition + self?.navigationView.doneButton.updateStyle(condition ? .primary : .gray) }, receiveValue: { _ in }) .store(in: cancelBag) } - @objc func nextButtonDidTap() { - guard let profile = sessionProfile else { return } - - let nicknameText = rootView.nickNameTextField.text ?? "" - let hasNicknameChanged = !nicknameText.isEmpty && nicknameText != profile.user.nickname - let hasImageChanged = defaultImage != nil || hasUserSelectedImage - - if hasNicknameChanged || hasImageChanged { - let finalNickname = hasNicknameChanged ? nicknameText : profile.user.nickname - - profileUseCase.execute( - profile: UserProfile( - user: User( - id: profile.user.id, - nickname: finalNickname, - profileURL: profile.user.profileURL, - fanTeam: profile.user.fanTeam - ), - introduction: profile.introduction, - ghostCount: profile.ghostCount, - lckYears: profile.lckYears, - userLevel: profile.userLevel - ), - image: defaultImage == nil ? rootView.profileImageView.image : nil, - defaultProfileType: defaultImage - ) - .withUnretained(self) - .sink { _ in - } receiveValue: { owner, _ in - owner.navigationController?.popViewController(animated: true) - } - .store(in: cancelBag) - } else { - navigationController?.popViewController(animated: true) - } - } - // MARK: - Function Method func presentPhotoPicker() { @@ -216,7 +268,7 @@ private extension ProfileEditViewController { present(alert, animated: true, completion: nil) } - func updateNextButtonState() { + func updateDoneButtonState() { guard let profile = sessionProfile else { return } let nicknameText = rootView.nickNameTextField.text ?? "" @@ -225,8 +277,8 @@ private extension ProfileEditViewController { let shouldEnable = hasNicknameChanged || hasImageChanged - rootView.nextButton.isUserInteractionEnabled = shouldEnable - rootView.nextButton.updateStyle(shouldEnable ? .primary : .gray) + navigationView.doneButton.isUserInteractionEnabled = shouldEnable + navigationView.doneButton.updateStyle(shouldEnable ? .primary : .gray) } } @@ -238,14 +290,16 @@ extension ProfileEditViewController { guard let self = self, let sessionID = sessionID else { return } - profileUseCase.execute(userID: sessionID) + profileUseCase.fetchProfile(userID: sessionID) .receive(on: DispatchQueue.main) .sink { _ in } receiveValue: { [weak self] profile in guard let self = self else { return } self.sessionProfile = profile - - self.rootView.configureProfileView(profileImageURL: profile.user.profileURL) + self.rootView.configureView( + profileImageURL: profile.user.profileURL, + team: profile.user.fanTeam + ) } .store(in: cancelBag) } @@ -264,7 +318,7 @@ extension ProfileEditViewController: PHPickerViewControllerDelegate { self.rootView.profileImageView.image = image self.defaultImage = nil self.hasUserSelectedImage = true - self.updateNextButtonState() + self.updateDoneButtonState() } } @@ -287,13 +341,13 @@ extension ProfileEditViewController: UITextFieldDelegate { let range = NSRange(location: 0, length: text.utf16.count) let condition = regex?.firstMatch(in: text, options: [], range: range) != nil - self.rootView.conditiionLabel.text = condition || text == "" ? StringLiterals.ProfileSetting.checkDefaultMessage : StringLiterals.ProfileSetting.checkInvaildError - self.rootView.conditiionLabel.textColor = condition || text == "" ? .gray600 : .error + self.rootView.conditionLabel.text = condition || text == "" ? StringLiterals.ProfileSetting.checkDefaultMessage : StringLiterals.ProfileSetting.checkInvaildError + self.rootView.conditionLabel.textColor = condition || text == "" ? .gray600 : .error self.rootView.duplicationCheckButton.isUserInteractionEnabled = condition self.rootView.duplicationCheckButton.configuration?.baseForegroundColor = condition ? .white : .gray600 self.rootView.duplicationCheckButton.configuration?.baseBackgroundColor = condition ? .wableBlack : .gray200 - self.rootView.nextButton.updateStyle(.gray) - self.rootView.nextButton.isUserInteractionEnabled = false + self.navigationView.doneButton.updateStyle(.gray) + self.navigationView.doneButton.isUserInteractionEnabled = false } func textField( diff --git a/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift b/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift index f7990704..9d623401 100644 --- a/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift +++ b/Wable-iOS/Presentation/Profile/My/View/MyProfileViewController.swift @@ -214,7 +214,8 @@ private extension MyProfileViewController { contentImageViewTapHandler: { [weak self] in guard let image = cell.contentImageView.image else { return } - self?.present(PhotoDetailViewController(image: image), animated: true) + let photoDetailViewController = PhotoDetailViewController(image: image) + self?.navigationController?.pushViewController(photoDetailViewController, animated: true) }, likeButtonTapHandler: { [weak self] in self?.viewModel.toggleLikeContent(for: item.id) }, settingButtonTapHandler: { [weak self] in @@ -464,25 +465,7 @@ private extension MyProfileViewController { return WableLogger.log("SceneDelegate 찾을 수 없음.", for: .debug) } - let loginViewController = LoginViewController( - viewModel: .init( - updateFCMTokenUseCase: UpdateFCMTokenUseCase( - repository: ProfileRepositoryImpl() - ), - fetchUserAuthUseCase: FetchUserAuthUseCase( - loginRepository: LoginRepositoryImpl(), - userSessionRepository: UserSessionRepositoryImpl( - userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init()) - ) - ), - updateUserSessionUseCase: FetchUserInformationUseCase( - repository: UserSessionRepositoryImpl( - userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init()) - ) - ), - userProfileUseCase: UserProfileUseCase(repository: ProfileRepositoryImpl()) - ) - ) + let loginViewController = LoginViewController(viewModel: LoginViewModel()) UIView.transition( with: window, diff --git a/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift b/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift index 51098444..5c307bc0 100644 --- a/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift +++ b/Wable-iOS/Presentation/Profile/Other/View/OtherProfileViewController.swift @@ -213,32 +213,47 @@ private extension OtherProfileViewController { contentImageViewTapHandler: { [weak self] in guard let image = cell.contentImageView.image else { return } - self?.present(PhotoDetailViewController(image: image), animated: true) + let photoDetailViewController = PhotoDetailViewController(image: image) + self?.navigationController?.pushViewController(photoDetailViewController, animated: true) }, likeButtonTapHandler: { [weak self] in self?.viewModel.toggleLikeContent(for: item.id) }, settingButtonTapHandler: { [weak self] in - guard let userRole = self?.viewModel.checkUserRole(), - userRole != .owner - else { - return - } + guard let userRole = self?.viewModel.checkUserRole(), userRole != .owner else { return } - let bottomSheet = WableBottomSheetController() let report = WableBottomSheetAction(title: "신고하기") { [weak self] in - self?.presentReportSheet(contentID: item.id) + self?.showReportSheet(onPrimary: { message in + let info = item.contentInfo + self?.viewModel.reportContent(for: info.author.nickname, message: message ?? info.text) + }) + } + guard userRole == .admin else { + self?.showBottomSheet(actions: report) + return } - bottomSheet.addAction(report) - if userRole == .admin { - let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in - self?.presentBanSheet(contentID: item.id) + let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in + let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in + self?.viewModel.banContent(for: item.id) } - bottomSheet.addAction(ban) + self?.showWableSheetWithCancel( + title: Constant.Ban.title, + message: StringLiterals.Ban.sheetMessage, + action: confirm + ) } - self?.present(bottomSheet, animated: true) + self?.showBottomSheet(actions: report, ban) }, profileImageViewTapHandler: nil, - ghostButtonTapHandler: { [weak self] in self?.presentGhostSheet(contentID: item.id) } + ghostButtonTapHandler: { [weak self] in + AmplitudeManager.shared.trackEvent(tag: .clickGhostPost) + self?.showGhostSheet(onCancel: { + AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) + }, onPrimary: { message in + AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) + + self?.viewModel.ghostContent(for: item.id, reason: message ?? "") + }) + } ) } @@ -251,28 +266,42 @@ private extension OtherProfileViewController { authorType: .others, likeButtonTapHandler: { [weak self] in self?.viewModel.toggleLikeComment(for: item.comment.id) }, settingButtonTapHandler: { [weak self] in - guard let userRole = self?.viewModel.checkUserRole(), - userRole != .owner - else { - return - } + guard let userRole = self?.viewModel.checkUserRole(), userRole != .owner else { return } - let bottomSheet = WableBottomSheetController() let report = WableBottomSheetAction(title: "신고하기") { [weak self] in - self?.presentReportSheet(commentID: item.comment.id) + self?.showReportSheet(onPrimary: { message in + let comment = item.comment + self?.viewModel.reportComment(for: comment.author.nickname, message: message ?? comment.text) + }) + } + guard userRole == .admin else { + self?.showBottomSheet(actions: report) + return } - bottomSheet.addAction(report) - if userRole == .admin { - let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in - self?.presentBanSheet(commentID: item.comment.id) + let ban = WableBottomSheetAction(title: "밴하기") { [weak self] in + let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in + self?.viewModel.banComment(for: item.comment.id) } - bottomSheet.addAction(ban) + self?.showWableSheetWithCancel( + title: Constant.Ban.title, + message: StringLiterals.Ban.sheetMessage, + action: confirm + ) } - self?.present(bottomSheet, animated: true) + self?.showBottomSheet(actions: report, ban) }, profileImageViewTapHandler: nil, - ghostButtonTapHandler: { [weak self] in self?.presentGhostSheet(commentID: item.comment.id) }, + ghostButtonTapHandler: { [weak self] in + AmplitudeManager.shared.trackEvent(tag: .clickGhostComment) + self?.showGhostSheet(onCancel: { + AmplitudeManager.shared.trackEvent(tag: .clickWithdrawghostPopup) + }, onPrimary: { message in + AmplitudeManager.shared.trackEvent(tag: .clickApplyghostPopup) + + self?.viewModel.ghostComment(for: item.comment.id, reason: message ?? "") + }) + }, replyButtonTapHandler: nil ) } @@ -343,6 +372,12 @@ private extension OtherProfileViewController { .sink { [weak self] in self?.applySnapshot(item: $0) } .store(in: cancelBag) + viewModel.$userNotFound + .receive(on: RunLoop.main) + .filter { $0 } + .sink { [weak self] _ in self?.showNotFound() } + .store(in: cancelBag) + viewModel.$isLoading .receive(on: RunLoop.main) .filter { $0 } @@ -408,80 +443,15 @@ private extension OtherProfileViewController { dataSource?.apply(snapshot) } - func presentReportSheet(contentID: Int) { - let actionSheet = WableSheetViewController( - title: StringLiterals.Report.sheetTitle, - message: StringLiterals.Report.sheetMessage - ) - - let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray) - let confirm = WableSheetAction(title: Constant.Report.title, style: .primary) { [weak self] in - self?.viewModel.reportContent(for: contentID) - } - actionSheet.addActions(cancel, confirm) - present(actionSheet, animated: true) - } - - func presentReportSheet(commentID: Int) { - let actionSheet = WableSheetViewController( - title: StringLiterals.Report.sheetTitle, - message: StringLiterals.Report.sheetMessage - ) - - let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray) - let confirm = WableSheetAction(title: Constant.Report.title, style: .primary) { [weak self] in - self?.viewModel.reportComment(for: commentID) - } - actionSheet.addActions(cancel, confirm) - present(actionSheet, animated: true) - } - - func presentBanSheet(contentID: Int) { - let actionSheet = WableSheetViewController( - title: Constant.Ban.title, - message: StringLiterals.Ban.sheetMessage - ) - - let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray) - let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in - self?.viewModel.banContent(for: contentID) - } - actionSheet.addActions(cancel, confirm) - present(actionSheet, animated: true) - } - - func presentBanSheet(commentID: Int) { - let actionSheet = WableSheetViewController( - title: Constant.Ban.title, - message: StringLiterals.Ban.sheetMessage - ) - - let cancel = WableSheetAction(title: Constant.Cancel.title, style: .gray) - let confirm = WableSheetAction(title: Constant.Ban.title, style: .primary) { [weak self] in - self?.viewModel.banComment(for: commentID) - } - actionSheet.addActions(cancel, confirm) - present(actionSheet, animated: true) - } - - func presentGhostSheet(contentID: Int) { - let actionSheet = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle) - let cancel = WableSheetAction(title: Constant.Ghost.grayTitle, style: .gray) - let confirm = WableSheetAction(title: Constant.Ghost.primaryTitle, style: .primary) { [weak self] in - self?.viewModel.ghostContent(for: contentID) - } - actionSheet.addActions(cancel, confirm) - present(actionSheet, animated: true) - } - - func presentGhostSheet(commentID: Int) { - let actionSheet = WableSheetViewController(title: StringLiterals.Ghost.sheetTitle) - let cancel = WableSheetAction(title: Constant.Ghost.grayTitle, style: .gray) - let confirm = WableSheetAction(title: Constant.Ghost.primaryTitle, style: .primary) { [weak self] in - self?.viewModel.ghostComment(for: commentID) + // MARK: - Helper + + func showNotFound() { + let tabBar = navigationController?.tabBarController + let notFoundVC = NotFoundViewController { [weak self] in + self?.navigationController?.popToRootViewController(animated: false) + tabBar?.selectedIndex = 0 } - actionSheet.addActions(cancel, confirm) - present(actionSheet, animated: true) + present(notFoundVC, animated: true) } // MARK: - Action @@ -553,14 +523,5 @@ private extension OtherProfileViewController { enum Ban { static let title = "밴하기" } - - enum Ghost { - static let grayTitle = "고민할게요" - static let primaryTitle = "네 맞아요" - } - - enum Cancel { - static let title = "취소" - } } } diff --git a/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift b/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift index c08e0394..89d66baf 100644 --- a/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift +++ b/Wable-iOS/Presentation/Profile/Other/ViewModel/OtherProfileViewModel.swift @@ -11,6 +11,7 @@ import Foundation final class OtherProfileViewModel { @Published private(set) var nickname: String? @Published private(set) var item = ProfileViewItem() + @Published private(set) var userNotFound = false @Published private(set) var isLoading = false @Published private(set) var isLoadingMore = false @Published private(set) var isReportCompleted = false @@ -125,13 +126,10 @@ final class OtherProfileViewModel { return checkUserRoleUseCase.execute(userID: userID) } - func reportContent(for contentID: Int) { - guard let content = item.contentList.first(where: { $0.id == contentID }) else { return } - let nickname = content.contentInfo.author.nickname - let text = content.contentInfo.text + func reportContent(for nickname: String, message: String) { Task { do { - try await reportRepository.createReport(nickname: nickname, text: text) + try await reportRepository.createReport(nickname: nickname, text: message) await MainActor.run { isReportCompleted = true } } catch { await handleError(error: error) @@ -139,14 +137,10 @@ final class OtherProfileViewModel { } } - func reportComment(for commentID: Int) { - guard let comment = item.commentList.first(where: { $0.comment.id == commentID }) else { return } - let nickname = comment.comment.author.nickname - let text = comment.comment.text - + func reportComment(for nickname: String, message: String) { Task { do { - try await reportRepository.createReport(nickname: nickname, text: text) + try await reportRepository.createReport(nickname: nickname, text: message) await MainActor.run { isReportCompleted = true } } catch { await handleError(error: error) @@ -188,14 +182,14 @@ final class OtherProfileViewModel { } } - func ghostContent(for contentID: Int) { + func ghostContent(for contentID: Int, reason: String) { Task { do { try await ghostRepository.postGhostReduction( alarmTriggerType: TriggerType.Ghost.contentGhost.rawValue, alarmTriggerID: contentID, targetMemberID: userID, - reason: "" + reason: reason ) fetchViewItems(userID: userID, segment: item.currentSegment) @@ -205,14 +199,14 @@ final class OtherProfileViewModel { } } - func ghostComment(for commentID: Int) { + func ghostComment(for commentID: Int, reason: String) { Task { do { try await ghostRepository.postGhostReduction( alarmTriggerType: TriggerType.Ghost.commentGhost.rawValue, alarmTriggerID: commentID, targetMemberID: userID, - reason: "" + reason: reason ) fetchViewItems(userID: userID, segment: item.currentSegment) @@ -316,6 +310,11 @@ private extension OtherProfileViewModel { @MainActor func handleError(error: Error) { + if case WableError.notFoundMember = error { + userNotFound = true + return + } + errorMessage = error.localizedDescription } } diff --git a/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift b/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift index b9257ffe..8de3c51f 100644 --- a/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift +++ b/Wable-iOS/Presentation/Profile/Withdrawal/Guide/View/WithdrawalGuideViewController.swift @@ -99,25 +99,7 @@ private extension WithdrawalGuideViewController { return WableLogger.log("SceneDelegate 찾을 수 없음.", for: .debug) } - let loginViewController = LoginViewController( - viewModel: .init( - updateFCMTokenUseCase: UpdateFCMTokenUseCase( - repository: ProfileRepositoryImpl() - ), - fetchUserAuthUseCase: FetchUserAuthUseCase( - loginRepository: LoginRepositoryImpl(), - userSessionRepository: UserSessionRepositoryImpl( - userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init()) - ) - ), - updateUserSessionUseCase: FetchUserInformationUseCase( - repository: UserSessionRepositoryImpl( - userDefaults: UserDefaultsStorage(jsonEncoder: .init(), jsonDecoder: .init()) - ) - ), - userProfileUseCase: UserProfileUseCase(repository: ProfileRepositoryImpl()) - ) - ) + let loginViewController = LoginViewController(viewModel: LoginViewModel()) UIView.transition( with: window, diff --git a/Wable-iOS/Presentation/Splash/SplashViewController.swift b/Wable-iOS/Presentation/Splash/SplashViewController.swift index f95b9d8d..6dbb4fa9 100644 --- a/Wable-iOS/Presentation/Splash/SplashViewController.swift +++ b/Wable-iOS/Presentation/Splash/SplashViewController.swift @@ -18,13 +18,11 @@ final class SplashViewController: UIViewController { view.contentMode = .scaleAspectFill view.loopMode = .playOnce view.play(fromProgress: 0, toProgress: 1, loopMode: .playOnce, completion: { - $0 ? DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - view.stop() - } : nil + $0 ? DispatchQueue.main.asyncAfter(deadline: .now() + 2) { view.stop() } : nil }) } - // MARK: - LifeCycle + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() @@ -34,19 +32,16 @@ final class SplashViewController: UIViewController { } } -// MARK: - Private Extension +// MARK: - Setup Method private extension SplashViewController { - - // MARK: - Setup - func setupView() { view.backgroundColor = .white - - view.addSubview(animationView) } func setupConstraint() { + view.addSubview(animationView) + animationView.snp.makeConstraints { $0.center.equalToSuperview() } diff --git a/Wable-iOS/Presentation/TabBar/TabBarController.swift b/Wable-iOS/Presentation/TabBar/TabBarController.swift index 895e2f63..b2895412 100644 --- a/Wable-iOS/Presentation/TabBar/TabBarController.swift +++ b/Wable-iOS/Presentation/TabBar/TabBarController.swift @@ -43,11 +43,7 @@ final class TabBarController: UITabBarController { } private let communityViewController = CommunityViewController( - viewModel: CommunityViewModel( - useCase: CommunityUseCaseImpl( - repository: CommunityRepositoryImpl() - ) - ) + viewModel: CommunityViewModel() ).then { $0.tabBarItem.title = "커뮤니티" $0.tabBarItem.image = .icCommunity diff --git a/Wable-iOS/Presentation/Viewit/List/Model/ViewitBottomSheetActionKind.swift b/Wable-iOS/Presentation/Viewit/List/Model/ViewitBottomSheetActionKind.swift deleted file mode 100644 index aa31d673..00000000 --- a/Wable-iOS/Presentation/Viewit/List/Model/ViewitBottomSheetActionKind.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// ViewitBottomSheetActionKind.swift -// Wable-iOS -// -// Created by 김진웅 on 5/12/25. -// - -import Foundation - -enum ViewitBottomSheetActionKind { - case report - case ban - case delete -} diff --git a/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift b/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift index 3ff7c43d..859e574d 100644 --- a/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift +++ b/Wable-iOS/Presentation/Viewit/List/View/Subview/ViewitListView.swift @@ -15,10 +15,11 @@ final class ViewitListView: UIView { // MARK: - UIComponent lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout).then { - $0.refreshControl = UIRefreshControl() $0.alwaysBounceVertical = true } + let refreshControl = UIRefreshControl() + let emptyLabel = UILabel().then { $0.attributedText = StringLiterals.Empty.post.pretendardString(with: .body2) $0.textColor = .gray500 @@ -53,6 +54,8 @@ private extension ViewitListView { func setupView() { backgroundColor = .wableWhite + collectionView.refreshControl = refreshControl + let statusBarBackgroundView = UIView(backgroundColor: .wableBlack) let navigationView = NavigationView(type: .hub(title: "뷰잇", isBeta: false)).then { $0.configureView() diff --git a/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift b/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift index cc5d1ab9..4636d0bc 100644 --- a/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift +++ b/Wable-iOS/Presentation/Viewit/List/View/ViewitListViewController.swift @@ -5,6 +5,7 @@ // Created by 김진웅 on 4/12/25. // +import Combine import UIKit import SafariServices @@ -32,7 +33,9 @@ final class ViewitListViewController: UIViewController { private let meatballRelay = PassthroughRelay() private let likeRelay = PassthroughRelay() private let willLastDisplayRelay = PassthroughRelay() - private let bottomSheetActionRelay = PassthroughRelay() + private let reportRelay = PassthroughRelay() + private let banRelay = PassthroughRelay() + private let deleteRelay = PassthroughRelay() private let profileDidTapRelay = PassthroughRelay() private let cancelBag = CancelBag() private let rootView = ViewitListView() @@ -90,7 +93,6 @@ extension ViewitListViewController: UICollectionViewDelegate { } } - // MARK: - CreateViewitViewDelegate extension ViewitListViewController: CreateViewitViewDelegate { @@ -99,9 +101,10 @@ extension ViewitListViewController: CreateViewitViewDelegate { } } -// MARK: - Setup Method - private extension ViewitListViewController { + + // MARK: - Setup Method + func setupDataSource() { let cellRegistration = CellRegistration { cell, indexPath, item in cell.configure( @@ -147,8 +150,18 @@ private extension ViewitListViewController { } func setupAction() { - createButton.addTarget(self, action: #selector(createButtonDidTap), for: .touchUpInside) - refreshControl?.addTarget(self, action: #selector(viewDidRefresh), for: .valueChanged) + rootView.createButton.publisher(for: .touchUpInside) + .sink { [weak self] _ in + let useCase = CreateViewitUseCaseImpl() + let writeViewController = CreateViewitViewController(viewModel: .init(useCase: useCase)) + writeViewController.delegate = self + self?.present(writeViewController, animated: true) + } + .store(in: cancelBag) + + rootView.refreshControl.publisher(for: .valueChanged) + .sink { [weak self] _ in self?.didLoadRelay.send() } + .store(in: cancelBag) } func setupDelegate() { @@ -161,7 +174,9 @@ private extension ViewitListViewController { like: likeRelay.eraseToAnyPublisher(), willLastDisplay: willLastDisplayRelay.eraseToAnyPublisher(), meatball: meatballRelay.eraseToAnyPublisher(), - bottomSheetAction: bottomSheetActionRelay.eraseToAnyPublisher(), + report: reportRelay.eraseToAnyPublisher(), + delete: deleteRelay.eraseToAnyPublisher(), + ban: banRelay.eraseToAnyPublisher(), profileDidTap: profileDidTapRelay.eraseToAnyPublisher() ) @@ -169,9 +184,7 @@ private extension ViewitListViewController { output.isLoading .filter { !$0 } - .sink { [weak self] _ in - self?.refreshControl?.endRefreshing() - } + .sink { [weak self] _ in self?.rootView.refreshControl.endRefreshing() } .store(in: cancelBag) output.viewitList @@ -189,9 +202,7 @@ private extension ViewitListViewController { .store(in: cancelBag) output.userRole - .sink { [weak self] role in - self?.presentBottomSheet(for: role) - } + .sink { [weak self] in self?.presentBottomSheet(for: $0) } .store(in: cancelBag) output.isReportSuccess @@ -231,49 +242,55 @@ private extension ViewitListViewController { switch userRole { case .admin: let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in - self?.presentActionSheet(for: .report) + self?.showReportSheet(onPrimary: { message in + self?.reportRelay.send(message ?? "") + }) } let banAction = WableBottomSheetAction(title: "밴하기") { [weak self] in - self?.presentActionSheet(for: .ban) + self?.showBanConfirmationSheet() } showBottomSheet(actions: reportAction, banAction) case .owner: let deleteAction = WableBottomSheetAction(title: "삭제하기") { [weak self] in - self?.presentActionSheet(for: .delete) + self?.showDeleteConfirmationSheet() } showBottomSheet(actions: deleteAction) case .viewer: let reportAction = WableBottomSheetAction(title: "신고하기") { [weak self] in - self?.presentActionSheet(for: .report) + self?.showReportSheet(onPrimary: { message in + self?.reportRelay.send(message ?? "") + }) } showBottomSheet(actions: reportAction) } } - func presentActionSheet(for action: ViewitBottomSheetActionKind) { - let title: String - let message: String - let buttonTitle: String - - switch action { - case .report: - title = StringLiterals.Report.sheetTitle - message = StringLiterals.Report.sheetMessage - buttonTitle = Constant.Report.buttonTitle - case .delete: - title = Constant.Delete.title - message = Constant.Delete.message - buttonTitle = Constant.Delete.buttonTitle - case .ban: - title = Constant.Ban.title - message = StringLiterals.Ban.sheetMessage - buttonTitle = Constant.Ban.buttonTitle + func showDeleteConfirmationSheet() { + let primaryAction = WableSheetAction( + title: Constant.Delete.buttonTitle, + style: .primary + ) { [weak self] in + self?.deleteRelay.send() } - - let primaryAction = WableSheetAction(title: buttonTitle, style: .primary) { [weak self] in - self?.bottomSheetActionRelay.send(action) + showWableSheetWithCancel( + title: Constant.Delete.title, + message: Constant.Delete.message, + action: primaryAction + ) + } + + func showBanConfirmationSheet() { + let primaryAction = WableSheetAction( + title: Constant.Ban.buttonTitle, + style: .primary + ) { [weak self] in + self?.banRelay.send() } - showWableSheetWithCancel(title: title, message: message, action: primaryAction) + showWableSheetWithCancel( + title: Constant.Ban.title, + message: StringLiterals.Ban.sheetMessage, + action: primaryAction + ) } func showMyProfile() { @@ -301,24 +318,11 @@ private extension ViewitListViewController { } // MARK: - Action Method - - @objc func createButtonDidTap() { - let useCase = CreateViewitUseCaseImpl() - let writeViewController = CreateViewitViewController(viewModel: .init(useCase: useCase)) - writeViewController.delegate = self - present(writeViewController, animated: true) - } @objc func viewDidRefresh() { didLoadRelay.send() } - // MARK: - Computed Property - - var collectionView: UICollectionView { rootView.collectionView } - var refreshControl: UIRefreshControl? { rootView.collectionView.refreshControl } - var createButton: UIButton { rootView.createButton } - // MARK: - Constant enum Constant { diff --git a/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift b/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift index 72bcac4d..7af8182a 100644 --- a/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift +++ b/Wable-iOS/Presentation/Viewit/List/ViewModel/ViewitListViewModel.swift @@ -15,6 +15,14 @@ final class ViewitListViewModel { private let checkUserRoleUseCase: CheckUserRoleUseCase private let userSessionUseCase: FetchUserInformationUseCase + private let loadingStateRelay = CurrentValueRelay(false) + private let viewitListRelay = CurrentValueRelay<[Viewit]>([]) + private let errorMessageRelay = PassthroughRelay() + private let moreLoadingStateRelay = CurrentValueRelay(false) + private let lastPageStateRelay = CurrentValueRelay(false) + private let indexMeatballDidTapRelay = CurrentValueRelay(0) + private let reportStateRelay = CurrentValueRelay(false) + init( useCase: ViewitUseCase, likeUseCase: LikeViewitUseCase, @@ -36,7 +44,9 @@ extension ViewitListViewModel: ViewModelType { let like: Driver let willLastDisplay: Driver let meatball: Driver - let bottomSheetAction: Driver + let report: Driver + let delete: Driver + let ban: Driver let profileDidTap: Driver } @@ -51,165 +61,222 @@ extension ViewitListViewModel: ViewModelType { } func transform(input: Input, cancelBag: CancelBag) -> Output { - let isLoadingRelay = CurrentValueRelay(false) - let viewitListRelay = CurrentValueRelay<[Viewit]>([]) - let errorMessageRelay = PassthroughRelay() - let isMoreLoadingRelay = CurrentValueRelay(false) - let isLastPageRelay = CurrentValueRelay(false) - let indexMeatballDidTapRelay = CurrentValueRelay(0) - let isReportSuccess = CurrentValueRelay(false) - - let viewitList = viewitListRelay - .removeDuplicates() - .asDriver() + bindLoad(input: input, cancelBag: cancelBag) + bindLike(input: input, cancelBag: cancelBag) + bindWillLastDisplay(input: input, cancelBag: cancelBag) + bindReport(input: input, cancelBag: cancelBag) + bindDelete(input: input, cancelBag: cancelBag) + bindBan(input: input, cancelBag: cancelBag) - input.load - .handleEvents(receiveOutput: { _ in - isLoadingRelay.send(true) - isLastPageRelay.send(false) + return Output( + isLoading: loadingStateRelay.asDriver(), + viewitList: createViewitListPublisher(), + isMoreLoading: moreLoadingStateRelay.asDriver(), + userRole: createUserRolePublisher(input: input), + isReportSuccess: reportStateRelay.asDriver(), + moveToProfile: createMoveToProfilePublisher(input: input), + errorMessage: errorMessageRelay.asDriver() + ) + } +} + +private extension ViewitListViewModel { + + // MARK: - Binding Methods + + func bindLoad(input: Input, cancelBag: CancelBag) { + let loadingEvents = input.load + .handleEvents(receiveOutput: { [weak self] _ in + self?.loadingStateRelay.send(true) + self?.lastPageStateRelay.send(false) }) + + let fetchPublisher: AnyPublisher<[Viewit], Never> = loadingEvents .withUnretained(self) - .flatMap { owner, _ in - return owner.useCase.fetchViewitList(last: IntegerLiterals.initialCursor) - .catch { error -> AnyPublisher<[Viewit], Never> in - errorMessageRelay.send(error.localizedDescription) - return .just([]) - } - .eraseToAnyPublisher() + .flatMap { owner, _ -> AnyPublisher<[Viewit], Never> in + return owner.fetchViewitList(last: IntegerLiterals.initialCursor) } + .eraseToAnyPublisher() + + fetchPublisher .handleEvents(receiveOutput: { [weak self] viewitList in - isLoadingRelay.send(false) - isLastPageRelay.send(self?.isLastPage(viewitList) ?? false) + self?.loadingStateRelay.send(false) + self?.lastPageStateRelay.send(self?.checkIsLastPage(viewitList) ?? false) }) - .sink { viewitListRelay.send($0) } + .sink { [weak self] in self?.viewitListRelay.send($0) } .store(in: cancelBag) - - input.like + } + + func bindLike(input: Input, cancelBag: CancelBag) { + let debouncedLike: AnyPublisher = input.like .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .compactMap { viewitID in - return viewitListRelay.value.firstIndex { $0.id == viewitID } + .eraseToAnyPublisher() + + let indexPublisher: AnyPublisher = debouncedLike + .compactMap { [weak self] viewitID in + return self?.viewitListRelay.value.firstIndex { $0.id == viewitID } } + .eraseToAnyPublisher() + + indexPublisher .withUnretained(self) .flatMap { owner, index -> AnyPublisher<(Int, Viewit), Never> in - let viewit = viewitListRelay.value[index] - - let publisher = viewit.like.status + let viewit = owner.viewitListRelay.value[index] + let likePublisher: AnyPublisher = viewit.like.status ? owner.likeUseCase.unlike(viewit: viewit) : owner.likeUseCase.like(viewit: viewit) - return publisher - .catch { error -> AnyPublisher in - errorMessageRelay.send(error.localizedDescription) + return likePublisher + .catch { [weak owner] error -> AnyPublisher in + owner?.errorMessageRelay.send(error.localizedDescription) return .just(nil) } .compactMap { $0 } .map { (index, $0) } .eraseToAnyPublisher() } - .sink { index, viewit in - viewitListRelay.value[index] = viewit + .sink { [weak self] index, viewit in + self?.viewitListRelay.value[index] = viewit } .store(in: cancelBag) - - input.willLastDisplay + } + + func bindWillLastDisplay(input: Input, cancelBag: CancelBag) { + let canLoadMore: AnyPublisher = input.willLastDisplay .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .filter { !isMoreLoadingRelay.value && !isLastPageRelay.value && !viewitListRelay.value.isEmpty } - .handleEvents(receiveOutput: { _ in - isMoreLoadingRelay.send(true) + .filter { [weak self] _ in + guard let self = self else { return false } + return !self.moreLoadingStateRelay.value && + !self.lastPageStateRelay.value && + !self.viewitListRelay.value.isEmpty + } + .eraseToAnyPublisher() + + let loadingEvents = canLoadMore + .handleEvents(receiveOutput: { [weak self] _ in + self?.moreLoadingStateRelay.send(true) }) - .compactMap { viewitListRelay.value.last?.id } + + let lastItemID: AnyPublisher = loadingEvents + .compactMap { [weak self] _ in self?.viewitListRelay.value.last?.id } + .eraseToAnyPublisher() + + lastItemID .withUnretained(self) .flatMap { owner, lastItemID -> AnyPublisher<[Viewit], Never> in - return owner.useCase.fetchViewitList(last: lastItemID) - .catch { error -> AnyPublisher<[Viewit], Never> in - errorMessageRelay.send(error.localizedDescription) - return .just([]) - } - .eraseToAnyPublisher() + return owner.fetchViewitList(last: lastItemID) } - .handleEvents(receiveOutput: { _ in - isMoreLoadingRelay.send(false) + .handleEvents(receiveOutput: { [weak self] _ in + self?.moreLoadingStateRelay.send(false) }) - .sink { viewitListRelay.value.append(contentsOf: $0) } + .sink { [weak self] in self?.viewitListRelay.value.append(contentsOf: $0) } .store(in: cancelBag) - - let userRole = input.meatball - .compactMap { viewitID in - return viewitListRelay.value.firstIndex { $0.id == viewitID } - } - .handleEvents(receiveOutput: { index in - indexMeatballDidTapRelay.send(index) - }) - .compactMap { [weak self] index in - let userID = viewitListRelay.value[index].userID - return self?.checkUserRoleUseCase.execute(userID: userID) + } + + func bindReport(input: Input, cancelBag: CancelBag) { + let reportData: AnyPublisher<(Int, String), Never> = input.report + .map { [weak self] message in + (self?.indexMeatballDidTapRelay.value ?? 0, message) } - .asDriver() + .eraseToAnyPublisher() - input.bottomSheetAction - .filter { $0 == .ban } - .map { _ in indexMeatballDidTapRelay.value } + reportData .withUnretained(self) - .flatMap { owner, index in - let viewit = viewitListRelay.value[index] - return owner.reportUseCase.ban(viewit: viewit) - .catch { error -> AnyPublisher in - errorMessageRelay.send(error.localizedDescription) + .flatMap { owner, data -> AnyPublisher in + let (index, message) = data + let viewit = owner.viewitListRelay.value[index] + return owner.reportUseCase.report(viewit: viewit, message: message) + .catch { [weak owner] error -> AnyPublisher in + owner?.errorMessageRelay.send(error.localizedDescription) return .just(nil) } - .compactMap { $0 } - .withUnretained(self) - .flatMap { owner, _ -> AnyPublisher<[Viewit], Never> in - return owner.useCase.fetchViewitList(last: IntegerLiterals.initialCursor) - .catch { error -> AnyPublisher<[Viewit], Never> in - errorMessageRelay.send(error.localizedDescription) - return .just([]) - } - .eraseToAnyPublisher() - } .eraseToAnyPublisher() } - .sink { viewitListRelay.send($0) } + .compactMap { $0 } + .sink { [weak self] _ in self?.reportStateRelay.send(true) } .store(in: cancelBag) + } + + func bindDelete(input: Input, cancelBag: CancelBag) { + let indexPublisher: AnyPublisher = input.delete + .map { [weak self] _ in self?.indexMeatballDidTapRelay.value ?? 0 } + .eraseToAnyPublisher() - input.bottomSheetAction - .filter { $0 == .delete } - .map { _ in indexMeatballDidTapRelay.value } + indexPublisher .withUnretained(self) - .flatMap { owner, index in - let viewit = viewitListRelay.value[index] + .flatMap { owner, index -> AnyPublisher<(Int, Viewit?), Never> in + let viewit = owner.viewitListRelay.value[index] return owner.useCase.delete(viewit: viewit) - .catch { error -> AnyPublisher in - errorMessageRelay.send(error.localizedDescription) + .catch { [weak owner] error -> AnyPublisher in + owner?.errorMessageRelay.send(error.localizedDescription) return .just(nil) } - .compactMap { $0 } .map { (index, $0) } .eraseToAnyPublisher() } - .sink { index, viewit in - viewitListRelay.value.remove(at: index) + .compactMap { index, viewit in viewit.map { (index, $0) } } + .sink { [weak self] index, _ in + self?.viewitListRelay.value.remove(at: index) } .store(in: cancelBag) + } + + func bindBan(input: Input, cancelBag: CancelBag) { + let indexPublisher: AnyPublisher = input.ban + .map { [weak self] _ in self?.indexMeatballDidTapRelay.value ?? 0 } + .eraseToAnyPublisher() - input.bottomSheetAction - .filter { $0 == .report } - .map { _ in indexMeatballDidTapRelay.value } + let banPublisher: AnyPublisher<[Viewit], Never> = indexPublisher .withUnretained(self) - .flatMap { owner, index in - let viewit = viewitListRelay.value[index] - return owner.reportUseCase.report(viewit: viewit) - .catch { error -> AnyPublisher in - errorMessageRelay.send(error.localizedDescription) + .flatMap { owner, index -> AnyPublisher in + let viewit = owner.viewitListRelay.value[index] + return owner.reportUseCase.ban(viewit: viewit) + .catch { [weak owner] error -> AnyPublisher in + owner?.errorMessageRelay.send(error.localizedDescription) return .just(nil) } - .compactMap { $0 } .eraseToAnyPublisher() } - .sink { _ in isReportSuccess.send(true) } + .compactMap { $0 } + .withUnretained(self) + .flatMap { owner, _ -> AnyPublisher<[Viewit], Never> in + return owner.fetchViewitList(last: IntegerLiterals.initialCursor) + } + .eraseToAnyPublisher() + + banPublisher + .sink { [weak self] in self?.viewitListRelay.send($0) } .store(in: cancelBag) + } + + // MARK: - Publisher Creation Method + + func createViewitListPublisher() -> AnyPublisher<[Viewit], Never> { + return viewitListRelay + .removeDuplicates() + .asDriver() + } + + func createUserRolePublisher(input: Input) -> AnyPublisher { + let indexPublisher: AnyPublisher = input.meatball + .compactMap { [weak self] viewitID in + return self?.viewitListRelay.value.firstIndex { $0.id == viewitID } + } + .handleEvents(receiveOutput: { [weak self] index in + self?.indexMeatballDidTapRelay.send(index) + }) + .eraseToAnyPublisher() - let moveToProfile = input.profileDidTap + return indexPublisher + .compactMap { [weak self] index in + let userID = self?.viewitListRelay.value[index].userID ?? 0 + return self?.checkUserRoleUseCase.execute(userID: userID) + } + .asDriver() + } + + func createMoveToProfilePublisher(input: Input) -> AnyPublisher { + let userIDPairs: AnyPublisher<(Int, Int)?, Never> = input.profileDidTap .withUnretained(self) .compactMap { owner, userID -> (Int, Int)? in guard let activeUserID = owner.userSessionUseCase.fetchActiveUserID() else { @@ -217,28 +284,28 @@ extension ViewitListViewModel: ViewModelType { } return (activeUserID, userID) } - .map { activeUserID, userID -> Int? in + .eraseToAnyPublisher() + + return userIDPairs + .map { pairs -> Int? in + guard let (activeUserID, userID) = pairs else { return nil } return activeUserID == userID ? .none : userID } .asDriver() - - return Output( - isLoading: isLoadingRelay.asDriver(), - viewitList: viewitList, - isMoreLoading: isMoreLoadingRelay.asDriver(), - userRole: userRole, - isReportSuccess: isReportSuccess.asDriver(), - moveToProfile: moveToProfile, - errorMessage: errorMessageRelay.asDriver() - ) } -} - -private extension ViewitListViewModel { - // MARK: - Helper Method - - func isLastPage(_ viewitList: [Viewit]) -> Bool { + // MARK: - Helper Methods + + func fetchViewitList(last: Int) -> AnyPublisher<[Viewit], Never> { + return useCase.fetchViewitList(last: last) + .catch { [weak self] error -> AnyPublisher<[Viewit], Never> in + self?.errorMessageRelay.send(error.localizedDescription) + return .just([]) + } + .eraseToAnyPublisher() + } + + func checkIsLastPage(_ viewitList: [Viewit]) -> Bool { return viewitList.isEmpty || viewitList.count < IntegerLiterals.defaultCountPerPage } } diff --git a/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift b/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift index f42a8ca1..d5f165a4 100644 --- a/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift +++ b/Wable-iOS/Presentation/WableComponent/Navigation/NavigationView.swift @@ -34,6 +34,7 @@ enum NavigationType { case plain case detail case profile + case profileEdit } case home(hasNewNotification: Bool) @@ -105,6 +106,12 @@ final class NavigationView: UIView { $0.contentMode = .scaleAspectFit } + lazy var doneButton: WableButton = WableButton(style: .primary).then { + $0.clipsToBounds = true + $0.layer.cornerRadius = 16.adjustedHeight + $0.configuration?.attributedTitle = "완료".pretendardString(with: .body3) + } + lazy var menuButton: UIButton = UIButton().then { $0.setImage(.btnHamberger, for: .normal) $0.contentMode = .scaleAspectFit @@ -144,6 +151,7 @@ private extension NavigationView { notificationButton, backButton, dismissButton, + doneButton, menuButton ].forEach { addSubviews($0) @@ -202,6 +210,13 @@ private extension NavigationView { $0.verticalEdges.trailing.equalToSuperview().inset(12) } + doneButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().inset(12) + $0.adjustedWidthEqualTo(53) + $0.adjustedHeightEqualTo(33) + } + menuButton.snp.makeConstraints { $0.verticalEdges.trailing.equalToSuperview().inset(12) } @@ -249,6 +264,13 @@ extension NavigationView { menuButton, pageUnderLineView ] + case .profileEdit: + visibleViewList = [ + pageTitleLabel, + backButton, + doneButton, + pageUnderLineView + ] } case .hub(title: let text, isBeta: let isBeta): backgroundColor = .wableBlack diff --git a/Wable-iOS/Presentation/WableComponent/View/TeamCollectionView.swift b/Wable-iOS/Presentation/WableComponent/View/TeamCollectionView.swift new file mode 100644 index 00000000..ccb5e382 --- /dev/null +++ b/Wable-iOS/Presentation/WableComponent/View/TeamCollectionView.swift @@ -0,0 +1,139 @@ +// +// TeamCollectionView.swift +// Wable-iOS +// +// Created by YOUJIM on 6/18/25. +// + +import UIKit + +final class TeamCollectionView: UICollectionView { + + // MARK: - Property + + let randomTeamList: [LCKTeam] = [.t1, .gen, .bro, .drx, .dk, .kt, .ns, .bfx, .hle, .dnf].shuffled() + private let onSelected: ((String) -> Void)? + private var selectedTeam: LCKTeam? + + // MARK: - LifeCycle + + init(cellDidTapped: ((String) -> Void)?) { + self.onSelected = cellDidTapped + + super.init(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout().then { + $0.itemSize = .init(width: 166.adjustedWidth, height: 64.adjustedHeight) + $0.minimumInteritemSpacing = 11 + $0.minimumLineSpacing = 12 + }) + + setupView() + setupDelegate() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Setup Method + +private extension TeamCollectionView { + func setupView() { + register( + LCKTeamCollectionViewCell.self, + forCellWithReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier + ) + isScrollEnabled = false + } + + func setupDelegate() { + delegate = self + dataSource = self + } +} + +// MARK: - Helper Method + +extension TeamCollectionView { + func selectInitialTeam(team: LCKTeam?) { + guard let team = team else { return } + + selectedTeam = team + + DispatchQueue.main.async { [weak self] in + guard let self = self, + let selectedTeam = selectedTeam, + let index = randomTeamList.firstIndex(of: selectedTeam) else { + return + } + + let indexPath = IndexPath(row: index, section: 0) + + for (index, _) in randomTeamList.enumerated() { + if let cell = cellForItem(at: IndexPath(row: index, section: 0)) as? LCKTeamCollectionViewCell { + cell.layer.borderColor = UIColor.gray300.cgColor + cell.teamLabel.textColor = .gray700 + } + } + + if let cell = cellForItem(at: indexPath) as? LCKTeamCollectionViewCell { + cell.layer.borderColor = UIColor.purple50.cgColor + cell.teamLabel.textColor = .wableBlack + } + + selectItem(at: indexPath, animated: false, scrollPosition: []) + } + } +} + +// MARK: - UICollectionViewDelegate + +extension TeamCollectionView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let selectedTeam = randomTeamList[indexPath.row] + self.selectedTeam = selectedTeam + + for cell in collectionView.visibleCells { + guard let cell = cell as? LCKTeamCollectionViewCell else { return } + + cell.layer.borderColor = UIColor.gray300.cgColor + cell.teamLabel.textColor = .gray700 + } + + guard let cell = collectionView.cellForItem(at: indexPath) as? LCKTeamCollectionViewCell else { return } + + cell.layer.borderColor = UIColor.purple50.cgColor + cell.teamLabel.textColor = .wableBlack + + onSelected?(selectedTeam.rawValue) + } +} + +extension TeamCollectionView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return randomTeamList.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: LCKTeamCollectionViewCell.reuseIdentifier, + for: indexPath + ) as? LCKTeamCollectionViewCell else { + return UICollectionViewCell() + } + + let team = randomTeamList[indexPath.row] + cell.teamLabel.text = team.rawValue + cell.teamImageView.image = UIImage(named: team.rawValue.lowercased()) + + if let selectedTeam = selectedTeam, selectedTeam == team { + cell.layer.borderColor = UIColor.purple50.cgColor + cell.teamLabel.textColor = .wableBlack + } else { + cell.layer.borderColor = UIColor.gray300.cgColor + cell.teamLabel.textColor = .gray700 + } + + return cell + } +} diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift index 6a99d1cf..d4a1aeb4 100644 --- a/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift +++ b/Wable-iOS/Presentation/WableComponent/ViewController/PhotoDetailViewController.swift @@ -6,6 +6,7 @@ // import UIKit +import Photos import SnapKit import Then @@ -14,16 +15,19 @@ final class PhotoDetailViewController: UIViewController { // MARK: - UIComponent + private let backButton = UIButton().then { + $0.setImage(.icBackCircle, for: .normal) + } + + private let saveButton = UIButton().then { + $0.setImage(.icDownloadCircle, for: .normal) + } + private let imageView = UIImageView().then { $0.contentMode = .scaleAspectFit $0.clipsToBounds = true } - private let dismissButton = UIButton().then { - var configuration = UIButton.Configuration.plain() - configuration.image = .btnRemovePhoto - $0.configuration = configuration - } // MARK: - Property @@ -36,10 +40,10 @@ final class PhotoDetailViewController: UIViewController { super.init(nibName: nil, bundle: nil) - modalTransitionStyle = .crossDissolve - modalPresentationStyle = .overFullScreen + hidesBottomBarWhenPushed = true } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -55,54 +59,115 @@ final class PhotoDetailViewController: UIViewController { } } -// MARK: - Setup Method - private extension PhotoDetailViewController { - func setupView() { - view.backgroundColor = .wableBlack.withAlphaComponent(0.7) + + // MARK: - Setup Method + + func setupView() { + view.backgroundColor = .wableBlack view.addSubviews( imageView, - dismissButton + backButton, + saveButton ) imageView.image = image } func setupConstraint() { + backButton.snp.makeConstraints { make in + make.top.equalTo(safeArea).offset(4) + make.leading.equalTo(safeArea).offset(12) + make.size.equalTo(48) + } + + saveButton.snp.makeConstraints { make in + make.top.size.equalTo(backButton) + make.trailing.equalTo(safeArea).offset(-12) + } + imageView.snp.makeConstraints { make in - make.width.equalToSuperview() make.center.equalToSuperview() - make.height.equalTo(optimalImageViewHeight) + make.width.lessThanOrEqualToSuperview() + make.height.lessThanOrEqualToSuperview() + make.size.equalTo(optimalImageViewSize) } + } + + func setupAction() { + let popAction = UIAction { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + } + backButton.addAction(popAction, for: .touchUpInside) - dismissButton.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.bottom.equalToSuperview().offset(-100) - make.adjustedWidthEqualTo(60) - make.height.equalTo(dismissButton.snp.width) + let saveAction = UIAction { [weak self] _ in + AmplitudeManager.shared.trackEvent(tag: .clickDownloadPhoto) + self?.saveImage() } + saveButton.addAction(saveAction, for: .touchUpInside) } - func setupAction() { - let dismissAction = UIAction { [weak self] _ in - self?.dismiss(animated: true) + // MARK: - Photo Method + + func saveImage() { + Task { + do { + guard try await requestPhotoPermissionIfNeeded() else { return } + try await saveImageToPhotoLibrary(image) + await MainActor.run { + ToastView(status: .complete, message: StringLiterals.PhotoDetail.successMessage).show() + } + } catch { + await MainActor.run { + ToastView(status: .error, message: StringLiterals.PhotoDetail.errorMessage).show() + } + WableLogger.log("\(error)", for: .error) + } + } + } + + func requestPhotoPermissionIfNeeded() async throws -> Bool { + let currentStatus = PHPhotoLibrary.authorizationStatus(for: .addOnly) + + if isPermissionGranted(currentStatus) { + return true } - dismissButton.addAction(dismissAction, for: .touchUpInside) + let newStatus = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + return isPermissionGranted(newStatus) } -} - -// MARK: - Computed Property - -private extension PhotoDetailViewController { - var optimalImageViewHeight: CGFloat { - let aspectRatio = image.size.height / image.size.width - let screenWidth = UIScreen.main.bounds.width - let height = screenWidth * aspectRatio + + func isPermissionGranted(_ status: PHAuthorizationStatus) -> Bool { + return status == .authorized || status == .limited + } + + func saveImageToPhotoLibrary(_ image: UIImage) async throws { + try await PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: image) + } + } + + // MARK: - Computed Property + + var optimalImageViewSize: CGSize { + let screenSize = UIScreen.main.bounds.size + let imageSize = image.size - let maxHeight: CGFloat = 812.adjustedHeight + let aspectRatio = imageSize.width / imageSize.height + + var finalWidth: CGFloat + var finalHeight: CGFloat + + let condition = imageSize.width > screenSize.width + finalWidth = condition ? screenSize.width : imageSize.width + finalHeight = condition ? finalWidth / aspectRatio : imageSize.height + + if finalHeight > screenSize.height { + finalHeight = screenSize.height + finalWidth = finalHeight * aspectRatio + } - return min(height, maxHeight) + return CGSize(width: finalWidth, height: finalHeight) } } diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift index 7e51dd84..04041061 100644 --- a/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift +++ b/Wable-iOS/Presentation/WableComponent/ViewController/WableBottomSheetController.swift @@ -102,6 +102,10 @@ extension WableBottomSheetController { buttonStackView.addArrangedSubview(button) } + func addActions(_ actions: [WableBottomSheetAction]) { + actions.forEach { addAction($0) } + } + func addActions(_ actions: WableBottomSheetAction...) { actions.forEach { addAction($0) } } diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift index 86ac9361..f2001197 100644 --- a/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift +++ b/Wable-iOS/Presentation/WableComponent/ViewController/WableSheetViewController.swift @@ -97,6 +97,7 @@ final class WableSheetViewController: UIViewController { modalPresentationStyle = .overFullScreen } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -111,23 +112,21 @@ final class WableSheetViewController: UIViewController { } } -// MARK: - Public Method - extension WableSheetViewController { func addAction(_ action: WableSheetAction) { let button = createWableButton(for: action) buttonStackView.addArrangedSubview(button) } + func addActions(_ actions: [WableSheetAction]) { + actions.forEach { addAction($0) } + } + func addActions(_ actions: WableSheetAction...) { actions.forEach { addAction($0) } } -} - -// MARK: - Private Method - -private extension WableSheetViewController { - func createWableButton(for action: WableSheetAction) -> WableButton { + + private func createWableButton(for action: WableSheetAction) -> WableButton { return WableButton(style: action.style.buttonStyle).then { var config = $0.configuration ?? .filled() config.attributedTitle = action.title.pretendardString(with: .body1) @@ -144,9 +143,10 @@ private extension WableSheetViewController { } } -// MARK: - Setup Method - private extension WableSheetViewController { + + // MARK: - Setup Method + func setupView() { view.backgroundColor = .wableBlack.withAlphaComponent(0.7) diff --git a/Wable-iOS/Presentation/WableComponent/ViewController/WableTextSheetViewController.swift b/Wable-iOS/Presentation/WableComponent/ViewController/WableTextSheetViewController.swift new file mode 100644 index 00000000..9720b57e --- /dev/null +++ b/Wable-iOS/Presentation/WableComponent/ViewController/WableTextSheetViewController.swift @@ -0,0 +1,348 @@ +// +// WableTextSheetViewController.swift +// Wable-iOS +// +// Created by 김진웅 on 6/22/25. +// + +import Combine +import UIKit + +import SnapKit +import Then + +// MARK: - WableTextSheetAction + +struct WableTextSheetAction { + enum Style { + case primary + case gray + } + + let title: String + let style: Style + let handler: ((String?) -> Void)? + + init( + title: String, + style: Style, + handler: ((String?) -> Void)? = nil + ) { + self.title = title + self.style = style + self.handler = handler + } +} + +fileprivate extension WableTextSheetAction.Style { + var buttonStyle: WableButton.Style { + switch self { + case .primary: + return .primary + case .gray: + return .gray + } + } +} + +// MARK: - WableTextSheetViewController + +final class WableTextSheetViewController: UIViewController { + + // MARK: - UIComponent + + private let scrollView = UIScrollView().then { + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.keyboardDismissMode = .interactive + } + + private let containerView = UIView(backgroundColor: .wableWhite).then { + $0.layer.cornerRadius = 16 + } + + private let titleLabel = UILabel().then { + $0.attributedText = "제목".pretendardString(with: .head1) + $0.numberOfLines = 0 + $0.textAlignment = .center + } + + private let messageTextView = UITextView(backgroundColor: .gray100).then { + $0.font = .pretendard(.body4) + $0.textColor = .wableBlack + $0.textContainerInset = UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12) + $0.layer.cornerRadius = 12 + } + + private let placeholderLabel = UILabel().then { + $0.attributedText = "플레이스홀더".pretendardString(with: .body4) + $0.textColor = .gray600 + $0.numberOfLines = 0 + } + + private let textCountLabel = UILabel().then { + $0.attributedText = "0/100".pretendardString(with: .caption4) + $0.textAlignment = .right + } + + private let buttonStackView = UIStackView(axis: .horizontal).then { + $0.spacing = 8 + $0.distribution = .fillEqually + $0.alignment = .fill + } + + // MARK: - Property + + private let sheetTitle: String + private let placeholder: String + private let cancelBag = CancelBag() + + // MARK: - Life Cycle + + init(title: String, placeholder: String) { + self.sheetTitle = title + self.placeholder = placeholder + + super.init(nibName: nil, bundle: nil) + + modalTransitionStyle = .crossDissolve + modalPresentationStyle = .overFullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + setupConstraint() + setupDelegate() + setupAction() + } +} + +extension WableTextSheetViewController { + func addAction(_ action: WableTextSheetAction) { + let button = createWableButton(for: action) + buttonStackView.addArrangedSubview(button) + } + + func addActions(_ actions: [WableTextSheetAction]) { + actions.forEach { addAction($0) } + } + + func addActions(_ actions: WableTextSheetAction...) { + actions.forEach { addAction($0) } + } + + private func createWableButton(for action: WableTextSheetAction) -> WableButton { + return WableButton(style: action.style.buttonStyle).then { + var config = $0.configuration ?? .filled() + config.attributedTitle = action.title.pretendardString(with: .body1) + $0.configuration = config + + let action = UIAction { [weak self] _ in + self?.dismiss(animated: true) { + action.handler?(self?.messageTextView.text) + } + } + + $0.addAction(action, for: .touchUpInside) + } + } +} + +// MARK: - UITextViewDelegate + +extension WableTextSheetViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + configure(textView: textView) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + let currentText = textView.text ?? "" + let newLength = currentText.count + text.count - range.length + return newLength <= Constant.maxCharacterCount + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension WableTextSheetViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if touch.view is UIButton || touch.view is UITextView { + return false + } + + if let view = touch.view, view.isDescendant(of: buttonStackView) { + return false + } + + return true + } +} + +private extension WableTextSheetViewController { + + // MARK: - Setup Method + + func setupView() { + view.backgroundColor = .wableBlack.withAlphaComponent(0.7) + + titleLabel.text = sheetTitle + placeholderLabel.text = placeholder + + view.addSubview(scrollView) + scrollView.addSubview(containerView) + messageTextView.addSubview(placeholderLabel) + + containerView.addSubviews( + titleLabel, + messageTextView, + textCountLabel, + buttonStackView + ) + } + + func setupConstraint() { + scrollView.snp.makeConstraints { make in + make.edges.equalTo(safeArea) + } + + containerView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(32) + make.centerY.equalToSuperview() + make.width.equalTo(view.snp.width).offset(-64) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(24) + make.horizontalEdges.equalToSuperview().inset(24) + } + + messageTextView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.horizontalEdges.equalTo(titleLabel) + make.adjustedHeightEqualTo(156) + } + + placeholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(12) + make.horizontalEdges.equalToSuperview().inset(16) + } + + textCountLabel.snp.makeConstraints { make in + make.top.equalTo(messageTextView.snp.bottom).offset(4) + make.trailing.equalTo(messageTextView) + } + + buttonStackView.snp.makeConstraints { make in + make.top.equalTo(textCountLabel.snp.bottom).offset(16) + make.horizontalEdges.equalTo(messageTextView) + make.adjustedHeightEqualTo(48) + make.bottom.equalToSuperview().offset(-16) + } + } + + func setupDelegate() { + messageTextView.delegate = self + } + + func setupAction() { + let tapGesture = UITapGestureRecognizer(target: self, action: nil) + tapGesture.delegate = self + + view.gesture(.tap(tapGesture)) + .sink { [weak self] _ in self?.view.endEditing(true) } + .store(in: cancelBag) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + .compactMap { notification -> (CGRect, TimeInterval)? in + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval else { + return nil + } + return (keyboardFrame, duration) + } + .sink { [weak self] keyboardFrame, duration in + self?.adjustScrollViewForKeyboard(keyboardFrame: keyboardFrame, duration: duration, isShowing: true) + } + .store(in: cancelBag) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .compactMap { notification -> TimeInterval? in + notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval + } + .sink { [weak self] duration in + self?.adjustScrollViewForKeyboard(keyboardFrame: .zero, duration: duration, isShowing: false) + } + .store(in: cancelBag) + } + + // MARK: - Helper Method + + func configure(textView: UITextView) { + let currentText = textView.text ?? "" + let currentCount = currentText.count + + placeholderLabel.isHidden = !currentText.isEmpty + + textCountLabel.text = "\(currentCount)/\(Constant.maxCharacterCount)" + textCountLabel.textColor = currentCount >= Constant.maxCharacterCount ? .red : .wableBlack + } + + func adjustScrollViewForKeyboard(keyboardFrame: CGRect, duration: TimeInterval, isShowing: Bool) { + let adjustedKeyboardHeight = calculateAdjustedKeyboardHeight(keyboardFrame: keyboardFrame, isShowing: isShowing) + updateScrollViewInsets(with: adjustedKeyboardHeight) + + isShowing + ? scrollToVisibleAreaIfNeeded(keyboardHeight: adjustedKeyboardHeight, duration: duration) + : animateScrollViewContentOffset(to: .zero, duration: duration) + } + + func calculateAdjustedKeyboardHeight(keyboardFrame: CGRect, isShowing: Bool) -> CGFloat { + if !isShowing { return 0 } + + let keyboardHeight = keyboardFrame.height + let safeAreaBottom = view.safeAreaInsets.bottom + return keyboardHeight - safeAreaBottom + } + + func updateScrollViewInsets(with keyboardHeight: CGFloat) { + let contentInsets = UIEdgeInsets(top: 0, left: 0, bottom: keyboardHeight, right: 0) + scrollView.contentInset = contentInsets + scrollView.scrollIndicatorInsets = contentInsets + } + + func scrollToVisibleAreaIfNeeded(keyboardHeight: CGFloat, duration: TimeInterval) { + let textCountLabelFrame = textCountLabel.convert(textCountLabel.bounds, to: scrollView) + let textCountLabelBottom = textCountLabelFrame.maxY + let keyboardTop = scrollView.frame.height - keyboardHeight + + guard textCountLabelBottom > keyboardTop else { return } + + let scrollOffset = textCountLabelBottom - keyboardTop + 8 + let targetContentOffset = CGPoint(x: 0, y: scrollOffset) + + animateScrollViewContentOffset(to: targetContentOffset, duration: duration) + } + + func animateScrollViewContentOffset(to offset: CGPoint, duration: TimeInterval) { + UIView.animate( + withDuration: duration, + delay: 0, + options: .curveEaseInOut + ) { + self.scrollView.setContentOffset(offset, animated: false) + } + } + + // MARK: - Constant + + enum Constant { + static let maxCharacterCount = 100 + } +} diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/Contents.json new file mode 100644 index 00000000..9e78ac74 --- /dev/null +++ b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "img_wable 3.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "img_wable 1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "img_wable 2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 1.png b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 1.png new file mode 100644 index 00000000..9a30ebbe Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 1.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 2.png b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 2.png new file mode 100644 index 00000000..9a30ebbe Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 2.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 3.png b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 3.png new file mode 100644 index 00000000..9a30ebbe Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/AppIcon_Dev.appiconset/img_wable 3.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/Contents.json new file mode 100644 index 00000000..4e2cc768 --- /dev/null +++ b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_back_circle.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_back_circle@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_back_circle@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle.png new file mode 100644 index 00000000..439197d4 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@2x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@2x.png new file mode 100644 index 00000000..0bf3574a Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@2x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@3x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@3x.png new file mode 100644 index 00000000..afa1098b Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_back_circle.imageset/ic_back_circle@3x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/Contents.json new file mode 100644 index 00000000..5aa96c2f --- /dev/null +++ b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_download_circle.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_download_circle@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_download_circle@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle.png new file mode 100644 index 00000000..f22f5451 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@2x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@2x.png new file mode 100644 index 00000000..60871578 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@2x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@3x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@3x.png new file mode 100644 index 00000000..1d795517 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_download_circle.imageset/ic_download_circle@3x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/Contents.json new file mode 100644 index 00000000..9ccc74e5 --- /dev/null +++ b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_ghost_info.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_ghost_info@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_ghost_info@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info.png new file mode 100644 index 00000000..c5217a0c Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@2x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@2x.png new file mode 100644 index 00000000..a86a780a Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@2x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@3x.png b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@3x.png new file mode 100644 index 00000000..9db531c6 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Icon/ic_ghost_info.imageset/ic_ghost_info@3x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/Contents.json b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/Contents.json new file mode 100644 index 00000000..2a36bb47 --- /dev/null +++ b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "img_ghost_tooltip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "img_ghost_tooltip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "img_ghost_tooltip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip.png b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip.png new file mode 100644 index 00000000..5854ead9 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@2x.png b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@2x.png new file mode 100644 index 00000000..7f06cc07 Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@2x.png differ diff --git a/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@3x.png b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@3x.png new file mode 100644 index 00000000..d3d02dea Binary files /dev/null and b/Wable-iOS/Resource/Assets.xcassets/Image/img_ghost_tooltip.imageset/img_ghost_tooltip@3x.png differ diff --git a/Wable-iOS/Resource/Color.xcassets/GrayScale/alpha50.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/GrayScale/alpha50.colorset/Contents.json new file mode 100644 index 00000000..f4ca2aec --- /dev/null +++ b/Wable-iOS/Resource/Color.xcassets/GrayScale/alpha50.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.500", + "blue" : "0x0E", + "green" : "0x0E", + "red" : "0x0E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Color.xcassets/Team/bfx10.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/bfx10.colorset/Contents.json new file mode 100644 index 00000000..7e8f38fe --- /dev/null +++ b/Wable-iOS/Resource/Color.xcassets/Team/bfx10.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Color.xcassets/Team/bfx50.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/bfx50.colorset/Contents.json new file mode 100644 index 00000000..0b427cfa --- /dev/null +++ b/Wable-iOS/Resource/Color.xcassets/Team/bfx50.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0xE4", + "red" : "0xFB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Color.xcassets/Team/dnf10.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/dnf10.colorset/Contents.json new file mode 100644 index 00000000..4ae755e7 --- /dev/null +++ b/Wable-iOS/Resource/Color.xcassets/Team/dnf10.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xEB", + "red" : "0xE2" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Color.xcassets/Team/dnf50.colorset/Contents.json b/Wable-iOS/Resource/Color.xcassets/Team/dnf50.colorset/Contents.json new file mode 100644 index 00000000..b4b60d1f --- /dev/null +++ b/Wable-iOS/Resource/Color.xcassets/Team/dnf50.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF2", + "green" : "0x50", + "red" : "0x0B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Resource/Info.plist b/Wable-iOS/Resource/Info.plist index 676ab412..27f46388 100644 --- a/Wable-iOS/Resource/Info.plist +++ b/Wable-iOS/Resource/Info.plist @@ -2,8 +2,6 @@ - ITSAppUsesNonExemptEncryption - AMPLITUDE_APP_KEY $(AMPLITUDE_APP_KEY) BASE_URL @@ -21,6 +19,8 @@ FirebaseAppDelegateProxyEnabled + ITSAppUsesNonExemptEncryption + LSApplicationQueriesSchemes kakaolink