Skip to content

Commit 7a7e162

Browse files
committed
Merge weather and resolve conflicts
2 parents 949a971 + 96ce389 commit 7a7e162

5 files changed

Lines changed: 171 additions & 5 deletions

File tree

‎Sources/VoidBar/Model/NotchViewModel.swift‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Combine
44
@MainActor
55
final class NotchViewModel: ObservableObject {
66
enum Tab: String, CaseIterable, Identifiable {
7-
case media, shelf, clipboard, snippets, calendar, timer, translate, notes, teleprompter, monitor
7+
case media, shelf, clipboard, snippets, calendar, timer, translate, notes, teleprompter, monitor, weather
88
var id: String { rawValue }
99

1010
var symbol: String {
@@ -19,6 +19,7 @@ final class NotchViewModel: ObservableObject {
1919
case .notes: return "note.text"
2020
case .teleprompter: return "text.line.first.and.arrowtriangle.forward"
2121
case .monitor: return "cpu"
22+
case .weather: return "cloud.sun"
2223
}
2324
}
2425

@@ -34,6 +35,7 @@ final class NotchViewModel: ObservableObject {
3435
case .notes: return localized("Notes")
3536
case .teleprompter: return localized("Teleprompter")
3637
case .monitor: return localized("Monitor")
38+
case .weather: return localized("Weather")
3739
}
3840
}
3941

@@ -46,7 +48,7 @@ final class NotchViewModel: ObservableObject {
4648
/// body has — so growth continues in a second column on the right,
4749
/// which the scratch notes open.
4850
static let leftRail: [Tab] = [.media, .shelf, .clipboard, .snippets, .calendar, .timer, .translate]
49-
static let rightRail: [Tab] = [.notes, .teleprompter, .monitor]
51+
static let rightRail: [Tab] = [.notes, .teleprompter, .monitor, .weather]
5052
}
5153

5254
@Published var isOpen = false {
@@ -96,6 +98,7 @@ final class NotchViewModel: ObservableObject {
9698
let notes: NoteStore
9799
let teleprompter: TeleprompterStore
98100
let monitor: SystemMonitorStore
101+
let weather: WeatherStore
99102

100103
private var cancellables = Set<AnyCancellable>()
101104

@@ -111,6 +114,7 @@ final class NotchViewModel: ObservableObject {
111114
self.notes = NoteStore()
112115
self.teleprompter = TeleprompterStore()
113116
self.monitor = SystemMonitorStore()
117+
self.weather = WeatherStore()
114118

115119
// The panel header reads through to the stores — counters, the source
116120
// name, the equalizer. Nested ObservableObjects do not propagate on
@@ -137,7 +141,8 @@ final class NotchViewModel: ObservableObject {
137141
clipboard.objectWillChange,
138142
calendar.objectWillChange,
139143
timer.objectWillChange,
140-
monitor.objectWillChange
144+
monitor.objectWillChange,
145+
weather.objectWillChange
141146
] {
142147
child
143148
.sink { [weak self] _ in
@@ -180,6 +185,7 @@ final class NotchViewModel: ObservableObject {
180185
// never prompts on its own.
181186
calendar.start()
182187
monitor.start()
188+
weather.start()
183189

184190
// Screenshots reach the shelf through here whether they were taken on
185191
// this Mac or on a phone: a copy made on the phone arrives in the same
@@ -203,6 +209,7 @@ final class NotchViewModel: ObservableObject {
203209
clipboard.stop()
204210
calendar.stop()
205211
monitor.stop()
212+
weather.stop()
206213
// Whatever was typed makes it to disk even when quitting mid-thought.
207214
notes.flush()
208215
teleprompter.flush()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Foundation
2+
import Combine
3+
4+
struct WeatherData: Codable {
5+
let temperature: Double
6+
let condition: Int
7+
let locationName: String?
8+
}
9+
10+
@MainActor
11+
final class WeatherStore: ObservableObject {
12+
@Published var weather: WeatherData?
13+
@Published var error: String?
14+
15+
private var timer: Timer?
16+
17+
func start() {
18+
stop()
19+
// Poll every 30 minutes
20+
timer = Timer.scheduledTimer(withTimeInterval: 1800, repeats: true) { [weak self] _ in
21+
Task { @MainActor in
22+
await self?.fetchWeather()
23+
}
24+
}
25+
Task {
26+
await fetchWeather()
27+
}
28+
}
29+
30+
func stop() {
31+
timer?.invalidate()
32+
timer = nil
33+
}
34+
35+
private func fetchWeather() async {
36+
do {
37+
// Get location via IP
38+
guard let locationUrl = URL(string: "https://ipapi.co/json/") else { return }
39+
let (locationData, _) = try await URLSession.shared.data(from: locationUrl)
40+
41+
struct IPResponse: Decodable {
42+
let latitude: Double
43+
let longitude: Double
44+
let city: String
45+
}
46+
let ipResponse = try JSONDecoder().decode(IPResponse.self, from: locationData)
47+
48+
// Get weather from Open-Meteo
49+
let weatherUrlString = "https://api.open-meteo.com/v1/forecast?latitude=\(ipResponse.latitude)&longitude=\(ipResponse.longitude)&current_weather=true"
50+
guard let weatherUrl = URL(string: weatherUrlString) else { return }
51+
52+
let (weatherJsonData, _) = try await URLSession.shared.data(from: weatherUrl)
53+
54+
struct MeteoResponse: Decodable {
55+
struct CurrentWeather: Decodable {
56+
let temperature: Double
57+
let weathercode: Int
58+
}
59+
let current_weather: CurrentWeather
60+
}
61+
let meteoResponse = try JSONDecoder().decode(MeteoResponse.self, from: weatherJsonData)
62+
63+
self.weather = WeatherData(
64+
temperature: meteoResponse.current_weather.temperature,
65+
condition: meteoResponse.current_weather.weathercode,
66+
locationName: ipResponse.city
67+
)
68+
self.error = nil
69+
} catch {
70+
self.error = error.localizedDescription
71+
}
72+
}
73+
}

‎Sources/VoidBar/UI/DynamicIslandView.swift‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ struct DynamicIslandView: View {
1717
timerPill
1818
} else if vm.media.isPlaying {
1919
mediaPill
20+
} else if let weather = vm.weather.weather {
21+
weatherPill(weather)
2022
}
2123
}
22-
.frame(width: notchWidth, height: notchHeight)
23-
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: vm.media.isPlaying || vm.timer.state == .running)
24+
.frame(width: notchWidth, height: notchHeight) // Center aligns with notch
25+
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: vm.media.isPlaying || vm.timer.state == .running || vm.weather.weather != nil)
2426
}
2527

2628
@ViewBuilder
@@ -116,4 +118,36 @@ struct DynamicIslandView: View {
116118
let secs = Int(seconds) % 60
117119
return String(format: "%d:%02d", mins, secs)
118120
}
121+
122+
@ViewBuilder
123+
private func weatherPill(_ weather: WeatherData) -> some View {
124+
if vm.geometry.isPhysical {
125+
// Right Wing (Weather)
126+
HStack {
127+
Text(String(format: "%.0f°", weather.temperature))
128+
.font(.system(size: 11, weight: .semibold))
129+
.foregroundColor(Color.white)
130+
.padding(.leading, 12)
131+
Spacer()
132+
}
133+
.frame(width: 44, height: notchHeight)
134+
.background(Color.black)
135+
.clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous))
136+
.offset(x: notchWidth / 2 + 22 - 8)
137+
} else {
138+
// Unified Pill
139+
HStack(spacing: 4) {
140+
Image(systemName: "cloud.sun.fill")
141+
.font(.system(size: 10, weight: .medium))
142+
.foregroundColor(.white)
143+
Text(String(format: "%.0f°", weather.temperature))
144+
.font(.system(size: 11, weight: .semibold))
145+
.foregroundColor(Color.white)
146+
}
147+
.padding(.horizontal, 12)
148+
.frame(height: notchHeight)
149+
.background(Color.black)
150+
.clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous))
151+
}
152+
}
119153
}

