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,7 +64,11 @@ 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 {
@@ -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.
+2
View File
@@ -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 = (
+4 -2
View File
@@ -1,5 +1,5 @@
import SwiftUI
import SwiftData
import SwiftUI
import TempestasPresentation
@main
@@ -14,10 +14,12 @@ struct TempestasApp: App {
}
var body: some Scene {
MenuBarExtra("Tempestas", systemImage: "cloud.sun") {
MenuBarExtra {
MainView(
viewModel: mainViewModel
)
} label: {
MenuBarView(viewModel: mainViewModel)
}
.menuBarExtraStyle(.window)
}
+1 -3
View File
@@ -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
}
}
-1
View File
@@ -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.
@@ -8,8 +8,7 @@
import XCTest
final class TempestasUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
override static var runsForEachTargetApplicationUIConfiguration: Bool {
true
}