Add weather feature with WeatherKit integration for mood entries
Fetch and display weather data (temp, condition, hi/lo, humidity) when users log a mood. Weather is stored as JSON on MoodEntryModel and shown as a card in EntryDetailView. Premium-gated with location permission prompt. Includes BGTask retry for failed fetches and full analytics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
70
Shared/Views/DayView/WeatherCardView.swift
Normal file
70
Shared/Views/DayView/WeatherCardView.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// WeatherCardView.swift
|
||||
// Reflect
|
||||
//
|
||||
// Visual weather card shown in EntryDetailView.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WeatherCardView: View {
|
||||
let weatherData: WeatherData
|
||||
|
||||
private var highTemp: String {
|
||||
formatTemperature(weatherData.highTemperature)
|
||||
}
|
||||
|
||||
private var lowTemp: String {
|
||||
formatTemperature(weatherData.lowTemperature)
|
||||
}
|
||||
|
||||
private var humidityPercent: String {
|
||||
"\(Int(weatherData.humidity * 100))%"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: weatherData.conditionSymbol)
|
||||
.font(.system(size: 36))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.frame(width: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(weatherData.condition)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Label(String(localized: "H: \(highTemp)"), systemImage: "thermometer.high")
|
||||
Label(String(localized: "L: \(lowTemp)"), systemImage: "thermometer.low")
|
||||
Label(humidityPercent, systemImage: "humidity")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private func formatTemperature(_ celsius: Double) -> String {
|
||||
let measurement = Measurement(value: celsius, unit: UnitTemperature.celsius)
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .providedUnit
|
||||
formatter.numberFormatter.maximumFractionDigits = 0
|
||||
formatter.unitStyle = .short
|
||||
// Use locale-aware conversion
|
||||
let locale = Locale.current
|
||||
if locale.measurementSystem == .us {
|
||||
let fahrenheit = measurement.converted(to: .fahrenheit)
|
||||
return formatter.string(from: fahrenheit)
|
||||
}
|
||||
return formatter.string(from: measurement)
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,12 @@ struct EntryDetailView: View {
|
||||
// Notes section
|
||||
notesSection
|
||||
|
||||
// Weather section
|
||||
if let weatherJSON = entry.weatherJSON,
|
||||
let weatherData = WeatherData.decode(from: weatherJSON) {
|
||||
weatherSection(weatherData)
|
||||
}
|
||||
|
||||
// Photo section
|
||||
photoSection
|
||||
|
||||
@@ -411,6 +417,16 @@ struct EntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func weatherSection(_ weatherData: WeatherData) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weather")
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
WeatherCardView(weatherData: weatherData)
|
||||
}
|
||||
}
|
||||
|
||||
private var photoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
|
||||
@@ -39,6 +39,8 @@ struct SettingsContentView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
||||
@AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true
|
||||
@State private var showWeatherSubscriptionStore = false
|
||||
|
||||
private var textColor: Color { theme.currentTheme.labelColor }
|
||||
|
||||
@@ -49,6 +51,7 @@ struct SettingsContentView: View {
|
||||
featuresSectionHeader
|
||||
privacyLockToggle
|
||||
healthKitToggle
|
||||
weatherToggle
|
||||
exportDataButton
|
||||
|
||||
// Settings section
|
||||
@@ -959,6 +962,72 @@ struct SettingsContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Weather Toggle
|
||||
|
||||
private var weatherToggle: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "cloud.sun.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Weather")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Show weather details for each day")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { iapManager.shouldShowPaywall ? false : weatherEnabled },
|
||||
set: { newValue in
|
||||
if iapManager.shouldShowPaywall {
|
||||
showWeatherSubscriptionStore = true
|
||||
return
|
||||
}
|
||||
|
||||
weatherEnabled = newValue
|
||||
|
||||
if newValue {
|
||||
LocationManager.shared.requestAuthorization()
|
||||
}
|
||||
|
||||
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(iapManager.shouldShowPaywall)
|
||||
.accessibilityLabel(String(localized: "Weather"))
|
||||
.accessibilityHint(String(localized: "Show weather details for each day"))
|
||||
}
|
||||
.padding()
|
||||
|
||||
if iapManager.shouldShowPaywall {
|
||||
HStack {
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Premium Feature")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "Premium feature, subscription required"))
|
||||
}
|
||||
}
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showWeatherSubscriptionStore) {
|
||||
ReflectSubscriptionStoreView(source: "settings")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data Button
|
||||
|
||||
private var exportDataButton: some View {
|
||||
@@ -1240,6 +1309,8 @@ struct SettingsView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
|
||||
@AppStorage(UserDefaultsStore.Keys.hapticFeedbackEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var hapticFeedbackEnabled = true
|
||||
@AppStorage(UserDefaultsStore.Keys.weatherEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var weatherEnabled = true
|
||||
@State private var showWeatherSubscriptionStore = false
|
||||
|
||||
private var textColor: Color { theme.currentTheme.labelColor }
|
||||
|
||||
@@ -1257,6 +1328,7 @@ struct SettingsView: View {
|
||||
featuresSectionHeader
|
||||
privacyLockToggle
|
||||
healthKitToggle
|
||||
weatherToggle
|
||||
exportDataButton
|
||||
|
||||
// Settings section
|
||||
@@ -1560,6 +1632,68 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Weather Toggle
|
||||
|
||||
private var weatherToggle: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "cloud.sun.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Weather")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Show weather details for each day")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { iapManager.shouldShowPaywall ? false : weatherEnabled },
|
||||
set: { newValue in
|
||||
if iapManager.shouldShowPaywall {
|
||||
showWeatherSubscriptionStore = true
|
||||
return
|
||||
}
|
||||
|
||||
weatherEnabled = newValue
|
||||
|
||||
if newValue {
|
||||
LocationManager.shared.requestAuthorization()
|
||||
}
|
||||
|
||||
AnalyticsManager.shared.track(.weatherToggled(enabled: newValue))
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(iapManager.shouldShowPaywall)
|
||||
}
|
||||
.padding()
|
||||
|
||||
if iapManager.shouldShowPaywall {
|
||||
HStack {
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Premium Feature")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showWeatherSubscriptionStore) {
|
||||
ReflectSubscriptionStoreView(source: "settings")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data Button
|
||||
|
||||
private var exportDataButton: some View {
|
||||
|
||||
Reference in New Issue
Block a user