‎Sources/VoidBar/UI/NotchContentView.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ struct NotchContentView: View {
112112
EmptyView()
113113
case .monitor:
114114
EmptyView()
115+
case .weather:
116+
EmptyView()
115117
}
116118
}
117119

@@ -179,6 +181,8 @@ struct NotchContentView: View {
179181
TeleprompterPane(store: vm.teleprompter)
180182
case .monitor:
181183
MonitorPane(monitor: vm.monitor)
184+
case .weather:
185+
WeatherPane(weatherStore: vm.weather)
182186
}
183187
}
184188
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import SwiftUI
2+
3+
struct WeatherPane: View {
4+
@ObservedObject var weatherStore: WeatherStore
5+
6+
var body: some View {
7+
VStack(spacing: 8) {
8+
if let error = weatherStore.error {
9+
Text(error)
10+
.font(.system(size: 11, weight: .medium))
11+
.foregroundStyle(Color.red)
12+
.multilineTextAlignment(.center)
13+
} else if let weather = weatherStore.weather {
14+
Image(systemName: icon(for: weather.condition))
15+
.font(.system(size: 36, weight: .light))
16+
.foregroundStyle(.white)
17+
18+
Text(String(format: "%.1f°", weather.temperature))
19+
.font(.system(size: 24, weight: .semibold))
20+
.foregroundStyle(.white)
21+
22+
if let location = weather.locationName {
23+
Text(location)
24+
.font(.system(size: 11, weight: .medium))
25+
.foregroundStyle(Theme.secondary)
26+
}
27+
} else {
28+
ProgressView()
29+
.controlSize(.small)
30+
}
31+
}
32+
.frame(maxWidth: .infinity, maxHeight: .infinity)
33+
.padding(.vertical, 16)
34+
}
35+
36+
private func icon(for code: Int) -> String {
37+
switch code {
38+
case 0: return "sun.max.fill"
39+
case 1...3: return "cloud.sun.fill"
40+
case 45, 48: return "cloud.fog.fill"
41+
case 51...67: return "cloud.drizzle.fill"
42+
case 71...77: return "cloud.snow.fill"
43+
case 80...82: return "cloud.heavyrain.fill"
44+
case 95...99: return "cloud.bolt.rain.fill"
45+
default: return "cloud.fill"
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)