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 thunderstormWithHail
|
||||||
case unknown(code: Int)
|
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 {
|
public static func from(code: Int) -> WeatherCode {
|
||||||
switch code {
|
codeMapping[code] ?? .unknown(code: 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ public struct DateOnly: Sendable, Hashable, Comparable, Codable {
|
|||||||
calendar.timeZone = timeZone
|
calendar.timeZone = timeZone
|
||||||
|
|
||||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
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 {
|
public static func < (lhs: DateOnly, rhs: DateOnly) -> Bool {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Testing
|
|
||||||
@testable import TempestasDomain
|
@testable import TempestasDomain
|
||||||
|
import Testing
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func example() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
|||||||
+9
-3
@@ -51,7 +51,9 @@ struct OpenMeteoApiClient: WeatherApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func request(latitude: Double, longitude: Double) async throws -> WeatherApiResponseContract? {
|
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 = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "latitude", value: String(latitude)),
|
URLQueryItem(name: "latitude", value: String(latitude)),
|
||||||
URLQueryItem(name: "longitude", value: String(longitude)),
|
URLQueryItem(name: "longitude", value: String(longitude)),
|
||||||
@@ -62,13 +64,17 @@ struct OpenMeteoApiClient: WeatherApiClient {
|
|||||||
URLQueryItem(name: "precipitation_unit", value: precipitationUnit)
|
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,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
(200...299).contains(httpResponse.statusCode) else {
|
(200...299).contains(httpResponse.statusCode) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try decoder.decode(WeatherApiResponseContract.self, from: data)
|
return try decoder.decode(WeatherApiResponseContract.self, from: data)
|
||||||
} catch let error as DecodingError {
|
} catch let error as DecodingError {
|
||||||
|
|||||||
+1
@@ -3,5 +3,6 @@ protocol WeatherApiClient: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum WeatherApiClientError: Error {
|
enum WeatherApiClientError: Error {
|
||||||
|
case invalidURL
|
||||||
case decodingFailed(DecodingError)
|
case decodingFailed(DecodingError)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ public actor DefaultForecastRepository: ForecastRepository {
|
|||||||
let cutoff = Date().addingTimeInterval(-Self.cacheDuration)
|
let cutoff = Date().addingTimeInterval(-Self.cacheDuration)
|
||||||
|
|
||||||
return records
|
return records
|
||||||
.max(by: { $0.fetchedAt < $1.fetchedAt })
|
.max { $0.fetchedAt < $1.fetchedAt }
|
||||||
.flatMap { $0.fetchedAt >= cutoff ? $0 : nil }
|
.flatMap { $0.fetchedAt >= cutoff ? $0 : nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
|
||||||
@testable import TempestasInfrastructure
|
@testable import TempestasInfrastructure
|
||||||
|
import Testing
|
||||||
|
|
||||||
@Suite struct OpenMeteoDateDecodingTests {
|
@Suite struct OpenMeteoDateDecodingTests {
|
||||||
private static let json = """
|
private static let json = """
|
||||||
@@ -27,7 +27,7 @@ import Testing
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@Test func decodesTruncatedIso8601SunriseInRequestedTimeZone() throws {
|
@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 decoder = OpenMeteoDateDecoding.decoder(timeZone: timeZone)
|
||||||
let contract = try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8))
|
let contract = try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8))
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import Testing
|
|
||||||
@testable import TempestasInfrastructure
|
@testable import TempestasInfrastructure
|
||||||
|
import Testing
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func example() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
|||||||
+4
-3
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Testing
|
|
||||||
@testable import TempestasInfrastructure
|
@testable import TempestasInfrastructure
|
||||||
|
import Testing
|
||||||
|
|
||||||
@Suite struct WeatherResponseRecordMapperTests {
|
@Suite struct WeatherResponseRecordMapperTests {
|
||||||
private static let json = """
|
private static let json = """
|
||||||
@@ -28,7 +28,8 @@ import Testing
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
private func makeContract() throws -> WeatherApiResponseContract {
|
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))
|
return try decoder.decode(WeatherApiResponseContract.self, from: Data(Self.json.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ import Testing
|
|||||||
#expect(fetched.count == 1)
|
#expect(fetched.count == 1)
|
||||||
#expect(fetched.first?.dailyForecasts.count == 2)
|
#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?.year == 2026)
|
||||||
#expect(firstDay?.month == 8)
|
#expect(firstDay?.month == 8)
|
||||||
#expect(firstDay?.day == 5)
|
#expect(firstDay?.day == 5)
|
||||||
|
|||||||
+17
@@ -44,4 +44,21 @@ public final class MainViewModel {
|
|||||||
loadError = error
|
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
|
import SwiftUI
|
||||||
|
|
||||||
public struct MainView: View {
|
public struct MainView: View {
|
||||||
@@ -10,10 +11,31 @@ public struct MainView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
NavigationStack {}
|
VStack(spacing: 0) {
|
||||||
.frame(width: 320, height: 400)
|
HStack {
|
||||||
.task {
|
Button {
|
||||||
await viewModel.load()
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
@testable import TempestasPresentation
|
||||||
|
import Testing
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func example() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
|||||||
@@ -461,6 +461,7 @@
|
|||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSUIElement = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -504,6 +505,7 @@
|
|||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSUIElement = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import SwiftUI
|
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
import TempestasPresentation
|
import TempestasPresentation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct TempestasApp: App {
|
struct TempestasApp: App {
|
||||||
private let composition: AppComposition
|
private let composition: AppComposition
|
||||||
@State private var mainViewModel: MainViewModel
|
@State private var mainViewModel: MainViewModel
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.composition = AppComposition()
|
self.composition = AppComposition()
|
||||||
|
|
||||||
_mainViewModel = State(initialValue: composition.makeMainViewModel())
|
_mainViewModel = State(initialValue: composition.makeMainViewModel())
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
MenuBarExtra("Tempestas", systemImage: "cloud.sun") {
|
MenuBarExtra {
|
||||||
MainView(
|
MainView(
|
||||||
viewModel: mainViewModel
|
viewModel: mainViewModel
|
||||||
)
|
)
|
||||||
|
} label: {
|
||||||
|
MenuBarView(viewModel: mainViewModel)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.window)
|
.menuBarExtraStyle(.window)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,13 @@
|
|||||||
// Created by Alan Brault on 8/4/26.
|
// Created by Alan Brault on 8/4/26.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
|
||||||
@testable import Tempestas
|
@testable import Tempestas
|
||||||
|
import Testing
|
||||||
|
|
||||||
struct TempestasTests {
|
struct TempestasTests {
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func example() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
// Swift Testing Documentation
|
// Swift Testing Documentation
|
||||||
// https://developer.apple.com/documentation/testing
|
// https://developer.apple.com/documentation/testing
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class TempestasUITests: XCTestCase {
|
final class TempestasUITests: XCTestCase {
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class TempestasUITestsLaunchTests: XCTestCase {
|
final class TempestasUITestsLaunchTests: XCTestCase {
|
||||||
|
override static var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user