Browse Source

cleaned some bits up

batman
boB Rudis 4 years ago
parent
commit
7411dc7ce5
No known key found for this signature in database GPG Key ID: 1D7529BE14E2BBA9
  1. 90
      .gitignore
  2. 23
      F5 Weather.xcodeproj/project.pbxproj
  3. 131
      f5-weather/ContentView.swift
  4. 2
      f5-weather/Info.plist
  5. 7
      f5-weather/en.lproj/Localizable.strings
  6. 32
      f5-weather/f5wxApp.swift
  7. 32
      f5-weather/globals.swift
  8. 9
      f5-weather/json-utils.swift
  9. 154
      f5-weather/model.swift

90
.gitignore

@ -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/

23
F5 Weather.xcodeproj/project.pbxproj

@ -15,6 +15,7 @@
015AB41A254993C200AB0EF9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 015AB419254993C200AB0EF9 /* Assets.xcassets */; };
015AB41D254993C200AB0EF9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 015AB41C254993C200AB0EF9 /* Preview Assets.xcassets */; };
015AB4272549943B00AB0EF9 /* json-utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015AB4262549943B00AB0EF9 /* json-utils.swift */; };
01B59D5A254C660E00D6F5ED /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 01B59D5C254C660E00D6F5ED /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -29,6 +30,7 @@
015AB41E254993C200AB0EF9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
015AB41F254993C200AB0EF9 /* f5-weather.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "f5-weather.entitlements"; sourceTree = "<group>"; };
015AB4262549943B00AB0EF9 /* json-utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "json-utils.swift"; sourceTree = "<group>"; };
01B59D5B254C660E00D6F5ED /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -61,16 +63,17 @@
015AB414254993C100AB0EF9 /* f5-weather */ = {
isa = PBXGroup;
children = (
015AB4262549943B00AB0EF9 /* json-utils.swift */,
015AB415254993C200AB0EF9 /* f5wxApp.swift */,
015AB417254993C200AB0EF9 /* ContentView.swift */,
010B0E822549F65E007385A6 /* model.swift */,
010B0E852549F6A6007385A6 /* globals.swift */,
015AB4262549943B00AB0EF9 /* json-utils.swift */,
010B0E7F2549F642007385A6 /* utils.swift */,
015AB419254993C200AB0EF9 /* Assets.xcassets */,
015AB41E254993C200AB0EF9 /* Info.plist */,
015AB41F254993C200AB0EF9 /* f5-weather.entitlements */,
015AB41B254993C200AB0EF9 /* Preview Content */,
010B0E7F2549F642007385A6 /* utils.swift */,
010B0E822549F65E007385A6 /* model.swift */,
010B0E852549F6A6007385A6 /* globals.swift */,
01B59D5C254C660E00D6F5ED /* Localizable.strings */,
);
path = "f5-weather";
sourceTree = "<group>";
@ -141,6 +144,7 @@
buildActionMask = 2147483647;
files = (
015AB41D254993C200AB0EF9 /* Preview Assets.xcassets in Resources */,
01B59D5A254C660E00D6F5ED /* Localizable.strings in Resources */,
015AB41A254993C200AB0EF9 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -163,6 +167,17 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
01B59D5C254C660E00D6F5ED /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
01B59D5B254C660E00D6F5ED /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
015AB420254993C200AB0EF9 /* Debug */ = {
isa = XCBuildConfiguration;

131
f5-weather/ContentView.swift

@ -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"))
)
})
}
}

2
f5-weather/Info.plist

@ -22,5 +22,7 @@
<string>public.app-category.weather</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>ForecastURL</key>
<string>https://rud.is/f5wx/conditions.json</string>
</dict>
</plist>

7
f5-weather/en.lproj/Localizable.strings

@ -0,0 +1,7 @@
/*
Localizable.strings
F5 Weather
Created by hrbrmstr on 10/30/20.
*/

32
f5-weather/f5wxApp.swift

@ -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)
}
}
}
}

32
f5-weather/globals.swift

@ -1,27 +1,37 @@
//
// globals.swift
// blah
//
// Created by hrbrmstr on 10/28/20.
//
import Foundation
import SwiftUI
import os
let DOMAIN_MIN: Double = 0
let DOMAIN_MAX: Double = 300
// min and max range for forecast temp line size
let RANGE_MIN: Double = 0
let RANGE_MAX: Double = 300
// height for each row element component
let HEIGHT: CGFloat = 20
// fixed view width and min height of the view
let VIEW_WIDTH: CGFloat = 548
let VIEW_MIN_HEIGHT: CGFloat = 256
// max possible rows from forecast and the height of each row (including padding)
let MAX_ROWS: Int = 20
let ROW_HEIGHT: Int = 30
// width of a char (not great to hardcode this)
let CHAR_WIDTH: CGFloat = 12
// we will log some things
let logger = Logger()
let etab: [String: String] = [
// assoc array to map conditions to SF Symbols
let conditionToSymbol: [String: String] = [
"Rain" : "cloud.rain.fill",
"Snow" : "cloud.snow.fill",
"Clear" : "sun.max.fill"
]
let ecol: [String: Color] = [
// assoc array to map conditions to the highlight color they should have
let conditionToSymbolColor: [String: Color] = [
"Rain" : Color.blue,
"Snow" : Color.blue,
"Clear" : Color.yellow

9
f5-weather/json-utils.swift

@ -1,9 +1,6 @@
//
// json-utils.swift
// xyz
//
// Created by hrbrmstr on 10/6/20.
//
// https://www.hackingwithswift.com/articles/55/how-to-use-dynamic-member-lookup-in-swift
// this makes working with deserialized JSON a bit more like scripting languages
import Foundation

154
f5-weather/model.swift

@ -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…
Cancel
Save