From 311ae1de9daccacfa01106b82ce6698f4240dfc2 Mon Sep 17 00:00:00 2001 From: bwees Date: Fri, 2 Aug 2024 22:19:57 -0400 Subject: [PATCH] add better error handling, marquee text, and fied SSL networking --- targets/watch/APIManager.swift | 27 +++++- targets/watch/APITypes.swift | 2 +- targets/watch/ErrorView.swift | 26 ++++++ targets/watch/Info.plist | 10 ++- targets/watch/Marquee_Main.swift | 140 +++++++++++++++++++++++++++++++ targets/watch/RouteCell.swift | 77 +++++++++-------- targets/watch/RouteDetail.swift | 44 +++++++--- targets/watch/RouteList.swift | 29 +++++-- targets/watch/StopCell.swift | 17 ++-- 9 files changed, 305 insertions(+), 67 deletions(-) create mode 100644 targets/watch/ErrorView.swift create mode 100644 targets/watch/Marquee_Main.swift diff --git a/targets/watch/APIManager.swift b/targets/watch/APIManager.swift index 44c82a4..e97ee3f 100644 --- a/targets/watch/APIManager.swift +++ b/targets/watch/APIManager.swift @@ -15,6 +15,19 @@ enum NetworkError: Error { case invalidResponse } +class SSLBypassDelegate: NSObject, URLSessionDelegate { + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust { + // Allow any certificate + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + } else { + completionHandler(.performDefaultHandling, nil) + } + } +} + class APIManager: ObservableObject { @Published var baseData: GetBaseDataResponse? @Published var error: Error? @@ -22,6 +35,8 @@ class APIManager: ObservableObject { private var authKey: String = "" var cancellables = Set() + let session = URLSession(configuration: .default, delegate: SSLBypassDelegate(), delegateQueue: nil) + func fetchData() { getAuthentication() @@ -49,7 +64,9 @@ class APIManager: ObservableObject { request.httpMethod = "GET" request.httpShouldHandleCookies = false - return URLSession.shared.dataTaskPublisher(for: request) + + + return session.dataTaskPublisher(for: request) .tryMap { data, response in guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, @@ -76,7 +93,7 @@ class APIManager: ObservableObject { request.httpMethod = "POST" request.addValue(auth, forHTTPHeaderField: "cookie") - return URLSession.shared.dataTaskPublisher(for: request) + return session.dataTaskPublisher(for: request) .map(\.data) .decode(type: GetBaseDataResponse.self, decoder: JSONDecoder()) .receive(on: RunLoop.main) @@ -84,6 +101,7 @@ class APIManager: ObservableObject { } func getPatternPaths(routeKeys: [String]) -> AnyPublisher<[GetPatternPathsResponse], Error> { + let bodyData = routeKeys.map { "routeKeys%5B%5D=\($0)" }.joined(separator: "&") guard let url = URL(string: "https://aggiespirit.ts.tamu.edu/RouteMap/GetPatternPaths") else { return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() @@ -96,7 +114,7 @@ class APIManager: ObservableObject { request.httpBody = bodyData.data(using: .utf8) - return URLSession.shared.dataTaskPublisher(for: request) + return session.dataTaskPublisher(for: request) .tryMap { data, response in guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw NetworkError.invalidResponse @@ -109,6 +127,7 @@ class APIManager: ObservableObject { } func getNextDepartureTimes(routeId: String, directionIds: [String], stopCode: String) -> AnyPublisher { + var bodyData = [String]() for (i, directionId) in directionIds.enumerated() { let directionData = "routeDirectionKeys[\(i)][routeKey]=\(routeId)&routeDirectionKeys[\(i)][directionKey]=\(directionId)&stopCode=\(stopCode)" @@ -126,7 +145,7 @@ class APIManager: ObservableObject { request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type") request.httpBody = bodyString.data(using: .utf8) - return URLSession.shared.dataTaskPublisher(for: request) + return session.dataTaskPublisher(for: request) .tryMap { data, response in guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw NetworkError.invalidResponse diff --git a/targets/watch/APITypes.swift b/targets/watch/APITypes.swift index dd4c1bb..174368c 100644 --- a/targets/watch/APITypes.swift +++ b/targets/watch/APITypes.swift @@ -44,7 +44,7 @@ struct PatternPath: Codable { let patternKey: String let directionKey: String let patternPoints: [PatternPoint] - let segmentPaths: [String] // Always blank... leaving as Any for now +// let segmentPaths: [Any] // Always blank... leaving as Any for now } struct MapRoute: Codable { diff --git a/targets/watch/ErrorView.swift b/targets/watch/ErrorView.swift new file mode 100644 index 0000000..10e54d3 --- /dev/null +++ b/targets/watch/ErrorView.swift @@ -0,0 +1,26 @@ +// +// ErrorView.swift +// ReveilleRides +// +// Created by Brandon Wees on 8/2/24. +// + +import SwiftUI + +struct ErrorView: View { + var text: String + + var body: some View { + VStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.gray) + Text(text) + .multilineTextAlignment(.center) + .foregroundStyle(.gray) + } + } +} + +#Preview { + ErrorView(text: "There was an error") +} diff --git a/targets/watch/Info.plist b/targets/watch/Info.plist index f683276..6a6654d 100644 --- a/targets/watch/Info.plist +++ b/targets/watch/Info.plist @@ -1,5 +1,11 @@ - - \ No newline at end of file + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/targets/watch/Marquee_Main.swift b/targets/watch/Marquee_Main.swift new file mode 100644 index 0000000..427dba4 --- /dev/null +++ b/targets/watch/Marquee_Main.swift @@ -0,0 +1,140 @@ +import SwiftUI +import Combine +import Foundation + +public struct MarqueeText : View { + public var text: String + public var font: UIFont + public var leftFade: CGFloat + public var rightFade: CGFloat + public var startDelay: Double + public var alignment: Alignment + + @State private var animate = false + var isCompact = false + + public var body : some View { + let stringWidth = text.widthOfString(usingFont: font) + let stringHeight = text.heightOfString(usingFont: font) + + let animation = Animation + .linear(duration: Double(stringWidth) / 30) + .delay(startDelay) + .repeatForever(autoreverses: false) + + let nullAnimation = Animation + .linear(duration: 0) + + return ZStack { + GeometryReader { geo in + if stringWidth > geo.size.width { // don't use self.animate as conditional here + Group { + Text(self.text) + .lineLimit(1) + .font(.init(font)) + .offset(x: self.animate ? -stringWidth - stringHeight * 2 : 0) + .animation(self.animate ? animation : nullAnimation, value: self.animate) + .onAppear { + DispatchQueue.main.async { + self.animate = geo.size.width < stringWidth + } + } + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + + Text(self.text) + .lineLimit(1) + .font(.init(font)) + .offset(x: self.animate ? 0 : stringWidth + stringHeight * 2) + .animation(self.animate ? animation : nullAnimation, value: self.animate) + .onAppear { + DispatchQueue.main.async { + self.animate = geo.size.width < stringWidth + } + } + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + } + .onValueChanged(of: self.text, perform: {text in + self.animate = geo.size.width < stringWidth + }) + + .offset(x: leftFade) + .mask( + HStack(spacing:0) { + Rectangle() + .frame(width:2) + .opacity(0) + LinearGradient(gradient: Gradient(colors: [Color.black.opacity(0), Color.black]), startPoint: /*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/, endPoint: /*@START_MENU_TOKEN@*/.trailing/*@END_MENU_TOKEN@*/) + .frame(width:leftFade) + LinearGradient(gradient: Gradient(colors: [Color.black, Color.black]), startPoint: /*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/, endPoint: /*@START_MENU_TOKEN@*/.trailing/*@END_MENU_TOKEN@*/) + LinearGradient(gradient: Gradient(colors: [Color.black, Color.black.opacity(0)]), startPoint: /*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/, endPoint: /*@START_MENU_TOKEN@*/.trailing/*@END_MENU_TOKEN@*/) + .frame(width:rightFade) + Rectangle() + .frame(width:2) + .opacity(0) + }) + .frame(width: geo.size.width + leftFade) + .offset(x: leftFade * -1) + } else { + Text(self.text) + .font(.init(font)) + .onValueChanged(of: self.text, perform: {text in + self.animate = geo.size.width < stringWidth + }) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: alignment) + } + } + } + .frame(height: stringHeight) + .frame(maxWidth: isCompact ? stringWidth : nil) + .onDisappear { + self.animate = false + } + } + + public init(text: String, font: UIFont, leftFade: CGFloat, rightFade: CGFloat, startDelay: Double, alignment: Alignment? = nil) { + self.text = text + self.font = font + self.leftFade = leftFade + self.rightFade = rightFade + self.startDelay = startDelay + self.alignment = alignment != nil ? alignment! : .topLeading + } +} + +extension MarqueeText { + public func makeCompact(_ compact: Bool = true) -> Self { + var view = self + view.isCompact = compact + return view + } +} + +extension String { + + func widthOfString(usingFont font: UIFont) -> CGFloat { + let fontAttributes = [NSAttributedString.Key.font: font] + let size = self.size(withAttributes: fontAttributes) + return size.width + } + + func heightOfString(usingFont font: UIFont) -> CGFloat { + let fontAttributes = [NSAttributedString.Key.font: font] + let size = self.size(withAttributes: fontAttributes) + return size.height + } +} + +extension View { + /// A backwards compatible wrapper for iOS 14 `onChange` + @ViewBuilder func onValueChanged(of value: T, perform onChange: @escaping (T) -> Void) -> some View { + if #available(iOS 14.0, *) { + self.onChange(of: value, perform: onChange) + } else { + self.onReceive(Just(value)) { (value) in + onChange(value) + } + } + } +} diff --git a/targets/watch/RouteCell.swift b/targets/watch/RouteCell.swift index 30c9ba1..afbc9b0 100644 --- a/targets/watch/RouteCell.swift +++ b/targets/watch/RouteCell.swift @@ -13,45 +13,56 @@ struct RouteCell: View { var color: Color var subtitle: String + @State var showNextLine = false + @State var rowHeight: CGFloat = 84 + + let titleFont = UIFont.preferredFont(forTextStyle: .headline) + var body: some View { - HStack { - VStack { + HStack { + VStack { + HStack(alignment: .center) { + Text(number) + .frame(height: 22) + .padding([.horizontal], 6) + .font(.system(size: 16).bold()) + .minimumScaleFactor(0.1) + .lineLimit(1) + .background(color) + .clipShape(.rect(cornerSize: CGSize(width: 8, height: 8))) + + MarqueeText( + text: name, + font: titleFont, + leftFade: 8, + rightFade: 8, + startDelay: 1 + ) + .padding([.leading], 2) + } + + if subtitle != "" { HStack { - Text(number) - .padding([.vertical], 2) - .padding([.horizontal], 6) - .font(.system(size: 16).bold()) - .minimumScaleFactor(0.1) - .lineLimit(1) - .background(color) - .clipShape(.rect(cornerSize: CGSize(width: 8, height: 8))) - Text(name) - .font(.headline) - .frame(alignment: .leading) - .lineLimit(1) + MarqueeText( + text: subtitle, + font: UIFont.systemFont(ofSize: 12), + leftFade: 8, + rightFade: 8, + startDelay: 2 + ) + .padding([.leading], 4) Spacer() } - if subtitle != "" { - HStack { - Text(subtitle) - .font(.system(size: 12)) - .frame(alignment: .leading) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() - } - } } - .padding([.leading], 4) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundStyle(.tertiary) } - .padding([.vertical], 8) - +// .padding([.leading], 4) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding([.vertical], 8) } } diff --git a/targets/watch/RouteDetail.swift b/targets/watch/RouteDetail.swift index c243de9..6419cc5 100644 --- a/targets/watch/RouteDetail.swift +++ b/targets/watch/RouteDetail.swift @@ -16,13 +16,15 @@ struct RouteDetail: View { @State var selectedPath: PatternPath? @State var favorited: Bool = false + @State var error: Error? + func updatePathData() { apiManager.getPatternPaths( routeKeys: [route.key] ) .sink(receiveCompletion: { completion in if case .failure(let error) = completion { - apiManager.error = error + self.error = error } }, receiveValue: { data in patternPaths = data @@ -44,10 +46,14 @@ struct RouteDetail: View { .background(Color(hex: route.directionList[0].lineColor)) .clipShape(.rect(cornerSize: CGSize(width: 8, height: 8))) - Text(route.name) - .font(.headline) - .frame(alignment: .leading) - .lineLimit(1) + MarqueeText( + text: route.name, + font: UIFont.preferredFont(forTextStyle: .headline), + leftFade: 8, + rightFade: 8, + startDelay: 1 + ) + .padding([.leading], 2) Spacer() @@ -90,10 +96,17 @@ struct RouteDetail: View { selectedDirection = newSelected selectedPath = patternPaths[0].patternPaths.filter({$0.directionKey == route.directionList[selectedDirection].direction.key}).first }) { - HStack { + HStack(alignment: .center) { Image(systemName: "chevron.up.chevron.down") - Text("to " + route.directionList[selectedDirection].destination) - .lineLimit(1) + + MarqueeText( + text: "to " + route.directionList[selectedDirection].destination, + font: UIFont.systemFont(ofSize: 16), + leftFade: 8, + rightFade: 8, + startDelay: 1 + ) + .padding([.leading], 2) } .padding([.vertical], 4) .padding([.horizontal], 8) @@ -103,8 +116,8 @@ struct RouteDetail: View { .buttonStyle(.plain) } - - if (patternPaths.count > 0) { + + if (patternPaths.count > 0 && (error == nil)) { Divider() ForEach(selectedPath!.patternPoints, id: \.key) { point in if (point.stop != nil) { @@ -115,9 +128,14 @@ struct RouteDetail: View { } else { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(1) + if (error != nil) { + ErrorView(text: "There was an error loading the route") + .padding(.top, 16) + } else { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1) + } } }.onAppear(perform: { updatePathData() diff --git a/targets/watch/RouteList.swift b/targets/watch/RouteList.swift index eccd1d9..e61384a 100644 --- a/targets/watch/RouteList.swift +++ b/targets/watch/RouteList.swift @@ -11,6 +11,7 @@ struct RouteList: View { @EnvironmentObject var apiManager: APIManager @State var favorites: [String] = [] + @State var error: Error? func getDirectionString(directions: [DirectionList]) -> String { return directions[0].destination + " | " + directions[1].destination @@ -21,16 +22,22 @@ struct RouteList: View { } var body: some View { - if apiManager.baseData?.routes.count == 0 { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(1) + if apiManager.baseData?.routes.count == 0 || apiManager.error != nil { + if (apiManager.error != nil) { + ErrorView(text: "There was an error loading the routes.") + } else { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1) + } } else { List { - // Favorites if (favorites.count > 0) { - Section(header: Text("Favorites")) { + Section(header: HStack { + Image(systemName: "star.fill") + Text("Favorites") + }) { ForEach(apiManager.baseData?.routes ?? [], id: \.key) { route in if (favorites.contains(route.shortName)) { NavigationLink { @@ -50,7 +57,11 @@ struct RouteList: View { } // All Routes - Section(header: Text("All Routes")) { + Section(header: HStack { + // what the hell is this + Image(systemName: "point.bottomleft.forward.to.point.topright.scurvepath.fill") + Text("All Routes") + }) { ForEach(apiManager.baseData?.routes ?? [], id: \.key) { route in NavigationLink { RouteDetail(route: route) @@ -60,7 +71,9 @@ struct RouteList: View { name: route.name, number: route.shortName, color: Color(hex: route.directionList[0].lineColor), - subtitle: route.directionList.count == 2 ? getDirectionString(directions: route.directionList) : "" + subtitle: route.directionList.count == 2 + ? getDirectionString(directions: route.directionList) + : "" ) } } diff --git a/targets/watch/StopCell.swift b/targets/watch/StopCell.swift index 5bf7518..8f67f95 100644 --- a/targets/watch/StopCell.swift +++ b/targets/watch/StopCell.swift @@ -13,6 +13,7 @@ struct StopCell: View { var route: MapRoute @State var estimates: GetNextDepartTimesResponse? + @State var error: Error? @EnvironmentObject var apiManager: APIManager @@ -20,7 +21,7 @@ struct StopCell: View { apiManager.getNextDepartureTimes(routeId: route.key, directionIds: [direction.key], stopCode: stop.stopCode) .sink(receiveCompletion: { completion in if case .failure(let error) = completion { - apiManager.error = error + self.error = error } }, receiveValue: { data in estimates = data @@ -37,10 +38,15 @@ struct StopCell: View { Text(stop.name) Spacer() } - if estimates == nil { - ProgressView() - .progressViewStyle(.circular) - .padding() + if estimates == nil || error != nil { + if error != nil { + Text("Error loading stop times") + .foregroundStyle(.gray) + } else { + ProgressView() + .progressViewStyle(.circular) + .padding() + } } else { if (estimates?.routeDirectionTimes[0].nextDeparts.count == 0) { HStack { @@ -77,7 +83,6 @@ struct StopCell: View { } .onReceive(timer, perform: { _ in - print("update") updateStopEstimates() }) .onAppear(perform: {