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/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/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/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 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() + } + } + } +} 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 }