From 461c5160164437d2b12469e832f06e5998ddb3ea Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Sun, 9 Aug 2026 11:10:09 -0400 Subject: [PATCH] chore: initial ui wiring, swiftlint Signed-off-by: Alan Brault --- .../TempestasDomain/Enums/WeatherCode.swift | 31 ++++++++++--------- .../TempestasDomain/Types/DateOnly.swift | 5 ++- .../TempestasDomainTests.swift | 2 +- .../Clients/OpenMeteoApiClient.swift | 12 +++++-- .../Clients/WeatherApiClient.swift | 1 + .../DefaultForecastRepository.swift | 2 +- .../OpenMeteoDateDecodingTests.swift | 4 +-- .../TempestasInfrastructureTests.swift | 2 +- .../WeatherResponseRecordMapperTests.swift | 7 +++-- .../ViewModels/MainViewModel.swift | 17 ++++++++++ .../Views/MainView.swift | 30 +++++++++++++++--- .../Views/MenuBarView.swift | 25 +++++++++++++++ .../WeatherCode+SFSymbol.swift | 22 +++++++++++++ .../TempestasPresentationTests.swift | 2 +- Tempestas.xcodeproj/project.pbxproj | 2 ++ Tempestas/TempestasApp.swift | 10 +++--- TempestasTests/TempestasTests.swift | 4 +-- TempestasUITests/TempestasUITests.swift | 1 - .../TempestasUITestsLaunchTests.swift | 3 +- 19 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MenuBarView.swift create mode 100644 Packages/TempestasPresentation/Sources/TempestasPresentation/WeatherCode+SFSymbol.swift diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift index 3fc83e3..9803554 100644 --- a/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Enums/WeatherCode.swift @@ -16,22 +16,23 @@ public enum WeatherCode: Sendable, Hashable, Decodable { case thunderstormWithHail case unknown(code: Int) + private static let codeMapping: [Int: WeatherCode] = [ + 0: .clearSky, + 1: .mainlyClear, + 2: .partlyCloudy, + 3: .overcast, + 45: .fog, 48: .fog, + 51: .drizzle, 53: .drizzle, 55: .drizzle, 56: .drizzle, 57: .drizzle, + 61: .rain, 63: .rain, 65: .rain, + 66: .freezingRain, 67: .freezingRain, + 71: .snow, 73: .snow, 75: .snow, 755: .snow, + 85: .snowShowers, 86: .snowShowers, + 95: .thunderstorm, + 96: .thunderstormWithHail, 99: .thunderstormWithHail + ] + public static func from(code: Int) -> WeatherCode { - switch code { - case 0: .clearSky - case 1: .mainlyClear - case 2: .partlyCloudy - case 3: .overcast - case 45, 48: .fog - case 51, 53, 55, 56, 57: .drizzle - case 61, 63, 65: .rain - case 66, 67: .freezingRain - case 71, 73, 75, 755: .snow - case 85, 86: .snowShowers - case 95: .thunderstorm - case 96, 99: .thunderstormWithHail - default: .unknown(code: code) - } + codeMapping[code] ?? .unknown(code: code) } public init(from decoder: Decoder) throws { diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift index bea87ec..7d3e1b7 100644 --- a/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Types/DateOnly.swift @@ -16,7 +16,10 @@ public struct DateOnly: Sendable, Hashable, Comparable, Codable { calendar.timeZone = timeZone let components = calendar.dateComponents([.year, .month, .day], from: date) - self.init(year: components.year!, month: components.month!, day: components.day!) + guard let year = components.year, let month = components.month, let day = components.day else { + preconditionFailure("Calendar failed to compute year/month/day components") + } + self.init(year: year, month: month, day: day) } public static func < (lhs: DateOnly, rhs: DateOnly) -> Bool { diff --git a/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift b/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift index f7ed038..517bb21 100644 --- a/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift +++ b/Packages/TempestasDomain/Tests/TempestasDomainTests/TempestasDomainTests.swift @@ -1,5 +1,5 @@ -import Testing @testable import TempestasDomain +import Testing @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift index 66b82c1..e05d0e5 100644 --- a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/OpenMeteoApiClient.swift @@ -51,7 +51,9 @@ struct OpenMeteoApiClient: WeatherApiClient { } func request(latitude: Double, longitude: Double) async throws -> WeatherApiResponseContract? { - var components = URLComponents(url: Self.baseURL, resolvingAgainstBaseURL: false)! + guard var components = URLComponents(url: Self.baseURL, resolvingAgainstBaseURL: false) else { + throw WeatherApiClientError.invalidURL + } components.queryItems = [ URLQueryItem(name: "latitude", value: String(latitude)), URLQueryItem(name: "longitude", value: String(longitude)), @@ -62,13 +64,17 @@ struct OpenMeteoApiClient: WeatherApiClient { URLQueryItem(name: "precipitation_unit", value: precipitationUnit) ] - let (data, response) = try await session.data(from: components.url!) + guard let url = components.url else { + throw WeatherApiClientError.invalidURL + } + + let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { return nil } - + do { return try decoder.decode(WeatherApiResponseContract.self, from: data) } catch let error as DecodingError { diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift index 70eef39..094b55d 100644 --- a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Clients/WeatherApiClient.swift @@ -3,5 +3,6 @@ protocol WeatherApiClient: Sendable { } enum WeatherApiClientError: Error { + case invalidURL case decodingFailed(DecodingError) } diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift index cb32505..09113f5 100644 --- a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Repositories/DefaultForecastRepository.swift @@ -41,7 +41,7 @@ public actor DefaultForecastRepository: ForecastRepository { let cutoff = Date().addingTimeInterval(-Self.cacheDuration) return records - .max(by: { $0.fetchedAt < $1.fetchedAt }) + .max { $0.fetchedAt < $1.fetchedAt } .flatMap { $0.fetchedAt >= cutoff ? $0 : nil } } diff --git a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift index e6de459..a442391 100644 --- a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift +++ b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/OpenMeteoDateDecodingTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import TempestasInfrastructure +import Testing @Suite struct OpenMeteoDateDecodingTests { private static let json = """ @@ -27,7 +27,7 @@ import Testing """ @Test func decodesTruncatedIso8601SunriseInRequestedTimeZone() throws { - let timeZone = TimeZone(identifier: "Asia/Tokyo")! + let timeZone = try #require(TimeZone(identifier: "Asia/Tokyo")) let decoder = OpenMeteoDateDecoding.decoder(timeZone: timeZone) let contract = try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8)) diff --git a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/TempestasInfrastructureTests.swift b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/TempestasInfrastructureTests.swift index e703142..0ee51a4 100644 --- a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/TempestasInfrastructureTests.swift +++ b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/TempestasInfrastructureTests.swift @@ -1,5 +1,5 @@ -import Testing @testable import TempestasInfrastructure +import Testing @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. diff --git a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift index 26ac8de..40850f8 100644 --- a/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift +++ b/Packages/TempestasInfrastructure/Tests/TempestasInfrastructureTests/WeatherResponseRecordMapperTests.swift @@ -1,7 +1,7 @@ import Foundation import SwiftData -import Testing @testable import TempestasInfrastructure +import Testing @Suite struct WeatherResponseRecordMapperTests { private static let json = """ @@ -28,7 +28,8 @@ import Testing """ private func makeContract() throws -> WeatherApiResponseContract { - let decoder = OpenMeteoDateDecoding.decoder(timeZone: TimeZone(identifier: "America/Toronto")!) + let timeZone = try #require(TimeZone(identifier: "America/Toronto")) + let decoder = OpenMeteoDateDecoding.decoder(timeZone: timeZone) return try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8)) } @@ -57,7 +58,7 @@ import Testing #expect(fetched.count == 1) #expect(fetched.first?.dailyForecasts.count == 2) - let firstDay = fetched.first?.dailyForecasts.sorted { $0.day < $1.day }.first + let firstDay = fetched.first?.dailyForecasts.min { $0.day < $1.day } #expect(firstDay?.year == 2026) #expect(firstDay?.month == 8) #expect(firstDay?.day == 5) diff --git a/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift b/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift index 0f3254b..80e4591 100644 --- a/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift +++ b/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift @@ -44,4 +44,21 @@ public final class MainViewModel { loadError = error } } + + public var menuBarSystemImage: String { + forecast?.weatherCode.systemImageName ?? "questionmark.circle" + } + + public var menuBarTitle: String? { + guard let temperature = forecast?.temperature else { return nil } + + let unit: UnitTemperature = Locale.current.measurementSystem == .metric ? .celsius : .fahrenheit + let measurement = Measurement(value: temperature, unit: unit) + + let formatter = MeasurementFormatter() + formatter.unitOptions = .providedUnit + formatter.numberFormatter.maximumFractionDigits = 0 + + return formatter.string(from: measurement) + } } diff --git a/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift b/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift index dadc899..04a662b 100644 --- a/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift +++ b/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI public struct MainView: View { @@ -10,10 +11,31 @@ public struct MainView: View { } public var body: some View { - NavigationStack {} - .frame(width: 320, height: 400) - .task { - await viewModel.load() + VStack(spacing: 0) { + HStack { + Button { + Task { + await viewModel.load(forceRefresh: true) + } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + .buttonStyle(.borderless) + + Spacer() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.borderless) } + .padding(8) + + Divider() + + NavigationStack {} + } + .frame(width: 320, height: 400) } } diff --git a/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MenuBarView.swift b/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MenuBarView.swift new file mode 100644 index 0000000..2853778 --- /dev/null +++ b/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MenuBarView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct MenuBarView: View { + private let viewModel: MainViewModel + + public init( + viewModel: MainViewModel + ) { + self.viewModel = viewModel + } + + public var body: some View { + Group { + if let title = viewModel.menuBarTitle { + Label(title, systemImage: viewModel.menuBarSystemImage) + .labelStyle(.titleAndIcon) + } else { + Image(systemName: viewModel.menuBarSystemImage) + } + } + .task { + await viewModel.load() + } + } +} diff --git a/Packages/TempestasPresentation/Sources/TempestasPresentation/WeatherCode+SFSymbol.swift b/Packages/TempestasPresentation/Sources/TempestasPresentation/WeatherCode+SFSymbol.swift new file mode 100644 index 0000000..db28ac5 --- /dev/null +++ b/Packages/TempestasPresentation/Sources/TempestasPresentation/WeatherCode+SFSymbol.swift @@ -0,0 +1,22 @@ +import TempestasDomain + +extension WeatherCode { + var systemImageName: String { + switch self { + case .clearSky: "sun.max.fill" + case .mainlyClear: "sun.min.fill" + case .partlyCloudy: "cloud.sun.fill" + case .overcast: "cloud.fill" + case .fog: "cloud.fog.fill" + case .drizzle: "cloud.drizzle.fill" + case .rain: "cloud.rain.fill" + case .freezingRain: "cloud.sleet.fill" + case .rainShowers: "cloud.heavyrain.fill" + case .snow: "cloud.snow.fill" + case .snowShowers: "cloud.snow.fill" + case .thunderstorm: "cloud.bolt.fill" + case .thunderstormWithHail: "cloud.bolt.rain.fill" + case .unknown: "questionmark.circle" + } + } +} diff --git a/Packages/TempestasPresentation/Tests/TempestasPresentationTests/TempestasPresentationTests.swift b/Packages/TempestasPresentation/Tests/TempestasPresentationTests/TempestasPresentationTests.swift index 6a0ac39..a8b01d8 100644 --- a/Packages/TempestasPresentation/Tests/TempestasPresentationTests/TempestasPresentationTests.swift +++ b/Packages/TempestasPresentation/Tests/TempestasPresentationTests/TempestasPresentationTests.swift @@ -1,5 +1,5 @@ -import Testing @testable import TempestasPresentation +import Testing @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. diff --git a/Tempestas.xcodeproj/project.pbxproj b/Tempestas.xcodeproj/project.pbxproj index 31d1563..fbd0c9c 100644 --- a/Tempestas.xcodeproj/project.pbxproj +++ b/Tempestas.xcodeproj/project.pbxproj @@ -461,6 +461,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions."; LD_RUNPATH_SEARCH_PATHS = ( @@ -504,6 +505,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions."; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Tempestas/TempestasApp.swift b/Tempestas/TempestasApp.swift index b75da7e..a6c2aad 100644 --- a/Tempestas/TempestasApp.swift +++ b/Tempestas/TempestasApp.swift @@ -1,23 +1,25 @@ -import SwiftUI import SwiftData +import SwiftUI import TempestasPresentation @main struct TempestasApp: App { private let composition: AppComposition @State private var mainViewModel: MainViewModel - + init() { self.composition = AppComposition() - + _mainViewModel = State(initialValue: composition.makeMainViewModel()) } var body: some Scene { - MenuBarExtra("Tempestas", systemImage: "cloud.sun") { + MenuBarExtra { MainView( viewModel: mainViewModel ) + } label: { + MenuBarView(viewModel: mainViewModel) } .menuBarExtraStyle(.window) } diff --git a/TempestasTests/TempestasTests.swift b/TempestasTests/TempestasTests.swift index 3ac52df..74bac54 100644 --- a/TempestasTests/TempestasTests.swift +++ b/TempestasTests/TempestasTests.swift @@ -5,15 +5,13 @@ // Created by Alan Brault on 8/4/26. // -import Testing @testable import Tempestas +import Testing struct TempestasTests { - @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. // Swift Testing Documentation // https://developer.apple.com/documentation/testing } - } diff --git a/TempestasUITests/TempestasUITests.swift b/TempestasUITests/TempestasUITests.swift index e317e5e..455c83d 100644 --- a/TempestasUITests/TempestasUITests.swift +++ b/TempestasUITests/TempestasUITests.swift @@ -8,7 +8,6 @@ import XCTest final class TempestasUITests: XCTestCase { - override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. diff --git a/TempestasUITests/TempestasUITestsLaunchTests.swift b/TempestasUITests/TempestasUITestsLaunchTests.swift index f0aa7be..7a2b8cb 100644 --- a/TempestasUITests/TempestasUITestsLaunchTests.swift +++ b/TempestasUITests/TempestasUITestsLaunchTests.swift @@ -8,8 +8,7 @@ import XCTest final class TempestasUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { + override static var runsForEachTargetApplicationUIConfiguration: Bool { true }