chore: initial ui wiring, swiftlint

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-08-09 11:10:09 -04:00
parent e1da561ff6
commit 461c516016
19 changed files with 140 additions and 42 deletions
@@ -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 {
@@ -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 {
@@ -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.
@@ -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 {
@@ -3,5 +3,6 @@ protocol WeatherApiClient: Sendable {
}
enum WeatherApiClientError: Error {
case invalidURL
case decodingFailed(DecodingError)
}
@@ -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 }
}
@@ -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))
@@ -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.
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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"
}
}
}
@@ -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.