boB Rudis
4 years ago
9 changed files with 331 additions and 149 deletions
@ -0,0 +1,90 @@ |
|||
# Xcode |
|||
# |
|||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore |
|||
|
|||
## User settings |
|||
xcuserdata/ |
|||
|
|||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) |
|||
*.xcscmblueprint |
|||
*.xccheckout |
|||
|
|||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) |
|||
build/ |
|||
DerivedData/ |
|||
*.moved-aside |
|||
*.pbxuser |
|||
!default.pbxuser |
|||
*.mode1v3 |
|||
!default.mode1v3 |
|||
*.mode2v3 |
|||
!default.mode2v3 |
|||
*.perspectivev3 |
|||
!default.perspectivev3 |
|||
|
|||
## Obj-C/Swift specific |
|||
*.hmap |
|||
|
|||
## App packaging |
|||
*.ipa |
|||
*.dSYM.zip |
|||
*.dSYM |
|||
|
|||
## Playgrounds |
|||
timeline.xctimeline |
|||
playground.xcworkspace |
|||
|
|||
# Swift Package Manager |
|||
# |
|||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. |
|||
# Packages/ |
|||
# Package.pins |
|||
# Package.resolved |
|||
# *.xcodeproj |
|||
# |
|||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata |
|||
# hence it is not needed unless you have added a package configuration file to your project |
|||
# .swiftpm |
|||
|
|||
.build/ |
|||
|
|||
# CocoaPods |
|||
# |
|||
# We recommend against adding the Pods directory to your .gitignore. However |
|||
# you should judge for yourself, the pros and cons are mentioned at: |
|||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control |
|||
# |
|||
# Pods/ |
|||
# |
|||
# Add this line if you want to avoid checking in source code from the Xcode workspace |
|||
# *.xcworkspace |
|||
|
|||
# Carthage |
|||
# |
|||
# Add this line if you want to avoid checking in source code from Carthage dependencies. |
|||
# Carthage/Checkouts |
|||
|
|||
Carthage/Build/ |
|||
|
|||
# Accio dependency management |
|||
Dependencies/ |
|||
.accio/ |
|||
|
|||
# fastlane |
|||
# |
|||
# It is recommended to not store the screenshots in the git repo. |
|||
# Instead, use fastlane to re-generate the screenshots whenever they are needed. |
|||
# For more information about the recommended setup visit: |
|||
# https://docs.fastlane.tools/best-practices/source-control/#source-control |
|||
|
|||
fastlane/report.xml |
|||
fastlane/Preview.html |
|||
fastlane/screenshots/**/*.png |
|||
fastlane/test_output |
|||
|
|||
# Code Injection |
|||
# |
|||
# After new code Injection tools there's a generated folder /iOSInjectionProject |
|||
# https://github.com/johnno1962/injectionforxcode |
|||
|
|||
iOSInjectionProject/ |
@ -1,64 +1,107 @@ |
|||
// |
|||
// ContentView.swift |
|||
// blah |
|||
// |
|||
// Created by hrbrmstr on 10/28/20. |
|||
// |
|||
|
|||
import SwiftUI |
|||
|
|||
struct ContentView: View { |
|||
|
|||
@EnvironmentObject var model: AppModel |
|||
|
|||
var body: some View { |
|||
VStack { |
|||
List { |
|||
ForEach(model.readings) { |
|||
(r) in DayView(reading: r, min: model.min, max: model.max) |
|||
} |
|||
} |
|||
}.frame( minWidth: 528, idealWidth: 528, maxWidth: 528, |
|||
minHeight: 256, idealHeight: 330, maxHeight: 330) |
|||
} |
|||
|
|||
} |
|||
// MARK: The view for each day row |
|||
|
|||
struct DayView: View { |
|||
|
|||
let reading: F5DayCast |
|||
let min: Double |
|||
let max: Double |
|||
let dayCast: F5DayCast |
|||
let minTemp: Double |
|||
let maxTemp: Double |
|||
|
|||
var body : some View { |
|||
|
|||
// example row: |
|||
// Day | ##/## | icon | ##° (=================) ##° |
|||
|
|||
HStack { |
|||
Text(reading.day.split(separator: " ")[0]).frame(width: 3*12, height: HEIGHT, alignment: .center) |
|||
Text(reading.day.split(separator: " ")[1]).frame(width: 3*12, height: HEIGHT, alignment: .center) |
|||
Image(systemName: etab[reading.condition, default: "sun.max.fill"]) |
|||
.renderingMode(.original) |
|||
.font(.title3) |
|||
.frame(width: 3*12, height: HEIGHT, alignment: .center) |
|||
.foregroundColor(ecol[reading.condition, default: Color.yellow]) |
|||
Spacer().frame(width: CGFloat(reading.low.rescale(from: self.min...self.max, to: DOMAIN_MIN...DOMAIN_MAX)-0), |
|||
height: HEIGHT, alignment: .leading) |
|||
Text(String(format: "%.f°", reading.low)).frame(width: 2*12, height: HEIGHT, alignment: .trailing) |
|||
Spacer().frame(width: 10.0, height: HEIGHT, alignment: .leading) |
|||
GeometryReader { g in |
|||
|
|||
Text(dayCast.day.split(separator: " ")[0]) |
|||
.frame(width: 3*CHAR_WIDTH, height: HEIGHT, alignment: .center) // Day |
|||
|
|||
Text(dayCast.day.split(separator: " ")[1]) |
|||
.frame(width: 3*CHAR_WIDTH, height: HEIGHT, alignment: .center) // ##/## |
|||
|
|||
Image(systemName: conditionToSymbol[dayCast.condition, default: "sun.max.fill"]) // icon |
|||
.renderingMode(.original) // enables color |
|||
.font(.title3) // a bit bigger than body |
|||
.frame(width: 3*CHAR_WIDTH, height: HEIGHT, alignment: .center) |
|||
.foregroundColor(conditionToSymbolColor[dayCast.condition, default: Color.yellow]) // change higlight color depending on the condition |
|||
|
|||
Spacer() // space before first temp label |
|||
.frame( |
|||
width: CGFloat(dayCast.low.rescale(from: self.minTemp...self.maxTemp, to: RANGE_MIN...RANGE_MAX)-0), |
|||
height: HEIGHT, |
|||
alignment: .leading |
|||
) |
|||
|
|||
Text(String(format: "%.f°", dayCast.low)) // ##° |
|||
.frame(width: 3*CHAR_WIDTH, height: HEIGHT, alignment: .trailing) |
|||
|
|||
Spacer() // small space before temperature line |
|||
.frame(width: CHAR_WIDTH, height: HEIGHT, alignment: .leading) |
|||
|
|||
GeometryReader { g in // temperature line (=================) |
|||
Path { path in |
|||
let w = g.size.width |
|||
let h = g.size.height |
|||
path.move(to: CGPoint(x: 0, y: h/2)) |
|||
path.addLine(to: CGPoint(x:w, y: h/2)) |
|||
}.stroke(style: StrokeStyle(lineWidth: HEIGHT/2, lineCap: .round)).foregroundColor(Color.primary) |
|||
}.frame(width: CGFloat(reading.high.rescale(from: self.min...self.max, to: DOMAIN_MIN...DOMAIN_MAX) - |
|||
reading.low.rescale(from: self.min...self.max, to: DOMAIN_MIN...DOMAIN_MAX)), |
|||
height: HEIGHT, alignment: .leading) |
|||
Spacer().frame(width: 10.0, height: HEIGHT, alignment: .leading) |
|||
Text(String(format: "%.f°", reading.high)).frame(width: 2*12, height: HEIGHT, alignment: .leading) |
|||
} |
|||
.stroke(style: StrokeStyle(lineWidth: HEIGHT/2, lineCap: .round)) |
|||
.foregroundColor(Color.primary) |
|||
|
|||
}.frame( |
|||
width: CGFloat(dayCast.high.rescale(from: self.minTemp...self.maxTemp, to: RANGE_MIN...RANGE_MAX) - |
|||
dayCast.low.rescale(from: self.minTemp...self.maxTemp, to: RANGE_MIN...RANGE_MAX)), |
|||
height: HEIGHT, |
|||
alignment: .leading |
|||
) |
|||
|
|||
Spacer() // small space after temperature line |
|||
.frame(width: CHAR_WIDTH, height: HEIGHT, alignment: .leading) |
|||
|
|||
Text(String(format: "%.f°", dayCast.high)) // ##° |
|||
.frame(width: 3*CHAR_WIDTH, height: HEIGHT, alignment: .leading) |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
|
|||
// MARK: The main view/window |
|||
|
|||
struct ContentView: View { |
|||
|
|||
@EnvironmentObject var model: AppModel // set in f5wxApp.swift |
|||
|
|||
var body: some View { |
|||
|
|||
GeometryReader { g in // greedy view in the event we want to do more complex things |
|||
VStack { |
|||
List { // list view for each day forecast |
|||
ForEach(model.forecast) { |
|||
(day) in DayView(dayCast: day, minTemp: model.minTemp, maxTemp: model.maxTemp) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
.frame( |
|||
minWidth: VIEW_WIDTH, |
|||
idealWidth: VIEW_WIDTH, |
|||
maxWidth: VIEW_WIDTH, |
|||
minHeight: VIEW_MIN_HEIGHT, |
|||
idealHeight: CGFloat((model.forecast.count > 0 ? model.forecast.count : MAX_ROWS) * ROW_HEIGHT), // dynamically resize window based on the number of forecast day lines we have |
|||
maxHeight: CGFloat(MAX_ROWS * ROW_HEIGHT) |
|||
) |
|||
.alert( |
|||
isPresented: $model.showAlert, |
|||
content: { |
|||
Alert( |
|||
title : Text("Forecast Retrieval Error"), |
|||
message: Text(model.alertMessage), |
|||
dismissButton: .default(Text("Continue")) |
|||
) |
|||
}) |
|||
} |
|||
|
|||
} |
|||
|
@ -0,0 +1,7 @@ |
|||
/* |
|||
Localizable.strings |
|||
F5 Weather |
|||
|
|||
Created by hrbrmstr on 10/30/20. |
|||
|
|||
*/ |
@ -1,29 +1,33 @@ |
|||
// |
|||
// blahApp.swift |
|||
// blah |
|||
// |
|||
// Created by hrbrmstr on 10/28/20. |
|||
// |
|||
|
|||
import SwiftUI |
|||
|
|||
var model = AppModel() |
|||
var model = AppModel() // initialize the app model |
|||
|
|||
@main |
|||
struct blahApp: App { |
|||
struct f5wxApp: App { |
|||
|
|||
typealias MenuItem = Button // (not rly thrilled SwiftUI uses "Button" for menu things) |
|||
|
|||
var body: some Scene { |
|||
|
|||
WindowGroup { |
|||
|
|||
ContentView() |
|||
.navigationTitle("F5 Weather • ECMWF • Berwick, Maine") |
|||
.environmentObject(model) |
|||
.navigationTitle("F5 Weather • ECMWF • Berwick, Maine") // set a title for the app |
|||
.environmentObject(model) // place our initialized app model into the Environment |
|||
.fixedSize() // set window to fixed size so we can programmatically resize it |
|||
|
|||
}.commands { |
|||
CommandMenu("Utilities") { |
|||
Button(action: { |
|||
model.getReadings() |
|||
|
|||
CommandMenu("Tools") { // add a "Tools" menu |
|||
MenuItem(action: { // Add a menu item and shortcut that will grab new forecast data |
|||
model.getForecast() |
|||
}) { |
|||
Text("Refresh") |
|||
}.keyboardShortcut("r", modifiers: .command) |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
|
@ -1,92 +1,106 @@ |
|||
// |
|||
// model.swift |
|||
// blah |
|||
// |
|||
// Created by hrbrmstr on 10/28/20. |
|||
// |
|||
|
|||
import Foundation |
|||
|
|||
// MARK: What each forecast row holds |
|||
struct F5DayCast: Identifiable { |
|||
|
|||
var id = UUID() |
|||
var day: String = "" |
|||
var low: Double = 0.0 |
|||
var high: Double = 0.0 |
|||
var condition: String = "" |
|||
var day: String = "" // e.g. "Fri 10/30" |
|||
var low: Double = 0.0 // e.g. 31 |
|||
var high: Double = 0.0 // e.g. 53 |
|||
var condition: String = "" // e.g. "Clear" |
|||
|
|||
} |
|||
|
|||
class AppModel: NSObject, ObservableObject { |
|||
// MARK: The core model for the application |
|||
class AppModel: ObservableObject { |
|||
|
|||
// example individual JSON line: |
|||
// {"V1":"Wed 10/28","V2":38,"V3":47,"conditions":"Rain","c_alpha":0.75} |
|||
|
|||
@Published var readings: [F5DayCast] = [] |
|||
@Published var min: Double = Double.infinity |
|||
@Published var max: Double = -Double.infinity |
|||
@Published var forecast: [F5DayCast] = [] // each day's forecast |
|||
|
|||
@Published var minTemp: Double = Double.infinity // we're holding the overall lowest and highest temps so we |
|||
@Published var maxTemp: Double = -Double.infinity // can draw the lines scaled properly |
|||
|
|||
@Published var showAlert: Bool = false // in case there are errors |
|||
@Published var alertMessage: String = "" |
|||
|
|||
override init() { |
|||
super.init() |
|||
getReadings() |
|||
init() { |
|||
getForecast() // get the forecast right away |
|||
} |
|||
|
|||
func getReadings() { |
|||
|
|||
let urlString = "https://rud.is/f5wx/conditions.json?q=\(Date().timeIntervalSince1970)" |
|||
|
|||
logger.info("Retrieving \(urlString)") |
|||
func getForecast() { |
|||
|
|||
let url = URL(string: urlString)! |
|||
let task = URLSession.shared.dataTask(with: url) { data, response, error in |
|||
if let urlBaseString = Bundle.main.object(forInfoDictionaryKey: "ForecastURL") as? String { |
|||
|
|||
if let error = error { |
|||
debugPrint("Error fetching data: \(error)") |
|||
return |
|||
} |
|||
let urlString : String = "\(urlBaseString)?q=\(Date().timeIntervalSince1970)" |
|||
|
|||
guard let httpResponse = response as? HTTPURLResponse, |
|||
(200...299).contains(httpResponse.statusCode) else { return } |
|||
logger.info("Retrieving \(urlString)") |
|||
|
|||
if let mimeType = httpResponse.mimeType, mimeType == "application/json", |
|||
|
|||
let data = data, |
|||
let res = String(data: data, encoding: .utf8) { |
|||
|
|||
DispatchQueue.main.async { |
|||
if let url = URL(string: urlString) { |
|||
|
|||
let task = URLSession.shared.dataTask(with: url) { data, response, error in |
|||
|
|||
if let error = error { // alert on error |
|||
self.showAlert = true |
|||
self.alertMessage = error.localizedDescription |
|||
logger.info("Error fetching data: \(error.localizedDescription)") |
|||
return |
|||
} |
|||
|
|||
guard let httpResponse = response as? HTTPURLResponse, |
|||
(200...299).contains(httpResponse.statusCode) else { // alert on non-200 response |
|||
self.showAlert = true |
|||
self.alertMessage = "Received a non-200 response from the server." |
|||
logger.info("Non-200 response.") |
|||
return |
|||
} |
|||
|
|||
if let mimeType = httpResponse.mimeType, mimeType == "application/json", |
|||
|
|||
let data = data, |
|||
let res = String(data: data, encoding: .utf8) { |
|||
DispatchQueue.main.async { |
|||
|
|||
let lines = res.split(whereSeparator: \.isNewline) // convert the String response to lines |
|||
|
|||
self.forecast = lines.map { line in // process JSON in each line |
|||
|
|||
let v = try? JSONDecoder().decode(JSON.self, from: line.data(using: .utf8)!) |
|||
|
|||
let lines = res.split(whereSeparator: \.isNewline) |
|||
if let m = v!.V2.doubleValue { // see if the day's min is the lowest we've seen |
|||
self.minTemp = (m < self.minTemp) ? m : self.minTemp |
|||
} |
|||
|
|||
if let m = v!.V3.doubleValue { // see if the day's max is the lowest we've seen |
|||
self.maxTemp = (m > self.maxTemp) ? m : self.maxTemp |
|||
} |
|||
|
|||
return( // if the records are junk but are still "records", populate the day with defaults |
|||
F5DayCast( |
|||
day: v!.V1.stringValue ?? "Day ##/##", |
|||
low: v!.V2.doubleValue ?? 32.0, |
|||
high: v!.V3.doubleValue ?? 90.0, |
|||
condition: v!.conditions.stringValue ?? "Clear" |
|||
) |
|||
) |
|||
|
|||
} // lines processor |
|||
|
|||
} // DQ |
|||
|
|||
self.readings = lines.map { line in |
|||
|
|||
let v = try? JSONDecoder().decode(JSON.self, from: line.data(using: .utf8)!) |
|||
|
|||
if let m = v!.V2.doubleValue { |
|||
self.min = (m < self.min) ? m : self.min |
|||
} |
|||
|
|||
if let m = v!.V3.doubleValue { |
|||
self.max = (m > self.max) ? m : self.max |
|||
} |
|||
|
|||
return( |
|||
F5DayCast( |
|||
day: v!.V1.stringValue ?? "ERROR", |
|||
low: v!.V2.doubleValue ?? -1.0, |
|||
high: v!.V3.doubleValue ?? -1.0, |
|||
condition: v!.conditions.stringValue ?? "ERROR" |
|||
) |
|||
) |
|||
|
|||
} |
|||
} // mimeType |
|||
|
|||
} |
|||
|
|||
} |
|||
} // task setup |
|||
|
|||
task.resume() |
|||
|
|||
} |
|||
|
|||
task.resume() |
|||
|
|||
} |
|||
|
|||
} |
|||
} else { |
|||
self.showAlert = true |
|||
self.alertMessage = "URL error" |
|||
logger.info("URL Error") |
|||
return |
|||
} // URL |
|||
} // urlString |
|||
} // getForecast() |
|||
} // class |
|||
|
Loading…
Reference in new issue