From c905e670876c099dddc5c3570612923599380f34 Mon Sep 17 00:00:00 2001 From: Pawel Milek Date: Wed, 27 Mar 2024 19:20:42 -0500 Subject: [PATCH] Create current temperature field Bump version number Clean up code --- SwiftyForecast.xcodeproj/project.pbxproj | 14 +- .../LocationWeather/View/LocationList.swift | 7 +- .../LocationWeather/View/LocationRow.swift | 168 ++++++++++++++---- .../MKCoordinateRegion+Equtable.swift | 16 ++ SwiftyForecast/Shared/ThemeStyle/Style.swift | 9 +- 5 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 SwiftyForecast/Shared/Extensions/MKCoordinateRegion+Equtable.swift diff --git a/SwiftyForecast.xcodeproj/project.pbxproj b/SwiftyForecast.xcodeproj/project.pbxproj index 8b5dcd76..584d272d 100644 --- a/SwiftyForecast.xcodeproj/project.pbxproj +++ b/SwiftyForecast.xcodeproj/project.pbxproj @@ -237,6 +237,8 @@ E2D430F22BB4DC1C003F26EE /* HourlyForecastChartDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D430AB2BB4D954003F26EE /* HourlyForecastChartDataSource.swift */; }; E2D430F32BB4DC24003F26EE /* BackgroundGroupBoxStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D430AD2BB4D954003F26EE /* BackgroundGroupBoxStyle.swift */; }; E2D430F42BB4DC30003F26EE /* MotionAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E224506F2B9F3F9800F14BAA /* MotionAnimationView.swift */; }; + E2D430F62BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D430F52BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift */; }; + E2D430F72BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D430F52BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift */; }; E2D960812120F34800AE0B6E /* Geocoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D960802120F34800AE0B6E /* Geocoder.swift */; }; E2DC1458248D2F8700D1A329 /* InvalidReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DC1457248D2F8700D1A329 /* InvalidReference.swift */; }; E2DC1466248D493700D1A329 /* JSONFileLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DC1465248D493700D1A329 /* JSONFileLoader.swift */; }; @@ -463,6 +465,7 @@ E2D430B82BB4D954003F26EE /* LocationWeatherView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationWeatherView.swift; sourceTree = ""; }; E2D430B92BB4D954003F26EE /* LocationWeatherViewViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationWeatherViewViewModel.swift; sourceTree = ""; }; E2D430BB2BB4D954003F26EE /* LocationWeatherViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocationWeatherViewModel.swift; sourceTree = ""; }; + E2D430F52BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MKCoordinateRegion+Equtable.swift"; sourceTree = ""; }; E2D960802120F34800AE0B6E /* Geocoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Geocoder.swift; sourceTree = ""; }; E2DC1457248D2F8700D1A329 /* InvalidReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidReference.swift; sourceTree = ""; }; E2DC1465248D493700D1A329 /* JSONFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONFileLoader.swift; sourceTree = ""; }; @@ -712,6 +715,7 @@ E2DC1474248F221E00D1A329 /* UIViewController+Containment.swift */, E2DC148A248F24E800D1A329 /* UIViewController+MakeFromStoryboard.swift */, E2B1D655211E55F000F0002A /* UIViewController+StoryboardIdentifiable.swift */, + E2D430F52BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift */, ); path = Extensions; sourceTree = ""; @@ -1612,6 +1616,7 @@ E290F24D2ADD8BDE001B3276 /* SpeedFormatterMetric.swift in Sources */, E2DC1458248D2F8700D1A329 /* InvalidReference.swift in Sources */, E28CEC0F2B3EB775003DB3A6 /* LocationsTip.swift in Sources */, + E2D430F62BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift in Sources */, E2D430BF2BB4D954003F26EE /* BackgroundGroupBoxStyle.swift in Sources */, E270891C24CE5AEC003F7587 /* Coordinator.swift in Sources */, E2A5675D2B077AC9000324C0 /* LottieAnimationViewController.swift in Sources */, @@ -1660,6 +1665,7 @@ E2AD326A2B23774400F46FBE /* URLRequest+Parameters.swift in Sources */, E2CFA7AB2B309E63001DE3E8 /* MockModelGenerator.swift in Sources */, E2D430E82BB4DB94003F26EE /* SpeedFormatterMetric.swift in Sources */, + E2D430F72BB4EBB9003F26EE /* MKCoordinateRegion+Equtable.swift in Sources */, E2D430E72BB4DB89003F26EE /* SpeedValueDisplayable.swift in Sources */, E2F671232B23682F00B791A0 /* WeatherProvider.swift in Sources */, E2D430EE2BB4DC04003F26EE /* PathFinder.swift in Sources */, @@ -1901,7 +1907,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.5.7; + MARKETING_VERSION = 3.6.0; PRODUCT_BUNDLE_IDENTIFIER = "com.pawelmilek.Swifty-Forecast"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1935,7 +1941,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.5.7; + MARKETING_VERSION = 3.6.0; PRODUCT_BUNDLE_IDENTIFIER = "com.pawelmilek.Swifty-Forecast"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2045,7 +2051,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.5.7; + MARKETING_VERSION = 3.6.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.pawelmilek.Swifty-Forecast.widget"; @@ -2092,7 +2098,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.5.7; + MARKETING_VERSION = 3.6.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.pawelmilek.Swifty-Forecast.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationList.swift b/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationList.swift index 6d1a7f3d..95481262 100644 --- a/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationList.swift +++ b/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationList.swift @@ -28,7 +28,12 @@ struct LocationList: View { .tint(Color(.customPrimary)) .listRowSeparator(.hidden) ForEach(locations) { location in - LocationRow(item: location) + LocationRow(viewModel: LocationRowViewModel( + location: location, + service: WeatherService(), + temperatureRenderer: TemperatureRenderer(), + measurementSystemNotification: MeasurementSystemNotification() + )) .deleteDisabled(location.isUserLocation) .onTapGesture { onSelectRow(location) diff --git a/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationRow.swift b/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationRow.swift index c294a871..e2ad2404 100644 --- a/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationRow.swift +++ b/SwiftyForecast/Scenes/LocationSearch/LocationWeather/View/LocationRow.swift @@ -8,10 +8,116 @@ import SwiftUI import MapKit +import Combine + +@MainActor +final class LocationRowViewModel: ObservableObject { + @Published private(set) var error: Error? + @Published private(set) var isLoading = false + @Published private(set) var locationName = "" + @Published private(set) var temperature = "" + @Published private(set) var localTime = "" + @Published private(set) var name = "" + @Published private(set) var region = MKCoordinateRegion() + @Published private(set) var temperatureValue: TemperatureValue? + @Published private(set) var location: LocationModel? + + private let service: WeatherServiceProtocol + private let measurementSystemNotification: MeasurementSystemNotification + private let temperatureRenderer: TemperatureRenderer + private var cancellables = Set() + + init( + location: LocationModel, + service: WeatherServiceProtocol, + temperatureRenderer: TemperatureRenderer, + measurementSystemNotification: MeasurementSystemNotification) { + self.service = service + self.temperatureRenderer = temperatureRenderer + self.measurementSystemNotification = measurementSystemNotification + + subscribeToPublisher() + registerMeasurementSystemObserver() + self.location = location + } + + func loadData(at locationModel: LocationModel) { + guard !isLoading else { return } + isLoading = true + + locationName = locationModel.name + let latitude = locationModel.latitude + let longitude = locationModel.longitude + + Task(priority: .userInitiated) { + do { + let currentResponse = try await service.fetchCurrent( + latitude: latitude, + longitude: longitude + ) + let model = ResponseParser.parse(current: currentResponse) + temperatureValue = model.temperatureValue + isLoading = false + } catch { + self.error = error + temperatureValue = nil + isLoading = false + } + } + } + + private func subscribeToPublisher() { + $location + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [self] location in + localTime = Date.timeOnly(from: location.secondsFromGMT) + name = location.name + ", " + location.country + + let annotation = MKPointAnnotation() + annotation.subtitle = "\(location.name) \(location.state)" + annotation.coordinate = CLLocationCoordinate2D( + latitude: location.latitude, + longitude: location.longitude + ) + + let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + region = MKCoordinateRegion(center: annotation.coordinate, span: span) + loadData(at: location) + } + .store(in: &cancellables) + + $temperatureValue + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [self] temperatureValue in + setTemperatureAccordingToUnitNotation(value: temperatureValue) + } + .store(in: &cancellables) + } + + private func registerMeasurementSystemObserver() { + measurementSystemNotification.addObserver( + self, + selector: #selector(measurementSystemChanged) + ) + } + + @objc + private func measurementSystemChanged() { + guard let temperatureValue else { return } + setTemperatureAccordingToUnitNotation(value: temperatureValue) + } + + private func setTemperatureAccordingToUnitNotation(value: TemperatureValue) { + let rendered = temperatureRenderer.render(value) + temperature = rendered.currentFormatted + } +} struct LocationRow: View { - let item: LocationModel @State private var position: MapCameraPosition = .automatic + @ObservedObject var viewModel: LocationRowViewModel var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -35,8 +141,8 @@ struct LocationRow: View { .frame(minHeight: 145, maxHeight: 145) .fixedSize(horizontal: false, vertical: true) .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - .onAppear { - position = .region(region) + .onChange(of: viewModel.region, initial: false) { + position = .region(viewModel.region) } } } @@ -44,46 +150,42 @@ struct LocationRow: View { private extension LocationRow { var headerView: some View { - VStack(alignment: .leading, spacing: 0) { - Text(localTime) - .font(Style.LocationRow.timeFont) - .foregroundStyle(.customPrimary) - Text(name) - .font(Style.LocationRow.nameFont) - .foregroundStyle(Style.LocationRow.nameColor) + HStack { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.localTime) + .font(Style.LocationRow.timeFont) + .foregroundStyle(.customPrimary) + Text(viewModel.name) + .font(Style.LocationRow.nameFont) + .foregroundStyle(Style.LocationRow.nameColor) + } + .frame(maxWidth: .infinity, alignment: .leading) + Text(viewModel.temperature) + .font(Style.LocationRow.tempFont) + .foregroundStyle(Style.LocationRow.tempColor) + .overlay { + ProgressView() + .tint(.customPrimary) + .opacity(viewModel.isLoading ? 1 : 0) + .animation(.easeOut, value: viewModel.isLoading) + } } } var mapView: some View { Map(position: $position, interactionModes: []) { - Marker(name, coordinate: region.center) + Marker(viewModel.name, coordinate: viewModel.region.center) .tint(.customPrimary) } .cornerRadius(Style.LocationRow.cornerRadius) } - - var localTime: String { - Date.timeOnly(from: item.secondsFromGMT) - } - - var name: String { - item.name + ", " + item.country - } - - var region: MKCoordinateRegion { - let annotation = MKPointAnnotation() - annotation.subtitle = "\(item.name) \(item.state)" - annotation.coordinate = CLLocationCoordinate2D( - latitude: item.latitude, - longitude: item.longitude - ) - - let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) - let region = MKCoordinateRegion(center: annotation.coordinate, span: span) - return region - } } #Preview(traits: .sizeThatFitsLayout) { - LocationRow(item: LocationModel.examples.first!) + LocationRow(viewModel: LocationRowViewModel( + location: LocationModel.examples.first!, + service: WeatherService(), + temperatureRenderer: TemperatureRenderer(), + measurementSystemNotification: MeasurementSystemNotification() + )) } diff --git a/SwiftyForecast/Shared/Extensions/MKCoordinateRegion+Equtable.swift b/SwiftyForecast/Shared/Extensions/MKCoordinateRegion+Equtable.swift new file mode 100644 index 00000000..ec33fbb0 --- /dev/null +++ b/SwiftyForecast/Shared/Extensions/MKCoordinateRegion+Equtable.swift @@ -0,0 +1,16 @@ +// +// MKCoordinateRegion+Equtable.swift +// SwiftyForecast +// +// Created by Pawel Milek on 3/27/24. +// Copyright © 2024 Pawel Milek. All rights reserved. +// + +import MapKit + +extension MKCoordinateRegion: Equatable { + public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool { + (lhs.center.latitude == rhs.center.latitude && lhs.center.longitude == rhs.center.longitude) && + (lhs.span.latitudeDelta == rhs.span.latitudeDelta && lhs.span.longitudeDelta == rhs.span.longitudeDelta) + } +} diff --git a/SwiftyForecast/Shared/ThemeStyle/Style.swift b/SwiftyForecast/Shared/ThemeStyle/Style.swift index 35cc15a8..02a913bf 100644 --- a/SwiftyForecast/Shared/ThemeStyle/Style.swift +++ b/SwiftyForecast/Shared/ThemeStyle/Style.swift @@ -97,12 +97,13 @@ struct Style { // MARK: - LocationRow struct LocationRow { static let backgroundColor = UIColor.clear - static let timeFont = Font.system(.subheadline, design: .monospaced, weight: .semibold) - static let timeAlignment = NSTextAlignment.left + static let timeFont = Font.system(.subheadline, design: .monospaced, weight: .bold) - static let nameFont = Font.system(.subheadline, design: .monospaced, weight: .semibold) + static let nameFont = Font.system(.subheadline, design: .monospaced, weight: .bold) static let nameColor = Color(.accent) - static let locationNameAlignment = NSTextAlignment.left + + static let tempFont = Font.system(.title2, design: .monospaced, weight: .heavy) + static let tempColor = Color(.customPrimary) static let cornerRadius = CGFloat(15) static let borderColor = Color(.shadow)