Skip to content

Commit

Permalink
[GWL-15] 운동 세션 Flow와 운동 요약 화면 이동 처리 (#130)
Browse files Browse the repository at this point in the history
* chore: ViewController를 프로퍼티로 갖도록 수정

* feat: LocationTrackingProtocol 구현

RouteMapViewController가 채택하고, Container가 protocol을 바라보게 함으로써
위치 정보를 제공받는 Publisher만 접근하도록 구현했습니다.

* feat: location publisher를 container view controller와 연결

* add: WorkoutHealth 추가

- 운동 종료후 서버에게 요청보낼 건강 데이터입니다.
- 아직 서버와 협의되지 않은 임시 모델입니다.

* feat: Implement `HealthDataProtocol` in WorkoutSession Flow

WorkoutSessionViewController에서 HealthKit을 이용해 데이터를 파싱할 예정입니다.
healthData가 업데이트되면, publisher를 갖고있는 ContainerViewModel에게 이벤트가 전달되도록 구현했습니다.

* chore: TODO 작성

* feat: WorkoutRecordRepository 구현

* feat: WorkoutRecordUseCase 추가

* feat: Workout Session Container Flow 구현

* chore: swiftformat 적용

* feat: WorkoutSessionCoordinating 구현

- WorkoutSessionContainer를 보여주기 위한 Coordinator

* feat: coordinator로 요약화면 이동

* feat: Mock JSON 연결

* fix: UI - Main thread 오류 수정

* fix: Repository decode response model 수정

GWResponse를 묶어 decode하도록 수정했습니다.

* chore: 버튼이 눌릴 때 이벤트가 실행되도록 변경

* feat: 종료 버튼 탭 시 요약화면으로 이동

* fix: locationManager 설정을 lazy var에서 let으로 수정

* chore: NavigationBar 숨김 처리

* add: deinit 코드와 preview 미비된 코드 추가
  • Loading branch information
WhiteHyun authored Nov 26, 2023
1 parent fefef07 commit 4e2debc
Show file tree
Hide file tree
Showing 12 changed files with 383 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"code": null,
"errorMessage": null,
"data": {
"recordId": 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// WorkoutRecordRepository.swift
// RecordFeature
//
// Created by 홍승현 on 11/25/23.
// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved.
//

import Combine
import Foundation
import Trinet

// MARK: - WorkoutRecordRepository

public struct WorkoutRecordRepository: WorkoutRecordRepositoryRepresentable {
private let provider: TNProvider<WorkoutRecordEndPoint>
private let jsonDecoder: JSONDecoder = .init()

init(session: URLSessionProtocol) {
provider = .init(session: session)
}

/// 운동을 기록합니다.
/// - Parameter locationData: 사용자의 위치 정보
/// - Parameter healthData: 사용자의 건강 정보
/// - Returns: 기록 고유 Identifier
func record(usingLocation locationData: [LocationDTO], andHealthData healthData: WorkoutHealth) -> AnyPublisher<Int, Error> {
return Deferred {
Future<Data, Error> { promise in
Task {
do {
let data = try await provider.request(.init(locationList: locationData, health: healthData))
promise(.success(data))
} catch {
promise(.failure(error))
}
}
}
}
.decode(type: GWResponse<[String: Int]>.self, decoder: jsonDecoder)
.tryMap {
guard let dictionary = $0.data,
let recordID = dictionary["recordId"]
else {
throw DataLayerError.noData
}
return recordID
}
.eraseToAnyPublisher()
}
}

// MARK: WorkoutRecordRepository.WorkoutRecordEndPoint

extension WorkoutRecordRepository {
// TODO: 서버 값으로 세팅
struct WorkoutRecordEndPoint: TNEndPoint {
var path: String = "api/v1/records"

var method: TNMethod = .post

var query: Encodable? = nil

var body: Encodable? = nil

var headers: TNHeaders = .init(headers: [])

init(locationList _: [LocationDTO], health _: WorkoutHealth) {
// TODO: 요청 모델 설정 필요
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// WorkoutHealth.swift
// RecordFeature
//
// Created by 홍승현 on 11/25/23.
// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved.
//

import Foundation

// TODO: 서버 API와 맞추어야합니다.

/// 건강 데이터를 body로 전달하기 위한 요청(request) 모델입니다. 운동 세션이 종료될 때 이 모델을 사용합니다.
public struct WorkoutHealth: Encodable {
/// 총 운동한 거리
let distance: Int?

/// 소모한 칼로리
let calorie: Int?

/// 평균 심박수
let averageHeartRate: Int?

/// 운동 중에 기록한 최소 심박수
let minimumHeartRate: Int?

/// 운동 중에 기록한 최대 심박수
let maximumHeartRate: Int?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// WorkoutRecordRepositoryRepresentable.swift
// RecordFeature
//
// Created by 홍승현 on 11/25/23.
// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved.
//

import Combine
import Foundation

protocol WorkoutRecordRepositoryRepresentable {
func record(usingLocation locationData: [LocationDTO], andHealthData healthData: WorkoutHealth) -> AnyPublisher<Int, Error>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// WorkoutRecordUseCase.swift
// RecordFeature
//
// Created by 홍승현 on 11/25/23.
// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved.
//

import Combine
import Foundation

// MARK: - WorkoutRecordUseCaseRepresentable

protocol WorkoutRecordUseCaseRepresentable {
func record(locations: [LocationDTO], healthData: WorkoutHealth) -> AnyPublisher<Int, Error>
}

// MARK: - WorkoutRecordUseCase

struct WorkoutRecordUseCase {
private let repository: WorkoutRecordRepositoryRepresentable

init(repository: WorkoutRecordRepositoryRepresentable) {
self.repository = repository
}
}

// MARK: WorkoutRecordUseCaseRepresentable

extension WorkoutRecordUseCase: WorkoutRecordUseCaseRepresentable {
func record(locations: [LocationDTO], healthData: WorkoutHealth) -> AnyPublisher<Int, Error> {
repository.record(usingLocation: locations, andHealthData: healthData)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// WorkoutSessionCoordinating.swift
// RecordFeature
//
// Created by 홍승현 on 11/26/23.
// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved.
//

import Coordinator
import Foundation

protocol WorkoutSessionCoordinating: Coordinating {
/// 운동 요약 화면으로 이동합니다.
/// - Parameter recordID: 요약 화면을 보여주기 위한 기록 Identifier
func pushWorkoutSummaryViewController(recordID: Int)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// WorkoutSessionCoordinator.swift
// RecordFeature
//
// Created by 홍승현 on 11/26/23.
// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved.
//

import Coordinator
import Log
import Trinet
import UIKit

// MARK: - WorkoutSessionCoordinator

final class WorkoutSessionCoordinator: WorkoutSessionCoordinating {
var navigationController: UINavigationController
var childCoordinators: [Coordinating] = []
weak var finishDelegate: CoordinatorFinishDelegate?
var flow: CoordinatorFlow = .workout
private let isMockEnvironment: Bool

init(navigationController: UINavigationController, isMockEnvironment: Bool) {
self.navigationController = navigationController
self.isMockEnvironment = isMockEnvironment
}

func start() {
// TODO: Mock Data 연결 필요
guard let jsonPath = Bundle(for: Self.self).path(forResource: "WorkoutSession", ofType: "json"),
let jsonData = try? Data(contentsOf: .init(filePath: jsonPath))
else {
Log.make().error("WorkoutSession Mock Data를 생성할 수 없습니다.")
return
}

let session: URLSessionProtocol = isMockEnvironment ? MockURLSession(mockData: jsonData) : URLSession.shared
let repository = WorkoutRecordRepository(session: session)
let useCase = WorkoutRecordUseCase(repository: repository)
let viewModel = WorkoutSessionContainerViewModel(workoutRecordUseCase: useCase, coordinating: self)
let viewController = WorkoutSessionContainerViewController(viewModel: viewModel)
navigationController.pushViewController(viewController, animated: true)
}

func pushWorkoutSummaryViewController(recordID: Int) {
guard let jsonPath = Bundle(for: Self.self).path(forResource: "WorkoutSummary", ofType: "json"),
let jsonData = try? Data(contentsOf: .init(filePath: jsonPath))
else {
Log.make().error("WorkoutSummary Mock Data를 생성할 수 없습니다.")
return
}

let session: URLSessionProtocol = isMockEnvironment ? MockURLSession(mockData: jsonData, mockResponse: .init()) : URLSession.shared
let repository = WorkoutSummaryRepository(session: session)
let useCase = WorkoutSummaryUseCase(repository: repository, workoutRecordID: recordID)
let viewModel = WorkoutSummaryViewModel(workoutSummaryUseCase: useCase)
let workoutSummaryViewController = WorkoutSummaryViewController(viewModel: viewModel)
navigationController.setViewControllers([workoutSummaryViewController], animated: true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import Log
import MapKit
import UIKit

// MARK: - LocationTrackingProtocol

/// 위치 정보를 제공받기 위해 사용합니다.
protocol LocationTrackingProtocol: UIViewController {
/// 위치 정보를 제공하는 Publisher
var locationPublisher: AnyPublisher<[CLLocation], Never> { get }
}

// MARK: - WorkoutRouteMapViewController

final class WorkoutRouteMapViewController: UIViewController {
Expand All @@ -21,15 +29,11 @@ final class WorkoutRouteMapViewController: UIViewController {
private let viewModel: WorkoutRouteMapViewModelRepresentable

/// 사용자 위치 추적 배열
private var locations: [CLLocation] = []
@Published private var locations: [CLLocation] = []

private var subscriptions: Set<AnyCancellable> = []

private lazy var locationManager: CLLocationManager = {
let locationManager = CLLocationManager()
locationManager.delegate = self
return locationManager
}()
private let locationManager: CLLocationManager = .init()

// MARK: UI Components

Expand All @@ -55,6 +59,7 @@ final class WorkoutRouteMapViewController: UIViewController {

deinit {
locationManager.stopUpdatingLocation()
Log.make().debug("\(Self.self) deinitialized")
}

// MARK: Life Cycles
Expand Down Expand Up @@ -104,11 +109,20 @@ final class WorkoutRouteMapViewController: UIViewController {
}

private func setupLocationManager() {
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
}
}

// MARK: LocationTrackingProtocol

extension WorkoutRouteMapViewController: LocationTrackingProtocol {
var locationPublisher: AnyPublisher<[CLLocation], Never> {
$locations.eraseToAnyPublisher()
}
}

// MARK: CLLocationManagerDelegate

extension WorkoutRouteMapViewController: CLLocationManagerDelegate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@

import Combine
import DesignSystem
import Log
import UIKit

// MARK: - HealthDataProtocol

/// 건강 정보를 제공받을 때 사용합니다.
protocol HealthDataProtocol: UIViewController {
/// 건강 데이터를 제공하는 Publisher
var healthDataPublisher: AnyPublisher<WorkoutHealth, Never> { get }
}

// MARK: - WorkoutSessionViewController

public final class WorkoutSessionViewController: UIViewController {
Expand All @@ -19,6 +28,14 @@ public final class WorkoutSessionViewController: UIViewController {

private var participantsDataSource: ParticipantsDataSource?

@Published private var healthData: WorkoutHealth = .init(
distance: nil,
calorie: nil,
averageHeartRate: nil,
minimumHeartRate: nil,
maximumHeartRate: nil
)

private var subscriptions: Set<AnyCancellable> = []

// MARK: UI Components
Expand All @@ -45,6 +62,10 @@ public final class WorkoutSessionViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}

deinit {
Log.make().debug("\(Self.self) deinitialized")
}

// MARK: Life Cycles

override public func viewDidLoad() {
Expand Down Expand Up @@ -150,6 +171,14 @@ public final class WorkoutSessionViewController: UIViewController {
}
}

// MARK: HealthDataProtocol

extension WorkoutSessionViewController: HealthDataProtocol {
var healthDataPublisher: AnyPublisher<WorkoutHealth, Never> {
$healthData.eraseToAnyPublisher()
}
}

// MARK: WorkoutSessionViewController.Metrics

private extension WorkoutSessionViewController {
Expand Down
Loading

0 comments on commit 4e2debc

Please sign in to comment.