diff --git a/.gitignore b/.gitignore index e8be73b..7d98539 100644 --- a/.gitignore +++ b/.gitignore @@ -90,4 +90,5 @@ fastlane/test_output # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode -iOSInjectionProject/ \ No newline at end of file +iOSInjectionProject/ +RunLog/Secrets.xcconfig diff --git a/RunLog/RunLog.xcodeproj/project.pbxproj b/RunLog/RunLog.xcodeproj/project.pbxproj index ed836f8..633cc53 100644 --- a/RunLog/RunLog.xcodeproj/project.pbxproj +++ b/RunLog/RunLog.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 16F688CA2D82974E00C2163F /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 16F688C92D82974E00C2163F /* Moya */; }; 595FD80C2D90474C0073F561 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 595FD80B2D90474C0073F561 /* Secrets.xcconfig */; }; 595FD80D2D90474C0073F561 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 595FD80B2D90474C0073F561 /* Secrets.xcconfig */; }; + 5EF1F5D62DA6266900C34BCB /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 5EF1F5D52DA6266900C34BCB /* Secrets.xcconfig */; }; + 5EF1F5D72DA6266900C34BCB /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 5EF1F5D52DA6266900C34BCB /* Secrets.xcconfig */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -31,6 +33,7 @@ 16811EF62D8A9E520013461D /* RpTest.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RpTest.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 16F686B72D827D6C00C2163F /* RunLog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RunLog.app; sourceTree = BUILT_PRODUCTS_DIR; }; 595FD80B2D90474C0073F561 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; + 5EF1F5D52DA6266900C34BCB /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -90,6 +93,7 @@ 16F686AE2D827D6C00C2163F = { isa = PBXGroup; children = ( + 5EF1F5D52DA6266900C34BCB /* Secrets.xcconfig */, 595FD80B2D90474C0073F561 /* Secrets.xcconfig */, 16F686B92D827D6C00C2163F /* RunLog */, 16811EF72D8A9E520013461D /* RpTest */, @@ -212,6 +216,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5EF1F5D62DA6266900C34BCB /* Secrets.xcconfig in Resources */, 595FD80C2D90474C0073F561 /* Secrets.xcconfig in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -220,6 +225,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5EF1F5D72DA6266900C34BCB /* Secrets.xcconfig in Resources */, 595FD80D2D90474C0073F561 /* Secrets.xcconfig in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/RunLog/RunLog/Resources/DesignSystem/FontSystem.swift b/RunLog/RunLog/Resources/DesignSystem/FontSystem.swift index 2a125ce..af67ee7 100644 --- a/RunLog/RunLog/Resources/DesignSystem/FontSystem.swift +++ b/RunLog/RunLog/Resources/DesignSystem/FontSystem.swift @@ -114,7 +114,7 @@ extension UIFont { case thin = "Pretendard-Thin" } - // 다이나믹 폰트 사이즈 설정 + /// 다이나믹 폰트 사이즈 설정 private static func dynamicFont( name: String, baseSize: CGFloat, diff --git a/RunLog/RunLog/Resources/DesignSystem/LabelSystem.swift b/RunLog/RunLog/Resources/DesignSystem/LabelSystem.swift index ef777da..61cc4c2 100644 --- a/RunLog/RunLog/Resources/DesignSystem/LabelSystem.swift +++ b/RunLog/RunLog/Resources/DesignSystem/LabelSystem.swift @@ -15,16 +15,18 @@ final class RLLabel: UIView { var icon = UIImageView() var label = UILabel() + /// Label의 attributedText 설정 var attributedText: NSAttributedString? { get { return label.attributedText } set { - label.text = nil + label.text = nil // 기존의 text가 있으면 삭제 후 지정 label.attributedText = newValue } } + /// text와 icon의 색상 변경 override var tintColor: UIColor! { get { return label.tintColor @@ -35,7 +37,14 @@ final class RLLabel: UIView { } } - /// 레이블 생성 + /// 아이콘을 내포한 레이블을 생성합니다. + /// - Parameters: + /// - text: 레이블의 텍스트 + /// - textColor: 텍스트 색상 (기본값: `.Gray000`) + /// - icon: 레이블에 들어갈 아이콘 이미지 (기본값: `nil`) + /// - align: 레이블 정렬 상태 (기본값: `.left`) + /// - font: 레이블의 폰트 (기본값: `.RLLabel2`) + /// - tintColor: 레이블의 틴트 색상 (기본값: `.Gray000`) public init( text: String = "Custom Label", textColor: UIColor = .Gray000, @@ -61,8 +70,10 @@ final class RLLabel: UIView { // MARK: - Setup UI private func setupUI() { - // UI 요소 추가 + // subview self.addSubviews(icon, label) + + // autoLayout if icon.image == nil { label.snp.makeConstraints { $0.top.bottom.leading.trailing.equalToSuperview() diff --git a/RunLog/RunLog/Resources/DesignSystem/TextFieldSystem.swift b/RunLog/RunLog/Resources/DesignSystem/TextFieldSystem.swift index f81f7db..ca18b94 100644 --- a/RunLog/RunLog/Resources/DesignSystem/TextFieldSystem.swift +++ b/RunLog/RunLog/Resources/DesignSystem/TextFieldSystem.swift @@ -19,7 +19,6 @@ open class RLTextField: UITextField { // MARK: - Init public init( - // Q) 패딩 디폴트값에는 어떻게 다이나믹 패딩을 적용하나요!? padding: UIEdgeInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16), placeholder: String? = nil ) { diff --git a/RunLog/RunLog/Sources/Data/DistanceManager.swift b/RunLog/RunLog/Sources/Data/DistanceManager.swift index 36968f1..4c5eb4a 100644 --- a/RunLog/RunLog/Sources/Data/DistanceManager.swift +++ b/RunLog/RunLog/Sources/Data/DistanceManager.swift @@ -9,6 +9,8 @@ import Foundation import Combine import CoreLocation + +/// 사용자가 움직인 거리를 측정하는 매니저 final class DistanceManager { // MARK: - Singleton @@ -49,7 +51,7 @@ final class DistanceManager { .store(in: &cancellables) } - // MARK: - 이동 거리를 계산해서 output으로 send + /// 이전위치와 현재위치를 전달받아 움직인거리를 output으로 send private func calculateDistance(previous: CLLocation, current: CLLocation) { let distance = current.distance(from: previous) if distance >= 1 { diff --git a/RunLog/RunLog/Sources/Data/DrawingManager.swift b/RunLog/RunLog/Sources/Data/DrawingManager.swift index addb5e9..6b50f68 100644 --- a/RunLog/RunLog/Sources/Data/DrawingManager.swift +++ b/RunLog/RunLog/Sources/Data/DrawingManager.swift @@ -28,7 +28,6 @@ final class DrawingManager: NSObject, MKMapViewDelegate { // MARK: - Output enum Output { case responsePolyline(MKPolyline) - case responseFullRoutePolyline(MKMapView) } let output = PassthroughSubject() @@ -50,7 +49,6 @@ final class DrawingManager: NSObject, MKMapViewDelegate { mapView.delegate = self let polyline = createFullRoutePolylines(route) mapView.addOverlay(polyline) - self.output.send(.responseFullRoutePolyline(mapView)) mapView.delegate = nil } } diff --git a/RunLog/RunLog/Sources/Data/LocationManager.swift b/RunLog/RunLog/Sources/Data/LocationManager.swift index c96c389..348878c 100644 --- a/RunLog/RunLog/Sources/Data/LocationManager.swift +++ b/RunLog/RunLog/Sources/Data/LocationManager.swift @@ -10,6 +10,7 @@ import MapKit import CoreLocation import Combine +/// 사용자의 위치 정보를 받아오는 매니저 final class LocationManager: NSObject, CLLocationManagerDelegate { // MARK: - Singleton @@ -19,6 +20,7 @@ final class LocationManager: NSObject, CLLocationManagerDelegate { setupLocationManager() bind() } + deinit { locationManager.stopUpdatingLocation() } @@ -53,49 +55,63 @@ final class LocationManager: NSObject, CLLocationManagerDelegate { case .requestCurrentLocation: guard let location = self.locationManager.location else { return } self.output.send(.locationUpdate(location)) + case .requestCityName(let location): self.fetchCityName(location: location) } } .store(in: &cancellables) } -} - -// MARK: - CLLocationManager 설정 -extension LocationManager { + + /// CLLocationManager 기본 설정 private func setupLocationManager() { locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest - // Q) 지피티 피셜 걷기+달리기면 5m가 적당하다 - 실제로 3, 5로 해서 측정해보고 결정 - locationManager.distanceFilter = 3 + // 4미터 이동 시 사용자의 위치를 받아옴 + locationManager.distanceFilter = 4 // 백그라운드 상태에서도 위치 업데이트 locationManager.allowsBackgroundLocationUpdates = true // 사용자가 멈춰있으면 업데이트 일시정지 locationManager.pausesLocationUpdatesAutomatically = true + // 사용자의 위치 정보 권한 확인 getLocationUsagePermission() } +} + +// MARK: - 사용자 위치 데이터 +extension LocationManager { - // MARK: - 사용자가 위치를 이동하면 output으로 send를 보냄 - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + /// 사용자의 위치를 받아오는 Delegate 함수 + func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { guard let location = locations.last else { return } + // GPS 신호가 불안정한 경우 필터링 - if previousLocation != nil && (location.horizontalAccuracy < 0 || location.horizontalAccuracy > 10) { + if previousLocation != nil && + (location.horizontalAccuracy < 0 || + location.horizontalAccuracy > 10) + { print("GPS 신호 불안정 - 위치 무시") return } - // 이전 위치와 비교하여 1m 이하 이동 시 무시 - if let previous = previousLocation, location.distance(from: previous) < 1 { + + // 이전 위치와 비교하여 1m 이하 이동 시 무시 - 위치가 튀는것을 방지 + if let previous = previousLocation, + location.distance(from: previous) < 1 + { print("이동 거리 1m 이하 - 위치 업데이트 안함") return } + + // 사용자의 위치를 output으로 send self.output.send(.locationUpdate(location)) + // 현재위치를 이전위치로 기억 previousLocation = location } -} - -// MARK: - 도시명 가져오기 -extension LocationManager { - // MARK: - 도시명 가져오기 + + /// 사용자 위치의 도시명 받아오기 private func fetchCityName(location: CLLocation) { let geocoder = CLGeocoder() geocoder.reverseGeocodeLocation(location) { @@ -112,23 +128,33 @@ extension LocationManager { // MARK: - 위치 정보 권한 요청 extension LocationManager { + // MARK: - 권한 정보가 바뀌면 실행 - func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + func locationManager( + _ manager: CLLocationManager, + didChangeAuthorization status: CLAuthorizationStatus + ) { getLocationUsagePermission() } + // MARK: - 권한 정보에 따른 분기 처리 private func getLocationUsagePermission() { let status = locationManager.authorizationStatus + switch status { case .notDetermined: // 허용 안한 상태 locationManager.requestWhenInUseAuthorization() + case .authorizedWhenInUse: // 앱을 사용동안 허용 locationManager.requestAlwaysAuthorization() + case .restricted, .denied: // 거부 상태 print("위치 권한이 거부됨 - 설정에서 변경 필요") openAppSettings() + case .authorizedAlways: // 항상 허용 locationManager.startUpdatingLocation() + @unknown default: return } @@ -136,6 +162,7 @@ extension LocationManager { // MARK: - 앱 설정 열기 private func openAppSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } diff --git a/RunLog/RunLog/Sources/Data/Network/OpenWeather/OpenWeatherService.swift b/RunLog/RunLog/Sources/Data/Network/OpenWeather/OpenWeatherService.swift index 2862749..e29dd20 100644 --- a/RunLog/RunLog/Sources/Data/Network/OpenWeather/OpenWeatherService.swift +++ b/RunLog/RunLog/Sources/Data/Network/OpenWeather/OpenWeatherService.swift @@ -43,8 +43,10 @@ final class OpenWeatherService: NetworkService { self.input .sink { [weak self] input in guard let self = self else { return } + switch input { case .requestWeather(let location): + // 두 데이터가 모두 도착하면 전달 Publishers.Zip( self.fetchWeatherData(location: location), self.fetchAqiData(location: location) @@ -55,6 +57,7 @@ final class OpenWeatherService: NetworkService { } } receiveValue: { [weak self] weather, aqi in guard let self = self else { return } + self.output.send(.responseWeather(weather: weather, aqi: aqi)) } .store(in: &self.cancellables) @@ -66,10 +69,13 @@ final class OpenWeatherService: NetworkService { // MARK: - 날씨정보 요청 extension OpenWeatherService { + + /// 위치를 전달하면 날씨 정보(WeatherResonse)를 반환하는 Publisher를 반환 private func fetchWeather(lat: Double, lon: Double) -> AnyPublisher { return request(.weather(lat: lat, lon: lon), responseType: WeatherResponse.self) } + /// 날씨정보를 반환하는 Publisher에서 온도와 날씨 상태 데이터를 뽑아서 반환하는 Publisher를 반환 private func fetchWeatherData(location: CLLocation) -> AnyPublisher<(Int, Double), Never> { return self.fetchWeather( lat: location.coordinate.latitude, @@ -90,10 +96,13 @@ extension OpenWeatherService { // MARK: - 대기질정보 요청 extension OpenWeatherService { + + /// 위치를 전달하면 대기질 정보(AQIResonse)를 반환하는 Publisher를 반환 private func fetchAqi(lat: Double, lon: Double) -> AnyPublisher { return request(.airQuality(lat: lat, lon: lon), responseType: AQIResponse.self) } + /// 대기질 정보를 반환하는 Publisher에서 대기질 상태 데이터를 뽑아서 반환하는 Publisher를 반환 private func fetchAqiData(location: CLLocation) -> AnyPublisher { return self.fetchAqi( lat: location.coordinate.latitude, diff --git a/RunLog/RunLog/Sources/Data/PedometerManager.swift b/RunLog/RunLog/Sources/Data/PedometerManager.swift index 4d57d7a..d1e9fdd 100644 --- a/RunLog/RunLog/Sources/Data/PedometerManager.swift +++ b/RunLog/RunLog/Sources/Data/PedometerManager.swift @@ -9,6 +9,7 @@ import Foundation import Combine import CoreMotion +/// 사용자가 걸은 걸음수를 받아오는 매니저 final class PedometerManager { // MARK: - Singleton @@ -44,6 +45,7 @@ final class PedometerManager { switch input { case .requestPedometerStart: self.pedometerUpdateStart() + case .requestPedometerStop: self.pedometerUpdateStop() } @@ -53,15 +55,21 @@ final class PedometerManager { // MARK: - 걸음 수 측정 시작 private func pedometerUpdateStart() { + + /// 걸음수를 측정 가능한 기기 확인 guard CMPedometer.isStepCountingAvailable() else { print("측정 불가 기기") return } + + /// 측정가능한 기기의 경우 측정 시작 pedometer.startUpdates(from: Date()) { [weak self] data, error in guard let self = self, let data = data, error == nil else { return } + + // 걸음수를 Int로 변경 let stepCount = data.numberOfSteps.intValue self.output.send(.responseSteps(stepCount)) } diff --git a/RunLog/RunLog/Sources/Presentation/Common/View/ViewController.swift b/RunLog/RunLog/Sources/Presentation/Common/View/ViewController.swift deleted file mode 100644 index 01e67d5..0000000 --- a/RunLog/RunLog/Sources/Presentation/Common/View/ViewController.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// ViewController.swift -// RunLog -// -// Created by 김도연 on 3/13/25. -// - -import UIKit -import Then -import SnapKit - -class ViewController: UIViewController { - - var label = UILabel().then { - $0.attributedText = .RLAttributedString(text: "Hello World!", font: .Heading1, align: .center) - $0.backgroundColor = .blue - } - - var closeButton = UIButton().then { - $0.setAttributedTitle(.RLAttributedString(text: "닫기", font: .Button, align: .center), for: .normal) - $0.setImage(UIImage(systemName: RLIcon.fold.name), for: .normal) - $0.backgroundColor = UIColor.orange - } - - var lbl = UILabel().then { - $0.font = .RLHeading1 - } - - lazy var rlBtn = RLButton(title: "예시 버튼", titleColor: .Gray000).then { - $0.configureTitle(title: "타이틀 변경", titleColor: .Gray900, font: .RLButton) - $0.configureRadius(8) - $0.configureBackgroundColor(.LightGreen) - $0.setHeight(100) - } - - private let textField = RLTextField(placeholder: "닉네임을 입력하세요") - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .Gray900 - // Do any additional setup after loading the view. - [label, closeButton, rlBtn, textField].forEach{ view.addSubview($0) } - - view.addSubview(label) - label.snp.makeConstraints { - $0.centerX.centerY.equalToSuperview() - } - closeButton.snp.makeConstraints { - $0.leading.trailing.equalTo(label) - $0.top.equalTo(label.snp.bottom) - } - rlBtn.snp.makeConstraints { - // 버튼 width는 leading, trailing으로 잡아주면 좋아요 - $0.leading.trailing.equalToSuperview().inset(24) - } - textField.snp.makeConstraints { - $0.leading.trailing.equalToSuperview().inset(24) // 좌우 여백 24 - $0.top.equalToSuperview().offset(100) // 상단 여백 50 - $0.height.equalTo(64) - } - -// label.snp.makeConstraints { -// $0.centerX.centerY.equalToSuperview() -// } -// closeButton.snp.makeConstraints { -// $0.leading.trailing.equalTo(label) -// $0.top.equalTo(label.snp.bottom) -// } -// rlBtn.snp.makeConstraints { -// // 버튼 width는 leading, trailing으로 잡아주면 좋아요 -// $0.leading.trailing.equalToSuperview().inset(24) -// } - } -} - - diff --git a/RunLog/RunLog/Sources/Presentation/Run/RunningDataProvider.swift b/RunLog/RunLog/Sources/Presentation/Run/RunningDataProvider.swift index ee524c0..db67c43 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/RunningDataProvider.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/RunningDataProvider.swift @@ -9,7 +9,7 @@ import Foundation import MapKit import Combine -// MARK: - 운동 정보에 대한 각종 정보를 가지고 있고 전달해주는 객체 +/// 운동관련 각종 정보를 가지고 있고 전달해주는 객체 final class RunningDataProvider { // MARK: - Singleton @@ -177,9 +177,6 @@ extension RunningDataProvider { switch output { case .responsePolyline(let polyline): self.runningOutput.send(.responseLineDraw(polyline)) - case .responseFullRoutePolyline(let mapView): - // 사진을 만드는 매니저? 랜더러?에 해당 맵뷰를 send - print("") } } .store(in: &cancellables) diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift b/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift index 85cbfd9..51fafdd 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift @@ -10,6 +10,7 @@ import SnapKit import Then final class CardElementView: UIView { + enum ElementType: String { case time = "시간" case distance = "거리" @@ -22,6 +23,7 @@ final class CardElementView: UIView { case .steps: return .Headline1 } } + var valueFont: RLFont { switch self { case .time: return .Heading4 @@ -29,6 +31,7 @@ final class CardElementView: UIView { case .steps: return .Title } } + var color: UIColor { switch self { case .time: return .LightOrange @@ -37,14 +40,17 @@ final class CardElementView: UIView { } } } + // MARK: - UI Components 선언 private var type: ElementType private var title = UILabel() private var value = UILabel() + // MARK: - Init init(type: ElementType) { self.type = type super.init(frame: .zero) + setupUI() setupLayout() } @@ -57,7 +63,11 @@ final class CardElementView: UIView { private func setupUI() { // UI 요소 추가 self.addSubviews(title, value) - title.attributedText = .RLAttributedString(text: type.rawValue, font: type.titleFont) + + title.attributedText = .RLAttributedString( + text: type.rawValue, + font: type.titleFont + ) } // MARK: - Setup Layout @@ -66,6 +76,7 @@ final class CardElementView: UIView { title.snp.makeConstraints { $0.top.leading.trailing.equalToSuperview() } + value.snp.makeConstraints { $0.bottom.leading.trailing.equalToSuperview() $0.top.equalTo(title.snp.bottom) diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift b/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift index a38d82a..267c2e2 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift @@ -10,13 +10,19 @@ import SnapKit import Then final class CardView: UIView { + // MARK: - UI Components 선언 var timeLabel = CardElementView(type: .time) var distanceLabel = CardElementView(type: .distance) var stepsLabel = CardElementView(type: .steps) - var finishButton = RLButton(title: "종료", titleColor: .Gray900).then { + + var finishButton = RLButton( + title: "종료", + titleColor: .Gray900 + ).then { $0.configureBackgroundColor(.Gray100) } + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) @@ -38,17 +44,20 @@ final class CardView: UIView { $0.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(36)) $0.bottom.equalToSuperview().inset(DynamicSize.scaledSize(31)) } + timeLabel.snp.makeConstraints { $0.top.equalToSuperview().inset(DynamicSize.scaledSize(36)) $0.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(36)) $0.height.equalTo(DynamicSize.scaledSize(79)) } + distanceLabel.snp.makeConstraints { $0.leading.equalTo(timeLabel) $0.trailing.equalTo(timeLabel.snp.centerX) $0.top.equalTo(timeLabel.snp.bottom).offset(DynamicSize.scaledSize(12)) $0.bottom.equalTo(finishButton.snp.top).offset(-DynamicSize.scaledSize(20)) } + stepsLabel.snp.makeConstraints { $0.leading.equalTo(timeLabel.snp.centerX) $0.trailing.equalTo(timeLabel) diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift b/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift index 0c7413c..4f4cefc 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift @@ -38,6 +38,7 @@ final class MapBlurView: UIView { // MARK: - AutoLayout 적용 후 Gradient 추가 override func didMoveToSuperview() { super.didMoveToSuperview() + DispatchQueue.main.async { self.setupLayout() } diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift b/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift index 5a4c848..3a7db4e 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift @@ -20,18 +20,25 @@ final class RunHomeViewController: UIViewController { // MARK: - UI private var mapView = MKMapView().then { $0.showsUserLocation = true + // 최대 줌아웃 거리 제한 let zoomRange = MKMapView.CameraZoomRange(maxCenterCoordinateDistance: 20000) $0.setCameraZoomRange(zoomRange, animated: false) $0.initZoomLevel() } - private var totalLabel = UILabel().then { + + // 지금까지 운동한 거리에 대한 레이블 + private var RoadRecordLabel = UILabel().then { $0.numberOfLines = 3 } + private var weatherLabel = RLLabel().then { $0.setImage(image: UIImage(systemName: RLIcon.weather.name)) $0.attributedText = .RLAttributedString(text: "Roading", font: .Label2) } + private var blurView = MapBlurView() + + // 사용자의 현재위치 레이블 private var locationLabel = UILabel().then { $0.attributedText = .RLAttributedString( text: Constants.LocationMessage.random.message, @@ -39,6 +46,7 @@ final class RunHomeViewController: UIViewController { align: .center ) } + private var startButton = RLButton( title: "운동 시작하기", titleColor: .Gray900 @@ -67,19 +75,24 @@ final class RunHomeViewController: UIViewController { bindViewModel() bindGesture() } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) setupData() } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - } // MARK: - Setup UI private func setupUI() { // UI 요소 추가 view.backgroundColor = .systemBackground - view.addSubviews(mapView, blurView, totalLabel, weatherLabel, locationLabel, startButton) + view.addSubviews( + mapView, + blurView, + RoadRecordLabel, + weatherLabel, + locationLabel, + startButton + ) // 맵뷰 mapView.snp.makeConstraints { @@ -88,15 +101,15 @@ final class RunHomeViewController: UIViewController { } // Road 정보 - totalLabel.snp.makeConstraints { + RoadRecordLabel.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide).offset(DynamicSize.scaledSize(36)) $0.leading.equalTo(view.safeAreaLayoutGuide).offset(DynamicSize.scaledSize(36)) } // 날씨 정보 weatherLabel.snp.makeConstraints { - $0.top.equalTo(totalLabel.snp.bottom).offset(DynamicSize.scaledSize(8)) - $0.leading.equalTo(totalLabel) + $0.top.equalTo(RoadRecordLabel.snp.bottom).offset(DynamicSize.scaledSize(8)) + $0.leading.equalTo(RoadRecordLabel) } // 운동 시작 버튼 @@ -133,9 +146,9 @@ final class RunHomeViewController: UIViewController { // MARK: - Setup Data private func setupData() { - // 처음 위치를 지도에 표현 + // 사용자의 위치 정보를 요청 viewModel.input.send(.requestCurrentLocation) - // 로드(기록)정보 표현 + // RoadRecord 정보를 요청 viewModel.input.send(.requestRoadRecord) } @@ -145,13 +158,19 @@ final class RunHomeViewController: UIViewController { .receive(on: DispatchQueue.main) .sink { [weak self] output in guard let self = self else { return } + switch output { + // 운동시작하면 운동화면으로 넘어감 case .responseRunningStart: let vc = RunningViewController() vc.modalPresentationStyle = .fullScreen self.present(vc, animated: false) + + // 사용자의 변경된 위치 반영 case .locationUpdate(let location): self.mapView.centerToLocation(location, region: self.mapView.region) + + // 사용자의 변경된 위치명 반영 case .locationNameUpdate(let text): self.locationLabel.attributedText = .RLAttributedString( @@ -159,18 +178,24 @@ final class RunHomeViewController: UIViewController { font: .Label2, align: .center ) + + // 변경된 날씨 정보 반영 case .weatherUpdate(let text): self.weatherLabel.attributedText = .RLAttributedString( text: text, font: .Label2 ) + + // RoadRecord 정보 표시 case .responseRoadRecord(let text): - self.totalLabel.attributedText = text + self.RoadRecordLabel.attributedText = text } } .store(in: &cancellables) + } + // MARK: - Bind Gesture private func bindGesture() { // 제스처 추가 diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift b/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift index ba4104d..eec0e07 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift @@ -20,7 +20,8 @@ final class RunningViewController: UIViewController { // MARK: - UI private lazy var mapView = MKMapView().then { $0.delegate = self - //최대 줌 거리 제한 + + //최대 줌아웃 거리 제한 let zoomRange = MKMapView.CameraZoomRange(maxCenterCoordinateDistance: 20000) $0.setCameraZoomRange(zoomRange, animated: false) $0.showsUserLocation = true @@ -28,8 +29,11 @@ final class RunningViewController: UIViewController { $0.pitchButtonVisibility = .visible $0.initZoomLevel() } + // 카드 뷰 private var cardView = CardView() + + // 카드 뷰 접는 버튼 private var foldButton = RLButton().then { $0.setHeight(DynamicSize.scaledSize(40)) // 높이 지정 $0.configureRadius(DynamicSize.scaledSize(8)) // 라운드 지정 @@ -44,11 +48,14 @@ final class RunningViewController: UIViewController { ) ) } + + // 카드 뷰 펼치는 버튼 private var unfoldButton = UIButton().then { $0.backgroundColor = .LightGreen $0.layer.cornerRadius = DynamicSize.scaledSize(40) $0.setImage(UIImage(systemName: RLIcon.unfold.name), for: .normal) $0.tintColor = .Gray900 + let sfConfig = UIImage.SymbolConfiguration( pointSize: DynamicSize.scaledSize(32), weight: .medium @@ -76,11 +83,13 @@ final class RunningViewController: UIViewController { bindViewModel() bindGesture() } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.setNavigationBarHidden(false, animated: animated) setupData() } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.navigationController?.setNavigationBarHidden(true, animated: false) @@ -91,21 +100,27 @@ final class RunningViewController: UIViewController { // UI 요소 추가 view.backgroundColor = .systemBackground view.addSubviews(mapView, cardView, foldButton, unfoldButton) + // 맵킷 mapView.snp.makeConstraints { $0.top.bottom.leading.trailing.equalToSuperview() } + // 카드 뷰 cardView.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(DynamicSize.scaledSize(16)) $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(DynamicSize.scaledSize(24)) $0.height.equalTo(DynamicSize.scaledSize(299)) } + + // 카드 뷰 접는 버튼 foldButton.snp.makeConstraints { $0.width.equalTo(DynamicSize.scaledSize(80)) $0.bottom.equalTo(cardView.snp.top).offset(-DynamicSize.scaledSize(8)) $0.trailing.equalToSuperview().inset(DynamicSize.scaledSize(16)) } + + // 카드 뷰 펼치는 버튼 unfoldButton.snp.makeConstraints { $0.width.height.equalTo(DynamicSize.scaledSize(80)) $0.trailing.equalToSuperview().inset(DynamicSize.scaledSize(16)) @@ -126,16 +141,27 @@ final class RunningViewController: UIViewController { .sink { [weak self] output in guard let self = self else { return } switch output { + // 운동 종료 case .responseRunningStop: self.dismiss(animated: false) + + // 사용자의 변경된 위치 반영 case .locationUpdate(let location): self.mapView.centerToLocation(location, region: self.mapView.region) + + // 운동 시간 반영 case .responseCurrentTimes(let time): self.cardView.timeLabel.setConfigure(text: time) + + // 운동 거리 반영 case .responseCurrentDistances(let distances): self.cardView.distanceLabel.setConfigure(text: distances) + + // 운동 걸음 수 반영 case .responseCurrentSteps(let steps): self.cardView.stepsLabel.setConfigure(text: steps) + + // 지도에 이동한 루트 표시 case .lineDraw(let polyline): self.mapView.addOverlay(polyline) } @@ -145,7 +171,8 @@ final class RunningViewController: UIViewController { // MARK: - Bind Gesture private func bindGesture() { - // 카드뷰 접었다 펴기 + + // 카드뷰 접었다 펴기 - 뷰의 형태만 다르고 같은 내용을 사용 Publishers.Merge( foldButton.publisher, unfoldButton.publisher @@ -153,6 +180,7 @@ final class RunningViewController: UIViewController { .receive(on: DispatchQueue.main) .sink { [weak self] in guard let self = self else { return } + self.cardView.isHidden.toggle() self.foldButton.isHidden.toggle() self.unfoldButton.isHidden.toggle() @@ -162,8 +190,7 @@ final class RunningViewController: UIViewController { // 종료 버튼 클릭 cardView.finishButton.publisher .sink { [weak self] in - guard let self = self else { return } - self.viewModel.input.send(.requestRunningStop) + self?.viewModel.input.send(.requestRunningStop) } .store(in: &cancellables) } @@ -173,21 +200,33 @@ final class RunningViewController: UIViewController { extension RunningViewController: MKMapViewDelegate { // 트랙킹 모드 변경 - func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) { + func mapView( + _ mapView: MKMapView, + didChange mode: MKUserTrackingMode, + animated: Bool + ) { guard let userLocation = mapView.userLocation.location else { return } + // none이 되면 현재위치로 지도 바로 이동 if mode == .none { - mapView.centerToLocation(userLocation, region: self.mapView.region) + mapView.centerToLocation( + userLocation, + region: self.mapView.region + ) } } // 풀리라인 설정 - func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { - guard let polyLine = overlay as? MKPolyline - else { + func mapView( + _ mapView: MKMapView, + rendererFor overlay: MKOverlay + ) -> MKOverlayRenderer { + + guard let polyLine = overlay as? MKPolyline else { print("can't draw polyline") return MKOverlayRenderer() } + let renderer = MKPolylineRenderer(polyline: polyLine) renderer.strokeColor = .LightGreen renderer.lineWidth = DynamicSize.scaledSize(3.0) diff --git a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift index 0dfb83c..c38f6cd 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift @@ -46,13 +46,21 @@ final class RunHomeViewModel { self.input .sink { [weak self] input in guard let self = self else { return } + switch input { + // 운동시작 case .requestRunningStart: self.provider.input.send(.requestRunningStart) + + // 사용자의 위치 요청 case .requestCurrentLocation: self.provider.input.send(.requestCurrentLocation) + + // 사용자의 위치에 대한 날씨 요청 case .requestCurrentWeahter: self.provider.input.send(.requestCurrentWeather) + + // RoadRecord 정보 요청 case .requestRoadRecord: self.getDistanceIndicator() } @@ -64,13 +72,20 @@ final class RunHomeViewModel { .sink { [weak self] output in guard let self = self else { return } switch output { + // 운동시작 case .responseRunningStart: self.output.send(.responseRunningStart) + + // 사용자의 위치 요청 case .responseCurrentLocation(let location): self.output.send(.locationUpdate(location)) + + // 사용자의 위치에 대한 도시명 요청 case .responseCurrentCityName(let name): let updateName = name.hasSuffix("...") ? name : "\(name)에서" self.output.send(.locationNameUpdate(updateName)) + + // 사용자의 위치에 대한 날씨 요청 case .responseCurrentWeather(let weahter, let aqi): let weatherString = self.toWeatherString(weahter, aqi) self.output.send(.weatherUpdate(weatherString)) @@ -83,7 +98,9 @@ final class RunHomeViewModel { // MARK: - 날씨 레이블 형태로 변경 extension RunHomeViewModel { private func toWeatherString(_ weather: (Int, Double), _ aqi: Int) -> String { + var formattedString = "" + if weather.0 == -1 { formattedString = "알 수 없음" } else { let condition = Constants.WeatherCondition.from(weather.0).description @@ -102,16 +119,19 @@ extension RunHomeViewModel { let nickname = try await appConfigUseCase.getNickname() let (roadName, countData) = try await appConfigUseCase.getDistanceIndicators() let count = countData.toString(withDecimal: 2) + let string = """ \(nickname) 님은 지금까지 \(roadName) \(count)회 거리만큼 걸었습니다! """ + let attributedString = string.styledText( highlightText: "\(roadName) \(count)회", baseFont: .RLMainTitle, highlightFont: .RLMainTitle ) + self.output.send(.responseRoadRecord(attributedString)) } } diff --git a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift index 6d24f8f..7425bc7 100644 --- a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift +++ b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift @@ -31,6 +31,7 @@ final class RunningViewModel { case responseCurrentSteps(String) // 운동 걸음 수 데이터 case lineDraw(MKPolyline) // 지도에 라인을 그림 } + let output = PassthroughSubject() // MARK: - Properties @@ -44,8 +45,10 @@ final class RunningViewModel { .sink { [weak self] input in guard let self = self else { return } switch input { + // 운동 종료 case .requestRunningStop: self.provider.input.send(.requestRunningStop) + // 사용자의 위치를 받아서 업데이트 case .requestCurrentLocation: self.provider.input.send(.requestCurrentLocation) } @@ -59,17 +62,22 @@ final class RunningViewModel { switch output { case .responseRunningStop: self.output.send(.responseRunningStop) + case .responseCurrentLocation(let location): self.output.send(.locationUpdate(location)) + case .responseCurrentTimes(let times): let timeString = times.asTimeString self.output.send(.responseCurrentTimes(timeString)) + case .responseCurrentDistances(let distances): let distanceString = "\(distances.toString(withDecimal: 2))km" self.output.send(.responseCurrentDistances(distanceString)) + case .responseCurrentSteps(let steps): let stepString = "\(steps)" self.output.send(.responseCurrentSteps(stepString)) + case .responseLineDraw(let polyline): self.output.send(.lineDraw(polyline)) } diff --git a/RunLog/RunLog/Sources/Util/Constant/Constants.swift b/RunLog/RunLog/Sources/Util/Constant/Constants.swift index 87a28e7..5b7982b 100644 --- a/RunLog/RunLog/Sources/Util/Constant/Constants.swift +++ b/RunLog/RunLog/Sources/Util/Constant/Constants.swift @@ -71,6 +71,7 @@ struct Constants { } } + /// 위치를 받아오는 과정에서 랜덤한 메시지를 띄웁니다. static var random: LocationMessage { [unknown, consultingWithMap, detectingFootsteps, connectingGPS].randomElement()! } diff --git a/RunLog/RunLog/Sources/Util/Extension/CLPlacemark+.swift b/RunLog/RunLog/Sources/Util/Extension/CLPlacemark+.swift index bf7f068..727e702 100644 --- a/RunLog/RunLog/Sources/Util/Extension/CLPlacemark+.swift +++ b/RunLog/RunLog/Sources/Util/Extension/CLPlacemark+.swift @@ -10,29 +10,56 @@ import MapKit // MARK: - 위치 정보 -> 한글 extension CLPlacemark { + + /// CLPlacemark 정보를 "도(광역시) 시(군,구) 동(읍,면)"의 형태로 변경하여 반환합니다. func placemarksToString() -> String { + var state: String = "" // 도, 광역시 var city: String = "" // 시, 군, 구 var district: String = "" // 동, 읍, 면 + if let subLocal = self.subLocality, subLocal.hasSuffix("동") { district = subLocal } + guard let description = self.description .split(separator: ",") .filter({ $0.contains("대한민국") }) .first - else { return Constants.LocationMessage.random.message } + else { + return Constants.LocationMessage.random.message + } let components = description.split(separator: " ").map { String($0) } + for component in components { - if state == "" && (component.hasSuffix("특별시") || component.hasSuffix("광역시") || component.hasSuffix("도")) { + + if state == "" && ( + component.hasSuffix("특별시") || + component.hasSuffix("광역시") || + component.hasSuffix("도") + ) { state = component - }else if city == "" && (component.hasSuffix("시") || component.hasSuffix("군") || component.hasSuffix("구")) { + + } else if city == "" && ( + component.hasSuffix("시") || + component.hasSuffix("군") || + component.hasSuffix("구") + ) { city = component - }else if district == "" && (component.hasSuffix("동") || component.hasSuffix("읍") || component.hasSuffix("면") || component.hasSuffix("로")) { + + } else if district == "" && ( + component.hasSuffix("동") || + component.hasSuffix("읍") || + component.hasSuffix("면") || + component.hasSuffix("로") + ) { district = component + } } + + // district는 있는 경우에만 포함 return district.isEmpty ? "\(state) \(city)" : "\(state) \(city) \(district)" } } diff --git a/RunLog/RunLog/Sources/Util/Extension/Date+.swift b/RunLog/RunLog/Sources/Util/Extension/Date+.swift index ced41c5..3fc975d 100644 --- a/RunLog/RunLog/Sources/Util/Extension/Date+.swift +++ b/RunLog/RunLog/Sources/Util/Extension/Date+.swift @@ -32,8 +32,8 @@ enum DateFormatStyle { case yearMonth // "2025년 3월" case yearMonthShort // "25년 3월" case fullTime // "12:23:34:456" - case detailedFull // "2025년 3월 3일 수요일" - case weekDay // "월요일" + case detailedFull // "2025년 3월 3일 수요일" + case weekDay // "월요일" var format: String { switch self { diff --git a/RunLog/RunLog/Sources/Util/Extension/Double+.swift b/RunLog/RunLog/Sources/Util/Extension/Double+.swift index 714b149..91bd94e 100644 --- a/RunLog/RunLog/Sources/Util/Extension/Double+.swift +++ b/RunLog/RunLog/Sources/Util/Extension/Double+.swift @@ -12,6 +12,8 @@ extension Double { func toString(withDecimal decimal: Int = 2) -> String { return String(format: "%.\(decimal)f", self) } + + /// mm : ss 의 형태로 반환 var asTimeString: String { let minutes = Int(self) / 60 let seconds = Int(self) % 60 diff --git a/RunLog/RunLog/Sources/Util/Extension/Int+.swift b/RunLog/RunLog/Sources/Util/Extension/Int+.swift index 6e5a57c..163b41d 100644 --- a/RunLog/RunLog/Sources/Util/Extension/Int+.swift +++ b/RunLog/RunLog/Sources/Util/Extension/Int+.swift @@ -15,24 +15,25 @@ extension Int { return numberFormatter.string(from: NSNumber(value: self)) ?? "\(self)" } + /// 오픈웨더에서 제공받은 날씨 정보를 한글화하여 반환 func toWeatherDescription() -> String { - switch self { - case 1: return "맑음" - case 2: return "흐림" - case 3: return "비" - case 4: return "눈" - default: return "알 수 없음" - } + switch self { + case 1: return "맑음" + case 2: return "흐림" + case 3: return "비" + case 4: return "눈" + default: return "알 수 없음" } + } - func toLevelDescription() -> String { - switch self { - case 0: return "매우 쉬움" - case 1: return "쉬움" - case 2: return "보통" - case 3: return "어려움" - case 4: return "매우 어려움" - default: return "알 수 없음" - } + func toLevelDescription() -> String { + switch self { + case 0: return "매우 쉬움" + case 1: return "쉬움" + case 2: return "보통" + case 3: return "어려움" + case 4: return "매우 어려움" + default: return "알 수 없음" } + } } diff --git a/RunLog/RunLog/Sources/Util/Extension/MKMapView+.swift b/RunLog/RunLog/Sources/Util/Extension/MKMapView+.swift index dae3381..b5cbd4e 100644 --- a/RunLog/RunLog/Sources/Util/Extension/MKMapView+.swift +++ b/RunLog/RunLog/Sources/Util/Extension/MKMapView+.swift @@ -9,25 +9,35 @@ import Foundation import MapKit extension MKMapView { - // MARK: - 카메라 위치 변경 + + /// 위치와 보여질 거리, 기존에 지정된 카메라위치에 따라 카메라 위치를 지정합니다. + /// - Parameters: + /// - location: 표현하고자하는 위치 + /// - regionRadius: 최대로 보여질 지도 거리 + /// - region: 기존 카메라 상태 func centerToLocation( _ location: CLLocation, regionRadius: CLLocationDistance = 150, // 주변 거리(미터) region: MKCoordinateRegion? = nil ) { - if let region = region { + + if let region = region { // 기존의 줌이 존재하면 해당 줌을 유지 + let updatedRegion = MKCoordinateRegion( center: location.coordinate, span: region.span // 기존 줌(span) 유지 ) setRegion(updatedRegion, animated: true) - } else { + + } else { // 기존 줌이 존재하지 않다면 거리에 대비해 카메라 줌을 조정 + let coordinateRegion = MKCoordinateRegion( center: location.coordinate, latitudinalMeters: regionRadius, longitudinalMeters: regionRadius ) setRegion(coordinateRegion, animated: true) + } let currentCamera = self.camera diff --git a/RunLog/RunLog/Sources/Util/Extension/UIColor+.swift b/RunLog/RunLog/Sources/Util/Extension/UIColor+.swift index 0d80e7c..5695672 100644 --- a/RunLog/RunLog/Sources/Util/Extension/UIColor+.swift +++ b/RunLog/RunLog/Sources/Util/Extension/UIColor+.swift @@ -10,6 +10,7 @@ import UIKit // MARK: - Hex -> UIColor extension UIColor { + /// hex값과 alpha값을 전달 받아 hex값에 맞는 색상으로 초기화합니다. convenience init(hex: String, alpha: CGFloat = 1.0) { var hexFormatted: String = hex.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).uppercased() if hexFormatted.hasPrefix("#") {