From e8cb6f6123e2927bba3530630c696512889dc98a Mon Sep 17 00:00:00 2001 From: MaraMincho <103064352+MaraMincho@users.noreply.github.com> Date: Fri, 12 Jan 2024 10:58:59 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[GWL-405]=20=EB=AC=B4=ED=95=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: HomeFeature DownSampling 모듈 추가 * feat: 다운 샘플링 적용 * feat: ImageCacher 및 PrepareForReuse 적용 * feat: Repository And UseCase 파일 생성 * feat: FetchCheckManager 구현 * move: 파일 위치 변경 * feat: coordaintor 및 UseCase 적용 * feat: 무한 스크롤 구현 * delete: 목 데이터 삭제 * feat: 화면에 피드가 표시되었을 때 Page넘버를 증가하는 기능 추가 * delete: Home화면 Resources 폴더 제거 * style: 피드백 적용 및 쓰지 않는 import문 제거 --- .../Coordinator/AppCoordinator.swift | 2 +- .../Coordinator/TabBarCoordinator.swift | 9 +- iOS/Projects/Features/Home/Project.swift | 11 ++- .../Home/Sources/Data/FeedRepository.swift | 66 +++++++++++++ .../Home/Sources/Data/HomeRepository.swift | 9 -- .../Sources/Domain/Entity/FeedElement.swift | 2 +- .../Home/Sources/Domain/HomeUseCase.swift | 60 ++++++++++++ .../FeedRepositoryRepresentable.swift | 16 ++++ .../Coordinator/HomeCoordinator.swift | 6 +- .../HomeScene/VIew/FeedImageCell.swift | 21 +++-- .../VIew/FeedItemCollectionViewCell.swift | 11 ++- .../ViewController/HomeViewController.swift | 93 ++++++++++--------- .../HomeScene/ViewModel/HomeViewModel.swift | 31 ++++++- 13 files changed, 270 insertions(+), 67 deletions(-) create mode 100644 iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift delete mode 100644 iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift create mode 100644 iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift rename iOS/Projects/Features/Home/Sources/{ => Presntaion}/Coordinator/HomeCoordinator.swift (84%) diff --git a/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift b/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift index fbe43751..6d3a67bb 100644 --- a/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift +++ b/iOS/Projects/App/WeTri/Sources/CommonScene/Coordinator/AppCoordinator.swift @@ -29,7 +29,7 @@ final class AppCoordinator: AppCoordinating { } func start() { - showSplashFlow() + showTabBarFlow() } private func showSplashFlow() { diff --git a/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift b/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift index c177f7cc..3d2dcffc 100644 --- a/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift +++ b/iOS/Projects/App/WeTri/Sources/TabBarScene/Coordinator/TabBarCoordinator.swift @@ -40,7 +40,7 @@ final class TabBarCoordinator: TabBarCoordinating { } func start() { - let tabBarViewControllers = [TabBarPage.record, TabBarPage.profile].map { + let tabBarViewControllers = TabBarPage.allCases.map { return makePageNavigationController(page: $0) } let tabBarController = makeTabBarController(tabBarViewControllers: tabBarViewControllers) @@ -58,6 +58,13 @@ final class TabBarCoordinator: TabBarCoordinating { private func startTabBarCoordinator(page: TabBarPage, pageNavigationViewController: UINavigationController) { switch page { + case .home: + let coordinator = HomeCoordinator( + navigationController: pageNavigationViewController, + delegate: self + ) + childCoordinators.append(coordinator) + coordinator.start() case .record: let recordCoordinator = RecordFeatureCoordinator( navigationController: pageNavigationViewController, diff --git a/iOS/Projects/Features/Home/Project.swift b/iOS/Projects/Features/Home/Project.swift index 211c08d4..e945f1b5 100644 --- a/iOS/Projects/Features/Home/Project.swift +++ b/iOS/Projects/Features/Home/Project.swift @@ -7,7 +7,16 @@ let project = Project.makeModule( targets: .feature( .home, testingOptions: [.unitTest], - dependencies: [.designSystem, .log, .combineCocoa, .trinet, .combineExtension, .coordinator, .commonNetworkingKeyManager], + dependencies: [ + .designSystem, + .log, + .combineCocoa, + .trinet, + .combineExtension, + .coordinator, + .commonNetworkingKeyManager, + .downSampling, + ], testDependencies: [] ) ) diff --git a/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift b/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift new file mode 100644 index 00000000..4b4eea40 --- /dev/null +++ b/iOS/Projects/Features/Home/Sources/Data/FeedRepository.swift @@ -0,0 +1,66 @@ +// +// FeedRepository.swift +// HomeFeature +// +// Created by MaraMincho on 12/7/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import CommonNetworkingKeyManager +import Foundation +import Trinet + +// MARK: - FeedRepository + +public struct FeedRepository: FeedRepositoryRepresentable { + let decoder = JSONDecoder() + let provider: TNProvider + init(session: URLSessionProtocol = URLSession.shared) { + provider = .init(session: session) + } + + public func fetchFeed(at page: Int) -> AnyPublisher<[FeedElement], Never> { + return Future<[FeedElement], Error> { promise in + Task { [provider] in + do { + let data = try await provider.request(.fetchPosts(page: page), interceptor: TNKeychainInterceptor.shared) + let feedElementList = try decoder.decode([FeedElement].self, from: data) + promise(.success(feedElementList)) + } catch { + promise(.failure(error)) + } + } + } + .catch { _ in return Empty() } + .eraseToAnyPublisher() + } +} + +// MARK: - FeedEndPoint + +public enum FeedEndPoint: TNEndPoint { + case fetchPosts(page: Int) + public var path: String { + return "" + } + + public var method: TNMethod { + return .post + } + + public var query: Encodable? { + return nil + } + + public var body: Encodable? { + switch self { + case let .fetchPosts(page): + return page + } + } + + public var headers: TNHeaders { + return .default + } +} diff --git a/iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift b/iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift deleted file mode 100644 index a9482ac8..00000000 --- a/iOS/Projects/Features/Home/Sources/Data/HomeRepository.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// HomeRepository.swift -// HomeFeature -// -// Created by MaraMincho on 12/7/23. -// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. -// - -import Foundation diff --git a/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift b/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift index d5ed5957..9349434d 100644 --- a/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift +++ b/iOS/Projects/Features/Home/Sources/Domain/Entity/FeedElement.swift @@ -8,7 +8,7 @@ import Foundation -struct FeedElement: Hashable { +public struct FeedElement: Hashable, Codable { /// 개시물의 아이디 입니다. let ID: Int diff --git a/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift b/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift index 77dc7570..c1c6e580 100644 --- a/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift +++ b/iOS/Projects/Features/Home/Sources/Domain/HomeUseCase.swift @@ -6,4 +6,64 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import Combine import Foundation + +// MARK: - HomeUseCaseRepresentable + +public protocol HomeUseCaseRepresentable { + func fetchFeed() -> AnyPublisher<[FeedElement], Never> + mutating func didDisplayFeed() +} + +// MARK: - HomeUseCase + +public struct HomeUseCase: HomeUseCaseRepresentable { + private let feedRepositoryRepresentable: FeedRepositoryRepresentable + + private var latestFeedPage = 0 + private var feedElementPublisher: PassthroughSubject<[FeedElement], Never> = .init() + private let checkManager: FetchCheckManager = .init() + + public init(feedRepositoryRepresentable: FeedRepositoryRepresentable) { + self.feedRepositoryRepresentable = feedRepositoryRepresentable + } + + public func fetchFeed() -> AnyPublisher<[FeedElement], Never> { + if checkManager[latestFeedPage] == false { + return Empty().eraseToAnyPublisher() + } + checkManager[latestFeedPage] = true + return feedElementPublisher.eraseToAnyPublisher() + } + + public mutating func didDisplayFeed() { + latestFeedPage += 1 + } +} + +// MARK: - FetchCheckManager + +final class FetchCheckManager { + subscript(page: Int) -> Bool { + get { + return check(at: page) + } + set { + set(at: page) + } + } + + private var requestedFetchPageNumber: [Int: Bool] = [:] + /// FetchCheckManager를 통해서 특정 페이지에 대해 요청을 했다고 저장합니다. + private func set(at page: Int) { + requestedFetchPageNumber[page] = true + } + + /// FetchCheckManager를 통해서, 특정 페이지에 대한 과거 요청에 대해서 살펴 봅니다. + /// + /// 만약 요청한 적이 없다면 false를, 요청을 했다면 true를 리턴 + private func check(at page: Int) -> Bool { + return requestedFetchPageNumber[page] ?? false + } +} diff --git a/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift b/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift new file mode 100644 index 00000000..164e4122 --- /dev/null +++ b/iOS/Projects/Features/Home/Sources/Domain/RepositoryInterface/FeedRepositoryRepresentable.swift @@ -0,0 +1,16 @@ +// +// FeedRepositoryRepresentable.swift +// HomeFeature +// +// Created by MaraMincho on 1/2/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import Foundation + +// MARK: - FeedRepositoryRepresentable + +public protocol FeedRepositoryRepresentable { + func fetchFeed(at page: Int) -> AnyPublisher<[FeedElement], Never> +} diff --git a/iOS/Projects/Features/Home/Sources/Coordinator/HomeCoordinator.swift b/iOS/Projects/Features/Home/Sources/Presntaion/Coordinator/HomeCoordinator.swift similarity index 84% rename from iOS/Projects/Features/Home/Sources/Coordinator/HomeCoordinator.swift rename to iOS/Projects/Features/Home/Sources/Presntaion/Coordinator/HomeCoordinator.swift index 946bbf79..c59b8678 100644 --- a/iOS/Projects/Features/Home/Sources/Coordinator/HomeCoordinator.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/Coordinator/HomeCoordinator.swift @@ -37,7 +37,11 @@ public final class HomeCoordinator: HomeCoordinating { } public func pushHome() { - let viewModel = HomeViewModel() + let repository = FeedRepository(session: URLSession.shared) + + let useCase = HomeUseCase(feedRepositoryRepresentable: repository) + + let viewModel = HomeViewModel(useCase: useCase) let viewController = HomeViewController(viewModel: viewModel) diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift index c3c76f66..275b40b5 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedImageCell.swift @@ -6,6 +6,7 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import Cacher import UIKit // MARK: - FeedImageCell @@ -24,6 +25,12 @@ final class FeedImageCell: UICollectionViewCell { fatalError("cant use this init") } + override func prepareForReuse() { + super.prepareForReuse() + + feedImage.image = nil + } + private func setupViewHierarchyAndConstraints() { contentView.addSubview(feedImage) feedImage.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true @@ -43,13 +50,15 @@ final class FeedImageCell: UICollectionViewCell { guard let imageURL else { return } - - DispatchQueue.global().async { - guard let data = try? Data(contentsOf: imageURL) else { return } - DispatchQueue.main.async { [weak self] in - self?.feedImage.image = UIImage(data: data) - self?.layoutIfNeeded() + guard let data = MemoryCacheManager.shared.fetch(cacheKey: imageURL.absoluteString) else { + DispatchQueue.global().async { + guard let data = try? Data(contentsOf: imageURL) else { return } + DispatchQueue.main.async { [weak self] in + self?.feedImage.image = UIImage(data: data) + } } + return } + feedImage.image = UIImage(data: data) } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift index 570a5712..a13df4b5 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/VIew/FeedItemCollectionViewCell.swift @@ -24,6 +24,12 @@ class FeedItemCollectionViewCell: UICollectionViewCell { fatalError("생성할 수 없습니다.") } + override func prepareForReuse() { + super.prepareForReuse() + + profileImage.image = nil + } + // MARK: - Property private var dataSource: UICollectionViewDiffableDataSource? = nil @@ -65,6 +71,7 @@ class FeedItemCollectionViewCell: UICollectionViewCell { label.text = "2023.12.07" label.font = .preferredFont(forTextStyle: .subheadline) label.textColor = DesignSystemColor.primaryText + label.contentMode = .scaleAspectFit label.translatesAutoresizingMaskIntoConstraints = false return label @@ -310,10 +317,10 @@ extension FeedItemCollectionViewCell { return } + let url = url.compactMap { $0 } snapshot.deleteAllItems() snapshot.appendSections([0]) - let url = url.compactMap { $0 } - snapshot.appendItems(Array(Set(url)), toSection: 0) + snapshot.appendItems(url, toSection: 0) dataSource?.apply(snapshot) } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift index b2151e38..3a5f7f1a 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift @@ -8,6 +8,7 @@ import Combine import DesignSystem +import Log import UIKit // MARK: - HomeViewController @@ -19,7 +20,12 @@ final class HomeViewController: UIViewController { private var subscriptions: Set = [] - var dataSource: UICollectionViewDiffableDataSource? = nil + private var dataSource: UICollectionViewDiffableDataSource? = nil + + private let fetchFeedPublisher: PassthroughSubject = .init() + private let didDisplayFeedPublisher: PassthroughSubject = .init() + + private var feedCount: Int = 0 // MARK: UI Components @@ -76,12 +82,26 @@ final class HomeViewController: UIViewController { private extension HomeViewController { func setup() { + setCollectionViewDelegate() setDataSource() + addSection() setupStyles() setupHierarchyAndConstraints() setNavigationItem() bind() - testCollectionViewDataSource() + fetchFeedPublisher.send() + } + + func setCollectionViewDelegate() { + feedListCollectionView.delegate = self + } + + func addSection() { + guard var snapshot = dataSource?.snapshot() else { + return + } + snapshot.appendSections([0]) + dataSource?.apply(snapshot) } func setDataSource() { @@ -111,11 +131,19 @@ private extension HomeViewController { } func bind() { - let output = viewModel.transform(input: .init()) - output.sink { state in + let output = viewModel.transform( + input: HomeViewModelInput( + requestFeedPublisher: fetchFeedPublisher.eraseToAnyPublisher(), + didDisplayFeed: didDisplayFeedPublisher.eraseToAnyPublisher() + ) + ) + + output.sink { [weak self] state in switch state { case .idle: break + case let .fetched(feed): + self?.updateFeed(feed) } } .store(in: &subscriptions) @@ -126,14 +154,18 @@ private extension HomeViewController { navigationItem.leftBarButtonItem = titleBarButtonItem } - func testCollectionViewDataSource() { + func updateFeed(_ item: [FeedElement]) { guard let dataSource else { return } var snapshot = dataSource.snapshot() - snapshot.appendSections([0]) - snapshot.appendItems(fakeData(), toSection: 0) - dataSource.apply(snapshot) + snapshot.appendItems(item) + DispatchQueue.main.async { [weak self] in + dataSource.apply(snapshot) + self?.didDisplayFeedPublisher.send() + } + + feedCount = snapshot.numberOfItems } enum Constants { @@ -156,40 +188,17 @@ private extension HomeViewController { return UICollectionViewCompositionalLayout(section: section) } +} + +// MARK: UICollectionViewDelegate - func fakeData() -> [FeedElement] { - return [ - .init( - ID: 1, - publicID: "", - nickName: "정다함", - publishDate: .now, - profileImage: URL(string: "https://i.ytimg.com/vi/fzzjgBAaWZw/hqdefault.jpg"), - sportText: "달리기", - content: "오운완. 오늘도 운동 조졌음. 기분은 좋네 ^^", - postImages: [ - URL(string: "https://cdn.seniordaily.co.kr/news/photo/202108/2444_1812_1557.jpg"), - URL(string: "https://t1.daumcdn.net/thumb/R1280x0/?fname=http://t1.daumcdn.net/brunch/service/guest/image/7MpZeU0-hBKjmb4tKFHR-Skd7bA.JPG"), - URL(string: "https://t1.daumcdn.net/brunch/service/guest/image/9xI2XnpJpggfVZV6l1opHBwyeqU.JPG"), - ], - like: 2 - ), - - .init( - ID: 2, - publicID: "", - nickName: "고양이 애호가", - publishDate: .now, - profileImage: URL(string: "https://ca.slack-edge.com/T05N9HAKPFW-U05PCNTCV9N-8bbbd8736a14-512"), - sportText: "수영", - content: "고양이 애호가입니다. 차린건 없지만 고양이 보고가세요", - postImages: [ - URL(string: "https://i.ytimg.com/vi/YCaGYUIfdy4/maxresdefault.jpg")!, - URL(string: "https://www.cats.org.uk/uploads/images/featurebox_sidebar_kids/grief-and-loss.jpg")!, - URL(string: "https://www.telegraph.co.uk/content/dam/pets/2017/01/06/1-JS117202740-yana-two-face-cat-news_trans_NvBQzQNjv4BqJNqHJA5DVIMqgv_1zKR2kxRY9bnFVTp4QZlQjJfe6H0.jpg?imwidth=450")!, - ], - like: 2 - ), - ] +extension HomeViewController: UICollectionViewDelegate { + func collectionView(_: UICollectionView, willDisplay _: UICollectionViewCell, forItemAt indexPath: IndexPath) { + + /// 사용자가 아직 보지 않은 셀의 갯수 + let toShowCellCount = (feedCount - 1) - indexPath.row + if toShowCellCount < 3 { + fetchFeedPublisher.send() + } } } diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift index 71d2d326..25b337bc 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewModel/HomeViewModel.swift @@ -11,7 +11,10 @@ import Foundation // MARK: - HomeViewModelInput -public struct HomeViewModelInput {} +public struct HomeViewModelInput { + let requestFeedPublisher: AnyPublisher + let didDisplayFeed: AnyPublisher +} public typealias HomeViewModelOutput = AnyPublisher @@ -19,6 +22,7 @@ public typealias HomeViewModelOutput = AnyPublisher public enum HomeState { case idle + case fetched(feed: [FeedElement]) } // MARK: - HomeViewModelRepresentable @@ -32,17 +36,38 @@ protocol HomeViewModelRepresentable { final class HomeViewModel { // MARK: - Properties + private var useCase: HomeUseCaseRepresentable private var subscriptions: Set = [] + var tempID: Int = 0 + init(useCase: HomeUseCaseRepresentable) { + self.useCase = useCase + } } // MARK: HomeViewModelRepresentable extension HomeViewModel: HomeViewModelRepresentable { - public func transform(input _: HomeViewModelInput) -> HomeViewModelOutput { + public func transform(input: HomeViewModelInput) -> HomeViewModelOutput { subscriptions.removeAll() + let fetched: HomeViewModelOutput = input.requestFeedPublisher + .flatMap { [useCase] _ in + useCase.fetchFeed() + } + .map { feed in + return HomeState.fetched(feed: feed) + } + .eraseToAnyPublisher() + + input.didDisplayFeed + .sink { [weak self] _ in + self?.useCase.didDisplayFeed() + } + .store(in: &subscriptions) + let initialState: HomeViewModelOutput = Just(.idle).eraseToAnyPublisher() - return initialState + return initialState.merge(with: fetched) + .eraseToAnyPublisher() } } From e523e0312e07af5282d226acff055b4dae4c2c83 Mon Sep 17 00:00:00 2001 From: MaraMincho <103064352+MaraMincho@users.noreply.github.com> Date: Sat, 13 Jan 2024 15:42:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[GWL-424]=20=EC=B9=BC=EB=A7=8C=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B0=9C=EC=97=B4=20=EC=9D=B4=EC=8A=88=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 칼만필터 발열 이슈 수정 * feat: 시작 위치 변경 * delete: 중복 코드 삭제 * build: SwiftFormat 수정 * style: SwiftFormat에 맞게 코드 수정 * feat: CI수정 * feat: SwiftForamt수정 --- .github/workflows/iOS_CI.yml | 2 +- .../ViewController/HomeViewController.swift | 3 +- .../KalmanFilterUpdateRequireElement.swift | 7 +- .../Domain/UseCases/KalmanFilter.swift | 134 +++++++++--------- .../Domain/UseCases/KalmanUseCase.swift | 15 +- .../WorkoutRouteMapViewController.swift | 49 +++---- .../WorkoutRouteMapViewModel.swift | 9 +- .../DesignSystem/Sources/GWPageConrol.swift | 1 - 8 files changed, 95 insertions(+), 125 deletions(-) diff --git a/.github/workflows/iOS_CI.yml b/.github/workflows/iOS_CI.yml index 1e8e510b..7f4ccbb3 100644 --- a/.github/workflows/iOS_CI.yml +++ b/.github/workflows/iOS_CI.yml @@ -10,7 +10,7 @@ on: jobs: swift-format: if: contains(github.event.pull_request.labels.*.name, '📱 iOS') - runs-on: macos-13 + runs-on: macos-latest env: working-directory: ./iOS diff --git a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift index 3a5f7f1a..1d07dcdc 100644 --- a/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift +++ b/iOS/Projects/Features/Home/Sources/Presntaion/HomeScene/ViewController/HomeViewController.swift @@ -194,8 +194,7 @@ private extension HomeViewController { extension HomeViewController: UICollectionViewDelegate { func collectionView(_: UICollectionView, willDisplay _: UICollectionViewCell, forItemAt indexPath: IndexPath) { - - /// 사용자가 아직 보지 않은 셀의 갯수 + // 사용자가 아직 보지 않은 셀의 갯수 let toShowCellCount = (feedCount - 1) - indexPath.row if toShowCellCount < 3 { fetchFeedPublisher.send() diff --git a/iOS/Projects/Features/Record/Sources/Domain/Entities/KalmanFilterUpdateRequireElement.swift b/iOS/Projects/Features/Record/Sources/Domain/Entities/KalmanFilterUpdateRequireElement.swift index 2db4e7c8..993e8631 100644 --- a/iOS/Projects/Features/Record/Sources/Domain/Entities/KalmanFilterUpdateRequireElement.swift +++ b/iOS/Projects/Features/Record/Sources/Domain/Entities/KalmanFilterUpdateRequireElement.swift @@ -6,13 +6,12 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import CoreLocation import Foundation // MARK: - KalmanFilterUpdateRequireElement struct KalmanFilterUpdateRequireElement { - let longitude: Double - let latitude: Double - let prevSpeedAtLongitude: Double - let prevSpeedAtLatitude: Double + let prevCLLocation: CLLocation + let currentCLLocation: CLLocation } diff --git a/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanFilter.swift b/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanFilter.swift index 2dc3420d..ec722a7a 100644 --- a/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanFilter.swift +++ b/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanFilter.swift @@ -6,29 +6,26 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import CoreLocation import Foundation import Log +import simd struct KalmanFilter { /// 새로운 값을 입력하게 된다면, 에측을 통해서 값을 작성하게 되는 변수 입니다. - var x = MatrixOfTwoDimension([[]]) + var x = simd_double4() - /// 초기 오차 공분산 입니다. - /// 초기 값은 에러가 많기 떄문에 다음과 같이 크게 가져갔습니다. - private var p = MatrixOfTwoDimension([ - [500, 0, 0, 0], + private var p = simd_double4x4([ + [1, 0, 0, 0], [0, 1, 0, 0], - [0, 0, 500, 0], + [0, 0, 1, 0], [0, 0, 0, 1], ]) - // 사용자 경험을 통해 얻어진 값 입니다. (일단 대한민국 GPS환경이 좋다고 가정하여, - // 애플 오차의 1/2로 가져갔습니다.) - - private var q = MatrixOfTwoDimension([ - [0.000455, 0, 0, 0], + private var q = simd_double4x4([ + [0.00082, 0, 0, 0], [0, 0, 0, 0], - [0, 0, 0.000059, 0], + [0, 0, 0.00082, 0], [0, 0, 0, 0], ]) @@ -45,77 +42,86 @@ struct KalmanFilter { /// 1도 일 때 몇 km? = 지구 반지름 6371 * cos(37) * 1 (pi/180) = 85.18km /// 1º : 85180m = y : 10m /// y = 0.00011739 ~= 0.000117 - private var r = MatrixOfTwoDimension([ - [0.000899, 0], - [0, 0.000117], + private var r = simd_double2x2([ + [0.00082, 0], + [0, 0.00082], ]) - var prevHeadingValue: Double - var prevSpeedAtLatitude: Double = 0 - var prevSpeedAtLongitude: Double = 0 - /// 관계 식 입니다. - lazy var A = MatrixOfTwoDimension([ - [1, cos(prevHeadingValue) * prevSpeedAtLatitude, 0, 0], + lazy var A = simd_double4x4([ + [1, timeInterval * prevVelocity.lat, 0, 0], [0, 1, 0, 0], - [0, 0, 1, sin(prevHeadingValue) * prevSpeedAtLongitude], + [0, 0, 1, timeInterval * prevVelocity.long], [0, 0, 0, 1], ]) - /// 우리가 궁금한건 위도와 경도이기 때문에 필요한 부분만 기재했습니다. - private var H = MatrixOfTwoDimension([ + let H = simd_double4x2([ + [1, 0], + [0, 0], + [0, 1], + [0, 0], + ]) + + // 우리가 궁금한건 위도와 경도이기 때문에 필요한 부분만 기재했습니다. + + var prevTime: Date + + init(initLocation: CLLocation) { + x = .init(initLocation.coordinate.latitude, 0, initLocation.coordinate.longitude, 0) + prevTime = initLocation.timestamp + } + + var timeInterval: Double = 0.0 + + var prevLocation = CLLocation() + + let pIdentity = simd_double4x4([ [1, 0, 0, 0], + [0, 1, 0, 0], [0, 0, 1, 0], + [0, 0, 0, 1], ]) - init(initLongitude: Double, initLatitude: Double, headingValue: Double) { - x = .init([ - [initLatitude], - [0], - [initLongitude], - [0], - ]) - prevHeadingValue = headingValue - } + var prevVelocity: (lat: Double, long: Double) = (0, 0) - /// 사용자가 가르키는 방향을 업데이트 합니다. - mutating func update(heading: Double) { - prevHeadingValue = heading - } + mutating func update(currentLocation: CLLocation) { + let currentTime = currentLocation.timestamp + + let prevTimeInterval = prevTime.timeIntervalSince1970 + let currentTimeInterval = currentTime.timeIntervalSince1970 + + timeInterval = currentTimeInterval - prevTimeInterval + + let velocityLatitude = (prevLocation.coordinate.latitude - currentLocation.coordinate.latitude) + let velocityLongitude = (prevLocation.coordinate.longitude - currentLocation.coordinate.longitude) - /// Update합니다. - mutating func update(initLongitude: Double, initLatitude: Double, prevSpeedAtLatitude: Double, prevSpeedAtLongitude: Double) { - let mesure = MatrixOfTwoDimension( + prevVelocity = (velocityLatitude, velocityLongitude) + + prevLocation = currentLocation + prevTime = currentTime + + let mesure = simd_double2( [ - [initLatitude], - [initLongitude], + currentLocation.coordinate.latitude, + currentLocation.coordinate.longitude, ] ) - self.prevSpeedAtLatitude = prevSpeedAtLatitude - self.prevSpeedAtLongitude = prevSpeedAtLongitude - guard - let prediction = A.multiply(x), - let predictionErrorCovariance = A.multiply(p)?.multiply(A.transPose())?.add(q), - - let notInversed = H.multiply(predictionErrorCovariance)?.multiply(H.transPose())?.add(r), - let prevKalman = notInversed.invert(), - let kalman = predictionErrorCovariance.multiply(H.transPose())?.multiply(prevKalman), - - let tempValue = H.multiply(prediction), - let subTractedValue = mesure.sub(tempValue), - let multiedKalmanValue = kalman.multiply(subTractedValue), - let currentX = prediction.add(multiedKalmanValue), - - let tempPredictionErrorCovariance = kalman.multiply(H)?.multiply(predictionErrorCovariance), - let currentPredictionErrorCovariance = predictionErrorCovariance.sub(tempPredictionErrorCovariance) - else { - return - } + + let xp = A * x + let pp = A * p * A.transpose + q + + let temp = pp * H.transpose + let invert = (H * pp * H.transpose + r).inverse + let kalman = temp * invert + + let currentX = xp + kalman * (mesure - H * xp) + + let currentP = pp - kalman * H * pp.inverse x = currentX - p = currentPredictionErrorCovariance + p = currentP } var latestCensoredPosition: KalmanFilterCensored { - return .init(longitude: x.value[2][0], latitude: x.value[0][0]) + return .init(longitude: x[2], latitude: x[0]) } } diff --git a/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanUseCase.swift b/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanUseCase.swift index 9cbd19ee..165dbfd4 100644 --- a/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanUseCase.swift +++ b/iOS/Projects/Features/Record/Sources/Domain/UseCases/KalmanUseCase.swift @@ -13,7 +13,6 @@ import Foundation protocol KalmanUseCaseRepresentable { func updateFilter(_ element: KalmanFilterUpdateRequireElement) -> KalmanFilterCensored? - func updateHeading(_ heading: Double) } // MARK: - KalmanUseCase @@ -27,21 +26,13 @@ final class KalmanUseCase { // MARK: KalmanUseCaseRepresentable extension KalmanUseCase: KalmanUseCaseRepresentable { - func updateHeading(_ heading: Double) { - filter?.update(heading: heading) - } - func updateFilter(_ element: KalmanFilterUpdateRequireElement) -> KalmanFilterCensored? { if filter == nil { - filter = .init(initLongitude: element.latitude, initLatitude: element.longitude, headingValue: 0) + let currentLocation = element.currentCLLocation + filter = .init(initLocation: currentLocation) return nil } - filter?.update( - initLongitude: element.longitude, - initLatitude: element.latitude, - prevSpeedAtLatitude: element.prevSpeedAtLatitude, - prevSpeedAtLongitude: element.prevSpeedAtLongitude - ) + filter?.update(currentLocation: element.currentCLLocation) return filter?.latestCensoredPosition } diff --git a/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewController.swift b/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewController.swift index 1b915661..63a63d61 100644 --- a/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewController.swift +++ b/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewController.swift @@ -35,13 +35,12 @@ final class WorkoutRouteMapViewController: UIViewController { private let viewModel: WorkoutRouteMapViewModelRepresentable /// 사용자 위치 추적 배열 - @Published private var locations: [CLLocation] = [] + @Published private var locations: [CLLocationCoordinate2D] = [] private let mapCaptureDataSubject: PassthroughSubject = .init() private let mapSnapshotterImageDataSubject: PassthroughSubject<[CLLocation], Never> = .init() private let kalmanFilterShouldUpdatePositionSubject: PassthroughSubject = .init() - private let kalmanFilterShouldUpdateHeadingSubject: PassthroughSubject = .init() private var subscriptions: Set = [] @@ -137,7 +136,6 @@ final class WorkoutRouteMapViewController: UIViewController { let input: WorkoutRouteMapViewModelInput = .init( filterShouldUpdatePositionPublisher: kalmanFilterShouldUpdatePositionSubject.eraseToAnyPublisher(), - filterShouldUpdateHeadingPublisher: kalmanFilterShouldUpdateHeadingSubject.eraseToAnyPublisher(), locationListPublisher: locationPublisher ) @@ -167,8 +165,7 @@ final class WorkoutRouteMapViewController: UIViewController { return } - let coordinates = locations.map(\.coordinate) - let polyLine = MKPolyline(coordinates: coordinates, count: coordinates.count) + let polyLine = MKPolyline(coordinates: locations, count: locations.count) let span = MKCoordinateSpan( latitudeDelta: (regionData.maxLatitude - regionData.minLatitude) * 1.15, longitudeDelta: (regionData.maxLongitude - regionData.minLongitude) * 1.15 @@ -209,14 +206,10 @@ final class WorkoutRouteMapViewController: UIViewController { self?.locations .forEach { location in - let currentCLLocationCoordinator2D = CLLocationCoordinate2D( - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) // snapshot에서 현재 위도 경도에 대한 데이터가 어느 CGPoint에 있는지 찾아내고, 이를 Polyline을 그립니다. - context.cgContext.addLine(to: snapshot.point(for: currentCLLocationCoordinator2D)) - context.cgContext.move(to: snapshot.point(for: currentCLLocationCoordinator2D)) + context.cgContext.addLine(to: snapshot.point(for: location)) + context.cgContext.move(to: snapshot.point(for: location)) } // 현재 컨텍스트 에서 여태 그린 Path를 적용합니다. @@ -232,9 +225,8 @@ final class WorkoutRouteMapViewController: UIViewController { } let currentLocation = CLLocation(latitude: value.latitude, longitude: value.longitude) - locations.append(currentLocation) - let coordinates = locations.map(\.coordinate) - let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count) + locations.append(currentLocation.coordinate) + let polyline = MKPolyline(coordinates: locations, count: locations.count) mapView.removeOverlays(mapView.overlays) mapView.addOverlay(polyline) @@ -260,6 +252,7 @@ final class WorkoutRouteMapViewController: UIViewController { extension WorkoutRouteMapViewController: LocationTrackingProtocol { func requestCapture() { + let locations = locations.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } mapSnapshotterImageDataSubject.send(locations) } @@ -268,7 +261,12 @@ extension WorkoutRouteMapViewController: LocationTrackingProtocol { } var locationPublisher: AnyPublisher<[CLLocation], Never> { - $locations.eraseToAnyPublisher() + return Just( + locations.map { + CLLocation(latitude: $0.latitude, longitude: $0.longitude) + } + ) + .eraseToAnyPublisher() } } @@ -293,28 +291,13 @@ extension WorkoutRouteMapViewController: CLLocationManagerDelegate { return } - let currentTime = Date.now - let timeDistance = currentTime.distance(to: prevDate) - - // 과거 위치와 현재 위치를 통해 위 경도에 관한 속력을 구합니다. - let v = ( - (newLocation.coordinate.latitude - prevLocation.coordinate.latitude) / timeDistance, - (newLocation.coordinate.longitude - prevLocation.coordinate.longitude) / timeDistance - ) - prevLocation = newLocation - kalmanFilterShouldUpdatePositionSubject.send( .init( - longitude: newLocation.coordinate.longitude, - latitude: newLocation.coordinate.latitude, - prevSpeedAtLongitude: v.1, - prevSpeedAtLatitude: v.0 + prevCLLocation: prevLocation, + currentCLLocation: newLocation ) ) - } - - func locationManager(_: CLLocationManager, didUpdateHeading newHeading: CLHeading) { - kalmanFilterShouldUpdateHeadingSubject.send(newHeading.trueHeading) + prevLocation = newLocation } func locationManager(_: CLLocationManager, didFailWithError error: Error) { diff --git a/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewModel.swift b/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewModel.swift index 0816b98e..9eeaead8 100644 --- a/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewModel.swift +++ b/iOS/Projects/Features/Record/Sources/Presentation/WorkoutSessionGroupScene/RouteMapScene/WorkoutRouteMapViewModel.swift @@ -13,7 +13,6 @@ import Foundation public struct WorkoutRouteMapViewModelInput { let filterShouldUpdatePositionPublisher: AnyPublisher - let filterShouldUpdateHeadingPublisher: AnyPublisher let locationListPublisher: AnyPublisher<[LocationModel], Never> } @@ -58,13 +57,6 @@ extension WorkoutRouteMapViewModel: WorkoutRouteMapViewModelRepresentable { public func transform(input: WorkoutRouteMapViewModelInput) -> WorkoutRouteMapViewModelOutput { subscriptions.removeAll() - input - .filterShouldUpdateHeadingPublisher - .sink { [kalmanUseCase] value in - kalmanUseCase.updateHeading(value) - } - .store(in: &subscriptions) - let region = input .locationListPublisher .map(locationPathUseCase.processPath(locations:)) @@ -73,6 +65,7 @@ extension WorkoutRouteMapViewModel: WorkoutRouteMapViewModelRepresentable { let updateValue: WorkoutRouteMapViewModelOutput = input .filterShouldUpdatePositionPublisher .dropFirst(4) + .throttle(for: 1, scheduler: RunLoop.main, latest: false) .map { [kalmanUseCase] element in let censoredValue = kalmanUseCase.updateFilter(element) return WorkoutRouteMapState.censoredValue(censoredValue) diff --git a/iOS/Projects/Shared/DesignSystem/Sources/GWPageConrol.swift b/iOS/Projects/Shared/DesignSystem/Sources/GWPageConrol.swift index 6264deb1..37efc007 100644 --- a/iOS/Projects/Shared/DesignSystem/Sources/GWPageConrol.swift +++ b/iOS/Projects/Shared/DesignSystem/Sources/GWPageConrol.swift @@ -62,7 +62,6 @@ private extension GWPageControl { var targetSpacing: CGFloat = 0 pages.forEach { page in - // 중요: 맨 처음 Page객체는 왼쪽으로 붙여야 하기에 필수 불가결적으로 다음 로직이 필요합니다. if targetLeadingAnchor != safeAreaLayoutGuide.leadingAnchor { targetSpacing = spacing } From 32ded2d13cf410f06b00c1d4be016e8a6f089f40 Mon Sep 17 00:00:00 2001 From: MaraMincho <103064352+MaraMincho@users.noreply.github.com> Date: Sat, 13 Jan 2024 15:42:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[GWL-445]=20CombineCocoa=20GesturePublisher?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: GesturePublisher 생성 * chore: 접근 제어자 수정 --- .../Sources/GestureSubscription.swift | 38 ++++++++++++ .../Sources/UIView+Publisher.swift | 60 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 iOS/Projects/Shared/CombineCocoa/Sources/GestureSubscription.swift create mode 100644 iOS/Projects/Shared/CombineCocoa/Sources/UIView+Publisher.swift diff --git a/iOS/Projects/Shared/CombineCocoa/Sources/GestureSubscription.swift b/iOS/Projects/Shared/CombineCocoa/Sources/GestureSubscription.swift new file mode 100644 index 00000000..f238b6bd --- /dev/null +++ b/iOS/Projects/Shared/CombineCocoa/Sources/GestureSubscription.swift @@ -0,0 +1,38 @@ +// +// GestureSubscription.swift +// CombineCocoa +// +// Created by MaraMincho on 1/11/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import UIKit + +final class GestureSubscription: Subscription where T.Input == UIGestureRecognizer, T.Failure == Never { + var subscriber: T? + let gesture: UIGestureRecognizer + var targetView: UIView? + + @objc func action() { + _ = subscriber?.receive(gesture) + } + + init(subscriber: T, gesture: UIGestureRecognizer, targetView: UIView) { + self.subscriber = subscriber + self.gesture = gesture + self.targetView = targetView + + gesture.addTarget(self, action: #selector(action)) + targetView.addGestureRecognizer(gesture) + } + + func request(_: Subscribers.Demand) {} + + func cancel() { + gesture.removeTarget(self, action: #selector(action)) + targetView?.removeGestureRecognizer(gesture) + targetView = nil + subscriber = nil + } +} diff --git a/iOS/Projects/Shared/CombineCocoa/Sources/UIView+Publisher.swift b/iOS/Projects/Shared/CombineCocoa/Sources/UIView+Publisher.swift new file mode 100644 index 00000000..2b51551b --- /dev/null +++ b/iOS/Projects/Shared/CombineCocoa/Sources/UIView+Publisher.swift @@ -0,0 +1,60 @@ +// +// UIView+Publisher.swift +// CombineCocoa +// +// Created by MaraMincho on 1/11/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import UIKit + +public extension UIView { + func publisher(gesture: GestureType) -> GesturePublisher { + return GesturePublisher(targetView: self, gesture: gesture.recognizer) + } + + struct GesturePublisher: Publisher { + public typealias Output = UIGestureRecognizer + public typealias Failure = Never + + private let targetView: UIView + private let gesture: UIGestureRecognizer + + public init(targetView: UIView, gesture: UIGestureRecognizer) { + self.targetView = targetView + self.gesture = gesture + } + + public func receive(subscriber: S) where S: Subscriber, Never == S.Failure, UIGestureRecognizer == S.Input { + let subscription = GestureSubscription(subscriber: subscriber, gesture: gesture, targetView: targetView) + subscriber.receive(subscription: subscription) + } + } + + enum GestureType { + case tap + case swipe + case longPress + case pan + case pinch + case edge + + var recognizer: UIGestureRecognizer { + switch self { + case .tap: + return UITapGestureRecognizer() + case .swipe: + return UISwipeGestureRecognizer() + case .longPress: + return UILongPressGestureRecognizer() + case .pan: + return UIPanGestureRecognizer() + case .pinch: + return UIPinchGestureRecognizer() + case .edge: + return UIPinchGestureRecognizer() + } + } + } +} From 9dd4665c35132a67e24cfc48544512ecd1eae69f Mon Sep 17 00:00:00 2001 From: MaraMincho <103064352+MaraMincho@users.noreply.github.com> Date: Sat, 13 Jan 2024 15:42:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[GWL-443]=20WorkoutHistoryView=20UI?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Tuist에 features에 WirteBoard 추가 * feat: View, ViewModel 생성 * feat: Coordaintor 생성 * faet: ContainerViewController 생성 * feat: ContainerViewController 매서드 생성 * feat: ProfileFlow에 WriteBoardFeature을 불러올 수 있게 Coordainotr 및 VM 코드 수정 * feat: WriteBoardFlow 삭제 * feat: Dependency 수정 * feat: ProfileFeature와 연결 * feat: WorkoutHistoryCell 생성 * feat: CollectionView -> TableView로 변경 * feat: stackViewHeight추가 --- .../Dependency+Target.swift | 1 + .../Coordinator/Sources/CoordinatorFlow.swift | 1 + iOS/Projects/Features/Profile/Project.swift | 1 + .../Coordinator/ProfileCoordinator.swift | 19 ++ .../Protocol/ProfileCoordinating.swift | 3 + .../ProfileScene/ProfileViewController.swift | 31 ++- .../ProfileScene/ProfileViewModel.swift | 7 + .../Features/WriteBoard/Project.swift | 13 ++ .../ContainerViewController.swift | 113 +++++++++++ .../ContainerViewModel.swift | 69 +++++++ .../Coordinator/WriteBoardCoordinator.swift | 76 ++++++++ .../View/WorkoutHistoryCell.swift | 181 ++++++++++++++++++ .../WorkoutHistorySelectViewController.swift | 139 ++++++++++++++ .../WorkoutHistorySelectViewModel.swift | 48 +++++ .../Features/WriteBoard/Tests/test.swift | 0 15 files changed, 696 insertions(+), 6 deletions(-) create mode 100644 iOS/Projects/Features/WriteBoard/Project.swift create mode 100644 iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewController.swift create mode 100644 iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewModel.swift create mode 100644 iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/Coordinator/WriteBoardCoordinator.swift create mode 100644 iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistoryCell.swift create mode 100644 iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistorySelectViewController.swift create mode 100644 iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/ViewModel/WorkoutHistorySelectViewModel.swift create mode 100644 iOS/Projects/Features/WriteBoard/Tests/test.swift diff --git a/iOS/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Target.swift b/iOS/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Target.swift index cee3c3ae..0a450058 100644 --- a/iOS/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Target.swift +++ b/iOS/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Target.swift @@ -10,6 +10,7 @@ import ProjectDescription // MARK: - Feature public enum Feature: String { + case writeBoard case home case splash case profile diff --git a/iOS/Projects/Core/Coordinator/Sources/CoordinatorFlow.swift b/iOS/Projects/Core/Coordinator/Sources/CoordinatorFlow.swift index 181cc528..5050e909 100644 --- a/iOS/Projects/Core/Coordinator/Sources/CoordinatorFlow.swift +++ b/iOS/Projects/Core/Coordinator/Sources/CoordinatorFlow.swift @@ -20,4 +20,5 @@ public enum CoordinatorFlow { case onboarding case profile case home + case writeBoard } diff --git a/iOS/Projects/Features/Profile/Project.swift b/iOS/Projects/Features/Profile/Project.swift index deedefdb..cbe04024 100644 --- a/iOS/Projects/Features/Profile/Project.swift +++ b/iOS/Projects/Features/Profile/Project.swift @@ -17,6 +17,7 @@ let project = Project.makeModule( .commonNetworkingKeyManager, .keychain, .userInformationManager, + .feature(.writeBoard), ], testDependencies: [], resources: "Resources/**" diff --git a/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/ProfileCoordinator.swift b/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/ProfileCoordinator.swift index ff6b2d7c..b480bf2b 100644 --- a/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/ProfileCoordinator.swift +++ b/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/ProfileCoordinator.swift @@ -11,6 +11,7 @@ import Keychain import Log import Trinet import UIKit +import WriteBoardFeature // MARK: - ProfileFinishFinishDelegate @@ -90,4 +91,22 @@ extension ProfileCoordinator: ProfileCoordinating { viewController.hidesBottomBarWhenPushed = true navigationController.pushViewController(viewController, animated: true) } + + public func presentWriteBoard() { + let writeBoardCoordinator = WriteBoardCoordinator( + navigationController: navigationController, + delegate: self + ) + childCoordinators.append(writeBoardCoordinator) + writeBoardCoordinator.start() + } +} + +// MARK: CoordinatorFinishDelegate + +extension ProfileCoordinator: CoordinatorFinishDelegate { + public func flowDidFinished(childCoordinator: Coordinating) { + childCoordinators = childCoordinators.filter { $0.flow != childCoordinator.flow } + childCoordinator.childCoordinators.removeAll() + } } diff --git a/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/Protocol/ProfileCoordinating.swift b/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/Protocol/ProfileCoordinating.swift index 6b45ae3c..58534066 100644 --- a/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/Protocol/ProfileCoordinating.swift +++ b/iOS/Projects/Features/Profile/Sources/Presentation/Coordinator/Protocol/ProfileCoordinating.swift @@ -18,4 +18,7 @@ public protocol ProfileCoordinating: Coordinating { /// 프로필 설정 화면으로 넘어갑니다. func moveToProfileSettings() + + /// 글쓰기 화면으로 넘어갑니다. + func presentWriteBoard() } diff --git a/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewController.swift b/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewController.swift index bfc5c407..a64ff057 100644 --- a/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewController.swift +++ b/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewController.swift @@ -11,6 +11,7 @@ public final class ProfileViewController: UICollectionViewController { private let viewDidLoadSubject: PassthroughSubject = .init() private let didTapSettingButtonSubject: PassthroughSubject = .init() + private let didTapWriteBarButtonSubject: PassthroughSubject = .init() private let paginationEventSubject: PassthroughSubject = .init() private var subscriptions: Set = [] @@ -61,13 +62,25 @@ public final class ProfileViewController: UICollectionViewController { private func setupStyles() { collectionView.backgroundColor = DesignSystemColor.primaryBackground navigationItem.backButtonDisplayMode = .minimal - navigationItem.rightBarButtonItem = .init( + + let settingBarButtonItem = UIBarButtonItem( image: .init(systemName: "gearshape"), style: .plain, target: self, - action: #selector(didTapSettingButton) + action: #selector(didTapSettingBarButton) + ) + + let writeBoardBarButtonItem = UIBarButtonItem( + image: .init(systemName: "plus.square"), + style: .plain, + target: self, + action: #selector(didTapWriteBarButton) ) - navigationItem.rightBarButtonItem?.tintColor = DesignSystemColor.primaryText + + let rightBarButtonItems = [writeBoardBarButtonItem, settingBarButtonItem] + rightBarButtonItems.forEach { $0.tintColor = DesignSystemColor.primaryText } + + navigationItem.rightBarButtonItems = rightBarButtonItems } private func bind() { @@ -76,7 +89,8 @@ public final class ProfileViewController: UICollectionViewController { viewDidLoadPublisher: viewDidLoadSubject.eraseToAnyPublisher(), didTapSettingButtonPublisher: didTapSettingButtonSubject.eraseToAnyPublisher(), paginationEventPublisher: paginationEventSubject.eraseToAnyPublisher(), - refreshPostsPublisher: refreshControl.publisher(.valueChanged).map { _ in () }.eraseToAnyPublisher() + refreshPostsPublisher: refreshControl.publisher(.valueChanged).map { _ in () }.eraseToAnyPublisher(), + writeBoardPublisher: didTapWriteBarButtonSubject.eraseToAnyPublisher() ) ) .receive(on: RunLoop.main) @@ -100,8 +114,13 @@ public final class ProfileViewController: UICollectionViewController { // MARK: - Custom Methods @objc - private func didTapSettingButton() { - didTapSettingButtonSubject.send(()) + private func didTapSettingBarButton() { + didTapSettingButtonSubject.send() + } + + @objc + private func didTapWriteBarButton() { + didTapWriteBarButtonSubject.send() } /// 에러 알림 문구를 보여줍니다. diff --git a/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewModel.swift b/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewModel.swift index d28b13c2..db6b2e4f 100644 --- a/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewModel.swift +++ b/iOS/Projects/Features/Profile/Sources/Presentation/ProfileScene/ProfileViewModel.swift @@ -8,6 +8,7 @@ public struct ProfileViewModelInput { let didTapSettingButtonPublisher: AnyPublisher let paginationEventPublisher: AnyPublisher let refreshPostsPublisher: AnyPublisher + let writeBoardPublisher: AnyPublisher } public typealias ProfileViewModelOutput = AnyPublisher @@ -59,6 +60,12 @@ extension ProfileViewModel: ProfileViewModelRepresentable { } .store(in: &subscriptions) + input.writeBoardPublisher + .sink { [weak self] _ in + self?.coordinating?.presentWriteBoard() + } + .store(in: &subscriptions) + let profileInfoPublisher = input.viewDidLoadPublisher .flatMap(useCase.fetchProfile) .map(ProfileViewModelState.setupProfile) diff --git a/iOS/Projects/Features/WriteBoard/Project.swift b/iOS/Projects/Features/WriteBoard/Project.swift new file mode 100644 index 00000000..5035bf38 --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Project.swift @@ -0,0 +1,13 @@ +import DependencyPlugin +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.makeModule( + name: "WriteBoardFeature", + targets: .feature( + .writeBoard, + testingOptions: [.unitTest], + dependencies: [.designSystem, .log, .combineCocoa, .trinet, .combineExtension, .coordinator, .commonNetworkingKeyManager], + testDependencies: [] + ) +) diff --git a/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewController.swift b/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewController.swift new file mode 100644 index 00000000..47247513 --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewController.swift @@ -0,0 +1,113 @@ +// +// ContainerViewController.swift +// WriteBoardFeature +// +// Created by MaraMincho on 1/9/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import DesignSystem +import UIKit + +// MARK: - ContainerViewController + +final class ContainerViewController: UINavigationController { + // MARK: Properties + + private let viewModel: ContainerViewModelRepresentable + + private var subscriptions: Set = [] + + private var cancelWriteBoardPublisher: PassthroughSubject = .init() + private var confirmAlertPublisher: PassthroughSubject = .init() + + // MARK: Initializations + + init(viewModel: ContainerViewModelRepresentable) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Life Cycles + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } + + private enum AlertHelper { + static var title: String { "정말 종료하시겠습니까?" } + static var description: String { "지금까지 작성했던 모든 것들이 사라집니다." } + + static var cancelDescription: String { "취소" } + + static var confimDescription: String { "확인" } + } +} + +private extension ContainerViewController { + func setup() { + setupStyles() + bind() + presentationController?.delegate = self + } + + func setupStyles() { + view.backgroundColor = DesignSystemColor.primaryBackground + } + + func bind() { + let output = viewModel.transform( + input: .init( + showAlertPublisher: cancelWriteBoardPublisher.eraseToAnyPublisher(), + dismissWriteBoardPublisher: confirmAlertPublisher.eraseToAnyPublisher() + ) + ) + output.sink { [weak self] state in + switch state { + case .showAlert: + self?.showFinishAlert() + + case .idle: + break + } + } + .store(in: &subscriptions) + } +} + +// MARK: UIAdaptivePresentationControllerDelegate + +extension ContainerViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerShouldDismiss(_: UIPresentationController) -> Bool { + showFinishAlert() + return false + } + + private func showFinishAlert() { + let alertController = UIAlertController( + title: AlertHelper.title, + message: AlertHelper.description, + preferredStyle: .alert + ) + + let cancelHandler: (UIAlertAction) -> Void = { _ in return } + + let confirmHandler: (UIAlertAction) -> Void = { [weak self] _ in + self?.confirmAlertPublisher.send() + } + + let cancelAction = UIAlertAction(title: AlertHelper.cancelDescription, style: .default, handler: cancelHandler) + let confirmAction = UIAlertAction(title: AlertHelper.confimDescription, style: .default, handler: confirmHandler) + + alertController.addAction(cancelAction) + alertController.addAction(confirmAction) + present(alertController, animated: true) + } +} diff --git a/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewModel.swift b/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewModel.swift new file mode 100644 index 00000000..dd55f003 --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/ContainerViewController/ContainerViewModel.swift @@ -0,0 +1,69 @@ +// +// ContainerViewModel.swift +// WriteBoardFeature +// +// Created by MaraMincho on 1/9/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import Foundation + +// MARK: - ContainerViewModelInput + +public struct ContainerViewModelInput { + let showAlertPublisher: AnyPublisher + let dismissWriteBoardPublisher: AnyPublisher +} + +public typealias ContainerViewModelOutput = AnyPublisher + +// MARK: - ContainerState + +public enum ContainerState { + case idle + case showAlert +} + +// MARK: - ContainerViewModelRepresentable + +protocol ContainerViewModelRepresentable { + func transform(input: ContainerViewModelInput) -> ContainerViewModelOutput +} + +// MARK: - ContainerViewModel + +final class ContainerViewModel { + // MARK: - Properties + + private weak var coordinator: WriteBoardFeatureCoordinating? + + private var subscriptions: Set = [] + + init(coordinator: WriteBoardFeatureCoordinating) { + self.coordinator = coordinator + } +} + +// MARK: ContainerViewModelRepresentable + +extension ContainerViewModel: ContainerViewModelRepresentable { + public func transform(input: ContainerViewModelInput) -> ContainerViewModelOutput { + subscriptions.removeAll() + + let showAlert: ContainerViewModelOutput = input + .showAlertPublisher + .map { _ in ContainerState.showAlert } + .eraseToAnyPublisher() + + input.dismissWriteBoardPublisher + .sink { [weak self] _ in + self?.coordinator?.cancelWriteBoard() + } + .store(in: &subscriptions) + + let initialState: ContainerViewModelOutput = Just(.idle).eraseToAnyPublisher() + + return initialState.merge(with: showAlert).eraseToAnyPublisher() + } +} diff --git a/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/Coordinator/WriteBoardCoordinator.swift b/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/Coordinator/WriteBoardCoordinator.swift new file mode 100644 index 00000000..d133f964 --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Sources/Presentation/Common/Coordinator/WriteBoardCoordinator.swift @@ -0,0 +1,76 @@ +// +// WriteBoardCoordinator.swift +// WriteBoardFeature +// +// Created by MaraMincho on 1/9/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Coordinator +import Foundation +import Log +import UIKit + +// MARK: - WriteBoardFeatureFinishDelegate + +public protocol WriteBoardFeatureFinishDelegate: AnyObject { + func writeBoardFeatureDiDFinish() +} + +// MARK: - WriteBoardFeatureCoordinating + +public protocol WriteBoardFeatureCoordinating: Coordinating { + func pushWriteBoardScene() + func didFinishWriteBoard() + func cancelWriteBoard() +} + +// MARK: - WriteBoardCoordinator + +public final class WriteBoardCoordinator: WriteBoardFeatureCoordinating { + public var navigationController: UINavigationController + public var childCoordinators: [Coordinating] = [] + public weak var finishDelegate: CoordinatorFinishDelegate? + public var flow: CoordinatorFlow = .writeBoard + + private var containerViewController: UINavigationController? + + public init( + navigationController: UINavigationController, + delegate: CoordinatorFinishDelegate? + ) { + self.navigationController = navigationController + finishDelegate = delegate + } + + public func start() { + pushContainerViewController() + } + + private func pushContainerViewController() { + let viewModel = ContainerViewModel(coordinator: self) + let vc = ContainerViewController(viewModel: viewModel) + containerViewController = vc + + vc.modalPresentationStyle = .automatic + navigationController.present(vc, animated: true) + pushWorkoutHistorySelectScene() + } + + private func pushWorkoutHistorySelectScene() { + let viewModel = WorkoutHistorySelectViewModel() + let viewController = WorkoutHistorySelectViewController(viewModel: viewModel) + + containerViewController?.pushViewController(viewController, animated: false) + } + + public func pushWriteBoardScene() {} + + public func didFinishWriteBoard() {} + + public func cancelWriteBoard() { + childCoordinators.removeAll() + navigationController.dismiss(animated: true) + finishDelegate?.flowDidFinished(childCoordinator: self) + } +} diff --git a/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistoryCell.swift b/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistoryCell.swift new file mode 100644 index 00000000..579ba762 --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistoryCell.swift @@ -0,0 +1,181 @@ +// +// WorkoutHistoryCell.swift +// WriteBoardFeature +// +// Created by MaraMincho on 1/10/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import DesignSystem +import UIKit + +// MARK: - WorkoutHistoryCell + +final class WorkoutHistoryCell: UITableViewCell { + static let identifier = "WorkoutHistoryCell" + + override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + setup() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("cant init") + } + + private let workoutTitleLabel: UILabel = { + let label = UILabel() + label.textColor = DesignSystemColor.primaryText + label.font = .preferredFont(forTextStyle: .title2) + label.text = "사이클" + label.textAlignment = .right + label.setContentHuggingPriority(.required, for: .horizontal) + + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let workoutDistanceLabel: UILabel = { + let label = UILabel() + label.textColor = DesignSystemColor.primaryText + label.font = .preferredFont(forTextStyle: .headline) + label.text = "1.5 km" + + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var header: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [workoutTitleLabel, workoutDistanceLabel]) + stackView.axis = .horizontal + stackView.spacing = Metrics.headerSpacing + + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private let dateLabel: UILabel = { + let label = UILabel() + label.textColor = DesignSystemColor.primaryText + label.font = .preferredFont(forTextStyle: .body) + label.text = "1월 10일" + label.textAlignment = .right + + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let timeLabel: UILabel = { + let label = UILabel() + label.textColor = DesignSystemColor.primaryText + label.font = .preferredFont(forTextStyle: .body) + label.text = "06:00 ~ 06:30 (30분)" + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var footer: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [dateLabel, timeLabel]) + stackView.axis = .horizontal + stackView.spacing = Metrics.footerSpacing + stackView.distribution = .fillProportionally + + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var descriptionContentStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + header, footer, + ]) + stackView.spacing = Metrics.headerAndFooterSpacing + stackView.axis = .vertical + + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private let workoutImageView: UIImageView = { + let configure: UIImage.SymbolConfiguration = .init(font: .boldSystemFont(ofSize: 35)) + var image = UIImage(systemName: "figure.run", withConfiguration: configure) + + let imageView = UIImageView(image: image) + imageView.contentMode = .scaleAspectFit + imageView.setContentHuggingPriority(.required, for: .horizontal) + imageView.tintColor = DesignSystemColor.primaryText + + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var itemStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [workoutImageView, descriptionContentStackView]) + stackView.spacing = Metrics.imageAndContentSpacing + + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private enum Metrics { + static let headerSpacing: CGFloat = 12 + static let footerSpacing: CGFloat = 12 + static let headerAndFooterSpacing: CGFloat = 9 + static let imageAndContentSpacing: CGFloat = 15 + + static let contentViewAndItemSpacing: CGFloat = 12 + + static let middleLabelWidth: CGFloat = 82 + + static let imageViewWidthAndHeight: CGFloat = 53 + } +} + +private extension WorkoutHistoryCell { + func setup() { + setupStyle() + setupViewHierarchyAndConstraints() + } + + func setupStyle() { + contentView.backgroundColor = DesignSystemColor.secondaryBackground + } + + func setupViewHierarchyAndConstraints() { + contentView.addSubview(itemStackView) + itemStackView.topAnchor + .constraint(equalTo: contentView.topAnchor, constant: Metrics.contentViewAndItemSpacing).isActive = true + itemStackView.leadingAnchor + .constraint(equalTo: contentView.leadingAnchor, constant: ConstraintsGuideLine.value).isActive = true + itemStackView.trailingAnchor + .constraint(equalTo: contentView.trailingAnchor, constant: -ConstraintsGuideLine.value).isActive = true + itemStackView.bottomAnchor + .constraint(equalTo: contentView.bottomAnchor, constant: -Metrics.contentViewAndItemSpacing).isActive = true + + workoutTitleLabel.widthAnchor.constraint(equalToConstant: Metrics.middleLabelWidth).isActive = true + + dateLabel.widthAnchor.constraint(equalToConstant: Metrics.middleLabelWidth).isActive = true + + workoutImageView.widthAnchor.constraint(equalToConstant: Metrics.imageViewWidthAndHeight).isActive = true + workoutImageView.heightAnchor.constraint(equalToConstant: Metrics.imageViewWidthAndHeight).isActive = true + } + + func makeShadowAndRounded() { + let radius: CGFloat = 10 + contentView.layer.cornerRadius = radius + contentView.layer.borderWidth = 1 + contentView.layer.borderColor = UIColor.clear.cgColor + contentView.layer.masksToBounds = true + + layer.shadowColor = UIColor.black.cgColor + layer.shadowOffset = CGSize(width: 0, height: 1.0) + layer.shadowRadius = 2.0 + layer.shadowOpacity = 0.5 + layer.masksToBounds = false + layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: radius).cgPath + layer.cornerRadius = radius + } +} diff --git a/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistorySelectViewController.swift b/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistorySelectViewController.swift new file mode 100644 index 00000000..bb4cdd97 --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/View/WorkoutHistorySelectViewController.swift @@ -0,0 +1,139 @@ +// +// WorkoutHistorySelectViewController.swift +// WriteBoardFeature +// +// Created by MaraMincho on 1/9/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import DesignSystem +import Log +import UIKit + +// MARK: - WorkoutHistorySelectViewController + +final class WorkoutHistorySelectViewController: UIViewController { + // MARK: Properties + + private let viewModel: SelectWorkoutViewModelRepresentable + + private var subscriptions: Set = [] + + private var dataSource: UITableViewDiffableDataSource? = nil + + // MARK: UI Components + + private lazy var workoutHistoryTableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.register(WorkoutHistoryCell.self, forCellReuseIdentifier: WorkoutHistoryCell.identifier) + tableView.estimatedRowHeight = UITableView.automaticDimension + tableView.delegate = self + + tableView.translatesAutoresizingMaskIntoConstraints = false + return tableView + }() + + // MARK: Initializations + + init(viewModel: SelectWorkoutViewModelRepresentable) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + deinit { + Log.make().debug("\(Self.self) did be deinit") + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Life Cycles + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } +} + +private extension WorkoutHistorySelectViewController { + func setup() { + setDataSource() + setupStyles() + setupNavigationItem() + setupHierarchyAndConstraints() + bind() + setFakeData() + } + + func setupHierarchyAndConstraints() { + let safeArea = view.safeAreaLayoutGuide + + view.addSubview(workoutHistoryTableView) + workoutHistoryTableView.topAnchor.constraint(equalTo: safeArea.topAnchor).isActive = true + workoutHistoryTableView.leadingAnchor + .constraint(equalTo: safeArea.leadingAnchor).isActive = true + workoutHistoryTableView.trailingAnchor + .constraint(equalTo: safeArea.trailingAnchor).isActive = true + workoutHistoryTableView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor).isActive = true + } + + func setDataSource() { + dataSource = .init(tableView: workoutHistoryTableView) { tableView, _, _ in + guard let cell = tableView.dequeueReusableCell(withIdentifier: WorkoutHistoryCell.identifier) as? WorkoutHistoryCell else { + return UITableViewCell() + } + + return cell + } + guard let dataSource else { return } + + var snapshot = dataSource.snapshot() + snapshot.appendSections([0]) + dataSource.apply(snapshot) + } + + func setFakeData() { + guard let dataSource else { + return + } + var snapshot = dataSource.snapshot() + snapshot.appendItems([.init(), .init(), .init()]) + dataSource.apply(snapshot) + } + + func setupStyles() { + view.backgroundColor = DesignSystemColor.primaryBackground + } + + func setupNavigationItem() { + navigationItem.title = "운동을 선택하세요" + } + + func bind() { + let output = viewModel.transform(input: .init()) + output.sink { state in + switch state { + case .idle: + break + } + } + .store(in: &subscriptions) + } + + enum Metrics {} +} + +// MARK: UITableViewDelegate + +extension WorkoutHistorySelectViewController: UITableViewDelegate { + func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat { + return 80 + } +} diff --git a/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/ViewModel/WorkoutHistorySelectViewModel.swift b/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/ViewModel/WorkoutHistorySelectViewModel.swift new file mode 100644 index 00000000..98cbc6cd --- /dev/null +++ b/iOS/Projects/Features/WriteBoard/Sources/Presentation/WorkoutHistorySelectScene/ViewModel/WorkoutHistorySelectViewModel.swift @@ -0,0 +1,48 @@ +// +// WorkoutHistorySelectViewModel.swift +// WriteBoardFeature +// +// Created by MaraMincho on 1/9/24. +// Copyright © 2024 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import Foundation + +// MARK: - SelectWorkoutViewModelInput + +public struct SelectWorkoutViewModelInput {} + +public typealias SelectWorkoutViewModelOutput = AnyPublisher + +// MARK: - SelectWorkoutState + +public enum SelectWorkoutState { + case idle +} + +// MARK: - SelectWorkoutViewModelRepresentable + +protocol SelectWorkoutViewModelRepresentable { + func transform(input: SelectWorkoutViewModelInput) -> SelectWorkoutViewModelOutput +} + +// MARK: - WorkoutHistorySelectViewModel + +final class WorkoutHistorySelectViewModel { + // MARK: - Properties + + private var subscriptions: Set = [] +} + +// MARK: SelectWorkoutViewModelRepresentable + +extension WorkoutHistorySelectViewModel: SelectWorkoutViewModelRepresentable { + public func transform(input _: SelectWorkoutViewModelInput) -> SelectWorkoutViewModelOutput { + subscriptions.removeAll() + + let initialState: SelectWorkoutViewModelOutput = Just(.idle).eraseToAnyPublisher() + + return initialState + } +} diff --git a/iOS/Projects/Features/WriteBoard/Tests/test.swift b/iOS/Projects/Features/WriteBoard/Tests/test.swift new file mode 100644 index 00000000..e69de29b