chore: initial ui wiring, swiftlint
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -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.
|
||||
|
||||
+8
-2
@@ -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 {
|
||||
|
||||
+1
@@ -3,5 +3,6 @@ protocol WeatherApiClient: Sendable {
|
||||
}
|
||||
|
||||
enum WeatherApiClientError: Error {
|
||||
case invalidURL
|
||||
case decodingFailed(DecodingError)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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.
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
+17
@@ -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 {
|
||||
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)
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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
-1
@@ -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.
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user