diff --git a/app.json b/app.json index 254edcb..e7a7fb4 100644 --- a/app.json +++ b/app.json @@ -73,7 +73,11 @@ }, "plugins": [ "@config-plugins/detox", - "expo-router", + ["@bacons/apple-targets", + { + "appleTeamId": "65AMD2STXG" + } + ], [ "expo-location", { diff --git a/package-lock.json b/package-lock.json index 4bd0dd7..e4ed80a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@expo/vector-icons": "^14.0.2", "@googlemaps/polyline-codec": "^1.0.28", + "@bacons/apple-targets": "^0.0.3", "@gorhom/bottom-sheet": "^4.6.4", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-segmented-control/segmented-control": "2.5.2", @@ -2139,6 +2140,94 @@ "node": ">=6.9.0" } }, + "node_modules/@bacons/apple-targets": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@bacons/apple-targets/-/apple-targets-0.0.3.tgz", + "integrity": "sha512-6uHFOLrt+1jMyxTZdjyaZ8imds9DuG8pKZ+Hz9+8ubwMMQyO+ncrLxGt0dOYDyYNtWPSVYl+/A1OKEcgmZzOEw==", + "dependencies": { + "@bacons/xcode": "^1.0.0-alpha.9", + "fs-extra": "^11.2.0", + "glob": "^10.2.6" + } + }, + "node_modules/@bacons/apple-targets/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@bacons/apple-targets/node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@bacons/apple-targets/node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@bacons/react-views": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@bacons/react-views/-/react-views-1.1.3.tgz", + "integrity": "sha512-aLipQAkQKRzG64e28XHBpByyBPfANz0A6POqYHGyryHizG9vLCLNQwLe8gwFANEMBWW2Mx5YdQ7RkNdQMQ+CXQ==", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/@bacons/xcode": { + "version": "1.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@bacons/xcode/-/xcode-1.0.0-alpha.12.tgz", + "integrity": "sha512-0tysq5qPKwtQi9+Szod6Cn2PNp4uLNxlNoGtOMkeE+ImRDvzsabjgxzbYPYY1Hqm8vlkA/IGfXJRMV4uIuZV9A==", + "dependencies": { + "@expo/plist": "^0.0.18", + "debug": "^4.3.4", + "uuid": "^8.3.2" + } + }, + "node_modules/@bacons/xcode/node_modules/@expo/plist": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.18.tgz", + "integrity": "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==", + "dependencies": { + "@xmldom/xmldom": "~0.7.0", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "node_modules/@bacons/xcode/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@config-plugins/detox": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@config-plugins/detox/-/detox-8.0.0.tgz", diff --git a/package.json b/package.json index fba2acb..1f50e98 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@expo/vector-icons": "^14.0.2", "@googlemaps/polyline-codec": "^1.0.28", + "@bacons/apple-targets": "^0.0.3", "@gorhom/bottom-sheet": "^4.6.4", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-segmented-control/segmented-control": "2.5.2", diff --git a/targets/watch/APIManager.swift b/targets/watch/APIManager.swift new file mode 100644 index 0000000..8c75218 --- /dev/null +++ b/targets/watch/APIManager.swift @@ -0,0 +1,147 @@ +// +// APIManager.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import Foundation +import Combine + +enum NetworkError: Error { + case invalidURL + case noData + case serverError + case invalidResponse +} + +class APIManager: ObservableObject { + @Published var baseData: GetBaseDataResponse? + @Published var error: Error? + + + private var authKey: String = "" + var cancellables = Set() + let session = URLSession.shared + + + func fetchData() { + getAuthentication() + .flatMap { auth in + self.getBaseData(auth: auth) + } + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + self.error = error + } + }, receiveValue: { baseData in + self.baseData = baseData + }) + .store(in: &cancellables) + + } + + + private func getAuthentication() -> AnyPublisher { + guard let url = URL(string: "https://aggiespirit.ts.tamu.edu/") else { + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.httpShouldHandleCookies = false + + + + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let allHeaders = httpResponse.allHeaderFields as? [String: String], + let setCookieHeader = allHeaders["Set-Cookie"] else { + throw NetworkError.serverError + } + + let cookies = setCookieHeader.split(separator: ",").map { String($0.trimmingCharacters(in: .whitespaces)) } + return cookies.map { $0.split(separator: ";").first ?? "" }.joined(separator: "; ") + } + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + private func getBaseData(auth: String) -> AnyPublisher { + self.authKey = auth + + guard let url = URL(string: "https://aggiespirit.ts.tamu.edu/RouteMap/GetBaseData") else { + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(auth, forHTTPHeaderField: "cookie") + + return session.dataTaskPublisher(for: request) + .map(\.data) + .decode(type: GetBaseDataResponse.self, decoder: JSONDecoder()) + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + 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() + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(self.authKey, forHTTPHeaderField: "cookie") + request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type") + + request.httpBody = bodyData.data(using: .utf8) + + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NetworkError.invalidResponse + } + return data + } + .decode(type: [GetPatternPathsResponse].self, decoder: JSONDecoder()) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + 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)" + bodyData.append(directionData) + } + let bodyString = bodyData.joined(separator: "&") + + guard let url = URL(string: "https://aggiespirit.ts.tamu.edu/RouteMap/GetNextDepartTimes") else { + return Fail(error: NetworkError.invalidURL).eraseToAnyPublisher() + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(self.authKey, forHTTPHeaderField: "cookie") + request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type") + request.httpBody = bodyString.data(using: .utf8) + + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NetworkError.invalidResponse + } + + return data + } + .decode(type: GetNextDepartTimesResponse.self, decoder: JSONDecoder()) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/targets/watch/APITypes.swift b/targets/watch/APITypes.swift new file mode 100644 index 0000000..cdfd1fc --- /dev/null +++ b/targets/watch/APITypes.swift @@ -0,0 +1,228 @@ +// +// APITypes.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import Foundation + +// From Map API +struct PatternList: Codable { + let key: String + let isDisplay: Bool +} + +struct Direction: Codable { + let key: String + let name: String +} + +struct DirectionList: Codable { + let direction: Direction + let destination: String + let lineColor: String + let textColor: String + let patternList: [PatternList] + let serviceInterruptionKeys: [Int] +} + +struct Stop: Codable { + let name: String + let stopCode: String + let stopType: Int +} + +struct PatternPoint: Codable { + let key: String + let latitude: Double + let longitude: Double + let stop: Stop? +} + +struct PatternPath: Codable { + let patternKey: String + let directionKey: String + let patternPoints: [PatternPoint] +// let segmentPaths: [Any] // Always blank... leaving as Any for now +} + +struct MapRoute: Codable { + let key: String + let name: String + let shortName: String + let directionList: [DirectionList] +} + +struct MapServiceInterruption: Codable { + let key: String + let name: String + let description: String + let timeRangeString: String + let startDateUtc: String + let endDateUtc: String? + let dailyStartTime: String + let dailyEndTime: String +} + +struct DepartTime: Codable, Hashable { + let estimatedDepartTimeUtc: String? + let scheduledDepartTimeUtc: String? + let isOffRoute: Bool +} + +struct RouteDirectionTime: Codable { + let routeKey: String + let directionKey: String + let nextDeparts: [DepartTime] + let frequencyInfo: String? // Always blank... leaving as Any for now +} + +struct BusLocation: Codable { + let lastGpsDate: String + let latitude: Double + let longitude: Double + let speed: Double + let heading: Double +} + +struct Amenity: Codable { + let name: String + let iconName: String +} + +struct Vehicle: Codable { + let key: String + let name: String + let location: BusLocation + let directionKey: String + let directionName: String + let routeKey: String + let passengerCapacity: Int + let passengersOnboard: Int + let amenities: [Amenity] + let isExtraTrip: Bool +} + +struct VehicleByDirection: Codable { + let directionKey: String + let vehicles: [Vehicle] +} + +// From Bus Times API +struct TimetableServiceInterruption: Codable { + let externalServiceInterruptionKey: String + let serviceInterruptionName: String + let serviceInterruptionTimeRange: String + let isStopClosed: Bool +} + +struct NextStopTime: Codable { + let scheduledDepartTimeUtc: String? + let estimatedDepartTimeUtc: String? + let isRealtime: Bool + let isOffRoute: Bool +} + +struct NearbyStops: Codable { + let directionKey: String + let directionName: String + let distance: Double + let stopCode: String + let stopName: String? + let isTemporary: Bool + let nextStopTimes: [NextStopTime] + let frequencyInfo: String? // Always blank... leaving as Any for now + let serviceInterruptions: [TimetableServiceInterruption] + let amenities: [Amenity] +} + +struct TimetableRoute: Codable { + let routeKey: String + let routeNumber: String? + let routeName: String? + let distanceString: String? + let distance: Double? + let nearbyStops: [NearbyStops] +} + +struct StopTime: Codable { + let scheduledDepartTimeUtc: String + let estimatedDepartTimeUtc: String? + let isRealtime: Bool + let tripPointId: String + let isLastPoint: Bool? + let isCancelled: Bool + let isOffRoute: Bool +} + +struct RouteStopSchedule: Codable { + let routeName: String + let routeNumber: String + let directionName: String + let stopTimes: [StopTime] + let frequencyInfo: String? + let hasTrips: Bool + let hasSchedule: Bool + let isEndOfRoute: Bool + let isTemporaryStopOnly: Bool + let isClosedRegularStop: Bool + let serviceInterruptions: [StopScheduleInterruption] // Determine datatype as needed +} + +struct StopScheduleInterruption: Codable { + let externalServiceInterruptionKey: String + let serviceInterruptionName: String + let serviceInterruptionTimeRange: String +} + +// /RouteMap/GetBaseData +struct GetBaseDataResponse: Codable { + let routes: [MapRoute] + let serviceInterruptions: [MapServiceInterruption] +} + +// /RouteMap/GetPatternPaths +struct GetPatternPathsResponse: Codable { + let routeKey: String + let patternPaths: [PatternPath] + let vehiclesByDirections: [VehicleByDirection]? +} + +// /RouteMap/GetNextDepartTimes +struct GetNextDepartTimesResponse: Codable { + let stopCode: String + let routeDirectionTimes: [RouteDirectionTime] + let amenities: [Amenity] +} + +// /RouteMap/GetVehicles +struct GetVehiclesResponse: Codable { + let routeKey: String + let vehiclesByDirections: [VehicleByDirection]? +} + +// /Home/GetNearbyRoutes +struct GetNearbyRoutesResponse: Codable { + let longitude: Double + let latitude: Double + let stopCode: String? // Always blank... leaving as Any for now + let busStopRouteResults: [String]? // Always blank... leaving as [Any] for now + let routeResults: [TimetableRoute] + let nextMinRadius: Double + let nextMaxRadius: Double + let canLoadMore: Bool +} + +// /Home/GetStopSchedules +struct GetStopSchedulesResponse: Codable { + let routeStopSchedules: [RouteStopSchedule] + let date: String + let amenities: [Amenity] +} + +struct GetStopEstimatesResponse: Codable { + let routeStopSchedules: [RouteStopSchedule] + let date: String + let amenities: [Amenity] +} diff --git a/targets/watch/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/targets/watch/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000..968b38d Binary files /dev/null and b/targets/watch/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/targets/watch/Assets.xcassets/AppIcon.appiconset/Contents.json b/targets/watch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..886556e --- /dev/null +++ b/targets/watch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "App-Icon-1024x1024@1x.png", + "idiom": "universal", + "platform": "watchos", + "size": "1024x1024" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/targets/watch/ContentView.swift b/targets/watch/ContentView.swift new file mode 100644 index 0000000..16f4319 --- /dev/null +++ b/targets/watch/ContentView.swift @@ -0,0 +1,22 @@ +// +// ContentView.swift +// basic-watchapp +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +struct ContentView: View { + @StateObject var apiManager: APIManager = APIManager() + var body: some View { + NavigationStack { + RouteList() + } + .onAppear(perform: { + apiManager.fetchData() + }) + .environmentObject(apiManager) + } +} + diff --git a/targets/watch/ErrorView.swift b/targets/watch/ErrorView.swift new file mode 100644 index 0000000..c010a04 --- /dev/null +++ b/targets/watch/ErrorView.swift @@ -0,0 +1,26 @@ +// +// ErrorView.swift +// MaroonRides +// +// 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 new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/targets/watch/Info.plist @@ -0,0 +1,5 @@ + + + + + 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/ReveilleRides_Watch.swift b/targets/watch/ReveilleRides_Watch.swift new file mode 100644 index 0000000..89c34a9 --- /dev/null +++ b/targets/watch/ReveilleRides_Watch.swift @@ -0,0 +1,17 @@ +// +// basic_watchappApp.swift +// basic-watchapp +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +@main +struct MaroonRides_Watch: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/targets/watch/RouteCell.swift b/targets/watch/RouteCell.swift new file mode 100644 index 0000000..6e35d01 --- /dev/null +++ b/targets/watch/RouteCell.swift @@ -0,0 +1,76 @@ +// +// Bus Icon.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +struct RouteCell: View { + var name: String + var number: String + 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(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 { + MarqueeText( + text: subtitle, + font: UIFont.systemFont(ofSize: 12), + leftFade: 8, + rightFade: 8, + startDelay: 2 + ) + .padding([.leading], 4) + Spacer() + } + } + } +// .padding([.leading], 4) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding([.vertical], 8) + } +} + +#Preview { + RouteCell( + name: "RELLIS", + number: "47", + color: Color.red, + subtitle: "MSC | RELLIS" + ) +} diff --git a/targets/watch/RouteDetail.swift b/targets/watch/RouteDetail.swift new file mode 100644 index 0000000..57fc040 --- /dev/null +++ b/targets/watch/RouteDetail.swift @@ -0,0 +1,145 @@ +// +// RouteDetail.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +struct RouteDetail: View { + var route: MapRoute + @EnvironmentObject var apiManager: APIManager + + @State var selectedDirection = 0 + @State var patternPaths: [GetPatternPathsResponse] = [] + @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 { + self.error = error + } + }, receiveValue: { data in + patternPaths = data + selectedPath = patternPaths[0].patternPaths.filter({$0.directionKey == route.directionList[selectedDirection].direction.key}).first + }) + .store(in: &apiManager.cancellables) + } + + + var body: some View { + ScrollView { + HStack { + Text(route.shortName) + .padding([.vertical], 2) + .padding([.horizontal], 6) + .font(.system(size: 20).bold()) + .minimumScaleFactor(0.1) + .lineLimit(1) + .background(Color(hex: route.directionList[0].lineColor)) + .clipShape(.rect(cornerSize: CGSize(width: 8, height: 8))) + + MarqueeText( + text: route.name, + font: UIFont.preferredFont(forTextStyle: .headline), + leftFade: 8, + rightFade: 8, + startDelay: 1 + ) + .padding([.leading], 2) + + Spacer() + + // favorites button + Button(action: { + var newFavorites = UserDefaults.standard.array(forKey: "favorites") as? [String] ?? [] + if favorited { + newFavorites.removeAll(where: {$0 == route.shortName}) + } else { + newFavorites.append(route.shortName) + } + + favorited = !favorited + + UserDefaults.standard.setValue(newFavorites, forKey: "favorites") + }) { + if favorited { + Image(systemName: "star.fill") + .font(.system(size: 24)) + .foregroundColor(.yellow) + } else { + Image(systemName: "star") + .font(.system(size: 24)) + .foregroundColor(.yellow) + } + + } + .buttonStyle(.plain) + + } + + + if (route.directionList.count > 1) { + Button(action: { + var newSelected = selectedDirection + 1 + + if newSelected >= route.directionList.count { + newSelected = 0 + } + selectedDirection = newSelected + selectedPath = patternPaths[0].patternPaths.filter({$0.directionKey == route.directionList[selectedDirection].direction.key}).first + }) { + HStack(alignment: .center) { + Image(systemName: "chevron.up.chevron.down") + + 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) + .background(.quaternary) + .cornerRadius(32) + } + .buttonStyle(.plain) + } + + + if (patternPaths.count > 0 && (error == nil)) { + Divider() + ForEach(selectedPath!.patternPoints, id: \.key) { point in + if (point.stop != nil) { + StopCell(stop: point.stop!, direction: route.directionList[selectedDirection].direction, route: route) + } + } + .listStyle(.plain) + + + } else { + 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 new file mode 100644 index 0000000..c993113 --- /dev/null +++ b/targets/watch/RouteList.swift @@ -0,0 +1,88 @@ +// +// RouteList.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +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 + } + + func reloadFavorites() { + favorites = UserDefaults.standard.array(forKey: "favorites") as? [String] ?? [] + } + + var body: some View { + 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: HStack { + Image(systemName: "star.fill") + Text("Favorites") + }) { + ForEach(apiManager.baseData?.routes ?? [], id: \.key) { route in + if (favorites.contains(route.shortName)) { + NavigationLink { + RouteDetail(route: route, favorited: true) + } + label: { + RouteCell( + name: route.name, + number: route.shortName, + color: Color(hex: route.directionList[0].lineColor), + subtitle: route.directionList.count == 2 ? getDirectionString(directions: route.directionList) : "" + ) + } + } + } + } + } + + // 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) + } + label: { + RouteCell( + name: route.name, + number: route.shortName, + color: Color(hex: route.directionList[0].lineColor), + subtitle: route.directionList.count == 2 + ? getDirectionString(directions: route.directionList) + : "" + ) + } + } + } + } + .onAppear(perform: { + reloadFavorites() + }) + } + } +} + diff --git a/targets/watch/StopCell.swift b/targets/watch/StopCell.swift new file mode 100644 index 0000000..1678fa8 --- /dev/null +++ b/targets/watch/StopCell.swift @@ -0,0 +1,92 @@ +// +// StopCell.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +struct StopCell: View { + var stop: Stop + var direction: Direction + var route: MapRoute + + @State var estimates: GetNextDepartTimesResponse? + @State var error: Error? + + @EnvironmentObject var apiManager: APIManager + + func updateStopEstimates() { + apiManager.getNextDepartureTimes(routeId: route.key, directionIds: [direction.key], stopCode: stop.stopCode) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + self.error = error + } + }, receiveValue: { data in + estimates = data + }) + .store(in: &apiManager.cancellables) + } + + // refresh ETA every 30s + let timer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + + var body: some View { + VStack { + HStack { + Text(stop.name) + Spacer() + } + 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 { + Text("No times to show") + .font(.subheadline) + .foregroundColor(.gray) + Spacer() + } + } else { + ScrollView(.horizontal) { + HStack { + ForEach((estimates?.routeDirectionTimes[0].nextDeparts.deduplicated(by: \.estimatedDepartTimeUtc))!, id: \.scheduledDepartTimeUtc) { depart in + // if first time + if estimates?.routeDirectionTimes[0].nextDeparts.firstIndex(where: {$0.scheduledDepartTimeUtc == depart.scheduledDepartTimeUtc}) == 0 { + TimeBubble( + date: (depart.estimatedDepartTimeUtc ?? depart.scheduledDepartTimeUtc)!, + isLive: depart.estimatedDepartTimeUtc != nil, + color: Color(hex: route.directionList[0].lineColor) + ) + } else { + TimeBubble( + date: (depart.estimatedDepartTimeUtc ?? depart.scheduledDepartTimeUtc)!, + isLive: depart.estimatedDepartTimeUtc != nil, + color: Color(hex: "#48484a") + ) + } + } + } + } + } + + } + Divider() + + } + .onReceive(timer, perform: { _ in + updateStopEstimates() + }) + .onAppear(perform: { + updateStopEstimates() + }) + } +} diff --git a/targets/watch/TimeBubble.swift b/targets/watch/TimeBubble.swift new file mode 100644 index 0000000..e03e47c --- /dev/null +++ b/targets/watch/TimeBubble.swift @@ -0,0 +1,52 @@ +// +// TimeBubble.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import SwiftUI + +struct TimeBubble: View { + var date: String + var isLive: Bool = false + var color: Color + + func parseRelativeTime(for iso: String) -> String { + let dateFormatter = ISO8601DateFormatter() + guard let date = dateFormatter.date(from: iso) else { + return "Invalid" + } + + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.minute], from: now, to: date) + + if let minutes = components.minute { + if minutes < 1 { + return "Now" + } else { + return "\(minutes) min" + } + } else { + return "Unknown" + } + } + + var body: some View { + HStack { + Text(parseRelativeTime(for: date)) + if (isLive) { + Image(systemName: "dot.radiowaves.up.forward") + .font(.system(size: 10)) + .padding([.bottom], 8) + .padding([.leading], -4) + } + } + .padding([.vertical], 4) + .padding([.horizontal], 8) + .background(color) + .cornerRadius(8) + } +} + diff --git a/targets/watch/Utils.swift b/targets/watch/Utils.swift new file mode 100644 index 0000000..12711e8 --- /dev/null +++ b/targets/watch/Utils.swift @@ -0,0 +1,44 @@ +// +// Utils.swift +// Maroon Rides Watch App +// +// Created by Brandon Wees on 1/31/24. +// + +import Foundation +import SwiftUI +import UIKit + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +extension Array where Element: Equatable { + func deduplicated(by keyPath: KeyPath) -> [Element] { + var seen = Set() + return filter { seen.insert($0[keyPath: keyPath]).inserted } + } +} diff --git a/targets/watch/expo-target.config.js b/targets/watch/expo-target.config.js new file mode 100644 index 0000000..c47a8a4 --- /dev/null +++ b/targets/watch/expo-target.config.js @@ -0,0 +1,11 @@ +/** @type {import('@bacons/apple-targets').Config} */ +module.exports = { + type: "watch", + name: "Maroon Rides", + identifier: "com.bwees.reveille-rides.watch", + deploymentTarget: "9.0", + icon: "../../assets/icon.png", + frameworks: [ + "SwiftUI" + ] +}; \ No newline at end of file