From 67593a7778b44119818eea20940cf4896bafd28e Mon Sep 17 00:00:00 2001 From: Colin Date: Sat, 7 Mar 2026 01:17:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Weather=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E6=B8=85=E7=90=86=E6=97=A0=E6=95=88=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留天气功能最小必要改动,移除与功能无关的链式顺序噪音改动。 - 删除 WeatherTabView 中未使用的 hourly UI 路径。 - 删除 WeatherManager 中未使用的小时预报/日出日落数据链路与冗余模型字段。 - 移除 ImageService.swift 中无用导入,并收敛 Tab 标签文本为最小实现。 --- boringNotch.xcodeproj/project.pbxproj | 12 + boringNotch/ContentView.swift | 5 +- .../components/Notch/BoringHeader.swift | 17 +- .../components/Notch/WeatherTabView.swift | 463 ++++++++ .../components/Settings/SettingsView.swift | 5 + .../Settings/Views/WeatherSettingsView.swift | 99 ++ .../components/Tabs/TabSelectionView.swift | 41 +- boringNotch/enums/generic.swift | 1 + boringNotch/managers/WeatherManager.swift | 994 ++++++++++++++++++ boringNotch/models/Constants.swift | 37 + 10 files changed, 1667 insertions(+), 7 deletions(-) create mode 100644 boringNotch/components/Notch/WeatherTabView.swift create mode 100644 boringNotch/components/Settings/Views/WeatherSettingsView.swift create mode 100644 boringNotch/managers/WeatherManager.swift diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 0e90a4fd3..fbfbba07d 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -83,6 +83,9 @@ 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */; }; 11DB267C2EDD0CDF001EA0CF /* OSDSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266D2EDD0CDF001EA0CF /* OSDSettingsView.swift */; }; 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */; }; + 6A5F2C0C2F6A0001A1B2C3D4 /* WeatherSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5F2C0B2F6A0001A1B2C3D4 /* WeatherSettingsView.swift */; }; + 6A5F2C0E2F6A0001A1B2C3D4 /* WeatherManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5F2C0D2F6A0001A1B2C3D4 /* WeatherManager.swift */; }; + 6A5F2C102F6A0001A1B2C3D4 /* WeatherTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5F2C0F2F6A0001A1B2C3D4 /* WeatherTabView.swift */; }; 11EFCD702E8E92D600D0B974 /* ShelfItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */; }; 11F747CE2EC75CEA00F841DB /* DragPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */; }; 11F7485B2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -263,6 +266,9 @@ 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpers.swift; sourceTree = ""; }; 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSettingsView.swift; sourceTree = ""; }; 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; + 6A5F2C0B2F6A0001A1B2C3D4 /* WeatherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherSettingsView.swift; sourceTree = ""; }; + 6A5F2C0D2F6A0001A1B2C3D4 /* WeatherManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherManager.swift; sourceTree = ""; }; + 6A5F2C0F2F6A0001A1B2C3D4 /* WeatherTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherTabView.swift; sourceTree = ""; }; 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemViewModel.swift; sourceTree = ""; }; 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPreviewView.swift; sourceTree = ""; }; 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = BoringNotchXPCHelper.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -530,6 +536,7 @@ 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */, 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */, 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */, + 6A5F2C0B2F6A0001A1B2C3D4 /* WeatherSettingsView.swift */, ); path = Views; sourceTree = ""; @@ -600,6 +607,7 @@ children = ( 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */, 11D58EA12E760AE100FA8377 /* ImageService.swift */, + 6A5F2C0D2F6A0001A1B2C3D4 /* WeatherManager.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, @@ -793,6 +801,7 @@ 1194E8862EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift */, 1160F8D72DD98230006FBB94 /* NotchShape.swift */, 9AB0C6BB2C73C9CB00F7CD30 /* NotchHomeView.swift */, + 6A5F2C0F2F6A0001A1B2C3D4 /* WeatherTabView.swift */, 14D570C52C5F38210011E668 /* BoringHeader.swift */, 14D570D12C5F6C6A0011E668 /* BoringExtrasMenu.swift */, 1471A8582C6281BD0058408D /* BoringNotchWindow.swift */, @@ -1038,6 +1047,7 @@ 1153BD8F2D986B1F00979FB0 /* MediaControllerProtocol.swift in Sources */, 11985BF42F38520A00F81585 /* DraggableProgressBar.swift in Sources */, 9AB0C6BD2C73C9CB00F7CD30 /* NotchHomeView.swift in Sources */, + 6A5F2C102F6A0001A1B2C3D4 /* WeatherTabView.swift in Sources */, B172AAC02C95DA0B001623F1 /* InlineOSD.swift in Sources */, 14E9FEAA2C70BF610062E83F /* DownloadView.swift in Sources */, B1B112912C6A572100093D8F /* EditPanelView.swift in Sources */, @@ -1059,6 +1069,7 @@ 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */, 11DB267C2EDD0CDF001EA0CF /* OSDSettingsView.swift in Sources */, 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */, + 6A5F2C0C2F6A0001A1B2C3D4 /* WeatherSettingsView.swift in Sources */, 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */, 14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */, 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */, @@ -1099,6 +1110,7 @@ 11CFC6612E097F6800748C80 /* PermissionsRequestView.swift in Sources */, 14D570D22C5F6C6A0011E668 /* BoringExtrasMenu.swift in Sources */, 11D58EA22E760AE100FA8377 /* ImageService.swift in Sources */, + 6A5F2C0E2F6A0001A1B2C3D4 /* WeatherManager.swift in Sources */, 11F748822ECB07A400F841DB /* MusicControlButton.swift in Sources */, 14288DDC2C6E015000B9F80C /* AudioPlayer.swift in Sources */, 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 8717ddd53..fcd412a2f 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -377,14 +377,17 @@ struct ContentView: View { } .zIndex(1) if vm.notchState == .open { - VStack { + VStack(alignment: .leading, spacing: 0) { switch coordinator.currentView { case .home: NotchHomeView(albumArtNamespace: albumArtNamespace) case .shelf: ShelfView() + case .weather: + WeatherTabView() } } + .frame(maxWidth: .infinity, alignment: .leading) .transition( .scale(scale: 0.8, anchor: .top) .combined(with: .opacity) diff --git a/boringNotch/components/Notch/BoringHeader.swift b/boringNotch/components/Notch/BoringHeader.swift index 9f9da3248..76a22a069 100644 --- a/boringNotch/components/Notch/BoringHeader.swift +++ b/boringNotch/components/Notch/BoringHeader.swift @@ -13,10 +13,25 @@ struct BoringHeader: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = BoringViewCoordinator.shared @StateObject var tvm = ShelfStateViewModel.shared + @Default(.boringShelf) private var showShelf + @Default(.showWeather) private var showWeather + + private var shouldShowShelfTab: Bool { + showShelf && (!tvm.isEmpty || coordinator.alwaysShowTabs) + } + + private var shouldShowWeatherTab: Bool { + showWeather + } + + private var shouldShowTabs: Bool { + shouldShowShelfTab || shouldShowWeatherTab + } + var body: some View { HStack(spacing: 0) { HStack { - if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.boringShelf] { + if shouldShowTabs { TabSelectionView() } else if vm.notchState == .open { EmptyView() diff --git a/boringNotch/components/Notch/WeatherTabView.swift b/boringNotch/components/Notch/WeatherTabView.swift new file mode 100644 index 000000000..d001dca9e --- /dev/null +++ b/boringNotch/components/Notch/WeatherTabView.swift @@ -0,0 +1,463 @@ +// +// WeatherTabView.swift +// boringNotch +// + +import Defaults +import SwiftUI + +struct WeatherTabView: View { + private enum WeatherPage: String, CaseIterable, Identifiable { + case current + case forecast + + var id: String { self.rawValue } + } + + @EnvironmentObject var vm: BoringViewModel + @ObservedObject private var weatherManager = WeatherManager.shared + @Default(.showWeather) private var showWeather + @Default(.weatherCity) private var weatherCity + @Default(.weatherContentPreference) private var weatherContentPreference + @State private var selectedPage: WeatherPage = .current + + private var openHeaderHeight: CGFloat { + let closedDisplayHeight = vm.effectiveClosedNotchHeight == 0 ? 10 : vm.effectiveClosedNotchHeight + return max(24, closedDisplayHeight) + } + + private var contentHeight: CGFloat { + // Keep Weather tab height aligned with ContentView.NotchLayout vertical math: + // total open height - header - VStack spacing - open bottom inset. + let notchLayoutSpacing: CGFloat = 8 + let openBottomInset: CGFloat = 12 + let availableHeight = vm.notchSize.height - openHeaderHeight - notchLayoutSpacing - openBottomInset + return Swift.max(0, availableHeight) + } + + private var showForecastPage: Bool { + weatherContentPreference == .currentAndForecast + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if !showWeather { + statePanel { + Label(localized("weather_tab.off.title", fallback: "Weather is off"), systemImage: "cloud.slash") + .font(.subheadline.weight(.semibold)) + Text(localized("weather_tab.off.message", fallback: "Turn on Show weather in Settings > Weather.")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else if let snapshot = weatherManager.snapshot { + weatherCanvas(for: snapshot, staleError: weatherManager.errorMessage) + } else if weatherManager.isLoading || !weatherManager.hasLoadedAtLeastOnce { + statePanel { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(localized("weather_tab.loading", fallback: "Loading weather...")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } else if let error = weatherManager.errorMessage { + statePanel { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + Text( + localizedFormat( + "weather_tab.current_city_format", + fallback: "Current city: %@", + weatherCity + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + Button { + weatherManager.requestRefresh(replacingCurrent: true) + } label: { + Label(localized("weather_tab.retry", fallback: "Retry"), systemImage: "arrow.clockwise") + } + .buttonStyle(.plain) + } + } else { + statePanel { + Text(localized("weather_tab.unavailable", fallback: "Weather not available")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, minHeight: contentHeight, maxHeight: contentHeight, alignment: .topLeading) + .onAppear { + if showWeather && weatherManager.snapshot == nil { + weatherManager.requestRefresh() + } + } + .onChange(of: showWeather) { _, newValue in + guard newValue else { return } + weatherManager.requestRefresh() + } + .onChange(of: weatherContentPreference) { _, newValue in + if newValue == .currentOnly { + selectedPage = .current + } + } + } + + private func localized(_ key: String, fallback: String) -> String { + let value = NSLocalizedString(key, comment: "") + return value == key ? fallback : value + } + + private func localizedFormat(_ key: String, fallback: String, _ arguments: CVarArg...) -> String { + String(format: localized(key, fallback: fallback), locale: Locale.current, arguments: arguments) + } + + private func statePanel(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + content() + } + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.09), lineWidth: 1) + ) + } + + @ViewBuilder + private func weatherCanvas(for snapshot: WeatherSnapshot, staleError: String?) -> some View { + VStack(alignment: .leading, spacing: 8) { + headerBar(for: snapshot, staleError: staleError) + + Group { + if selectedPage == .current || !showForecastPage { + currentWeatherPage(for: snapshot) + } else { + forecastWeatherPage(for: snapshot) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 18) + .onEnded { value in + guard showForecastPage else { return } + if value.translation.width < -32 { + withAnimation(.smooth(duration: 0.22)) { + selectedPage = .forecast + } + } else if value.translation.width > 32 { + withAnimation(.smooth(duration: 0.22)) { + selectedPage = .current + } + } + } + ) + } + .padding(10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.09), lineWidth: 1) + ) + .clipped() + } + + @ViewBuilder + private func weatherPageButton(page: WeatherPage) -> some View { + Button { + withAnimation(.smooth(duration: 0.2)) { + selectedPage = page + } + } label: { + Text( + page == .current + ? localized("weather_tab.segment.current", fallback: "Current") + : localized("weather_tab.segment.forecast", fallback: "Forecast") + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(selectedPage == page ? .white : .white.opacity(0.78)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(selectedPage == page ? Color.white.opacity(0.16) : Color.clear) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func currentWeatherPage(for snapshot: WeatherSnapshot) -> some View { + HStack(alignment: .top, spacing: 10) { + heroBlock(for: snapshot, compact: true) + .frame(maxWidth: .infinity, alignment: .leading) + metricsGrid(for: snapshot, limit: 4, compact: true, singleColumn: false) + .frame(width: 236, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func forecastWeatherPage(for snapshot: WeatherSnapshot) -> some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text( + localizedFormat( + "weather_tab.next_days_format", + fallback: "Next %d days", + 6 + ) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.92)) + dailyRow(for: snapshot, limit: 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func dailyRow(for snapshot: WeatherSnapshot, limit: Int) -> some View { + let points = Array(snapshot.dailyForecast.prefix(max(1, limit))) + if points.isEmpty { + Text(localized("weather_tab.no_forecast", fallback: "No forecast data")) + .font(.caption) + .foregroundStyle(.white.opacity(0.82)) + } else { + HStack(spacing: 6) { + ForEach(points) { day in + VStack(alignment: .leading, spacing: 2) { + Text(day.dayLabel) + .font(.caption2) + .foregroundStyle(.white.opacity(0.78)) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: WeatherCodeMapper.symbolName(for: day.weatherCode, isDay: true)) + .font(.caption2) + .foregroundStyle(.white.opacity(0.92)) + Text("\(Int(day.minTemperature.rounded()))° / \(Int(day.maxTemperature.rounded()))°") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + + if let rain = day.precipitationProbability { + Text( + localizedFormat( + "weather_tab.rain_value_format", + fallback: "Rain %d%%", + Int(rain.rounded()) + ) + ) + .font(.caption2) + .foregroundStyle(.white.opacity(0.72)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + + @ViewBuilder + private func headerBar(for snapshot: WeatherSnapshot, staleError: String?) -> some View { + HStack(alignment: .center, spacing: 8) { + Text(snapshot.cityName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + + Text( + localizedFormat( + "weather_tab.updated_format", + fallback: "Updated %@", + snapshot.updatedAt.formatted(date: .omitted, time: .shortened) + ) + ) + .font(.caption2) + .foregroundStyle(.white.opacity(0.84)) + .lineLimit(1) + + Spacer() + + if staleError != nil { + Label(localized("weather_tab.cached_badge", fallback: "Cached"), systemImage: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.white.opacity(0.86)) + } + + if showForecastPage { + HStack(spacing: 6) { + weatherPageButton(page: .current) + weatherPageButton(page: .forecast) + } + } + + } + } + + @ViewBuilder + private func heroBlock(for snapshot: WeatherSnapshot, compact: Bool) -> some View { + let temperatureStyle = temperatureGradient(for: snapshot) + let iconColors = weatherIconPalette(for: snapshot) + + HStack(alignment: .center, spacing: compact ? 12 : 14) { + HStack(alignment: .center, spacing: compact ? 10 : 12) { + Text(snapshot.temperatureText) + .font(.system(size: compact ? 46 : 52, weight: .bold, design: .rounded)) + .foregroundStyle(temperatureStyle) + .lineLimit(1) + .minimumScaleFactor(0.9) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + + Image(systemName: snapshot.symbolName) + .font(.system(size: compact ? 34 : 40, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(iconColors.0, iconColors.1) + .shadow(color: iconColors.0.opacity(0.18), radius: 6, x: 0, y: 2) + } + .layoutPriority(2) + + VStack(alignment: .leading, spacing: 4) { + Text(snapshot.conditionText) + .font(.headline.weight(.semibold)) + .foregroundStyle(.white.opacity(0.9)) + .lineLimit(1) + + if let highLow = highLowText(for: snapshot) { + Text(highLow) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func metricsGrid(for snapshot: WeatherSnapshot, limit: Int, compact: Bool, singleColumn: Bool) -> some View { + let metrics: [(String, String, String)] = [ + (localized("weather_tab.metric.feels", fallback: "Feels"), snapshot.feelsLikeText ?? "--", "thermometer.medium"), + (localized("weather_tab.metric.humidity", fallback: "Humidity"), snapshot.humidityText ?? "--", "humidity.fill"), + (localized("weather_tab.metric.wind", fallback: "Wind"), snapshot.windSpeedText ?? "--", "wind"), + (localized("weather_tab.metric.rain", fallback: "Rain"), snapshot.precipitationText ?? "--", "drop.fill") + ] + let visibleMetrics = Array(metrics.prefix(max(1, min(limit, metrics.count)))) + let columns = singleColumn ? [GridItem(.flexible())] : [GridItem(.flexible()), GridItem(.flexible())] + + LazyVGrid(columns: columns, spacing: compact ? 4 : 6) { + ForEach(visibleMetrics, id: \.0) { metric in + HStack(spacing: 4) { + Image(systemName: metric.2) + .font(.caption2) + .foregroundStyle(.white.opacity(0.84)) + + VStack(alignment: .leading, spacing: 0) { + Text(metric.0) + .font(.caption2) + .foregroundStyle(.white.opacity(0.78)) + Text(metric.1) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, compact ? 3 : 5) + .background(Color.black.opacity(0.16)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + + private func highLowText(for snapshot: WeatherSnapshot) -> String? { + guard let high = snapshot.highTemperature, let low = snapshot.lowTemperature else { return nil } + return localizedFormat( + "weather_tab.high_low_format", + fallback: "L %d° H %d°", + Int(low.rounded()), + Int(high.rounded()) + ) + } + + private func temperatureGradient(for snapshot: WeatherSnapshot) -> LinearGradient { + let celsius = snapshot.unit == .fahrenheit + ? (snapshot.temperature - 32.0) * 5.0 / 9.0 + : snapshot.temperature + + switch celsius { + case 32...: + return LinearGradient( + colors: [Color(red: 1.00, green: 0.52, blue: 0.44), Color(red: 0.95, green: 0.28, blue: 0.30)], + startPoint: .top, + endPoint: .bottom + ) + case 26..<32: + return LinearGradient( + colors: [Color(red: 1.00, green: 0.72, blue: 0.36), Color(red: 1.00, green: 0.52, blue: 0.30)], + startPoint: .top, + endPoint: .bottom + ) + case 18..<26: + return LinearGradient( + colors: [Color(red: 0.43, green: 0.86, blue: 0.66), Color(red: 0.33, green: 0.73, blue: 0.86)], + startPoint: .top, + endPoint: .bottom + ) + case 10..<18: + return LinearGradient( + colors: [Color(red: 0.60, green: 0.82, blue: 1.00), Color(red: 0.41, green: 0.66, blue: 0.96)], + startPoint: .top, + endPoint: .bottom + ) + default: + return LinearGradient( + colors: [Color.white.opacity(0.98), Color(red: 0.86, green: 0.91, blue: 0.98)], + startPoint: .top, + endPoint: .bottom + ) + } + } + + private func weatherIconPalette(for snapshot: WeatherSnapshot) -> (Color, Color) { + switch snapshot.weatherCode { + case 0: + return snapshot.isDay + ? (Color(red: 1.00, green: 0.84, blue: 0.34), Color(red: 1.00, green: 0.62, blue: 0.28)) + : (Color(red: 0.76, green: 0.82, blue: 1.00), Color(red: 0.58, green: 0.64, blue: 0.92)) + case 1, 2: + return snapshot.isDay + ? (Color(red: 1.00, green: 0.80, blue: 0.36), Color.white.opacity(0.95)) + : (Color(red: 0.66, green: 0.76, blue: 1.00), Color.white.opacity(0.88)) + case 3, 45, 48: + return (Color.white.opacity(0.94), Color(red: 0.68, green: 0.72, blue: 0.79)) + case 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82: + return (Color(red: 0.45, green: 0.84, blue: 1.00), Color(red: 0.28, green: 0.60, blue: 0.96)) + case 71, 73, 75, 77, 85, 86: + return (Color.white.opacity(0.99), Color(red: 0.74, green: 0.90, blue: 1.00)) + case 95, 96, 99: + return (Color(red: 1.00, green: 0.86, blue: 0.36), Color(red: 0.62, green: 0.60, blue: 1.00)) + default: + return (Color.white.opacity(0.95), Color.white.opacity(0.76)) + } + } + +} diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index a1a174886..447eb6096 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -34,6 +34,9 @@ struct SettingsView: View { NavigationLink(value: "Calendar") { Label("Calendar", systemImage: "calendar") } + NavigationLink(value: "Weather") { + Label("Weather", systemImage: "cloud.sun") + } NavigationLink(value: "OSD") { Label("OSD", systemImage: "dial.medium.fill") } @@ -68,6 +71,8 @@ struct SettingsView: View { Media() case "Calendar": CalendarSettings() + case "Weather": + WeatherSettings() case "OSD": OSDSettings() case "Battery": diff --git a/boringNotch/components/Settings/Views/WeatherSettingsView.swift b/boringNotch/components/Settings/Views/WeatherSettingsView.swift new file mode 100644 index 000000000..7a60dbfbe --- /dev/null +++ b/boringNotch/components/Settings/Views/WeatherSettingsView.swift @@ -0,0 +1,99 @@ +// +// WeatherSettingsView.swift +// boringNotch +// +// Created by TheBoredTeam on 2026-03-03. +// + +import Defaults +import SwiftUI + +struct WeatherSettings: View { + @ObservedObject private var weatherManager = WeatherManager.shared + @Default(.showWeather) private var showWeather + @Default(.weatherCity) private var weatherCity + @Default(.weatherUnit) private var weatherUnit + @Default(.weatherRefreshMinutes) private var weatherRefreshMinutes + @Default(.weatherContentPreference) private var weatherContentPreference + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .showWeather) { + Text("Show weather") + } + if showWeather { + TextField("City (supports lowercase pinyin)", text: $weatherCity) + .onSubmit { + weatherManager.refreshForEnteredCity() + } + + if weatherManager.isLoadingCitySuggestions { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Searching cities...") + .font(.caption) + .foregroundStyle(.secondary) + } + } else if !weatherManager.citySuggestions.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("City suggestions") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(Array(weatherManager.citySuggestions.prefix(8))) { suggestion in + Button { + weatherManager.selectCitySuggestion(suggestion) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(suggestion.displayName) + .lineLimit(1) + if !suggestion.subtitle.isEmpty { + Text(suggestion.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + } + .contentShape(Rectangle()) + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + } + } + + Picker("Temperature unit", selection: $weatherUnit) { + ForEach(WeatherTemperatureUnit.allCases, id: \.self) { unit in + Text(unit.displayName).tag(unit) + } + } + + Picker("Weather content", selection: $weatherContentPreference) { + Text("Current weather only") + .tag(WeatherContentPreference.currentOnly) + Text("Current and forecast") + .tag(WeatherContentPreference.currentAndForecast) + } + .pickerStyle(.segmented) + + Stepper(value: $weatherRefreshMinutes, in: 5...120, step: 5) { + HStack { + Text("Weather refresh interval") + Spacer() + Text("\(weatherRefreshMinutes) min") + .foregroundStyle(.secondary) + } + } + } + } header: { + Text("General") + } + } + .accentColor(.effectiveAccent) + .navigationTitle("Weather") + } +} diff --git a/boringNotch/components/Tabs/TabSelectionView.swift b/boringNotch/components/Tabs/TabSelectionView.swift index b99d4af1d..69ac52bdf 100644 --- a/boringNotch/components/Tabs/TabSelectionView.swift +++ b/boringNotch/components/Tabs/TabSelectionView.swift @@ -5,6 +5,7 @@ // Created by Hugo Persson on 2024-08-25. // +import Defaults import SwiftUI struct TabModel: Identifiable { @@ -14,14 +15,44 @@ struct TabModel: Identifiable { let view: NotchViews } -let tabs = [ - TabModel(label: "Home", icon: "house.fill", view: .home), - TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) -] - struct TabSelectionView: View { @ObservedObject var coordinator = BoringViewCoordinator.shared + @StateObject private var shelfState = ShelfStateViewModel.shared + @Default(.showWeather) private var showWeather + @Default(.boringShelf) private var showShelf @Namespace var animation + + private var tabs: [TabModel] { + var visibleTabs: [TabModel] = [ + TabModel( + label: "Home", + icon: "house.fill", + view: .home + ) + ] + + if showWeather { + visibleTabs.append( + TabModel( + label: "Weather", + icon: "cloud.sun.fill", + view: .weather + ) + ) + } + + if showShelf && (!shelfState.isEmpty || coordinator.alwaysShowTabs) { + visibleTabs.append( + TabModel( + label: "Shelf", + icon: "tray.fill", + view: .shelf + ) + ) + } + + return visibleTabs + } var body: some View { HStack(spacing: 0) { ForEach(tabs) { tab in diff --git a/boringNotch/enums/generic.swift b/boringNotch/enums/generic.swift index 709afba75..ba9005376 100644 --- a/boringNotch/enums/generic.swift +++ b/boringNotch/enums/generic.swift @@ -21,6 +21,7 @@ public enum NotchState { public enum NotchViews { case home case shelf + case weather } enum DownloadIndicatorStyle: String, Defaults.Serializable { diff --git a/boringNotch/managers/WeatherManager.swift b/boringNotch/managers/WeatherManager.swift new file mode 100644 index 000000000..eb29b5af0 --- /dev/null +++ b/boringNotch/managers/WeatherManager.swift @@ -0,0 +1,994 @@ +// +// WeatherManager.swift +// boringNotch +// + +import Foundation +import Combine +import Defaults + +struct WeatherSnapshot { + let cityName: String + let temperature: Double + let unit: WeatherTemperatureUnit + let timeZone: TimeZone + let weatherCode: Int + let isDay: Bool + let highTemperature: Double? + let lowTemperature: Double? + let precipitationProbability: Double? + let apparentTemperature: Double? + let humidity: Int? + let windSpeed: Double? + let dailyForecast: [WeatherDailyPoint] + let updatedAt: Date + + var temperatureText: String { + "\(Int(temperature.rounded()))°\(unit.symbol)" + } + + var conditionText: String { + WeatherCodeMapper.description(for: weatherCode) + } + + var symbolName: String { + WeatherCodeMapper.symbolName(for: weatherCode, isDay: isDay) + } + + var precipitationText: String? { + guard let precipitationProbability else { return nil } + return "Rain \(Int(precipitationProbability.rounded()))%" + } + + var feelsLikeText: String? { + guard let apparentTemperature else { return nil } + return "\(Int(apparentTemperature.rounded()))°\(unit.symbol)" + } + + var humidityText: String? { + guard let humidity else { return nil } + return "\(humidity)%" + } + + var windSpeedText: String? { + guard let windSpeed else { return nil } + return "\(Int(windSpeed.rounded())) km/h" + } +} + +struct WeatherDailyPoint: Identifiable { + let id: String + let dayLabel: String + let weatherCode: Int + let maxTemperature: Double + let minTemperature: Double + let precipitationProbability: Double? +} + +struct CitySuggestion: Identifiable, Equatable { + let id: String + let displayName: String + let subtitle: String + let queryText: String +} + +@MainActor +final class WeatherManager: ObservableObject { + static let shared = WeatherManager() + + @Published private(set) var snapshot: WeatherSnapshot? + @Published private(set) var isLoading = false + @Published private(set) var errorMessage: String? + @Published private(set) var hasLoadedAtLeastOnce = false + @Published private(set) var citySuggestions: [CitySuggestion] = [] + @Published private(set) var isLoadingCitySuggestions = false + + private let session: URLSession + private var cancellables: Set = [] + private var refreshLoopTask: Task? + private var refreshTask: Task? + private var citySuggestionTask: Task? + private var suppressNextCitySuggestionSearch = false + private var geocodeCache: [String: GeocodingResult] = [:] + private var dateFormatterCache: [String: DateFormatter] = [:] + private var calendarCache: [String: Calendar] = [:] + + private init(session: URLSession = .shared) { + self.session = session + bindDefaults() + + if Defaults[.showWeather] { + startRefreshLoop() + requestRefresh() + } + } + + deinit { + refreshLoopTask?.cancel() + refreshTask?.cancel() + citySuggestionTask?.cancel() + cancellables.forEach { $0.cancel() } + } + + func requestRefresh(replacingCurrent: Bool = false) { + if replacingCurrent { + refreshTask?.cancel() + refreshTask = nil + } else if refreshTask != nil { + return + } + + refreshTask = Task { [weak self] in + guard let self else { return } + defer { self.refreshTask = nil } + await self.refreshWeather() + } + } + + func refreshForEnteredCity() { + clearCitySuggestions() + requestRefresh(replacingCurrent: true) + } + + func selectCitySuggestion(_ suggestion: CitySuggestion) { + clearCitySuggestions() + let newQuery = normalizedCityKey(suggestion.queryText) + let currentQuery = normalizedCityKey(Defaults[.weatherCity]) + suppressNextCitySuggestionSearch = (newQuery != currentQuery) + Defaults[.weatherCity] = suggestion.queryText + requestRefresh(replacingCurrent: true) + } + + private func bindDefaults() { + Defaults.publisher(.showWeather, options: []) + .sink { [weak self] change in + Task { @MainActor in + self?.handleEnabledChange(change.newValue) + } + } + .store(in: &cancellables) + + Defaults.publisher(.weatherCity, options: []) + .sink { [weak self] change in + Task { @MainActor in + guard let self else { return } + guard Defaults[.showWeather] else { return } + if self.suppressNextCitySuggestionSearch { + self.suppressNextCitySuggestionSearch = false + self.clearCitySuggestions(cancelTask: false) + return + } + self.scheduleCitySuggestionSearch(for: change.newValue) + } + } + .store(in: &cancellables) + + Defaults.publisher(.weatherUnit, options: []) + .sink { [weak self] _ in + Task { @MainActor in + guard Defaults[.showWeather] else { return } + self?.requestRefresh(replacingCurrent: true) + } + } + .store(in: &cancellables) + + Defaults.publisher(.weatherRefreshMinutes, options: []) + .sink { [weak self] _ in + Task { @MainActor in + guard Defaults[.showWeather] else { return } + self?.startRefreshLoop() + } + } + .store(in: &cancellables) + } + + private func handleEnabledChange(_ enabled: Bool) { + if enabled { + startRefreshLoop() + requestRefresh() + return + } + + refreshLoopTask?.cancel() + refreshLoopTask = nil + refreshTask?.cancel() + refreshTask = nil + clearCitySuggestions() + suppressNextCitySuggestionSearch = false + isLoading = false + errorMessage = nil + snapshot = nil + hasLoadedAtLeastOnce = false + } + + private func startRefreshLoop() { + refreshLoopTask?.cancel() + refreshLoopTask = Task { [weak self] in + while !Task.isCancelled { + let minutes = max(5, min(120, Defaults[.weatherRefreshMinutes])) + try? await Task.sleep(for: .seconds(Double(minutes * 60))) + guard !Task.isCancelled else { break } + await self?.refreshWeather() + } + } + } + + private func refreshWeather() async { + guard Defaults[.showWeather] else { return } + + let city = sanitizedCity(Defaults[.weatherCity]) + let unit = Defaults[.weatherUnit] + + isLoading = true + defer { isLoading = false } + + do { + let location = try await geocodeCity(city) + let forecast = try await fetchWeatherForecastWithRetry( + latitude: location.latitude, + longitude: location.longitude, + unit: unit + ) + snapshot = try makeWeatherSnapshot( + from: forecast, + location: location, + unit: unit + ) + errorMessage = nil + hasLoadedAtLeastOnce = true + } catch is CancellationError { + return + } catch let error as WeatherServiceError { + errorMessage = error.userMessage + hasLoadedAtLeastOnce = true + } catch { + errorMessage = WeatherServiceError.invalidResponse.userMessage + hasLoadedAtLeastOnce = true + } + } + + private func sanitizedCity(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Cupertino" : trimmed + } + + private func geocodeCity(_ city: String) async throws -> GeocodingResult { + let cacheKey = normalizedCityKey(city) + if let cached = geocodeCache[cacheKey] { + return cached + } + + let rankedResults = try await searchCitiesRanked(query: city, count: 8) + guard let result = rankedResults.first else { + throw WeatherServiceError.locationNotFound + } + if geocodeCache.count > 32 { + geocodeCache.removeAll(keepingCapacity: true) + } + geocodeCache[cacheKey] = result + return result + } + + private func scheduleCitySuggestionSearch(for rawQuery: String) { + citySuggestionTask?.cancel() + let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard shouldSearchSuggestions(for: query) else { + citySuggestions = [] + isLoadingCitySuggestions = false + return + } + + citySuggestionTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(220)) + guard !Task.isCancelled else { return } + await self?.fetchCitySuggestions(query: query) + } + } + + private func clearCitySuggestions(cancelTask: Bool = true) { + if cancelTask { + citySuggestionTask?.cancel() + citySuggestionTask = nil + } + citySuggestions = [] + isLoadingCitySuggestions = false + } + + private func shouldSearchSuggestions(for query: String) -> Bool { + if query.range(of: "\\p{Han}", options: .regularExpression) != nil { + return true + } + return query.count >= 2 + } + + private func fetchCitySuggestions(query: String) async { + isLoadingCitySuggestions = true + defer { isLoadingCitySuggestions = false } + + do { + let results = try await searchCitiesRanked(query: query, count: 8) + let suggestions = results.prefix(8).map(makeCitySuggestion(from:)) + citySuggestions = suggestions + } catch is CancellationError { + return + } catch { + citySuggestions = [] + } + } + + private func searchCitiesRanked( + query: String, + count: Int + ) async throws -> [GeocodingResult] { + let queries = geocodingQueryVariants(for: query) + let languages = geocodingLanguageOrder(for: query) + guard !queries.isEmpty else { return [] } + + var combinedResults: [GeocodingResult] = [] + var firstError: Error? + + for language in languages { + for queryVariant in queries { + do { + let results = try await searchCities(query: queryVariant, count: count, language: language) + if !results.isEmpty { + combinedResults.append(contentsOf: results) + } + } catch is CancellationError { + throw CancellationError() + } catch { + if firstError == nil { + firstError = error + } + } + } + } + + let ranked = rankCityResults(deduplicateCityResults(combinedResults), for: query) + if !ranked.isEmpty { + return ranked + } + + if let firstError { + throw firstError + } + + return [] + } + + private func searchCities( + query: String, + count: Int, + language: String + ) async throws -> [GeocodingResult] { + var components = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search") + components?.queryItems = [ + URLQueryItem(name: "name", value: query), + URLQueryItem(name: "count", value: "\(count)"), + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "format", value: "json"), + ] + + guard let url = components?.url else { + throw WeatherServiceError.invalidRequest + } + + let (data, response) = try await session.data(for: URLRequest(url: url)) + try validateHTTPResponse(response) + let decoded = try JSONDecoder().decode(GeocodingResponse.self, from: data) + return decoded.results ?? [] + } + + private func makeCitySuggestion(from result: GeocodingResult) -> CitySuggestion { + let identifier = cityIdentifier(for: result) + let name = preferredDisplayName(for: result) + let subtitleParts = [result.admin2, result.admin1, result.country] + .compactMap { value in + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + return CitySuggestion( + id: identifier, + displayName: name, + subtitle: subtitleParts.joined(separator: " · "), + queryText: name + ) + } + + private func preferredDisplayName(for result: GeocodingResult) -> String { + let name = result.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard result.countryCode?.uppercased() == "CN" else { + return name + } + if containsHanCharacters(name) { + return name + } + if let admin2 = result.admin2?.trimmingCharacters(in: .whitespacesAndNewlines), + !admin2.isEmpty, containsHanCharacters(admin2) { + return admin2 + } + if let admin1 = result.admin1?.trimmingCharacters(in: .whitespacesAndNewlines), + !admin1.isEmpty, containsHanCharacters(admin1) { + return admin1 + } + return name + } + + private func containsHanCharacters(_ text: String) -> Bool { + text.range(of: "\\p{Han}", options: .regularExpression) != nil + } + + private func normalizedCityKey(_ city: String) -> String { + city.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func geocodingQueryVariants(for query: String) -> [String] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let compact = trimmed.replacingOccurrences( + of: "[\\s'’\\-_.]", + with: "", + options: .regularExpression + ) + let withCitySuffix: String? = { + guard containsHanCharacters(trimmed) else { return nil } + guard !trimmed.hasSuffix("市") else { return nil } + return "\(trimmed)市" + }() + + var variants: [String] = [] + var seenKeys: Set = [] + for candidate in [trimmed, compact, withCitySuffix].compactMap({ $0 }) { + let cleaned = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { continue } + let key = normalizedCityKey(cleaned) + if seenKeys.insert(key).inserted { + variants.append(cleaned) + } + } + return variants + } + + private func geocodingLanguageOrder(for query: String) -> [String] { + containsHanCharacters(query) ? ["zh", "en"] : ["en", "zh"] + } + + private func deduplicateCityResults(_ results: [GeocodingResult]) -> [GeocodingResult] { + var seen: Set = [] + return results.filter { seen.insert(cityIdentifier(for: $0)).inserted } + } + + private func cityIdentifier(for result: GeocodingResult) -> String { + result.id.map(String.init) ?? "\(result.latitude),\(result.longitude)" + } + + private func rankCityResults(_ results: [GeocodingResult], for query: String) -> [GeocodingResult] { + let normalizedQuery = normalizedSearchToken(query) + return results.sorted { lhs, rhs in + let lhsScore = cityMatchScore(for: lhs, normalizedQuery: normalizedQuery) + let rhsScore = cityMatchScore(for: rhs, normalizedQuery: normalizedQuery) + if lhsScore != rhsScore { + return lhsScore > rhsScore + } + + let lhsPopulation = lhs.population ?? 0 + let rhsPopulation = rhs.population ?? 0 + if lhsPopulation != rhsPopulation { + return lhsPopulation > rhsPopulation + } + + let lhsName = preferredDisplayName(for: lhs) + let rhsName = preferredDisplayName(for: rhs) + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending + } + } + + private func cityMatchScore(for result: GeocodingResult, normalizedQuery: String) -> Int { + guard !normalizedQuery.isEmpty else { return 0 } + + let primary = normalizedSearchToken(result.name) + let secondary = [result.admin2, result.admin1, result.country] + .compactMap { $0 } + .map(normalizedSearchToken) + .filter { !$0.isEmpty } + + var score = 0 + if primary == normalizedQuery { + score += 120 + } else if primary.hasPrefix(normalizedQuery) { + score += 90 + } else if primary.contains(normalizedQuery) { + score += 60 + } + + if secondary.contains(normalizedQuery) { + score += 40 + } else if secondary.contains(where: { $0.hasPrefix(normalizedQuery) }) { + score += 30 + } else if secondary.contains(where: { $0.contains(normalizedQuery) }) { + score += 20 + } + + return score + } + + private func normalizedSearchToken(_ text: String) -> String { + let folded = text.folding( + options: [.diacriticInsensitive, .caseInsensitive, .widthInsensitive], + locale: .current + ) + return folded.replacingOccurrences( + of: "[\\s'’\\-_.]", + with: "", + options: .regularExpression + ) + } + + private func makeWeatherSnapshot( + from forecast: WeatherForecastResponse, + location: GeocodingResult, + unit: WeatherTemperatureUnit + ) throws -> WeatherSnapshot { + guard let current = forecast.resolvedCurrent else { + throw WeatherServiceError.invalidResponse + } + + let weatherTimeZone = forecast.resolvedTimeZone ?? .current + let dailyForecast = buildDailyForecast( + from: forecast.daily, + timeZone: weatherTimeZone + ) + let today = dailyForecast.first + + return WeatherSnapshot( + cityName: preferredDisplayName(for: location), + temperature: current.temperature, + unit: unit, + timeZone: weatherTimeZone, + weatherCode: current.weatherCode, + isDay: current.isDay == 1, + highTemperature: today?.maxTemperature, + lowTemperature: today?.minTemperature, + precipitationProbability: today?.precipitationProbability, + apparentTemperature: current.apparentTemperature, + humidity: current.relativeHumidity.map { Int($0.rounded()) }, + windSpeed: current.windSpeed, + dailyForecast: dailyForecast, + updatedAt: Date() + ) + } + + private func buildDailyForecast( + from daily: DailyWeather?, + timeZone: TimeZone + ) -> [WeatherDailyPoint] { + guard let daily, + let dates = daily.time, + let maxTemperatures = daily.temperatureMax, + let minTemperatures = daily.temperatureMin else { + return [] + } + + let weatherCodes = daily.weatherCode ?? [] + let precipitation = daily.precipitationProbabilityMax ?? [] + let count = min(dates.count, maxTemperatures.count, minTemperatures.count) + guard count > 0 else { return [] } + + return (0.. String { + if index == 0 { + return l10n("weather_relative_today", fallback: "Today") + } + if index == 1 { + return l10n("weather_relative_tomorrow", fallback: "Tomorrow") + } + guard let date else { + return l10nFormat("weather_relative_day_format", fallback: "Day %d", index + 1) + } + + let timeAwareCalendar = calendar(for: timeZone) + let weekday = timeAwareCalendar.component(.weekday, from: date) + return timeAwareCalendar.shortWeekdaySymbols[max(0, min(weekday - 1, timeAwareCalendar.shortWeekdaySymbols.count - 1))] + } + + private func parseDateString(_ raw: String, timeZone: TimeZone) -> Date? { + dayFormatter(for: timeZone).date(from: raw) + } + + private func dayFormatter(for timeZone: TimeZone) -> DateFormatter { + formatter(dateFormat: "yyyy-MM-dd", locale: Locale(identifier: "en_US_POSIX"), timeZone: timeZone) + } + + private func formatter(dateFormat: String, locale: Locale, timeZone: TimeZone) -> DateFormatter { + let key = "\(dateFormat)|\(locale.identifier)|\(timeZone.identifier)" + if let cached = dateFormatterCache[key] { + return cached + } + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.dateFormat = dateFormat + dateFormatterCache[key] = formatter + return formatter + } + + private func calendar(for timeZone: TimeZone) -> Calendar { + if let cached = calendarCache[timeZone.identifier] { + return cached + } + var calendar = Calendar.current + calendar.timeZone = timeZone + calendarCache[timeZone.identifier] = calendar + return calendar + } + + private func fetchWeatherForecastWithRetry( + latitude: Double, + longitude: Double, + unit: WeatherTemperatureUnit + ) async throws -> WeatherForecastResponse { + var lastError: Error? + for attempt in 0..<2 { + do { + return try await fetchWeatherForecast( + latitude: latitude, + longitude: longitude, + unit: unit + ) + } catch { + lastError = error + guard attempt == 0 else { break } + try? await Task.sleep(for: .milliseconds(450)) + } + } + throw lastError ?? WeatherServiceError.invalidResponse + } + + private func fetchWeatherForecast( + latitude: Double, + longitude: Double, + unit: WeatherTemperatureUnit + ) async throws -> WeatherForecastResponse { + do { + let modern = try await fetchWeatherForecastModern( + latitude: latitude, + longitude: longitude, + unit: unit + ) + if modern.resolvedCurrent != nil { + return modern + } + } catch { + // fall through to legacy request as compatibility fallback + } + + return try await fetchWeatherForecastLegacy( + latitude: latitude, + longitude: longitude, + unit: unit + ) + } + + private func fetchWeatherForecastModern( + latitude: Double, + longitude: Double, + unit: WeatherTemperatureUnit + ) async throws -> WeatherForecastResponse { + var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast") + components?.queryItems = [ + URLQueryItem(name: "latitude", value: "\(latitude)"), + URLQueryItem(name: "longitude", value: "\(longitude)"), + URLQueryItem(name: "current", value: "temperature_2m,weather_code,is_day,apparent_temperature,relative_humidity_2m,wind_speed_10m"), + URLQueryItem(name: "daily", value: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max"), + URLQueryItem(name: "forecast_days", value: "7"), + URLQueryItem(name: "temperature_unit", value: unit.rawValue), + URLQueryItem(name: "wind_speed_unit", value: "kmh"), + URLQueryItem(name: "timezone", value: "auto"), + ] + + guard let url = components?.url else { + throw WeatherServiceError.invalidRequest + } + + let (data, response) = try await session.data(for: URLRequest(url: url)) + try validateHTTPResponse(response, data: data) + return try decodeWeatherForecast(from: data) + } + + private func fetchWeatherForecastLegacy( + latitude: Double, + longitude: Double, + unit: WeatherTemperatureUnit + ) async throws -> WeatherForecastResponse { + var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast") + components?.queryItems = [ + URLQueryItem(name: "latitude", value: "\(latitude)"), + URLQueryItem(name: "longitude", value: "\(longitude)"), + URLQueryItem(name: "current_weather", value: "true"), + URLQueryItem(name: "daily", value: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max"), + URLQueryItem(name: "forecast_days", value: "7"), + URLQueryItem(name: "temperature_unit", value: unit.rawValue), + URLQueryItem(name: "wind_speed_unit", value: "kmh"), + URLQueryItem(name: "timezone", value: "auto"), + ] + + guard let url = components?.url else { + throw WeatherServiceError.invalidRequest + } + + let (data, response) = try await session.data(for: URLRequest(url: url)) + try validateHTTPResponse(response, data: data) + return try decodeWeatherForecast(from: data) + } + + private func decodeWeatherForecast(from data: Data) throws -> WeatherForecastResponse { + do { + return try JSONDecoder().decode(WeatherForecastResponse.self, from: data) + } catch { + throw WeatherServiceError.invalidResponse + } + } + + private func validateHTTPResponse(_ response: URLResponse, data: Data? = nil) throws { + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let bodyText = data.flatMap { String(data: $0, encoding: .utf8) } + throw WeatherServiceError.httpStatus(code: (response as? HTTPURLResponse)?.statusCode ?? -1, responseText: bodyText) + } + } +} + +private func l10n(_ key: String, fallback: String) -> String { + let localized = NSLocalizedString(key, comment: "") + return localized == key ? fallback : localized +} + +private func l10nFormat(_ key: String, fallback: String, _ arguments: CVarArg...) -> String { + String(format: l10n(key, fallback: fallback), locale: Locale.current, arguments: arguments) +} + +private enum WeatherServiceError: Error { + case invalidRequest + case invalidResponse + case locationNotFound + case httpStatus(code: Int, responseText: String?) + + var userMessage: String { + switch self { + case .invalidRequest, .invalidResponse: + return l10n("weather_error_unavailable", fallback: "Weather unavailable") + case .locationNotFound: + return l10n("weather_error_location_not_found", fallback: "Location not found") + case let .httpStatus(code, _): + return l10nFormat( + "weather_error_service_unavailable_format", + fallback: "Weather service unavailable (%d)", + code + ) + } + } +} + +private struct GeocodingResponse: Decodable { + let results: [GeocodingResult]? +} + +private struct GeocodingResult: Decodable { + let id: Int? + let name: String + let latitude: Double + let longitude: Double + let population: Int? + let countryCode: String? + let country: String? + let admin1: String? + let admin2: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case latitude + case longitude + case population + case countryCode = "country_code" + case country + case admin1 + case admin2 + } +} + +private struct WeatherForecastResponse: Decodable { + let current: CurrentWeatherModern? + let currentWeather: CurrentWeather? + let daily: DailyWeather? + let timezone: String? + + var resolvedCurrent: ResolvedCurrentWeather? { + if let current { + return ResolvedCurrentWeather( + time: current.time, + temperature: current.temperature2m, + weatherCode: current.weatherCode, + isDay: current.isDay, + apparentTemperature: current.apparentTemperature, + relativeHumidity: current.relativeHumidity2m, + windSpeed: current.windSpeed10m + ) + } + if let currentWeather { + return ResolvedCurrentWeather( + time: currentWeather.time, + temperature: currentWeather.temperature, + weatherCode: currentWeather.weatherCode, + isDay: currentWeather.isDay, + apparentTemperature: nil, + relativeHumidity: nil, + windSpeed: currentWeather.windSpeed + ) + } + return nil + } + + var resolvedTimeZone: TimeZone? { + guard let timezone else { return nil } + return TimeZone(identifier: timezone) + } + + enum CodingKeys: String, CodingKey { + case current + case currentWeather = "current_weather" + case daily + case timezone + } +} + +private struct ResolvedCurrentWeather { + let time: String? + let temperature: Double + let weatherCode: Int + let isDay: Int + let apparentTemperature: Double? + let relativeHumidity: Double? + let windSpeed: Double? +} + +private struct CurrentWeatherModern: Decodable { + let time: String? + let temperature2m: Double + let weatherCode: Int + let isDay: Int + let apparentTemperature: Double? + let relativeHumidity2m: Double? + let windSpeed10m: Double? + + enum CodingKeys: String, CodingKey { + case time + case temperature2m = "temperature_2m" + case weatherCode = "weather_code" + case isDay = "is_day" + case apparentTemperature = "apparent_temperature" + case relativeHumidity2m = "relative_humidity_2m" + case windSpeed10m = "wind_speed_10m" + } +} + +private struct DailyWeather: Decodable { + let time: [String]? + let weatherCode: [Int]? + let temperatureMax: [Double]? + let temperatureMin: [Double]? + let precipitationProbabilityMax: [Double]? + + enum CodingKeys: String, CodingKey { + case time + case weatherCode = "weather_code" + case temperatureMax = "temperature_2m_max" + case temperatureMin = "temperature_2m_min" + case precipitationProbabilityMax = "precipitation_probability_max" + } +} + +private struct CurrentWeather: Decodable { + let time: String? + let temperature: Double + let windSpeed: Double? + let weatherCode: Int + let isDay: Int + + enum CodingKeys: String, CodingKey { + case time + case temperature + case windSpeed = "windspeed" + case weatherCode = "weathercode" + case isDay = "is_day" + } +} + +enum WeatherCodeMapper { + static func description(for code: Int) -> String { + let (key, fallback) = descriptionResource(for: code) + return l10n(key, fallback: fallback) + } + + private static func descriptionResource(for code: Int) -> (key: String, fallback: String) { + switch code { + case 0: + return ("weather_condition_clear", "Clear") + case 1: + return ("weather_condition_mainly_clear", "Mainly clear") + case 2: + return ("weather_condition_partly_cloudy", "Partly cloudy") + case 3: + return ("weather_condition_cloudy", "Cloudy") + case 45, 48: + return ("weather_condition_fog", "Fog") + case 51, 53, 55: + return ("weather_condition_drizzle", "Drizzle") + case 56, 57: + return ("weather_condition_freezing_drizzle", "Freezing drizzle") + case 61, 63, 65: + return ("weather_condition_rain", "Rain") + case 66, 67: + return ("weather_condition_freezing_rain", "Freezing rain") + case 71, 73, 75, 77: + return ("weather_condition_snow", "Snow") + case 80, 81, 82: + return ("weather_condition_rain_showers", "Rain showers") + case 85, 86: + return ("weather_condition_snow_showers", "Snow showers") + case 95: + return ("weather_condition_thunderstorm", "Thunderstorm") + case 96, 99: + return ("weather_condition_thunderstorm_hail", "Thunderstorm with hail") + default: + return ("weather_condition_unknown", "Unknown") + } + } + + static func symbolName(for code: Int, isDay: Bool) -> String { + switch code { + case 0: + return isDay ? "sun.max.fill" : "moon.stars.fill" + case 1, 2: + return isDay ? "cloud.sun.fill" : "cloud.moon.fill" + case 3: + return "cloud.fill" + case 45, 48: + return "cloud.fog.fill" + case 51, 53, 55: + return "cloud.drizzle.fill" + case 56, 57, 66, 67: + return "cloud.sleet.fill" + case 61, 63, 65: + return "cloud.rain.fill" + case 71, 73, 75, 77, 85, 86: + return "cloud.snow.fill" + case 80, 81, 82: + return "cloud.heavyrain.fill" + case 95, 96, 99: + return "cloud.bolt.rain.fill" + default: + return "cloud.fill" + } + } +} diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 0747d328d..8b4f85ead 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -114,6 +114,38 @@ enum OptionKeyAction: String, CaseIterable, Identifiable, Defaults.Serializable } } +enum WeatherTemperatureUnit: String, CaseIterable, Identifiable, Defaults.Serializable { + case celsius + case fahrenheit + + var id: String { self.rawValue } + + var symbol: String { + switch self { + case .celsius: + return "C" + case .fahrenheit: + return "F" + } + } + + var displayName: String { + switch self { + case .celsius: + return "Celsius" + case .fahrenheit: + return "Fahrenheit" + } + } +} + +enum WeatherContentPreference: String, CaseIterable, Identifiable, Defaults.Serializable { + case currentOnly + case currentAndForecast + + var id: String { self.rawValue } +} + // Source/provider for OSD control (user-facing: "Source") enum OSDControlSource: String, CaseIterable, Identifiable, Defaults.Serializable { case builtin @@ -174,6 +206,11 @@ extension Defaults.Keys { static let showNotHumanFace = Key("showNotHumanFace", default: false) static let tileShowLabels = Key("tileShowLabels", default: false) static let showCalendar = Key("showCalendar", default: false) + static let showWeather = Key("showWeather", default: false) + static let weatherCity = Key("weatherCity", default: "Cupertino") + static let weatherUnit = Key("weatherUnit", default: .celsius) + static let weatherRefreshMinutes = Key("weatherRefreshMinutes", default: 30) + static let weatherContentPreference = Key("weatherContentPreference", default: .currentAndForecast) static let hideCompletedReminders = Key("hideCompletedReminders", default: true) static let sliderColor = Key( "sliderUseAlbumArtColor", From 2eaf8d11681ff63262a195d76bfd12b1a1484c08 Mon Sep 17 00:00:00 2001 From: Colin Date: Sat, 7 Mar 2026 19:17:18 +0800 Subject: [PATCH 2/2] chore(weather): remove unused weather payload fields - remove unused timeZone field from WeatherSnapshot\n- remove unused time fields from current weather decoding models\n- keep weather behavior unchanged while reducing dead code --- boringNotch/managers/WeatherManager.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/boringNotch/managers/WeatherManager.swift b/boringNotch/managers/WeatherManager.swift index eb29b5af0..b7f1ea530 100644 --- a/boringNotch/managers/WeatherManager.swift +++ b/boringNotch/managers/WeatherManager.swift @@ -11,7 +11,6 @@ struct WeatherSnapshot { let cityName: String let temperature: Double let unit: WeatherTemperatureUnit - let timeZone: TimeZone let weatherCode: Int let isDay: Bool let highTemperature: Double? @@ -545,7 +544,6 @@ final class WeatherManager: ObservableObject { cityName: preferredDisplayName(for: location), temperature: current.temperature, unit: unit, - timeZone: weatherTimeZone, weatherCode: current.weatherCode, isDay: current.isDay == 1, highTemperature: today?.maxTemperature, @@ -826,7 +824,6 @@ private struct WeatherForecastResponse: Decodable { var resolvedCurrent: ResolvedCurrentWeather? { if let current { return ResolvedCurrentWeather( - time: current.time, temperature: current.temperature2m, weatherCode: current.weatherCode, isDay: current.isDay, @@ -837,7 +834,6 @@ private struct WeatherForecastResponse: Decodable { } if let currentWeather { return ResolvedCurrentWeather( - time: currentWeather.time, temperature: currentWeather.temperature, weatherCode: currentWeather.weatherCode, isDay: currentWeather.isDay, @@ -863,7 +859,6 @@ private struct WeatherForecastResponse: Decodable { } private struct ResolvedCurrentWeather { - let time: String? let temperature: Double let weatherCode: Int let isDay: Int @@ -873,7 +868,6 @@ private struct ResolvedCurrentWeather { } private struct CurrentWeatherModern: Decodable { - let time: String? let temperature2m: Double let weatherCode: Int let isDay: Int @@ -882,7 +876,6 @@ private struct CurrentWeatherModern: Decodable { let windSpeed10m: Double? enum CodingKeys: String, CodingKey { - case time case temperature2m = "temperature_2m" case weatherCode = "weather_code" case isDay = "is_day" @@ -909,14 +902,12 @@ private struct DailyWeather: Decodable { } private struct CurrentWeather: Decodable { - let time: String? let temperature: Double let windSpeed: Double? let weatherCode: Int let isDay: Int enum CodingKeys: String, CodingKey { - case time case temperature case windSpeed = "windspeed" case weatherCode = "weathercode"