├── .gitignore ├── App Clip ├── App Clip.entitlements ├── Delegates │ └── AppDelegate.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── ShuttleTrackerApp.swift ├── Config ├── Shared (App Clip).xcconfig └── Shared.xcconfig ├── Icons ├── iOS.sketch ├── macOS.sketch └── watchOS.sketch ├── LICENSE.txt ├── Locations.gpx ├── Packages └── RealityKitContent │ └── Package.realitycomposerpro │ └── WorkspaceData │ └── Settings.rcprojectdata ├── README.md ├── Screenshots ├── iOS │ ├── Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.05.png │ ├── Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.23.png │ ├── Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.30.png │ ├── Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.36.png │ └── Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.29.14.png └── macOS │ ├── Screenshot 2023-09-01 at 3.31.12 PM.png │ └── Screenshot 2023-09-01 at 3.31.20 PM.png ├── Shared ├── API.swift ├── Analytics.swift ├── AppStorageManager.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon1024.png │ │ ├── Icon128.png │ │ ├── Icon128@2x.png │ │ ├── Icon16.png │ │ ├── Icon16@2x.png │ │ ├── Icon20.png │ │ ├── Icon20@2x 1.png │ │ ├── Icon20@2x 2.png │ │ ├── Icon20@3x.png │ │ ├── Icon256.png │ │ ├── Icon256@2x.png │ │ ├── Icon29.png │ │ ├── Icon29@2x 1.png │ │ ├── Icon29@2x 2.png │ │ ├── Icon29@3x.png │ │ ├── Icon32.png │ │ ├── Icon32@2x.png │ │ ├── Icon40.png │ │ ├── Icon40@2x 1.png │ │ ├── Icon40@2x 2.png │ │ ├── Icon40@3x.png │ │ ├── Icon512.png │ │ ├── Icon512@2x.png │ │ ├── Icon60@2x.png │ │ ├── Icon60@3x.png │ │ ├── Icon76.png │ │ ├── Icon76@2x.png │ │ └── Icon83.5@2x.png │ └── Contents.json ├── BoardBusManager.swift ├── BusID.swift ├── CustomAnnotation.swift ├── Delegates │ ├── LocationManagerDelegate.swift │ ├── MailComposeViewDelegate.swift │ ├── MapViewDelegate.swift │ └── UserNotificationCenterDelegate.swift ├── Logging.swift ├── MapCameraPositionWrapper.swift ├── Models │ ├── Announcement.swift │ ├── Bus.swift │ ├── ColorName.swift │ ├── Coordinate.swift │ ├── Route.swift │ ├── Schedule.swift │ └── Stop.swift ├── RawRepresentableInJSONArray.swift ├── RefreshSequence.swift ├── SFSymbol.swift ├── ShuttleTrackerSheetStack.swift ├── State │ ├── MapState.swift │ └── ViewState.swift ├── Utilities.swift ├── Views │ ├── AnalyticsDetailView.swift │ ├── AnalyticsOnboardingView.swift │ ├── AnnouncementDetailView.swift │ ├── AnnouncementsSheet.swift │ ├── BlockButton.swift │ ├── BusOption.swift │ ├── BusSelectionSheet.swift │ ├── CloseButton.swift │ ├── ContentView.swift │ ├── FadeScrollView.swift │ ├── InfoSheet.swift │ ├── InfoView.swift │ ├── LegacyMapView.swift │ ├── LegendToast.swift │ ├── LogDetailView.swift │ ├── LoggingAnalyticsSettingsView.swift │ ├── MailComposeView.swift │ ├── MapContainer.swift │ ├── PrimaryOverlay.swift │ ├── PrivacySheet.swift │ ├── PrivacyView.swift │ ├── SettingsView.swift │ ├── Toast.swift │ ├── VisualEffectView.swift │ ├── WhatsNewItem.swift │ └── WhatsNewView.swift └── WrappedError.swift ├── Shuttle Tracker.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Shuttle Tracker (App Clip).xcscheme │ ├── Shuttle Tracker (iOS).xcscheme │ ├── Shuttle Tracker (macOS).xcscheme │ └── ShuttleTracker Watch App.xcscheme ├── ShuttleTracker Watch App ├── AnnouncementDetailView.swift ├── AnnouncementsSheet.swift ├── AnnouncementsView.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── ContentView.swift ├── InfoView.swift ├── InformationTypeView.swift ├── PlusSheet.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── PrivacyView.swift ├── ScheduleView.swift ├── SettingsView.swift └── ShuttleTrackerApp.swift ├── ShuttleTracker-Watch-App-Info.plist ├── iOS ├── Delegates │ └── AppDelegate.swift ├── Info.plist ├── ShuttleTrackerApp.swift ├── Views │ ├── AboutView.swift │ ├── AdvancedSettingsView.swift │ ├── BoardBusToast.swift │ ├── NetworkOnboardingView.swift │ ├── NetworkTextView.swift │ ├── NetworkToast.swift │ ├── PermissionsSheet.swift │ ├── SecondaryOverlay.swift │ ├── SecondaryOverlayButton.swift │ ├── SettingsSheet.swift │ └── WhatsNewSheet.swift └── iOS.entitlements └── macOS ├── Delegates └── AppDelegate.swift ├── Info.plist ├── ShuttleTrackerApp.swift ├── Views ├── LegacyMapView.swift └── VisualEffectView.swift └── macOS.entitlements /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | xcuserdata/ 3 | /Config/Personal.xcconfig 4 | Package.resolved 5 | -------------------------------------------------------------------------------- /App Clip/App Clip.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.associated-domains 8 | 9 | appclips:shuttletracker.app 10 | appclips:web.shuttletracker.app 11 | 12 | com.apple.developer.parent-application-identifiers 13 | 14 | $(AppIdentifierPrefix)com.gerzer.shuttletracker 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /App Clip/Delegates/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Shuttle Tracker (App Clip) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 4/2/23. 6 | // 7 | 8 | import UIKit 9 | 10 | @MainActor 11 | final class AppDelegate: NSObject, UIApplicationDelegate { } 12 | -------------------------------------------------------------------------------- /App Clip/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Shuttle Tracker 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSRequiresIPhoneOS 24 | 25 | NSAppClip 26 | 27 | NSAppClipRequestEphemeralUserNotification 28 | 29 | NSAppClipRequestLocationConfirmation 30 | 31 | 32 | NSAppTransportSecurity 33 | 34 | NSLocationAlwaysAndWhenInUseUsageDescription 35 | Shuttle Tracker crowdsources bus-location data and shows where you are on the map. 36 | NSLocationTemporaryUsageDescriptionDictionary 37 | 38 | BoardBus 39 | Shuttle Tracker requires precise location access to improve data accuracy for everyone. 40 | 41 | NSLocationWhenInUseUsageDescription 42 | Shuttle Tracker crowdsources bus-location data and shows where you are on the map. 43 | UIApplicationSceneManifest 44 | 45 | UIApplicationSupportsMultipleScenes 46 | 47 | 48 | UIApplicationSupportsIndirectInputEvents 49 | 50 | UILaunchScreen 51 | 52 | UIRequiredDeviceCapabilities 53 | 54 | armv7 55 | location-services 56 | 57 | UIRequiresFullScreen 58 | 59 | UISupportedInterfaceOrientations 60 | 61 | UIInterfaceOrientationPortrait 62 | 63 | UISupportedInterfaceOrientations~ipad 64 | 65 | UIInterfaceOrientationPortrait 66 | UIInterfaceOrientationPortraitUpsideDown 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /App Clip/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App Clip/ShuttleTrackerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShuttleTrackerApp.swift 3 | // Shuttle Tracker (App Clip) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/30/20. 6 | // 7 | 8 | import CoreLocation 9 | import STLogging 10 | import StoreKit 11 | import SwiftUI 12 | 13 | @main 14 | struct ShuttleTrackerApp: App { 15 | 16 | @State 17 | private var mapCameraPosition: MapCameraPositionWrapper = .default 18 | 19 | @ObservedObject 20 | private var mapState = MapState.shared 21 | 22 | @ObservedObject 23 | private var viewState = ViewState.shared 24 | 25 | @ObservedObject 26 | private var boardBusManager = BoardBusManager.shared 27 | 28 | @ObservedObject 29 | private var appStorageManager = AppStorageManager.shared 30 | 31 | static let sheetStack = ShuttleTrackerSheetStack() 32 | 33 | @UIApplicationDelegateAdaptor(AppDelegate.self) 34 | private var appDelegate 35 | 36 | var body: some Scene { 37 | WindowGroup { 38 | ContentView(mapCameraPosition: self.$mapCameraPosition) 39 | .environmentObject(self.mapState) 40 | .environmentObject(self.viewState) 41 | .environmentObject(self.boardBusManager) 42 | .environmentObject(self.appStorageManager) 43 | .environmentObject(Self.sheetStack) 44 | .refreshable { 45 | // For “standard” refresh operations, we only refresh the buses. 46 | await self.mapState.refreshBuses() 47 | } 48 | .onAppear { 49 | let overlay = SKOverlay( 50 | configuration: SKOverlay.AppClipConfiguration(position: .bottom) 51 | ) 52 | for scene in UIApplication.shared.connectedScenes { 53 | guard let windowScene = scene as? UIWindowScene else { 54 | continue 55 | } 56 | overlay.present(in: windowScene) 57 | } 58 | } 59 | } 60 | } 61 | 62 | init() { 63 | let formattedVersion = if let version = Bundle.main.version { " \(version)" } else { "" } 64 | let formattedBuild = if let build = Bundle.main.build { " (\(build))" } else { "" } 65 | #log(system: Logging.system, "Shuttle Tracker App Clip\(formattedVersion, privacy: .public)\(formattedBuild, privacy: .public)") 66 | CLLocationManager.default = CLLocationManager() 67 | CLLocationManager.default.requestWhenInUseAuthorization() 68 | CLLocationManager.default.activityType = .automotiveNavigation 69 | CLLocationManager.default.showsBackgroundLocationIndicator = true 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Config/Shared (App Clip).xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Shared (App Clip).xcconfig 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/3/21. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | #include? "./Personal.xcconfig" 12 | 13 | PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).shuttletracker.appclip 14 | -------------------------------------------------------------------------------- /Config/Shared.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Shared.xcconfig 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/3/21. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | #include? "./Personal.xcconfig" 12 | 13 | PRODUCT_BUNDLE_IDENTIFIER = $(ORGANIZATION_IDENTIFIER).shuttletracker 14 | -------------------------------------------------------------------------------- /Icons/iOS.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Icons/iOS.sketch -------------------------------------------------------------------------------- /Icons/macOS.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Icons/macOS.sketch -------------------------------------------------------------------------------- /Icons/watchOS.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Icons/watchOS.sketch -------------------------------------------------------------------------------- /Locations.gpx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Student Union 5 | 6 | 7 | -------------------------------------------------------------------------------- /Packages/RealityKitContent/Package.realitycomposerpro/WorkspaceData/Settings.rcprojectdata: -------------------------------------------------------------------------------- 1 | { 2 | "cameraPresets" : { 3 | 4 | }, 5 | "secondaryToolbarData" : { 6 | "isGridVisible" : true, 7 | "sceneReverbPreset" : -1 8 | }, 9 | "unitDefaults" : { 10 | "°" : "°", 11 | "kg" : "g", 12 | "m" : "cm", 13 | "m\/s" : "m\/s", 14 | "m\/s²" : "m\/s²", 15 | "s" : "s" 16 | } 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shuttle Tracker 2 | An app for iPhone, iPad, iPod touch, Apple Watch (soon), and Mac to track the Rensselaer campus shuttles 3 | 4 | Download the app today: https://shuttletracker.app/swiftui 5 | 6 | 7 | ## Development 8 | [STLogging](https://github.com/wtg/Shuttle-Tracker-Logging) is a required dependency, but it isn’t included as a remote package dependency in the Xcode project. The easiest way to include it is to clone the STLogging repository separately and to add it to a shared Xcode workspace alongside the main Shuttle Tracker project. Otherwise, Xcode might complain about being unable to find the STLogging implementation when building Shuttle Tracker. 9 | -------------------------------------------------------------------------------- /Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.05.png -------------------------------------------------------------------------------- /Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.23.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.23.png -------------------------------------------------------------------------------- /Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.30.png -------------------------------------------------------------------------------- /Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.28.36.png -------------------------------------------------------------------------------- /Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.29.14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/iOS/Simulator Screenshot - iPhone SE (3rd generation) - 2023-09-01 at 15.29.14.png -------------------------------------------------------------------------------- /Screenshots/macOS/Screenshot 2023-09-01 at 3.31.12 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/macOS/Screenshot 2023-09-01 at 3.31.12 PM.png -------------------------------------------------------------------------------- /Screenshots/macOS/Screenshot 2023-09-01 at 3.31.20 PM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Screenshots/macOS/Screenshot 2023-09-01 at 3.31.20 PM.png -------------------------------------------------------------------------------- /Shared/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/2/20. 6 | // 7 | 8 | import Foundation 9 | import HTTPStatus 10 | import Moya 11 | 12 | typealias HTTPMethod = Moya.Method 13 | 14 | typealias HTTPTask = Moya.Task 15 | 16 | enum API: TargetType { 17 | 18 | case readVersion 19 | 20 | case readAnnouncements 21 | 22 | case readBuses 23 | 24 | case readAllBuses 25 | 26 | case readBus(id: Int) 27 | 28 | case updateBus(id: Int, location: Bus.Location) 29 | 30 | case boardBus(id: Int) 31 | 32 | case leaveBus(id: Int) 33 | 34 | case readRoutes 35 | 36 | case readStops 37 | 38 | case readSchedule 39 | 40 | case uploadAnalyticsEntry(analyticsEntry: Analytics.Entry) 41 | 42 | case uploadLog(log: Logging.Log) 43 | 44 | case uploadAPNSToken(token: String) 45 | 46 | static let provider = MoyaProvider() 47 | 48 | static let lastVersion = 3 49 | 50 | @MainActor 51 | var baseURL: URL { 52 | get { 53 | return AppStorageManager.shared.baseURL 54 | } 55 | } 56 | 57 | var path: String { 58 | get { 59 | switch self { 60 | case .readVersion: 61 | return "/version" 62 | case .readAnnouncements: 63 | return "/announcements" 64 | case .readBuses: 65 | return "/buses" 66 | case .readAllBuses: 67 | return "/buses/all" 68 | case .readBus(let id), .updateBus(let id, _): 69 | return "/buses/\(id)" 70 | case .boardBus(let id): 71 | return "/buses/\(id)/board" 72 | case .leaveBus(let id): 73 | return "/buses/\(id)/leave" 74 | case .readRoutes: 75 | return "/routes" 76 | case .readStops: 77 | return "/stops" 78 | case .readSchedule: 79 | return "/schedule" 80 | case .uploadAnalyticsEntry: 81 | return "/analytics/entries" 82 | case .uploadLog: 83 | return "/logs" 84 | case .uploadAPNSToken(let token): 85 | return "/notifications/devices/\(token)" 86 | } 87 | } 88 | } 89 | 90 | var method: HTTPMethod { 91 | get { 92 | switch self { 93 | case .readVersion, .readAnnouncements, .readBuses, .readAllBuses, .readBus, .readRoutes, .readStops, .readSchedule: 94 | return .get 95 | case .uploadAnalyticsEntry, .uploadLog, .uploadAPNSToken: 96 | return .post 97 | case .updateBus: 98 | return .patch 99 | case .boardBus, .leaveBus: 100 | return .put 101 | } 102 | } 103 | } 104 | 105 | var task: HTTPTask { 106 | get { 107 | let encoder = JSONEncoder(dateEncodingStrategy: .iso8601) 108 | switch self { 109 | case .readVersion, .readAnnouncements, .readBuses, .readAllBuses, .boardBus, .leaveBus, .readRoutes, .readStops, .readSchedule, .uploadAPNSToken: 110 | return .requestPlain 111 | case .readBus(let id): 112 | let parameters = [ 113 | "busid": id 114 | ] 115 | return .requestParameters(parameters: parameters, encoding: URLEncoding.default) 116 | case .updateBus(_, let location): 117 | return .requestCustomJSONEncodable(location, encoder: encoder) 118 | case .uploadLog(let log): 119 | return .requestCustomJSONEncodable(log, encoder: encoder) 120 | case .uploadAnalyticsEntry(let analyticsEntry): 121 | return .requestCustomJSONEncodable(analyticsEntry, encoder: encoder) 122 | } 123 | } 124 | } 125 | 126 | var headers: [String: String]? { 127 | get { 128 | return [:] 129 | } 130 | } 131 | 132 | @discardableResult 133 | func perform() async throws -> Data { 134 | let request = try API.provider.endpoint(self).urlRequest() 135 | let (data, response) = try await URLSession.shared.data(for: request) 136 | guard let httpResponse = response as? HTTPURLResponse else { 137 | throw APIError.invalidResponse 138 | } 139 | guard let statusCode = HTTPStatusCodes.statusCode(httpResponse.statusCode) else { 140 | throw APIError.invalidStatusCode 141 | } 142 | if let error = statusCode as? any Error { 143 | throw error 144 | } else { 145 | return data 146 | } 147 | } 148 | 149 | func perform( 150 | decodingJSONWith decoder: JSONDecoder = JSONDecoder(dateDecodingStrategy: .iso8601), 151 | as responseType: ResponseType.Type, 152 | onMainActor: Bool = false 153 | ) async throws -> ResponseType where ResponseType: Sendable & Decodable { 154 | let data = try await self.perform() 155 | if onMainActor { 156 | return try await MainActor.run { 157 | return try decoder.decode(responseType, from: data) 158 | } 159 | } else { 160 | return try decoder.decode(responseType, from: data) 161 | } 162 | } 163 | 164 | } 165 | 166 | fileprivate enum APIError: LocalizedError { 167 | 168 | case invalidResponse 169 | 170 | case invalidStatusCode 171 | 172 | var errorDescription: String? { 173 | get { 174 | switch self { 175 | case .invalidResponse: 176 | return "The server returned an invalid response." 177 | case .invalidStatusCode: 178 | return "The server returned an invalid HTTP status code." 179 | } 180 | } 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /Shared/Analytics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Analytics.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Aidan Flaherty on 2/14/23. 6 | // 7 | 8 | import Foundation 9 | import STLogging 10 | import SwiftUI 11 | 12 | public enum Analytics { 13 | 14 | enum EventType: Codable, Hashable { 15 | 16 | case coldLaunch 17 | 18 | case boardBusTapped 19 | 20 | case leaveBusTapped 21 | 22 | case boardBusActivated(manual: Bool) 23 | 24 | case boardBusDeactivated(manual: Bool) 25 | 26 | case busSelectionCanceled 27 | 28 | case announcementsListOpened 29 | 30 | case announcementViewed(id: UUID) 31 | 32 | case permissionsSheetOpened 33 | 34 | case networkToastPermissionsTapped 35 | 36 | case colorBlindModeToggled(enabled: Bool) 37 | 38 | case debugModeToggled(enabled: Bool) 39 | 40 | case serverBaseURLChanged(url: URL) 41 | 42 | case locationAuthorizationStatusDidChange(authorizationStatus: Int) 43 | 44 | case locationAccuracyAuthorizationDidChange(accuracyAuthorization: Int) 45 | 46 | } 47 | 48 | struct UserSettings: Codable, Hashable, Equatable { 49 | 50 | let colorScheme: String? 51 | 52 | let colorBlindMode: Bool 53 | 54 | let debugMode: Bool? 55 | 56 | let logging: Bool? 57 | 58 | let maximumStopDistance: Int? 59 | 60 | let serverBaseURL: URL? 61 | 62 | } 63 | 64 | struct Entry: Hashable, Identifiable, RawRepresentableInJSONArray { 65 | 66 | enum ClientPlatform: String, Codable, Hashable, Equatable { 67 | 68 | case ios, macos, watchOS 69 | 70 | } 71 | 72 | let id: UUID 73 | 74 | let userID: UUID 75 | 76 | let date: Date 77 | 78 | let clientPlatform: ClientPlatform 79 | 80 | let clientPlatformVersion: String 81 | 82 | let appVersion: String 83 | 84 | let boardBusCount: Int? 85 | 86 | let userSettings: UserSettings 87 | 88 | let eventType: EventType 89 | 90 | var jsonString: String { 91 | get throws { 92 | let encoder = JSONEncoder(dateEncodingStrategy: .iso8601) 93 | let json = try encoder.encode(self) 94 | let jsonObject = try JSONSerialization.jsonObject(with: json) 95 | let data = try JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted) 96 | return String(data: data, encoding: .utf8) ?? "" 97 | } 98 | } 99 | 100 | init(_ eventType: EventType) async { 101 | self.id = UUID() 102 | #if !os(watchOS) 103 | self.userID = await AppStorageManager.shared.userID 104 | #else 105 | self.userID = UUID() 106 | #endif 107 | self.eventType = eventType 108 | #if os(iOS) 109 | self.clientPlatform = .ios 110 | #elseif os(macOS) // os(iOS) 111 | self.clientPlatform = .macos 112 | #elseif os(watchOS) 113 | self.clientPlatform = .watchOS 114 | #endif // os(macOS) 115 | self.date = .now 116 | self.clientPlatformVersion = ProcessInfo.processInfo.operatingSystemVersionString 117 | #if !os(watchOS) 118 | if let version = Bundle.main.version { 119 | if let build = Bundle.main.build { 120 | self.appVersion = "\(version) (\(build))" 121 | } else { 122 | self.appVersion = version 123 | } 124 | } else { 125 | self.appVersion = "" 126 | } 127 | #else 128 | self.appVersion = Bundle.version().description 129 | #endif 130 | #if os(iOS) 131 | self.boardBusCount = await AppStorageManager.shared.boardBusCount 132 | #else // os(iOS) 133 | self.boardBusCount = 0 134 | #endif 135 | let colorScheme: String? 136 | #if !os(watchOS) 137 | switch await ViewState.shared.colorScheme { 138 | case .light: 139 | colorScheme = "light" 140 | case .dark: 141 | colorScheme = "dark" 142 | case .none: 143 | colorScheme = nil 144 | @unknown default: 145 | fatalError() 146 | } 147 | #endif 148 | #if os(watchOS) 149 | colorScheme = "light" 150 | #endif 151 | var debugMode: Bool? 152 | var maximumStopDistance: Int? 153 | #if os(iOS) 154 | debugMode = false // TODO: Set properly once the Debug Mode implementation is merged 155 | maximumStopDistance = await AppStorageManager.shared.maximumStopDistance 156 | #endif // os(iOS) 157 | self.userSettings = UserSettings( 158 | colorScheme: colorScheme, 159 | colorBlindMode: await AppStorageManager.shared.colorBlindMode, 160 | debugMode: debugMode, 161 | logging: await AppStorageManager.shared.doUploadLogs, 162 | maximumStopDistance: maximumStopDistance, 163 | serverBaseURL: await AppStorageManager.shared.baseURL 164 | ) 165 | } 166 | 167 | @available(iOS 16, macOS 13, *) 168 | func writeToDisk() throws -> URL { 169 | let url = FileManager.default.temporaryDirectory.appending(component: "\(self.id.uuidString).json") 170 | do { 171 | try self.jsonString.write(to: url, atomically: false, encoding: .utf8) 172 | } catch { 173 | #log(system: Logging.system, level: .error, doUpload: true, "Failed to save analytics entry file to temporary directory: \(error, privacy: .public)") 174 | } 175 | return url 176 | } 177 | 178 | } 179 | 180 | static func upload(eventType: EventType) async throws { 181 | guard await AppStorageManager.shared.doShareAnalytics else { 182 | return 183 | } 184 | do { 185 | let analyticsEntry = await Entry(eventType) 186 | try await API.uploadAnalyticsEntry(analyticsEntry: analyticsEntry).perform() 187 | await MainActor.run { 188 | #if os(iOS) 189 | withAnimation { 190 | AppStorageManager.shared.uploadedAnalyticsEntries.append(analyticsEntry) 191 | } 192 | #elseif os(macOS) // os(iOS) 193 | AppStorageManager.shared.uploadedAnalyticsEntries.append(analyticsEntry) 194 | #endif // os(macOS) 195 | } 196 | } catch { 197 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 198 | } 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /Shared/AppStorageManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStorageManager.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/27/22. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | 11 | @MainActor 12 | final class AppStorageManager: ObservableObject, LoggingConfigurationProvider { 13 | 14 | typealias CategoryType = Logging.Category 15 | 16 | enum Defaults { 17 | 18 | static let userID = UUID() 19 | 20 | static let colorBlindMode = false 21 | 22 | static let maximumStopDistance = 50 23 | 24 | static let boardBusCount = 0 25 | 26 | static let baseURL = URL(string: "https://shuttles.rpi.edu")! 27 | 28 | static let viewedAnnouncementIDs: Set = [] 29 | 30 | static let doUploadLogs = true 31 | 32 | static let doShareAnalytics = false 33 | 34 | static let uploadedLogs: [Logging.Log] = [] 35 | 36 | static let uploadedAnalyticsEntries: [Analytics.Entry] = [] 37 | 38 | static let routeTolerance = 10 39 | 40 | } 41 | 42 | static let shared = AppStorageManager() 43 | 44 | @AppStorage("UserID") 45 | var userID = Defaults.userID 46 | 47 | @AppStorage("ColorBlindMode") 48 | var colorBlindMode = Defaults.colorBlindMode 49 | 50 | @AppStorage("MaximumStopDistance") 51 | var maximumStopDistance = Defaults.maximumStopDistance 52 | 53 | @AppStorage("BoardBusCount") 54 | var boardBusCount = Defaults.boardBusCount 55 | 56 | @AppStorage("BaseURL") 57 | var baseURL = Defaults.baseURL 58 | 59 | @AppStorage("ViewedAnnouncementIDs") 60 | var viewedAnnouncementIDs = Defaults.viewedAnnouncementIDs 61 | 62 | @AppStorage("DoUploadLogs") 63 | var doUploadLogs = Defaults.doUploadLogs 64 | 65 | @AppStorage("DoShareAnalytics") 66 | var doShareAnalytics = Defaults.doShareAnalytics 67 | 68 | @AppStorage("UploadedLogs") 69 | var uploadedLogs = Defaults.uploadedLogs 70 | 71 | @AppStorage("UploadedAnalyticsEntries") 72 | var uploadedAnalyticsEntries = Defaults.uploadedAnalyticsEntries 73 | 74 | @AppStorage("RouteTolerance") 75 | var routeTolerance = Defaults.routeTolerance 76 | 77 | private init() { } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemRedColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon20@2x 1.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "Icon20@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon29@2x 1.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon29@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon40@2x 1.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon20@2x 2.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon29@2x 2.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon40@2x 2.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "Icon1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | }, 111 | { 112 | "filename" : "Icon16.png", 113 | "idiom" : "mac", 114 | "scale" : "1x", 115 | "size" : "16x16" 116 | }, 117 | { 118 | "filename" : "Icon16@2x.png", 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "16x16" 122 | }, 123 | { 124 | "filename" : "Icon32.png", 125 | "idiom" : "mac", 126 | "scale" : "1x", 127 | "size" : "32x32" 128 | }, 129 | { 130 | "filename" : "Icon32@2x.png", 131 | "idiom" : "mac", 132 | "scale" : "2x", 133 | "size" : "32x32" 134 | }, 135 | { 136 | "filename" : "Icon128.png", 137 | "idiom" : "mac", 138 | "scale" : "1x", 139 | "size" : "128x128" 140 | }, 141 | { 142 | "filename" : "Icon128@2x.png", 143 | "idiom" : "mac", 144 | "scale" : "2x", 145 | "size" : "128x128" 146 | }, 147 | { 148 | "filename" : "Icon256.png", 149 | "idiom" : "mac", 150 | "scale" : "1x", 151 | "size" : "256x256" 152 | }, 153 | { 154 | "filename" : "Icon256@2x.png", 155 | "idiom" : "mac", 156 | "scale" : "2x", 157 | "size" : "256x256" 158 | }, 159 | { 160 | "filename" : "Icon512.png", 161 | "idiom" : "mac", 162 | "scale" : "1x", 163 | "size" : "512x512" 164 | }, 165 | { 166 | "filename" : "Icon512@2x.png", 167 | "idiom" : "mac", 168 | "scale" : "2x", 169 | "size" : "512x512" 170 | } 171 | ], 172 | "info" : { 173 | "author" : "xcode", 174 | "version" : 1 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon1024.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon128.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon128@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon16.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon16@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon20.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon20@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon20@2x 1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon20@2x 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon20@2x 2.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon20@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon256.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon256@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon29.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon29@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon29@2x 1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon29@2x 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon29@2x 2.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon29@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon32.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon32@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon40.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon40@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon40@2x 1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon40@2x 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon40@2x 2.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon40@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon512.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon512@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon60@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon60@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon76.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon76@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Icon83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wtg/Shuttle-Tracker-SwiftUI/11e926a8d1f003f15d0250495d1859ea2483d229/Shared/Assets.xcassets/AppIcon.appiconset/Icon83.5@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Shared/BusID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BusID.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/22/21. 6 | // 7 | 8 | // TODO: Revisit choice to implement a non-interned wrapper class 9 | final class BusID: Equatable, Comparable, Identifiable, RawRepresentable { 10 | 11 | static let unknown = BusID() 12 | 13 | let id: Int 14 | 15 | var isUnknown: Bool { 16 | get { 17 | return self.id < 0 18 | } 19 | } 20 | 21 | var rawValue: Int { 22 | get { 23 | return self.id 24 | } 25 | } 26 | 27 | init?(_ id: Int) { 28 | guard id > 0 else { 29 | return nil 30 | } 31 | self.id = id 32 | } 33 | 34 | private init() { 35 | self.id = .random(in: Int(Int8.min) ..< 0) 36 | } 37 | 38 | required init(rawValue: Int) { 39 | self.id = rawValue 40 | } 41 | 42 | static func == (_ left: BusID, _ right: BusID) -> Bool { 43 | return left.id == right.id 44 | } 45 | 46 | static func < (_ left: BusID, _ right: BusID) -> Bool { 47 | return left.id < right.id 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Shared/CustomAnnotation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAnnotation.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/21/20. 6 | // 7 | 8 | import MapKit 9 | 10 | protocol CustomAnnotation: MKAnnotation { 11 | 12 | #if os(iOS) || os(macOS) 13 | var annotationView: MKAnnotationView { get } 14 | #endif 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Delegates/MailComposeViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MailComposeViewDelegate.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 12/2/22. 6 | // 7 | 8 | import MessageUI 9 | import STLogging 10 | 11 | final class MailComposeViewControllerDelegate: NSObject, MFMailComposeViewControllerDelegate { 12 | 13 | let dismissalHandler: (((any Error)?) -> Void)? 14 | 15 | func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: (any Error)?) { 16 | #log(system: Logging.system, category: .mailComposeViewControllerDelegate, level: .info, "Did finish with \(result.rawValue) error \(error, privacy: .public)") 17 | if let error { 18 | #log(system: Logging.system, category: .mailCompose, level: .error, doUpload: true, "Failed to send email: \(error, privacy: .public)") 19 | } 20 | self.dismissalHandler?(error) 21 | } 22 | 23 | init(dismissalHandler: (((any Error)?) -> Void)?) { 24 | self.dismissalHandler = dismissalHandler 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Shared/Delegates/MapViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapViewDelegate.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/20/20. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | 11 | final class MapViewDelegate: NSObject, MKMapViewDelegate { 12 | 13 | func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { 14 | if annotation is MKUserLocation { 15 | #if os(iOS) 16 | switch BoardBusManager.globalTravelState { 17 | case .onBus: 18 | let markerAnnotationView = MKMarkerAnnotationView() 19 | markerAnnotationView.annotation = annotation 20 | markerAnnotationView.displayPriority = .required 21 | markerAnnotationView.markerTintColor = .systemBlue 22 | markerAnnotationView.animatesWhenAdded = true 23 | markerAnnotationView.glyphImage = UIImage(systemName: SFSymbol.user.systemName) 24 | return markerAnnotationView 25 | case .notOnBus: 26 | return nil 27 | } 28 | #endif // os(iOS) 29 | } else if let customAnnotation = annotation as? CustomAnnotation { 30 | return customAnnotation.annotationView 31 | } 32 | return nil 33 | } 34 | 35 | func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { 36 | if let route = overlay as? Route { 37 | return route.polylineRenderer 38 | } 39 | return MKOverlayRenderer() 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Shared/Delegates/UserNotificationCenterDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserNotificationCenterDelegate.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/31/23. 6 | // 7 | 8 | import STLogging 9 | import UserNotifications 10 | 11 | /// The standard `UNUserNotificationCenterDelegate` implementation. 12 | @MainActor 13 | final class UserNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { 14 | 15 | /// The default delegate object. 16 | /// 17 | /// Generally, there’s no need to create new delegate objects; just use the default one. 18 | static let `default` = UserNotificationCenterDelegate() 19 | 20 | func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { 21 | #log(system: Logging.system, category: .userNotificationCenterDelegate, level: .info, "Did receive \(response, privacy: .public)") 22 | await UNUserNotificationCenter.handleNotification(userInfo: response.notification.request.content.userInfo) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Shared/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/17/22. 6 | // 7 | 8 | import Foundation 9 | import STLogging 10 | 11 | enum Logging { 12 | 13 | typealias Log = LoggingSystem.Log 14 | 15 | enum Category: String, LoggingCategory { 16 | 17 | case api = "API" 18 | 19 | /// The category for logging interactions with the Apple Push Notification Service. 20 | case apns = "APNS" 21 | 22 | /// The category for logging method invocations in ``AppDelegate``. 23 | case appDelegate = "AppDelegate" 24 | 25 | case boardBus = "BoardBus" 26 | 27 | /// The default category. 28 | case general = "General" 29 | 30 | case location = "Location" 31 | 32 | /// The category for logging method invocations in ``LocationManagerDelegate``. 33 | case locationManagerDelegate = "LocationManagerDelegate" 34 | 35 | case mailCompose = "MailCompose" 36 | 37 | /// The category for logging method invocations in ``MailComposeViewControllerDelegate``. 38 | case mailComposeViewControllerDelegate = "MailComposeViewControllerDelegate" 39 | 40 | case permissions = "Permissions" 41 | 42 | /// The category for logging method invocations in ``UserNotificationCenterDelegate``. 43 | case userNotificationCenterDelegate = "UserNotificationCenterDelegate" 44 | 45 | static var `default`: Self = .general 46 | 47 | } 48 | 49 | @MainActor 50 | static let system = LoggingSystem(configurationProvider: AppStorageManager.shared, uploader: API.self) 51 | 52 | } 53 | 54 | extension Logging.Log: RawRepresentableInJSONArray { } 55 | 56 | extension API: LogUploader { 57 | 58 | typealias CategoryType = Logging.Category 59 | 60 | static func upload(log: Logging.Log) async throws -> UUID { 61 | return try await self.uploadLog(log: log).perform(as: UUID.self) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Shared/MapCameraPositionWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapCameraPositionWrapper.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 8/23/23. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | 11 | /// A wrapper type for `MapCameraPosition` from MapKit that enables its use safely as a stored property in other types, even when backported before iOS 17 or macOS 14. 12 | /// 13 | /// Use the ``mapCameraPosition`` property to access the underlying `MapCameraPosition` instance. The property is available only on supported OS versions, which is enforced at buildtime. On older OS versions, wrapper instance contain inaccessible null data. 14 | struct MapCameraPositionWrapper { 15 | 16 | private var storage: Any? 17 | 18 | /// The underlying map camera position. 19 | @available(iOS 17, macOS 14, *) 20 | var mapCameraPosition: MapCameraPosition { 21 | get { 22 | return self.storage! as! MapCameraPosition 23 | } 24 | set { 25 | self.storage = newValue 26 | } 27 | } 28 | 29 | /// Creates a null map camera position wrapper. 30 | /// 31 | /// - Warning: Don’t call this initializer on OS versions that support `MapCameraPosition`; doing so will result in an invalid instance, potentially causing fatal crashes. 32 | @available(iOS, deprecated: 17) 33 | @available(macOS, deprecated: 14) 34 | private init() { 35 | self.storage = nil 36 | } 37 | 38 | /// Creates a map camera position wrapper. 39 | /// - Parameter mapCameraPosition: The underlying map camera position. 40 | @available(iOS 17, macOS 14, *) 41 | init(_ mapCameraPosition: MapCameraPosition) { 42 | self.storage = mapCameraPosition 43 | } 44 | 45 | /// The default map camera position wrapper. 46 | /// 47 | /// On supported OS versions, the wrapper instance is initialized to wrap ``MapConstants/defaultCameraPosition``. On unsupported OS versions, the instance is initialized with inaccessible null data. 48 | static let `default`: MapCameraPositionWrapper = { 49 | if #available(iOS 17, macOS 14, *) { 50 | return MapCameraPositionWrapper(MapConstants.defaultCameraPosition) 51 | } else { 52 | return MapCameraPositionWrapper() 53 | } 54 | }() 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Shared/Models/Announcement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Announcement.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/20/21. 6 | // 7 | 8 | import Foundation 9 | import STLogging 10 | 11 | final class Announcement: Decodable, Hashable, Identifiable, Sendable { 12 | 13 | enum ScheduleType: String, Decodable { 14 | 15 | case none = "none" 16 | 17 | case startOnly = "startOnly" 18 | 19 | case endOnly = "endOnly" 20 | 21 | case startAndEnd = "startAndEnd" 22 | 23 | } 24 | 25 | let id: UUID 26 | 27 | let subject: String 28 | 29 | let body: String 30 | 31 | let start: Date 32 | 33 | let end: Date 34 | 35 | let scheduleType: ScheduleType 36 | 37 | var startString: String { 38 | get { 39 | let formatter = RelativeDateTimeFormatter() 40 | formatter.formattingContext = .dynamic 41 | return formatter.localizedString(for: self.start, relativeTo: .now) 42 | } 43 | } 44 | 45 | var endString: String { 46 | get { 47 | let formatter = RelativeDateTimeFormatter() 48 | formatter.formattingContext = .dynamic 49 | return formatter.localizedString(for: self.end, relativeTo: .now) 50 | } 51 | } 52 | 53 | func hash(into hasher: inout Hasher) { 54 | hasher.combine(self.id) 55 | } 56 | 57 | static func == (lhs: Announcement, rhs: Announcement) -> Bool { 58 | return lhs.id == rhs.id 59 | } 60 | 61 | } 62 | 63 | extension Array where Element == Announcement { 64 | 65 | static func download() async -> [Announcement] { 66 | do { 67 | return try await API.readAnnouncements.perform(as: [Announcement].self) 68 | .filter { (announcement) in 69 | switch announcement.scheduleType { 70 | case .none: 71 | return true 72 | case .startOnly: 73 | return announcement.start <= .now 74 | case .endOnly: 75 | return announcement.end > .now 76 | case .startAndEnd: 77 | return announcement.start <= .now && announcement.end > .now 78 | } 79 | } 80 | } catch { 81 | await #log(system: Logging.system, category: .api, level: .error, "Failed to download announcements: \(error, privacy: .public)") 82 | return [] 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /Shared/Models/Bus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bus.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/20/20. 6 | // 7 | 8 | import MapKit 9 | import STLogging 10 | import SwiftUI 11 | 12 | class Bus: NSObject, Codable, Identifiable, CustomAnnotation { 13 | 14 | struct Location: Codable { 15 | 16 | enum LocationType: String, Codable { 17 | 18 | case system 19 | 20 | case user 21 | 22 | } 23 | 24 | let id: UUID 25 | 26 | let date: Date 27 | 28 | let coordinate: Coordinate 29 | 30 | let type: LocationType 31 | 32 | func convertedForCoreLocation() -> CLLocation { 33 | return CLLocation( 34 | coordinate: self.coordinate.convertedForCoreLocation(), 35 | altitude: .nan, 36 | horizontalAccuracy: .nan, 37 | verticalAccuracy: .nan, 38 | timestamp: self.date 39 | ) 40 | } 41 | 42 | } 43 | 44 | let id: Int 45 | 46 | private(set) var location: Location 47 | 48 | var coordinate: CLLocationCoordinate2D { 49 | get { 50 | return self.location.coordinate.convertedForCoreLocation() 51 | } 52 | } 53 | 54 | var title: String? { 55 | get { 56 | return self.id > 0 ? "Bus \(self.id)" : "Bus" 57 | } 58 | } 59 | 60 | var subtitle: String? { 61 | get { 62 | let formatter = RelativeDateTimeFormatter() 63 | formatter.dateTimeStyle = .named 64 | formatter.formattingContext = .standalone 65 | return formatter.localizedString(for: self.location.date, relativeTo: Date()) 66 | } 67 | } 68 | 69 | @MainActor 70 | var tintColor: Color { 71 | get { 72 | switch self.location.type { 73 | case .system: 74 | return AppStorageManager.shared.colorBlindMode ? .purple : .red 75 | case .user: 76 | return self.id > 0 ? .green : (AppStorageManager.shared.colorBlindMode ? .purple : .red) 77 | } 78 | } 79 | } 80 | 81 | @MainActor 82 | var iconSystemName: String { 83 | get { 84 | let colorBlindSytemImage: String 85 | switch self.location.type { 86 | case .system: 87 | colorBlindSytemImage = SFSymbol.colorBlindLowQualityLocation.systemName 88 | case .user: 89 | if self.id > 0 { 90 | colorBlindSytemImage = SFSymbol.colorBlindHighQualityLocation.systemName 91 | } else { 92 | colorBlindSytemImage = SFSymbol.colorBlindLowQualityLocation.systemName 93 | } 94 | } 95 | return AppStorageManager.shared.colorBlindMode ? colorBlindSytemImage : SFSymbol.bus.systemName 96 | } 97 | } 98 | 99 | #if !os(watchOS) 100 | @MainActor 101 | var annotationView: MKAnnotationView { 102 | get { 103 | let markerAnnotationView = MKMarkerAnnotationView() 104 | markerAnnotationView.displayPriority = .required 105 | markerAnnotationView.canShowCallout = true 106 | #if canImport(AppKit) 107 | markerAnnotationView.markerTintColor = NSColor(self.tintColor) 108 | markerAnnotationView.glyphImage = NSImage(systemSymbolName: self.iconSystemName, accessibilityDescription: nil) 109 | #elseif canImport(UIKit) // canImport(AppKit) 110 | markerAnnotationView.markerTintColor = UIColor(self.tintColor) 111 | markerAnnotationView.glyphImage = UIImage(systemName: self.iconSystemName) 112 | #endif // canImport(UIKit) 113 | return markerAnnotationView 114 | } 115 | } 116 | #endif 117 | 118 | init(id: Int, location: Location) { 119 | self.id = id 120 | self.location = location 121 | } 122 | 123 | static func == (_ left: Bus, _ right: Bus) -> Bool { 124 | return left.id == right.id 125 | } 126 | 127 | } 128 | 129 | extension Array where Element == Bus { 130 | 131 | static func download() async -> [Bus] { 132 | #if os(iOS) 133 | let busID = await BoardBusManager.shared.busID 134 | let travelState = await BoardBusManager.shared.travelState 135 | #endif // os(iOS) 136 | do { 137 | return try await API.readBuses.perform(as: [Bus].self) 138 | .filter { (bus) in 139 | return abs(bus.location.date.timeIntervalSinceNow) < 300 // 5 minutes 140 | } 141 | #if os(iOS) 142 | .filter { (bus) in 143 | switch travelState { 144 | case .onBus: 145 | return bus.id != busID 146 | case .notOnBus: 147 | return true 148 | } 149 | } 150 | #endif // os(iOS) 151 | } catch { 152 | #log(system: Logging.system, category: .api, level: .error, "Failed to download buses: \(error, privacy: .public)") 153 | return [] 154 | } 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /Shared/Models/ColorName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorName.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 8/27/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum ColorName: String, Codable { 11 | 12 | case red, orange, yellow, green, blue, purple, pink, gray 13 | 14 | var color: Color { 15 | get { 16 | switch self { 17 | case .red: 18 | return .red 19 | case .orange: 20 | return .orange 21 | case .yellow: 22 | return .yellow 23 | case .green: 24 | return .green 25 | case .blue: 26 | return .blue 27 | case .purple: 28 | return .purple 29 | case .pink: 30 | return .pink 31 | case .gray: 32 | return .gray 33 | } 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Shared/Models/Coordinate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinate.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/4/21. 6 | // 7 | 8 | import CoreLocation 9 | 10 | struct Coordinate: Codable { 11 | 12 | let latitude: Double 13 | 14 | let longitude: Double 15 | 16 | func convertedForCoreLocation() -> CLLocationCoordinate2D { 17 | return CLLocationCoordinate2D(latitude: self.latitude, longitude: self.longitude) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Shared/Models/Schedule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Schedule.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Emily Ngo on 2/15/22. 6 | // 7 | 8 | import Foundation 9 | import STLogging 10 | 11 | final class Schedule: Decodable, Identifiable { 12 | 13 | struct Content: Decodable { 14 | 15 | struct DaySchedule: Decodable { 16 | 17 | let start: String 18 | 19 | let end: String 20 | 21 | } 22 | 23 | let monday: DaySchedule 24 | 25 | let tuesday: DaySchedule 26 | 27 | let wednesday: DaySchedule 28 | 29 | let thursday: DaySchedule 30 | 31 | let friday: DaySchedule 32 | 33 | let saturday: DaySchedule 34 | 35 | let sunday: DaySchedule 36 | 37 | } 38 | 39 | let name: String 40 | 41 | let start: Date 42 | 43 | let end: Date 44 | 45 | let content: Content 46 | 47 | init(name: String, start: Date, end: Date, content: Schedule.Content) { 48 | self.name = name 49 | self.start = start 50 | self.end = end 51 | self.content = content 52 | } 53 | 54 | static func download() async -> Schedule? { 55 | do { 56 | return try await API.readSchedule.perform(as: [Schedule].self) 57 | .first { (schedule) in 58 | return schedule.start <= Date.now && schedule.end >= Date.now 59 | } 60 | } catch { 61 | #log(system: Logging.system, category: .api, level: .error, "Failed to download schedule: \(error, privacy: .public)") 62 | return nil 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Shared/Models/Stop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stop.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/21/20. 6 | // 7 | 8 | import MapKit 9 | import STLogging 10 | 11 | class Stop: NSObject, Decodable, Identifiable, CustomAnnotation { 12 | 13 | enum CodingKeys: String, CodingKey { 14 | 15 | case name, coordinate 16 | 17 | } 18 | 19 | let name: String 20 | 21 | let coordinate: CLLocationCoordinate2D 22 | 23 | var location: CLLocation { 24 | get { 25 | return CLLocation(latitude: self.coordinate.latitude, longitude: self.coordinate.longitude) 26 | } 27 | } 28 | 29 | var title: String? { 30 | get { 31 | return self.name 32 | } 33 | } 34 | #if !os(watchOS) 35 | @MainActor 36 | let annotationView: MKAnnotationView = { 37 | let annotationView = MKAnnotationView() 38 | annotationView.displayPriority = .defaultHigh 39 | annotationView.canShowCallout = true 40 | #if canImport(AppKit) 41 | annotationView.image = NSImage(systemSymbolName: SFSymbol.stop.systemName, accessibilityDescription: nil)? 42 | .withTintColor(.white) 43 | annotationView.layer?.borderColor = .black 44 | annotationView.layer?.borderWidth = 2 45 | annotationView.layer?.cornerRadius = annotationView.frame.width / 2 46 | #elseif canImport(UIKit) // canImport(AppKit) 47 | let image = UIImage(systemName: SFSymbol.stop.systemName)! 48 | let imageView = UIImageView(image: image) 49 | imageView.tintColor = .white 50 | imageView.layer.borderColor = UIColor.black.cgColor 51 | imageView.layer.borderWidth = 2 52 | imageView.layer.cornerRadius = imageView.frame.width / 2 53 | imageView.frame = imageView.frame.offsetBy(dx: imageView.frame.width / -2, dy: imageView.frame.height / -2) 54 | annotationView.addSubview(imageView) 55 | #endif // canImport(UIKit) 56 | return annotationView 57 | }() 58 | #endif 59 | 60 | @MainActor 61 | required init(from decoder: Decoder) throws { 62 | let container = try decoder.container(keyedBy: CodingKeys.self) 63 | self.name = try container.decode(String.self, forKey: .name) 64 | self.coordinate = try container.decode(Coordinate.self, forKey: .coordinate).convertedForCoreLocation() 65 | } 66 | 67 | } 68 | 69 | extension Array where Element == Stop { 70 | 71 | static func download() async -> [Stop] { 72 | do { 73 | return try await API.readStops.perform(as: [Stop].self, onMainActor: true) // Stops must be decoded on the main thread because initializing the annotationView property indirectly invokes UIView’s main-thread-isolated init() initializer. 74 | } catch { 75 | await #log(system: Logging.system, category: .api, level: .error, "Failed to download stops: \(error, privacy: .public)") 76 | return [] 77 | } 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Shared/RawRepresentableInJSONArray.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RawRepresentableInJSONArray.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Aidan Flaherty on 2/17/23. 6 | // 7 | 8 | import Foundation 9 | 10 | // We use a dedicated protocol instead of just extending Array for all Element specializations that conform to Codable to avoid creating retroactive conformaces. 11 | 12 | public protocol RawRepresentableInJSONArray: Codable { } 13 | 14 | extension Array: RawRepresentable where Element: RawRepresentableInJSONArray { 15 | 16 | public var rawValue: String { 17 | get { 18 | // Serialize this array into a single JSON string 19 | return (try? JSONEncoder().encode(self)).flatMap { (data) in 20 | return String(data: data, encoding: .utf8) 21 | } ?? "[ ]" 22 | } 23 | } 24 | 25 | public init?(rawValue: String) { 26 | guard let data = rawValue.data(using: .utf8) else { 27 | return nil 28 | } 29 | guard let log = try? JSONDecoder().decode(Self.self, from: data) else { 30 | return nil 31 | } 32 | self = log 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Shared/RefreshSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshSequence.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 12/27/22. 6 | // 7 | 8 | import STLogging 9 | 10 | /// An asynchronous sequence that emits refresh signals for the Shuttle Tracker map. 11 | /// 12 | /// The element type is ``RefreshType``, which is an enumeration that specifies whether the refresh was triggered manually (by the user) or automatically. Each type of refresh might be handled differently by code that awaits the emitted elements. Manual refreshes are generated when ``trigger()`` is invoked; automatic refreshes are generated by an internal timer with a configurable interval (see ``interval``). 13 | @available(iOS 16, macOS 13, *) 14 | actor RefreshSequence: AsyncSequence, AsyncIteratorProtocol { 15 | 16 | typealias Element = RefreshType 17 | 18 | /// The interval between ``RefreshType/automatic`` refresh events. 19 | let interval: Duration 20 | 21 | /// The current automatic-refresh task. 22 | private lazy var productionTask = self.newProductionTask() 23 | 24 | /// Creates a refresh sequence. 25 | /// - Parameter interval: The interval between ``RefreshType/automatic`` refresh events. 26 | init(interval: Duration) { 27 | self.interval = interval 28 | } 29 | 30 | func next() async -> RefreshType? { 31 | do { 32 | try Task.checkCancellation() 33 | } catch { 34 | #log(system: Logging.system, level: .error, doUpload: true, "Refresh sequence canceled: \(error, privacy: .public)") 35 | return nil 36 | } 37 | do { 38 | self.productionTask = self.newProductionTask() 39 | return try await self.productionTask.value // Wait for the next automatic refresh event 40 | } catch is CancellationError { 41 | // Manual refresh events are signaled by canceling the automatic refresh production task. 42 | return .manual 43 | } catch { 44 | #log(system: Logging.system, level: .error, doUpload: true, "Refresh sequence production task failed: \(error, privacy: .public)") 45 | return nil 46 | } 47 | } 48 | 49 | /// Triggers a manual refresh event. 50 | func trigger() { 51 | // Manual refresh events are signaled by canceling the automatic refresh production task. 52 | self.productionTask.cancel() 53 | } 54 | 55 | /// Dispatches and returns a new task that emits an ``RefreshType/automatic`` refresh event after a particular interval. 56 | /// 57 | /// The interval is specified by ``interval``. 58 | /// - Returns: The task. 59 | private func newProductionTask() -> Task { 60 | return Task { 61 | try await Task.sleep(for: self.interval) 62 | return .automatic 63 | } 64 | } 65 | 66 | nonisolated func makeAsyncIterator() -> RefreshSequence { 67 | return self 68 | } 69 | 70 | } 71 | 72 | enum RefreshType { 73 | 74 | case manual, automatic 75 | 76 | } 77 | -------------------------------------------------------------------------------- /Shared/SFSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SFSymbol.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Tommy Truong on 9/14/23. 6 | // 7 | 8 | enum SFSymbol { 9 | 10 | case announcements 11 | 12 | case bus 13 | 14 | case close 15 | 16 | case colorBlindHighQualityLocation 17 | 18 | case colorBlindLowQualityLocation 19 | 20 | case info 21 | 22 | case loggingAnalytics 23 | 24 | case onboardingNode 25 | 26 | case onboardingPhone 27 | 28 | case onboardingServer 29 | 30 | case onboardingSignal 31 | 32 | case onboardingSwipeLeft 33 | 34 | case permissionDenied 35 | 36 | case permissionGranted 37 | 38 | case permissionNotDetermined 39 | 40 | case privacy 41 | 42 | case recenter 43 | 44 | case refresh 45 | 46 | case schedule 47 | 48 | case settings 49 | 50 | case stop 51 | 52 | case shuttleTrackerPlus 53 | 54 | case user 55 | 56 | case whatsNewAnalytics 57 | 58 | case whatsNewAutomaticBoardBus 59 | 60 | case whatsNewDesign 61 | 62 | case whatsNewNetwork 63 | 64 | case whatsNewNotifications 65 | 66 | var systemName: String { 67 | get { 68 | switch self { 69 | case .announcements: 70 | #if os(watchOS) 71 | return "bell.badge.circle.fill" 72 | #else 73 | return "exclamationmark.bubble.fill" 74 | #endif 75 | case .bus: 76 | return "bus" 77 | case .close: 78 | return "xmark.circle.fill" 79 | case .colorBlindHighQualityLocation: 80 | return "scope" 81 | case .colorBlindLowQualityLocation: 82 | return "circle.dotted" 83 | case .info: 84 | return "info" 85 | case .loggingAnalytics: 86 | return "text.redaction" 87 | case .onboardingNode: 88 | return "antenna.radiowaves.left.and.right.circle.fill" 89 | case .onboardingPhone: 90 | return "iphone" 91 | case .onboardingServer: 92 | return "cloud" 93 | case .onboardingSignal: 94 | return "wave.3.forward" 95 | case .onboardingSwipeLeft: 96 | return "chevron.compact.left" 97 | case .permissionDenied: 98 | return "gear.badge.xmark" 99 | case .permissionGranted: 100 | return "gear.badge.checkmark" 101 | case .permissionNotDetermined: 102 | return "gear.badge.questionmark" 103 | case .privacy: 104 | return "hand.raised.circle.fill" 105 | case .recenter: 106 | return "location.viewfinder" 107 | case .refresh: 108 | return "arrow.clockwise" 109 | case .schedule: 110 | #if os(watchOS) 111 | return "calendar.circle.fill" 112 | #else 113 | return "calendar.badge.clock" 114 | #endif 115 | case .settings: 116 | #if os(watchOS) 117 | return "gear.circle.fill" 118 | #else 119 | return "gearshape" 120 | #endif 121 | case .stop: 122 | return "circle.fill" 123 | case .shuttleTrackerPlus: 124 | return "plus.circle.fill" 125 | case .user: 126 | return "person.crop.circle" 127 | case .whatsNewAnalytics: 128 | return "stethoscope" 129 | case .whatsNewAutomaticBoardBus: 130 | return "location.square" 131 | case .whatsNewDesign: 132 | return "star.square" 133 | case .whatsNewNetwork: 134 | return "point.3.filled.connected.trianglepath.dotted" 135 | case .whatsNewNotifications: 136 | return "bell.badge" 137 | } 138 | } 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /Shared/ShuttleTrackerSheetStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShuttleTrackerSheetStack.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/13/23. 6 | // 7 | 8 | import OrderedCollections 9 | import SheetStack 10 | import SwiftUI 11 | 12 | typealias ShuttleTrackerSheetStack = SheetStack 13 | 14 | struct ShuttleTrackerSheetPresentationProvider: SheetPresentationProvider { 15 | 16 | enum SheetType: Hashable, Identifiable { 17 | 18 | #if os(macOS) 19 | case analyticsOnboarding 20 | #endif // os(macOS) 21 | 22 | case announcements 23 | 24 | case announcement(_ announcement: Announcement) 25 | 26 | #if os(iOS) 27 | case busSelection 28 | #endif // os(iOS) 29 | 30 | case info 31 | 32 | #if os(iOS) 33 | case mailCompose( 34 | subject: String = "", 35 | toRecipients: OrderedSet = [], 36 | ccRecipients: OrderedSet = [], 37 | bccRecipients: OrderedSet = [], 38 | messageBody: String = "", 39 | isHTMLMessageBody: Bool = false, 40 | attachments: [MailComposeView.Attachment] = [] 41 | ) 42 | #endif // os(iOS) 43 | 44 | #if os(iOS) && !APPCLIP 45 | case networkOnboarding 46 | #endif // os(iOS) && !APPCLIP 47 | 48 | #if os(iOS) && !APPCLIP 49 | case permissions 50 | #endif // os(iOS) && !APPCLIP 51 | 52 | case privacy 53 | 54 | #if os(iOS) && !APPCLIP 55 | case settings 56 | #endif // os(iOS) && !APPCLIP 57 | 58 | #if !APPCLIP 59 | case whatsNew(onboarding: Bool) 60 | #endif // !APPCLIP 61 | 62 | var id: Self { 63 | get { 64 | return self 65 | } 66 | } 67 | 68 | } 69 | 70 | let sheetStack: SheetStack 71 | 72 | func content(sheetType: SheetType) -> some View { 73 | switch sheetType { 74 | #if os(macOS) 75 | case .analyticsOnboarding: 76 | AnalyticsOnboardingView() 77 | .frame(minWidth: 300, idealWidth: 500, minHeight: 200, idealHeight: 500) 78 | #endif // os(macOS) 79 | case .announcements: 80 | AnnouncementsSheet() 81 | .frame(idealWidth: 500, idealHeight: 500) 82 | case .announcement(let announcement): 83 | #if os(iOS) 84 | NavigationView { 85 | AnnouncementDetailView(announcement: announcement) 86 | } 87 | #elseif os(macOS) // os(iOS) 88 | AnnouncementDetailView(announcement: announcement) 89 | #endif // os(macOS) 90 | #if os(iOS) 91 | case .busSelection: 92 | BusSelectionSheet() 93 | .interactiveDismissDisabled() 94 | #endif // os(iOS) 95 | case .info: 96 | #if os(iOS) 97 | InfoSheet() 98 | #elseif os(macOS) // os(iOS) 99 | InfoView() 100 | .frame(minWidth: 300, idealWidth: 500, minHeight: 300, idealHeight: 500) 101 | #endif // os(macOS) 102 | #if os(iOS) 103 | case .mailCompose( 104 | let subject, 105 | let toRecipients, 106 | let ccRecipients, 107 | let bccRecipients, 108 | let messageBody, 109 | let isHTMLMessageBody, 110 | let attachments 111 | ): 112 | MailComposeView( 113 | subject: subject, 114 | toRecipients: toRecipients, 115 | ccRecipients: ccRecipients, 116 | bccRecipients: bccRecipients, 117 | messageBody: messageBody, 118 | isHTMLMessageBody: isHTMLMessageBody, 119 | attachments: attachments 120 | ) { (_) in 121 | Task { 122 | await self.sheetStack.pop() 123 | } 124 | } 125 | #endif // os(iOS) 126 | #if os(iOS) && !APPCLIP 127 | case .networkOnboarding: 128 | NetworkOnboardingView() 129 | #endif // os(iOS) && !APPCLIP 130 | #if os(iOS) && !APPCLIP 131 | case .permissions: 132 | PermissionsSheet() 133 | .interactiveDismissDisabled() 134 | #endif // os(iOS) && !APPCLIP 135 | case .privacy: 136 | #if os(iOS) 137 | PrivacySheet() 138 | #elseif os(macOS) // os(iOS) 139 | // Don’t use a navigation view on macOS 140 | PrivacyView() 141 | .frame(idealWidth: 500, idealHeight: 500) 142 | #endif // os(macOS) 143 | #if os(iOS) && !APPCLIP 144 | case .settings: 145 | SettingsSheet() 146 | #endif // os(iOS) && !APPCLIP 147 | #if !APPCLIP 148 | case .whatsNew(let onboarding): 149 | #if os(iOS) 150 | WhatsNewSheet(onboarding: onboarding) 151 | .interactiveDismissDisabled() 152 | #elseif os(macOS) // os(iOS) 153 | // Don’t use a navigation view on macOS 154 | WhatsNewView(onboarding: onboarding) 155 | .frame(idealWidth: 500, idealHeight: 500) 156 | #endif // os(macOS) 157 | #endif // !APPCLIP 158 | } 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /Shared/State/MapState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapState.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/20/20. 6 | // 7 | 8 | import MapKit 9 | import STLogging 10 | import SwiftUI 11 | import UserNotifications 12 | 13 | actor MapState: ObservableObject { 14 | 15 | static let shared = MapState() 16 | 17 | #if !os(watchOS) 18 | static weak var mapView: MKMapView? 19 | #endif 20 | 21 | private(set) var buses = [Bus]() 22 | 23 | private(set) var stops = [Stop]() 24 | 25 | private(set) var routes = [Route]() 26 | 27 | private init() { } 28 | 29 | func refreshBuses() async { 30 | self.buses = await [Bus].download() 31 | await MainActor.run { 32 | self.objectWillChange.send() 33 | } 34 | } 35 | func refreshAll() async { 36 | Task { // Dispatch a new task because we don’t need to await the result 37 | do { 38 | try await UNUserNotificationCenter.updateBadge() 39 | } catch { 40 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 41 | } 42 | } 43 | async let buses = [Bus].download() 44 | async let stops = [Stop].download() 45 | async let routes = [Route].download() 46 | self.buses = await buses 47 | self.stops = await stops 48 | self.routes = await routes 49 | await MainActor.run { 50 | self.objectWillChange.send() 51 | } 52 | } 53 | 54 | @MainActor 55 | func recenter(position: Binding) async { 56 | if #available(iOS 17, macOS 14, watchOS 10, *) { 57 | let dx = (MapConstants.mapRectInsets.left + MapConstants.mapRectInsets.right) * -15 58 | let dy = (MapConstants.mapRectInsets.top + MapConstants.mapRectInsets.bottom) * -15 59 | let mapRect = await self.routes.boundingMapRect.insetBy(dx: dx, dy: dy) 60 | withAnimation { 61 | position.mapCameraPosition.wrappedValue = .rect(mapRect) 62 | } 63 | } else { 64 | #if !os(watchOS) 65 | Self.mapView?.setVisibleMapRect( 66 | await self.routes.boundingMapRect, 67 | edgePadding: MapConstants.mapRectInsets, 68 | animated: true 69 | ) 70 | #endif 71 | } 72 | } 73 | 74 | #if !os(watchOS) 75 | func distance(to coordinate: CLLocationCoordinate2D) -> Double { 76 | return self.routes 77 | .map { (route) in 78 | return route.distance(to: coordinate) 79 | } 80 | .min() ?? .infinity 81 | } 82 | #endif 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Shared/State/ViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewState.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/7/21. 6 | // 7 | 8 | import Combine 9 | import OnboardingKit 10 | import SwiftUI 11 | 12 | @MainActor 13 | final class ViewState: OnboardingFlags { 14 | 15 | final class Handles { 16 | 17 | var tripCount: OnboardingConditions.ManualCounter.Handle? 18 | 19 | var whatsNew: OnboardingConditions.ManualCounter.Handle? 20 | 21 | } 22 | 23 | enum AlertType: Identifiable { 24 | 25 | case noNearbyStop, updateAvailable, serverUnavailable 26 | 27 | var id: Self { 28 | get { 29 | return self 30 | } 31 | } 32 | 33 | } 34 | 35 | enum ToastType: Identifiable { 36 | 37 | case legend, boardBus, network 38 | 39 | var id: Self { 40 | get { 41 | return self 42 | } 43 | } 44 | 45 | } 46 | 47 | enum StatusText { 48 | 49 | case mapRefresh, locationData, thanks 50 | 51 | var string: String { 52 | get { 53 | switch self { 54 | case .mapRefresh: 55 | return "The map automatically refreshes every 5 seconds." 56 | case .locationData: 57 | return "You’re helping other users with real-time bus location data." 58 | case .thanks: 59 | return "Thanks for helping other users with real-time bus location data!" 60 | } 61 | } 62 | } 63 | 64 | } 65 | 66 | static let shared = ViewState() 67 | 68 | @Published 69 | var alertType: AlertType? 70 | 71 | @Published 72 | var toastType: ToastType? 73 | 74 | @Published 75 | var statusText = StatusText.mapRefresh 76 | 77 | #if !os(watchOS) 78 | @Published 79 | var legendToastHeadlineText: LegendToast.HeadlineText? 80 | #endif 81 | 82 | /// The number that should be displayed in notification badges. 83 | /// 84 | /// Generally, the value of this property should be the count of announcements that the user has not yet viewed. 85 | /// - Warning: Don’t set this property directly; instead, use `updateBadge()` on `UNUserNotificationCenter`. 86 | @Published 87 | var badgeNumber = 0 88 | 89 | let handles = Handles() 90 | 91 | // TODO: Simplify to a single stored property when we drop support for iOS 15 and macOS 12 92 | // We have to do this annoying dance with a separate refreshSequenceStorage backing because Swift doesn’t yet support gating stored properties on API availability. 93 | 94 | @available(iOS 16, macOS 13, *) 95 | var refreshSequence: RefreshSequence { 96 | get { 97 | return self.refreshSequenceStorage as! RefreshSequence 98 | } 99 | } 100 | 101 | private let refreshSequenceStorage: Any! 102 | 103 | var colorScheme: ColorScheme? 104 | 105 | private init() { 106 | if #available(iOS 16, macOS 13, *) { 107 | self.refreshSequenceStorage = RefreshSequence(interval: .seconds(5)) 108 | } else { 109 | self.refreshSequenceStorage = nil 110 | } 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Shared/Views/AnalyticsDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsDetailView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Aidan Flaherty on 2/17/23. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | 11 | struct AnalyticsDetailView: View { 12 | 13 | let entry: Analytics.Entry 14 | 15 | private let dateFormatter: DateFormatter = { 16 | let dateFormatter = DateFormatter() 17 | dateFormatter.dateStyle = .long 18 | dateFormatter.timeStyle = .short 19 | dateFormatter.doesRelativeDateFormatting = true 20 | return dateFormatter 21 | }() 22 | 23 | private var jsonString: String? { 24 | get { 25 | do { 26 | return try self.entry.jsonString 27 | } catch { 28 | #log(system: Logging.system, level: .error, doUpload: true, "Failed to encode analytics entry: \(error, privacy: .public)") 29 | return nil 30 | } 31 | } 32 | } 33 | 34 | var body: some View { 35 | ScrollView { 36 | VStack { 37 | HStack { 38 | if #available(iOS 16, macOS 13, *) { 39 | Text(self.entry.id.uuidString) 40 | .font(.subheadline.monospaced().bold()) 41 | .textSelection(.enabled) 42 | } else { 43 | Text(self.entry.id.uuidString) 44 | .font(.subheadline.monospaced().bold()) 45 | .lineLimit(2) 46 | } 47 | Spacer() 48 | } 49 | Spacer() 50 | HStack { 51 | if let jsonString = self.jsonString { 52 | if #available(iOS 16.1, macOS 13, *) { 53 | Text(jsonString) 54 | .fontDesign(.monospaced) 55 | #if os(macOS) 56 | .textSelection(.enabled) 57 | #endif // os(macOS) 58 | } else { 59 | Text(jsonString) 60 | .font(.body.monospaced()) 61 | #if os(macOS) 62 | .textSelection(.enabled) 63 | #endif // os(macOS) 64 | } 65 | } else { 66 | Text("The analytics entry can’t be displayed.") 67 | .italic() 68 | } 69 | Spacer() 70 | } 71 | } 72 | .padding(.horizontal) 73 | } 74 | #if os(iOS) 75 | .navigationTitle(self.dateFormatter.string(from: self.entry.date)) 76 | .navigationBarTitleDisplayMode(.inline) 77 | .toolbar { 78 | if #available(iOS 16, *), let url = try? self.entry.writeToDisk() { 79 | ShareLink(item: url) 80 | } 81 | } 82 | #endif // os(iOS) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Shared/Views/AnalyticsOnboardingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsOnboardingView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/19/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AnalyticsOnboardingView: View { 11 | 12 | @EnvironmentObject 13 | private var appStorageManager: AppStorageManager 14 | 15 | @EnvironmentObject 16 | private var sheetStack: ShuttleTrackerSheetStack 17 | 18 | var body: some View { 19 | VStack(alignment: .leading) { 20 | HStack { 21 | Spacer() 22 | Text("Analytics") 23 | .font(.largeTitle) 24 | .bold() 25 | .multilineTextAlignment(.center) 26 | Spacer() 27 | } 28 | .padding(.vertical) 29 | Text("Share analytics with the Shuttle Tracker team to help us improve the app. You can see a record of uploaded analytics entries or enable or disable the feature in Settings > Logging & Analytics.") 30 | .accessibilityShowsLargeContentViewer() 31 | .padding(.bottom) 32 | Button("Show Privacy Information") { 33 | self.sheetStack.push(.privacy) 34 | } 35 | Spacer() 36 | HStack { 37 | Button { 38 | self.appStorageManager.doShareAnalytics = false 39 | self.sheetStack.pop() 40 | } label: { 41 | Text("Don’t Share") 42 | #if os(iOS) 43 | .bold() 44 | #endif // os(iOS) 45 | .padding(5) 46 | .frame(maxWidth: .infinity) 47 | } 48 | .buttonStyle(.bordered) 49 | Button { 50 | self.appStorageManager.doShareAnalytics = true 51 | self.sheetStack.pop() 52 | } label: { 53 | Text("Share Analytics") 54 | #if os(iOS) 55 | .bold() 56 | #endif // os(iOS) 57 | .padding(5) 58 | .frame(maxWidth: .infinity) 59 | } 60 | .buttonStyle(.borderedProminent) 61 | } 62 | } 63 | .padding(.horizontal) 64 | .padding(.bottom) 65 | .sheetPresentation( 66 | provider: ShuttleTrackerSheetPresentationProvider(sheetStack: self.sheetStack), 67 | sheetStack: self.sheetStack 68 | ) 69 | } 70 | 71 | } 72 | 73 | #Preview { 74 | AnalyticsOnboardingView() 75 | .environmentObject(AppStorageManager.shared) 76 | .environmentObject(ShuttleTrackerSheetStack()) 77 | } 78 | -------------------------------------------------------------------------------- /Shared/Views/AnnouncementDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnnouncementDetailView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/20/21. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | import UserNotifications 11 | 12 | struct AnnouncementDetailView: View { 13 | 14 | let announcement: Announcement 15 | 16 | @Binding 17 | private(set) var didResetViewedAnnouncements: Bool 18 | 19 | @EnvironmentObject 20 | private var appStorageManager: AppStorageManager 21 | 22 | @EnvironmentObject 23 | private var sheetStack: ShuttleTrackerSheetStack 24 | 25 | var body: some View { 26 | ScrollView { 27 | #if os(macOS) 28 | HStack { 29 | Text(self.announcement.subject) 30 | .font(.headline) 31 | Spacer() 32 | } 33 | .padding(.top) 34 | #endif // os(macOS) 35 | HStack { 36 | Text(self.announcement.body) 37 | Spacer() 38 | } 39 | HStack { 40 | switch self.announcement.scheduleType { 41 | case .none: 42 | EmptyView() 43 | case .startOnly: 44 | Text("Posted \(self.announcement.startString)") 45 | case .endOnly: 46 | Text("Expires \(self.announcement.endString)") 47 | case .startAndEnd: 48 | Text("Posted \(self.announcement.startString); expires \(self.announcement.endString)") 49 | } 50 | Spacer() 51 | } 52 | .font(.footnote) 53 | .foregroundColor(.secondary) 54 | .padding(.bottom) 55 | } 56 | .padding(.horizontal) 57 | .frame(minWidth: 300) 58 | .navigationTitle(self.announcement.subject) 59 | .toolbar { 60 | #if os(iOS) 61 | ToolbarItem { 62 | CloseButton() 63 | } 64 | #elseif os(macOS) // os(iOS) 65 | // TODO: Move conditional outside the ToolbarItem’s closure when we drop support for macOS 12 66 | // macOS 13 doesn’t support conditional toolbar builders, so we need to put the conditional inside the ToolbarItem’s closure for now, even though it’s not quite semantically correct to do so. 67 | ToolbarItem(placement: .confirmationAction) { 68 | if case .some(.announcement) = self.sheetStack.top { 69 | Button("Close") { 70 | self.sheetStack.pop() 71 | } 72 | } 73 | } 74 | #endif // os(macOS) 75 | } 76 | .task { 77 | self.didResetViewedAnnouncements = false 78 | self.appStorageManager.viewedAnnouncementIDs.insert(self.announcement.id) 79 | 80 | do { 81 | try await UNUserNotificationCenter.updateBadge() 82 | } catch { 83 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 84 | } 85 | 86 | do { 87 | try await Analytics.upload(eventType: .announcementViewed(id: self.announcement.id)) 88 | } catch { 89 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics entry: \(error, privacy: .public)") 90 | } 91 | } 92 | } 93 | 94 | init(announcement: Announcement, didResetViewedAnnouncements: Binding = .constant(false)) { 95 | self.announcement = announcement 96 | self._didResetViewedAnnouncements = didResetViewedAnnouncements 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Shared/Views/AnnouncementsSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnnouncementsSheet.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/20/21. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | import UserNotifications 11 | 12 | struct AnnouncementsSheet: View { 13 | 14 | @State 15 | private var announcements: [Announcement]? 16 | 17 | @State 18 | private var didResetViewedAnnouncements = false 19 | 20 | @EnvironmentObject 21 | private var viewState: ViewState 22 | 23 | @EnvironmentObject 24 | private var appStorageManager: AppStorageManager 25 | 26 | @EnvironmentObject 27 | private var sheetStack: ShuttleTrackerSheetStack 28 | 29 | var body: some View { 30 | NavigationView { 31 | Group { 32 | if let announcements = self.announcements { 33 | if announcements.count > 0 { 34 | List(announcements) { (announcement) in 35 | NavigationLink { 36 | AnnouncementDetailView( 37 | announcement: announcement, 38 | didResetViewedAnnouncements: self.$didResetViewedAnnouncements 39 | ) 40 | } label: { 41 | HStack { 42 | let isUnviewed = !self.appStorageManager.viewedAnnouncementIDs.contains(announcement.id) 43 | Circle() 44 | .fill(isUnviewed ? .blue : .clear) 45 | .frame(width: 10, height: 10) 46 | if #available(iOS 16, macOS 13, *) { 47 | Text(announcement.subject) 48 | .bold(isUnviewed) 49 | } else { 50 | Text(announcement.subject) 51 | } 52 | } 53 | } 54 | } 55 | } else { 56 | #if os(macOS) // os(macOS) 57 | Text("No Announcements") 58 | .font(.callout) 59 | .multilineTextAlignment(.center) 60 | .foregroundColor(.secondary) 61 | .frame(minWidth: 100) 62 | .padding() 63 | Text("No Announcements") 64 | .font(.title2) 65 | .multilineTextAlignment(.center) 66 | .foregroundColor(.secondary) 67 | .padding() 68 | #endif 69 | } 70 | } else { 71 | ProgressView("Loading") 72 | .font(.callout) 73 | .textCase(.uppercase) 74 | .foregroundColor(.secondary) 75 | .padding() 76 | } 77 | Text("No Announcement Selected") 78 | .font(.title2) 79 | .multilineTextAlignment(.center) 80 | .foregroundColor(.secondary) 81 | .padding() 82 | } 83 | .navigationTitle("Announcements") 84 | .frame(minHeight: 300) 85 | .toolbar { 86 | #if os(iOS) 87 | ToolbarItem { 88 | CloseButton() 89 | } 90 | #endif // os(iOS) 91 | } 92 | } 93 | .task { 94 | self.announcements = await [Announcement].download() 95 | do { 96 | try await UNUserNotificationCenter.updateBadge() 97 | } catch { 98 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 99 | } 100 | } 101 | .toolbar { 102 | #if os(macOS) 103 | ToolbarItem { 104 | Button(role: .destructive) { 105 | Task { 106 | do { 107 | try await UNUserNotificationCenter.updateBadge() 108 | } catch { 109 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 110 | } 111 | } 112 | self.appStorageManager.viewedAnnouncementIDs.removeAll() 113 | self.didResetViewedAnnouncements = true 114 | } label: { 115 | HStack { 116 | Text("Reset Viewed Announcements") 117 | if self.didResetViewedAnnouncements { 118 | Text("✓") 119 | } 120 | } 121 | } 122 | .disabled(self.appStorageManager.viewedAnnouncementIDs.isEmpty) 123 | .focusable(false) 124 | } 125 | ToolbarItem(placement: .cancellationAction) { 126 | Button("Close") { 127 | self.sheetStack.pop() 128 | } 129 | .buttonStyle(.bordered) 130 | .keyboardShortcut(.cancelAction) 131 | } 132 | #endif // os(macOS) 133 | } 134 | .task { 135 | self.announcements = await [Announcement].download() 136 | } 137 | .task { 138 | do { 139 | try await Analytics.upload(eventType: .announcementsListOpened) 140 | } catch { 141 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics entry: \(error, privacy: .public)") 142 | } 143 | } 144 | } 145 | 146 | } 147 | 148 | #Preview { 149 | AnnouncementsSheet() 150 | .environmentObject(ViewState.shared) 151 | .environmentObject(ShuttleTrackerSheetStack()) 152 | } 153 | -------------------------------------------------------------------------------- /Shared/Views/BlockButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlockButton.swift 3 | // Rensselaer Shuttle 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/22/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BlockButtonStyle: ButtonStyle { 11 | 12 | @State 13 | var color = Color.accentColor 14 | 15 | struct BlockButton: View { 16 | 17 | let configuration: BlockButtonStyle.Configuration 18 | 19 | @State 20 | var color: Color 21 | 22 | @Environment(\.isEnabled) 23 | var isEnabled 24 | 25 | var body: some View { 26 | self.configuration.label 27 | .padding(12) 28 | .frame(maxWidth: .infinity) 29 | .background(self.isEnabled ? self.color : Color.gray) 30 | .foregroundColor(.white) 31 | .opacity(self.configuration.isPressed ? 0.5 : 1) 32 | .mask { 33 | RoundedRectangle(cornerRadius: 10, style: .continuous) 34 | } 35 | } 36 | 37 | } 38 | 39 | func makeBody(configuration: Configuration) -> some View { 40 | return BlockButton(configuration: configuration, color: self.color) 41 | } 42 | 43 | } 44 | 45 | extension ButtonStyle where Self == BlockButtonStyle { 46 | 47 | static var block: BlockButtonStyle { 48 | get { 49 | return BlockButtonStyle() 50 | } 51 | } 52 | 53 | } 54 | 55 | #Preview { 56 | Button { 57 | print("Tapped!") 58 | } label: { 59 | Text("Do Something") 60 | .fontWeight(.semibold) 61 | } 62 | .buttonStyle(BlockButtonStyle()) 63 | .padding() 64 | } 65 | -------------------------------------------------------------------------------- /Shared/Views/BusOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BusOption.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/22/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BusOption: View { 11 | 12 | private let busID: BusID 13 | 14 | @Binding 15 | private var selectedBusID: BusID? 16 | 17 | private let feedbackGenerator = UISelectionFeedbackGenerator() 18 | 19 | var body: some View { 20 | Button { 21 | if self.selectedBusID != self.busID { 22 | withAnimation { 23 | self.feedbackGenerator.selectionChanged() 24 | self.selectedBusID = self.busID 25 | } 26 | } 27 | } label: { 28 | Text(self.busID.isUnknown ? "I Don’t Know" : "\(self.busID.rawValue)") 29 | .bold() 30 | .foregroundColor(.primary) 31 | .frame(maxWidth: .infinity, minHeight: 100) 32 | .innerShadow( 33 | using: RoundedRectangle(cornerRadius: 10), 34 | color: .primary, 35 | width: self.busID == self.selectedBusID ? 5 : 0 36 | ) 37 | .overlay( 38 | RoundedRectangle(cornerRadius: 10) 39 | .stroke( 40 | self.busID == self.selectedBusID ? Color.accentColor : .primary, 41 | lineWidth: self.busID == self.selectedBusID ? 5 : 2 42 | ) 43 | ) 44 | } 45 | .onAppear { 46 | self.feedbackGenerator.prepare() 47 | } 48 | } 49 | 50 | init(_ busID: BusID, selection selectedBusID: Binding) { 51 | self.busID = busID 52 | self._selectedBusID = selectedBusID 53 | } 54 | 55 | } 56 | 57 | #Preview { 58 | BusOption(BusID(42)!, selection: .constant(nil)) 59 | } 60 | -------------------------------------------------------------------------------- /Shared/Views/BusSelectionSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BusSelectionSheet.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/21/21. 6 | // 7 | 8 | import CoreLocation 9 | import STLogging 10 | import SwiftUI 11 | import UserNotifications 12 | 13 | struct BusSelectionSheet: View { 14 | 15 | @State 16 | private var busIDs: [BusID]? 17 | 18 | @State 19 | private var suggestedBusID: BusID? 20 | 21 | @State 22 | private var selectedBusID: BusID? 23 | 24 | @State 25 | private var didContinueWithSelectedBusID = false 26 | 27 | @EnvironmentObject 28 | private var mapState: MapState 29 | 30 | @EnvironmentObject 31 | private var viewState: ViewState 32 | 33 | @EnvironmentObject 34 | private var boardBusManager: BoardBusManager 35 | 36 | @EnvironmentObject 37 | private var sheetStack: ShuttleTrackerSheetStack 38 | 39 | var body: some View { 40 | NavigationView { 41 | VStack { 42 | if let allBusIDs = self.busIDs { 43 | ScrollView { 44 | VStack { 45 | HStack { 46 | Text("Which bus did you board?") 47 | .font(.title3) 48 | Spacer() 49 | } 50 | Spacer() 51 | HStack { 52 | Text("Select the number that’s printed on the side of the bus:") 53 | .font(.callout) 54 | Spacer() 55 | } 56 | if let suggestedBusID = self.suggestedBusID { 57 | HStack { 58 | if #available(iOS 16, *) { 59 | Label("Suggested", systemImage: "sparkles") 60 | .font(.caption) 61 | .italic() 62 | .foregroundColor(.secondary) 63 | } else { 64 | Label("Suggested", systemImage: "sparkles") 65 | .font(.caption.italic()) 66 | .foregroundColor(.secondary) 67 | } 68 | VStack { 69 | Divider() 70 | .background(.secondary) 71 | } 72 | } 73 | BusOption(suggestedBusID, selection: self.$selectedBusID) 74 | Divider() 75 | .background(.secondary) 76 | .padding(.vertical, 10) 77 | } 78 | LazyVGrid( 79 | columns: [GridItem]( 80 | repeating: GridItem(.flexible()), 81 | count: 3 82 | ) 83 | ) { 84 | ForEach(allBusIDs.sorted()) { (busID) in 85 | BusOption(busID, selection: self.$selectedBusID) 86 | } 87 | } 88 | Divider() 89 | .background(.secondary) 90 | .padding(.vertical, 10) 91 | BusOption(.unknown, selection: self.$selectedBusID) 92 | Spacer(minLength: 20) 93 | } 94 | .padding(.horizontal) 95 | } 96 | } else { 97 | ProgressView("Loading") 98 | .font(.callout) 99 | .textCase(.uppercase) 100 | } 101 | } 102 | .navigationTitle("Bus Selection") 103 | .toolbar { 104 | ToolbarItem(placement: .navigationBarTrailing) { 105 | CloseButton() 106 | } 107 | ToolbarItem(placement: .bottomBar) { 108 | Button { 109 | self.didContinueWithSelectedBusID = true 110 | Task { 111 | switch CLLocationManager.default.accuracyAuthorization { 112 | case .fullAccuracy: 113 | await self.boardBus() 114 | case .reducedAccuracy: 115 | do { 116 | try await CLLocationManager.default.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "BoardBus") 117 | } catch { 118 | #log(system: Logging.system, category: .permissions, level: .error, doUpload: true, "Temporary full-accuracy location authorization request failed: \(error, privacy: .public)") 119 | self.sheetStack.pop() 120 | throw error 121 | } 122 | guard case .fullAccuracy = CLLocationManager.default.accuracyAuthorization else { 123 | #log(system: Logging.system, category: .permissions, "User declined full location accuracy authorization") 124 | return 125 | } 126 | await self.boardBus() 127 | @unknown default: 128 | fatalError() 129 | } 130 | } 131 | } label: { 132 | Text("Continue") 133 | .bold() 134 | } 135 | .buttonStyle(.block) 136 | .disabled(self.selectedBusID == nil) 137 | .padding(.vertical) 138 | } 139 | } 140 | } 141 | .task { 142 | do { 143 | self.busIDs = try await API.readAllBuses.perform(as: [Int].self) 144 | .compactMap { (id) in 145 | return BusID(id) 146 | } 147 | } catch { 148 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to get list of known bus IDs from the server: \(error, privacy: .public)") 149 | } 150 | guard let location = CLLocationManager.default.location else { 151 | #log(system: Logging.system, category: .location, level: .error, doUpload: true, "Can’t suggest nearest bus because the user’s location is unavailable") 152 | return 153 | } 154 | let closestBus = await self.mapState.buses.min { (first, second) in 155 | let firstBusDistance = first.location 156 | .convertedForCoreLocation() 157 | .distance(from: location) 158 | let secondBusDistance = second.location 159 | .convertedForCoreLocation() 160 | .distance(from: location) 161 | return firstBusDistance < secondBusDistance 162 | } 163 | self.suggestedBusID = closestBus.flatMap { (bus) in 164 | return BusID(bus.id) 165 | } 166 | } 167 | .onDisappear { 168 | if !self.didContinueWithSelectedBusID { 169 | Task { 170 | do { 171 | try await Analytics.upload(eventType: .busSelectionCanceled) 172 | } catch { 173 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics entry: \(error, privacy: .public)") 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | /// Works with ``BoardBusManager`` to activate Board Bus. 181 | /// - Precondition: The user has granted full location accuracy authorization. 182 | private func boardBus() async { 183 | precondition(CLLocationManager.default.accuracyAuthorization == .fullAccuracy) 184 | #log(system: Logging.system, category: .boardBus, level: .info, "Activating Board Bus manually…") 185 | guard let id = self.selectedBusID?.rawValue else { 186 | #log(system: Logging.system, category: .boardBus, level: .error, doUpload: true, "No selected bus ID while trying to activate manual Board Bus") 187 | return 188 | } 189 | await self.boardBusManager.boardBus(id: id, manually: true) 190 | self.sheetStack.pop() 191 | CLLocationManager.default.startUpdatingLocation() 192 | } 193 | 194 | } 195 | 196 | #Preview { 197 | BusSelectionSheet() 198 | .environmentObject(MapState.shared) 199 | .environmentObject(ViewState.shared) 200 | .environmentObject(BoardBusManager.shared) 201 | .environmentObject(ShuttleTrackerSheetStack()) 202 | } 203 | -------------------------------------------------------------------------------- /Shared/Views/CloseButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloseButton.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CloseButton: View { 11 | 12 | @EnvironmentObject 13 | private var sheetStack: ShuttleTrackerSheetStack 14 | 15 | private let dismissHandler: (() -> Void)? 16 | 17 | var body: some View { 18 | Button { 19 | self.sheetStack.pop() 20 | } label: { 21 | Image(systemName: SFSymbol.close.systemName) 22 | .symbolRenderingMode(.hierarchical) 23 | .resizable() 24 | .opacity(0.5) 25 | .frame(width: ViewConstants.sheetCloseButtonDimension, height: ViewConstants.sheetCloseButtonDimension) 26 | } 27 | .tint(.primary) 28 | } 29 | 30 | init(_ dismissHandler: (() -> Void)? = nil) { 31 | self.dismissHandler = dismissHandler 32 | } 33 | 34 | } 35 | 36 | #Preview { 37 | CloseButton() 38 | .environmentObject(ShuttleTrackerSheetStack()) 39 | } 40 | -------------------------------------------------------------------------------- /Shared/Views/FadeScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FadeScrollView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Yi Chen on 12/8/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FadeScrollView: View { 11 | 12 | private let content: Content 13 | 14 | @State 15 | private var height: Double = .zero 16 | 17 | @Environment(\.colorScheme) 18 | private var colorScheme 19 | 20 | private var fadeColor: Color { 21 | get { 22 | switch self.colorScheme { 23 | case .dark: 24 | return .black 25 | case .light: 26 | return .white 27 | @unknown default: 28 | return .clear 29 | } 30 | } 31 | } 32 | 33 | var body: some View { 34 | GeometryReader { (outerGeometry) in 35 | ZStack { 36 | ScrollView { 37 | self.content 38 | .background( 39 | GeometryReader { (innerGeometry) in 40 | Color.clear 41 | .onAppear { 42 | self.height = innerGeometry.size.height 43 | } 44 | } 45 | ) 46 | } 47 | if self.height >= outerGeometry.frame(in: .local).size.height { 48 | Rectangle() 49 | .fill( 50 | LinearGradient( 51 | colors: [ 52 | self.fadeColor, 53 | self.fadeColor 54 | .opacity(0.1) 55 | ], 56 | startPoint: .top, 57 | endPoint: .bottom 58 | ) 59 | ) 60 | .frame(height: outerGeometry.frame(in: .global).size.height / 3) 61 | .position( 62 | CGPoint( 63 | x: outerGeometry.frame(in: .global).size.width / 2, 64 | y: outerGeometry.frame(in: .global).size.height / 100 65 | ) 66 | ) 67 | Rectangle() 68 | .fill( 69 | LinearGradient( 70 | colors: [ 71 | self.fadeColor 72 | .opacity(0.1), 73 | self.fadeColor 74 | ], 75 | startPoint: .top, 76 | endPoint: .bottom 77 | ) 78 | ) 79 | .frame(height: outerGeometry.frame(in:.global).size.height / 3) 80 | .position( 81 | CGPoint( 82 | x: outerGeometry.frame(in: .global).size.width / 2, 83 | y: outerGeometry.frame(in: .global).size.height - (outerGeometry.frame(in: .global).size.height / 15) 84 | ) 85 | ) 86 | } 87 | } 88 | } 89 | } 90 | 91 | init(@ViewBuilder content: () -> Content) { 92 | self.content = content() 93 | } 94 | 95 | } 96 | 97 | #Preview { 98 | FadeScrollView { 99 | ForEach(0 ..< 100) { (_) in 100 | Text("Hello, world!") 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Shared/Views/InfoSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoSheet.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Andrew Emanuel on 10/5/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InfoSheet: View { 11 | 12 | var body: some View { 13 | NavigationView { 14 | InfoView() 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Shared/Views/InfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/4/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InfoView: View { 11 | 12 | @State 13 | private var schedule: Schedule? 14 | 15 | @EnvironmentObject 16 | private var viewState: ViewState 17 | 18 | @EnvironmentObject 19 | private var appStorageManager: AppStorageManager 20 | 21 | @EnvironmentObject 22 | private var sheetStack: ShuttleTrackerSheetStack 23 | 24 | private var highQualityMessage: String { 25 | get { 26 | return self.appStorageManager.colorBlindMode ? "The scope icon indicates high-quality location data" : "Green buses indicate high-quality location data" // Capitalization is appropriate for the beginning of a sentence 27 | } 28 | } 29 | 30 | private var lowQualityMessage: String { 31 | get { 32 | return self.appStorageManager.colorBlindMode ? "the dotted-circle icon indicates low-quality location data" : "red buses indicate low-quality location data" // Capitalization is appropriate for the middle of a sentence 33 | } 34 | } 35 | 36 | var body: some View { 37 | ScrollView { 38 | VStack(alignment: .leading, spacing: 0) { 39 | #if os(macOS) 40 | Text("Shuttle Tracker 🚐") 41 | .font(.largeTitle) 42 | .bold() 43 | .padding(.top) 44 | #endif //os(macOS) 45 | Text("Shuttle Tracker shows you the real-time locations of the Rensselaer campus shuttle buses, powered by crowdsourced location data.") 46 | .padding(.bottom) 47 | if let schedule = self.schedule { 48 | Section { 49 | let weekday = Calendar.current.component(.weekday, from: .now) 50 | HStack { 51 | VStack(alignment: .leading, spacing: 0) { 52 | Text("Monday") 53 | .fontWeight(weekday == 2 ? .bold : .regular) 54 | Text("Tuesday") 55 | .fontWeight(weekday == 3 ? .bold : .regular) 56 | Text("Wednesday") 57 | .fontWeight(weekday == 4 ? .bold : .regular) 58 | Text("Thursday") 59 | .fontWeight(weekday == 5 ? .bold : .regular) 60 | Text("Friday") 61 | .fontWeight(weekday == 6 ? .bold : .regular) 62 | Text("Saturday") 63 | .fontWeight(weekday == 7 ? .bold : .regular) 64 | Text("Sunday") 65 | .fontWeight(weekday == 1 ? .bold : .regular) 66 | } 67 | VStack(alignment: .leading, spacing: 0) { 68 | Text("\(schedule.content.monday.start) to \(schedule.content.monday.end)") 69 | .fontWeight(weekday == 2 ? .bold : .regular) 70 | Text("\(schedule.content.tuesday.start) to \(schedule.content.tuesday.end)") 71 | .fontWeight(weekday == 3 ? .bold : .regular) 72 | Text("\(schedule.content.wednesday.start) to \(schedule.content.wednesday.end)") 73 | .fontWeight(weekday == 4 ? .bold : .regular) 74 | Text("\(schedule.content.thursday.start) to \(schedule.content.thursday.end)") 75 | .fontWeight(weekday == 5 ? .bold : .regular) 76 | Text("\(schedule.content.friday.start) to \(schedule.content.friday.end)") 77 | .fontWeight(weekday == 6 ? .bold : .regular) 78 | Text("\(schedule.content.saturday.start) to \(schedule.content.saturday.end)") 79 | .fontWeight(weekday == 7 ? .bold : .regular) 80 | Text("\(schedule.content.sunday.start) to \(schedule.content.sunday.end)") 81 | .fontWeight(weekday == 1 ? .bold : .regular) 82 | } 83 | Spacer() 84 | } 85 | .padding(.bottom) 86 | } header: { 87 | Text("Schedule") 88 | .font(.headline) 89 | } 90 | } 91 | Section { 92 | Text("The map is automatically refreshed every 5 seconds. \(self.highQualityMessage), and \(self.lowQualityMessage). When boarding a bus, tap “Board Bus”, and when getting off, tap “Leave Bus”. You must be within \(self.appStorageManager.maximumStopDistance) meter\(self.appStorageManager.maximumStopDistance == 1 ? "" : "s") of a stop to board a bus.") 93 | .padding(.bottom) 94 | } header: { 95 | Text("Instructions") 96 | .font(.headline) 97 | } 98 | Section { 99 | Button("Show Privacy Information") { 100 | self.sheetStack.push(.privacy) 101 | } 102 | .padding(.bottom) 103 | } 104 | } 105 | .padding(.horizontal) 106 | } 107 | .navigationTitle("Shuttle Tracker 🚐") 108 | .toolbar { 109 | #if os(iOS) 110 | ToolbarItem { 111 | CloseButton() 112 | } 113 | #elseif os(macOS) // os(iOS) 114 | ToolbarItem(placement: .confirmationAction) { 115 | if case .some(.info) = self.sheetStack.top { 116 | Button("Close") { 117 | self.sheetStack.pop() 118 | } 119 | } 120 | } 121 | #endif // os(macOS) 122 | } 123 | .onAppear { 124 | Task { 125 | self.schedule = await Schedule.download() 126 | } 127 | } 128 | .sheetPresentation( 129 | provider: ShuttleTrackerSheetPresentationProvider(sheetStack: self.sheetStack), 130 | sheetStack: self.sheetStack 131 | ) 132 | } 133 | 134 | } 135 | 136 | #Preview { 137 | InfoView() 138 | .environmentObject(ViewState.shared) 139 | .environmentObject(AppStorageManager.shared) 140 | .environmentObject(ShuttleTrackerSheetStack()) 141 | } 142 | -------------------------------------------------------------------------------- /Shared/Views/LegacyMapView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LegacyMapView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/20/20. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | 11 | struct LegacyMapView: UIViewRepresentable { 12 | 13 | @Binding 14 | private var position: MapCameraPositionWrapper 15 | 16 | @EnvironmentObject 17 | private var mapState: MapState 18 | 19 | init(position: Binding) { 20 | self._position = position 21 | } 22 | 23 | func makeUIView(context: Context) -> MKMapView { 24 | let uiView = MKMapView(frame: .zero) 25 | Task { 26 | MapState.mapView = uiView 27 | await self.mapState.refreshAll() 28 | await self.mapState.recenter(position: self.$position) 29 | } 30 | uiView.delegate = context.coordinator 31 | uiView.showsUserLocation = true 32 | uiView.showsCompass = true 33 | if #available(iOS 16, *) { 34 | // Set a custom preferred map configuration 35 | let configuration = MKStandardMapConfiguration(emphasisStyle: .muted) 36 | configuration.pointOfInterestFilter = .excludingAll 37 | uiView.preferredConfiguration = configuration 38 | } 39 | return uiView 40 | } 41 | 42 | func updateUIView(_ uiView: MKMapView, context: Context) { 43 | uiView.delegate = context.coordinator 44 | Task { 45 | let buses = await self.mapState.buses 46 | let stops = await self.mapState.stops 47 | let routes = await self.mapState.routes 48 | await MainActor.run { 49 | let allRoutesOnMap = routes.allSatisfy { (route) in 50 | return uiView.overlays.contains { (overlay) in 51 | if let existingRoute = overlay as? Route, existingRoute == route { 52 | return true 53 | } 54 | return false 55 | } 56 | } 57 | uiView.removeAnnotations(uiView.annotations) 58 | uiView.addAnnotations(buses) 59 | uiView.addAnnotations(stops) 60 | if !allRoutesOnMap { 61 | uiView.removeOverlays(uiView.overlays) 62 | uiView.addOverlays(routes) 63 | } 64 | } 65 | } 66 | } 67 | 68 | func makeCoordinator() -> MapViewDelegate { 69 | return MapViewDelegate() 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Shared/Views/LegendToast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LegendToast.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 8/31/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LegendToast: View { 11 | 12 | @EnvironmentObject 13 | private var viewState: ViewState 14 | 15 | @EnvironmentObject 16 | private var appStorageManager: AppStorageManager 17 | 18 | enum HeadlineText: String { 19 | 20 | case tip = "Here’s a tip!" 21 | case reminder = "Just in case you forgot…" 22 | 23 | } 24 | 25 | private var highQualityString: String { 26 | get { 27 | return self.appStorageManager.colorBlindMode ? "The scope icon indicates high-quality location data." : "Green buses indicate high-quality location data." 28 | } 29 | } 30 | 31 | private var lowQualityString: String { 32 | get { 33 | return self.appStorageManager.colorBlindMode ? "The dotted-circle icon indicates low-quality location data." : "Red buses indicate low-quality location data." 34 | } 35 | } 36 | 37 | private var highQualityAttributedString: AttributedString { 38 | get { 39 | var attributedString = AttributedString(self.highQualityString) 40 | if self.appStorageManager.colorBlindMode { 41 | let scopeRange = attributedString.range(of: SFSymbol.colorBlindHighQualityLocation.systemName)! 42 | attributedString[scopeRange].inlinePresentationIntent = .stronglyEmphasized 43 | } else { 44 | let greenRange = attributedString.range(of: "Green")! 45 | attributedString[greenRange].foregroundColor = .green 46 | attributedString[greenRange].inlinePresentationIntent = .stronglyEmphasized 47 | } 48 | let highQualityRange = attributedString.range(of: "high-quality")! 49 | attributedString[highQualityRange].inlinePresentationIntent = .stronglyEmphasized 50 | return attributedString 51 | } 52 | } 53 | 54 | private var lowQualityAttributedString: AttributedString { 55 | get { 56 | var attributedString = AttributedString(self.lowQualityString) 57 | if self.appStorageManager.colorBlindMode { 58 | let dottedCircleRange = attributedString.range(of: "dotted-circle")! 59 | attributedString[dottedCircleRange].inlinePresentationIntent = .stronglyEmphasized 60 | } else { 61 | let redRange = attributedString.range(of: "Red")! 62 | attributedString[redRange].foregroundColor = .red 63 | attributedString[redRange].inlinePresentationIntent = .stronglyEmphasized 64 | } 65 | let lowQualityRange = attributedString.range(of: "low-quality")! 66 | attributedString[lowQualityRange].inlinePresentationIntent = .stronglyEmphasized 67 | return attributedString 68 | } 69 | } 70 | 71 | var body: some View { 72 | Toast(self.viewState.legendToastHeadlineText?.rawValue ?? "Legend", item: self.$viewState.toastType) { (_, _) in 73 | HStack { 74 | ZStack { 75 | Circle() 76 | .fill(.green) 77 | Image(systemName: self.appStorageManager.colorBlindMode ? SFSymbol.colorBlindHighQualityLocation.systemName : SFSymbol.bus.systemName) 78 | .resizable() 79 | .frame(width: 30, height: 30) 80 | .foregroundColor(.white) 81 | } 82 | .frame(width: 50) 83 | Text(self.highQualityAttributedString) 84 | .accessibilityShowsLargeContentViewer() 85 | } 86 | .frame(height: 50) 87 | Spacer() 88 | .frame(height: 15) 89 | HStack { 90 | ZStack { 91 | Circle() 92 | .fill(self.appStorageManager.colorBlindMode ? .purple : .red) 93 | Image(systemName: self.appStorageManager.colorBlindMode ? SFSymbol.colorBlindLowQualityLocation.systemName : SFSymbol.bus.systemName) 94 | .resizable() 95 | .frame(width: 30, height: 30) 96 | .foregroundColor(.white) 97 | } 98 | .frame(width: 50) 99 | Text(self.lowQualityAttributedString) 100 | .accessibilityShowsLargeContentViewer() 101 | } 102 | .frame(height: 50) 103 | } 104 | 105 | } 106 | 107 | } 108 | 109 | #Preview { 110 | LegendToast() 111 | .environmentObject(ViewState.shared) 112 | } 113 | -------------------------------------------------------------------------------- /Shared/Views/LogDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogDetailView.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/19/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LogDetailView: View { 11 | 12 | let log: Logging.Log 13 | 14 | private let dateFormatter: DateFormatter = { 15 | let dateFormatter = DateFormatter() 16 | dateFormatter.dateStyle = .long 17 | dateFormatter.timeStyle = .short 18 | dateFormatter.doesRelativeDateFormatting = true 19 | return dateFormatter 20 | }() 21 | 22 | var body: some View { 23 | ScrollView { 24 | VStack { 25 | HStack { 26 | if #available(iOS 16, macOS 13, *) { 27 | Text("\(self.log.id.uuidString)") 28 | .font(.subheadline.monospaced().bold()) 29 | .textSelection(.enabled) 30 | } else { 31 | Text("\(self.log.id.uuidString)") 32 | .font(.subheadline.monospaced().bold()) 33 | .lineLimit(2) 34 | } 35 | Spacer() 36 | } 37 | Spacer() 38 | HStack { 39 | if #available(iOS 16.1, macOS 13, *) { 40 | Text(self.log.content) 41 | .fontDesign(.monospaced) 42 | #if os(macOS) 43 | .textSelection(.enabled) 44 | #endif // os(macOS) 45 | } else { 46 | Text(self.log.content) 47 | .font(.body.monospaced()) 48 | #if os(macOS) 49 | .textSelection(.enabled) 50 | #endif // os(macOS) 51 | } 52 | Spacer() 53 | } 54 | } 55 | .padding(.horizontal) 56 | } 57 | #if os(iOS) 58 | .navigationTitle(self.dateFormatter.string(from: self.log.date)) 59 | .navigationBarTitleDisplayMode(.inline) 60 | .toolbar { 61 | if #available(iOS 16, *), let url = try? self.log.writeToDisk() { 62 | ShareLink(item: url) 63 | } 64 | } 65 | #endif // os(iOS) 66 | } 67 | 68 | } 69 | 70 | #Preview { 71 | NavigationView { 72 | LogDetailView(log: Logging.Log(content: "This is a test.")) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Shared/Views/MailComposeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MailComposeView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 12/2/22. 6 | // 7 | 8 | import MessageUI 9 | import OrderedCollections 10 | import STLogging 11 | import SwiftUI 12 | import UniformTypeIdentifiers 13 | 14 | struct MailComposeView: UIViewControllerRepresentable { 15 | 16 | struct Attachment: Equatable, Hashable { 17 | 18 | let data: Data 19 | 20 | let type: UTType 21 | 22 | let filename: String 23 | 24 | } 25 | 26 | static var canSendMail: Bool { 27 | get { 28 | return MFMailComposeViewController.canSendMail() 29 | } 30 | } 31 | 32 | let subject: String 33 | 34 | let toRecipients: OrderedSet 35 | 36 | let ccRecipients: OrderedSet 37 | 38 | #if !targetEnvironment(macCatalyst) 39 | // Setting BCC recipients isn’t supported in Mac Catalyst 40 | let bccRecipients: OrderedSet 41 | #endif // !targetEnvironment(macCatalyst) 42 | 43 | let messageBody: String 44 | 45 | let isHTMLMessageBody: Bool 46 | 47 | let attachments: [Attachment] 48 | 49 | let dismissalHandler: (((any Error)?) -> Void)? 50 | 51 | init( 52 | subject: String = "", 53 | toRecipients: OrderedSet = [], 54 | ccRecipients: OrderedSet = [], 55 | bccRecipients: OrderedSet = [], 56 | messageBody: String = "", 57 | isHTMLMessageBody: Bool = false, 58 | attachments: [Attachment] = [], 59 | dismissalHandler: (((any Error)?) -> Void)? = nil 60 | ) { 61 | self.subject = subject 62 | self.toRecipients = toRecipients 63 | self.ccRecipients = ccRecipients 64 | self.bccRecipients = bccRecipients 65 | self.messageBody = messageBody 66 | self.isHTMLMessageBody = isHTMLMessageBody 67 | self.attachments = attachments 68 | self.dismissalHandler = dismissalHandler 69 | } 70 | 71 | func makeUIViewController(context: Context) -> MFMailComposeViewController { 72 | let uiViewController = MFMailComposeViewController() 73 | uiViewController.setSubject(subject) 74 | uiViewController.setToRecipients(Array(self.toRecipients)) 75 | uiViewController.setCcRecipients(Array(self.ccRecipients)) 76 | #if !targetEnvironment(macCatalyst) 77 | // Setting BCC recipients isn’t supported in Mac Catalyst 78 | uiViewController.setBccRecipients(Array(self.bccRecipients)) 79 | #endif // !targetEnvironment(macCatalyst) 80 | uiViewController.setMessageBody(self.messageBody, isHTML: self.isHTMLMessageBody) 81 | for attachment in self.attachments { 82 | guard let mimeType = attachment.type.preferredMIMEType else { 83 | #log(system: Logging.system, category: .mailCompose, level: .error, doUpload: true, "Can’t add attachment without a MIME type: \(attachment.type, privacy: .public)") 84 | continue 85 | } 86 | uiViewController.addAttachmentData(attachment.data, mimeType: mimeType, fileName: attachment.filename) 87 | } 88 | return uiViewController 89 | } 90 | 91 | func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) { 92 | uiViewController.mailComposeDelegate = context.coordinator 93 | } 94 | 95 | func makeCoordinator() -> MailComposeViewControllerDelegate { 96 | return MailComposeViewControllerDelegate(dismissalHandler: self.dismissalHandler) 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Shared/Views/MapContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapContainer.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 8/22/23. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | 11 | @available(iOS 17, macOS 14, *) 12 | struct MapContainer: View { 13 | 14 | @State 15 | private var buses: [Bus] = [] 16 | 17 | @State 18 | private var stops: [Stop] = [] 19 | 20 | @State 21 | private var routes: [Route] = [] 22 | 23 | @Binding 24 | private var position: MapCameraPositionWrapper 25 | 26 | @EnvironmentObject 27 | private var mapState: MapState 28 | 29 | @EnvironmentObject 30 | private var appStorageManager: AppStorageManager 31 | 32 | #if os(iOS) 33 | @State 34 | private var busID: Int? 35 | 36 | @State 37 | private var travelState: BoardBusManager.TravelState? 38 | 39 | @EnvironmentObject 40 | private var boardBusManager: BoardBusManager 41 | #endif // os(iOS) 42 | 43 | var body: some View { 44 | Map(position: self.$position.mapCameraPosition) { 45 | ForEach(self.buses) { (bus) in 46 | Marker( 47 | bus.title!, // MKAnnotation requires that the title property be optional, but our implementation always returns a non-nil value. 48 | systemImage: bus.iconSystemName, 49 | coordinate: bus.coordinate 50 | ) 51 | .tint(bus.tintColor) 52 | .mapOverlayLevel(level: .aboveLabels) 53 | } 54 | ForEach(self.stops) { (stop) in 55 | Annotation( 56 | stop.title!, // MKAnnotation requires that the title property be optional, but our implementation always returns a non-nil value. 57 | coordinate: stop.coordinate 58 | ) { 59 | Circle() 60 | .size(width: 12, height: 12) 61 | .fill(.white) 62 | .stroke(.black, lineWidth: 3) 63 | } 64 | } 65 | ForEach(self.routes) { (route) in 66 | #if !os(watchOS) 67 | MapPolyline(points: route.mapPoints, contourStyle: .geodesic) 68 | .stroke(route.mapColor, lineWidth: 5) 69 | #else 70 | MapPolyline(coordinates: route.mapPoints,contourStyle: .geodesic) 71 | .stroke(route.mapColor, lineWidth: 5) 72 | #endif 73 | } 74 | #if os(iOS) 75 | if case .some(.onBus) = self.travelState, let busID = self.busID, let coordinate = CLLocationManager.default.location?.coordinate { 76 | Marker( 77 | busID > 0 ? "Bus \(busID)" : "Bus", 78 | systemImage: SFSymbol.user.systemName, 79 | coordinate: coordinate 80 | ) 81 | } else { 82 | UserAnnotation() 83 | } 84 | #else // os(iOS) 85 | UserAnnotation() 86 | #endif // os(macOS) 87 | } 88 | .mapStyle(.standard(emphasis: .muted, pointsOfInterest: .excludingAll, showsTraffic: true)) 89 | .task { 90 | await self.updateAppStorageData() 91 | #if os(iOS) 92 | await self.updateBoardBusData() 93 | #endif // os(iOS) 94 | } 95 | .onReceive(self.mapState.objectWillChange) { 96 | Task { 97 | await self.updateAppStorageData() 98 | } 99 | } 100 | #if os(iOS) 101 | .onReceive(self.boardBusManager.objectWillChange) { 102 | Task { 103 | await self.updateBoardBusData() 104 | } 105 | } 106 | #endif // os(iOS) 107 | } 108 | 109 | init(position: Binding) { 110 | self._position = position 111 | } 112 | 113 | private func updateAppStorageData() async { 114 | self.buses = await self.mapState.buses 115 | self.stops = await self.mapState.stops 116 | self.routes = await self.mapState.routes 117 | } 118 | 119 | #if os(iOS) 120 | private func updateBoardBusData() async { 121 | self.busID = await self.boardBusManager.busID 122 | self.travelState = await self.boardBusManager.travelState 123 | } 124 | #endif // os(iOS) 125 | 126 | } 127 | 128 | @available(iOS 17, macOS 14, *) 129 | #Preview { 130 | MapContainer(position: .constant(MapCameraPositionWrapper(MapConstants.defaultCameraPosition))) 131 | .environmentObject(MapState.shared) 132 | .environmentObject(AppStorageManager.shared) 133 | #if os(iOS) 134 | .environmentObject(BoardBusManager.shared) 135 | #endif // os(iOS) 136 | } 137 | -------------------------------------------------------------------------------- /Shared/Views/PrivacySheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivacySheet.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/4/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PrivacySheet: View { 11 | 12 | var body: some View { 13 | NavigationView { 14 | PrivacyView() 15 | } 16 | } 17 | 18 | } 19 | 20 | #Preview { 21 | PrivacySheet() 22 | } 23 | -------------------------------------------------------------------------------- /Shared/Views/PrivacyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivacyView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/14/21. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | 11 | struct PrivacyView: View { 12 | 13 | @State 14 | private var doShowMailFailedAlert = false 15 | 16 | @EnvironmentObject 17 | private var sheetStack: ShuttleTrackerSheetStack 18 | 19 | var body: some View { 20 | ScrollView { 21 | #if os(macOS) 22 | Text("Privacy") 23 | .font(.largeTitle) 24 | .bold() 25 | .padding(.vertical) 26 | #endif // os(macOS) 27 | VStack(alignment: .leading) { 28 | #if os(iOS) 29 | Section { 30 | Text("Shuttle Tracker sends your location data to our server only when Board Bus is activated and stops sending these data when Board Bus is deactivated. You can activate Board Bus manually by tapping “Board Bus” or automatically by positioning your device within Bluetooth range of a Shuttle Tracker Node device on a bus if you opted in to the Shuttle Tracker Network. You can deactivate Board Bus manually by tapping “Leave Bus” or automatically by positioning your device out of Bluetooth range of a Shuttle Tracker Node device on a bus if you opted in to the Shuttle Tracker Network. Your location data are associated with an anonymous, random identifier that rotates every time you start a new shuttle trip. These data aren’t associated with your name, Apple ID, RCS ID, RIN, or any other information that might identify you or your device. We continuously purge location data that are more than 30 seconds old from our server. We may retain resolved location data that are calculated using a combination of system- and user-reported data indefinitely, but these resolved data don’t correspond with any specific user-reported coordinates. Even if you opt in to the Shuttle Tracker Network, we never track your location unless you manually activate Board Bus or physically board a bus. Your device might alert you to Shuttle Tracker’s location monitoring in the background even when Shuttle Tracker isn’t actually tracking your location. This is due to a system limitation; Shuttle Tracker occasionally scans for Shuttle Tracker Node devices in the background, and your device might show that activity as location tracking. The results of these scans never leave your device, and we only start collecting location data if a scan indicates that you’re physically on a bus.") 31 | .padding(.bottom) 32 | } header: { 33 | Text("Location") 34 | .font(.headline) 35 | } 36 | #endif // os(iOS) 37 | Section { 38 | Text("Shuttle Tracker automatically detects errors and uploads diagnostic logs to our server when they occur. These logs aren’t associated with your name, Apple ID, RCS ID, RIN, or any other information that might identify you or your device. They contain information about, for example, failed network requests. We redact sensitive information like your location, replacing those data with irreversible hashes. These hashes let us correlate different logs without revealing any of the redacted information. Logs are retained indefinitely; contact us if you want to request that we delete a log from our server. Due to the privacy-preserving nature of how we identify logs, we might not be able to find and to verify the log that you want to delete. You can see a record of recently uploaded logs or disable automatic uploads entirely in Settings > Logging & Analytics.") 39 | .padding(.bottom) 40 | } header: { 41 | Text("Logging") 42 | .font(.headline) 43 | } 44 | Section { 45 | Text("If you opt in to analytics, then Shuttle Tracker will send anonymous usage data to our server. These data include your app settings, feature usage frequency, and other similar metrics. No analytics data are ever collected unless you explicitly opt in. You can see a record of recently uploaded analytics reports or opt-in status in Settings > Logging & Analytics.") 46 | .padding(.bottom) 47 | } header: { 48 | Text("Analytics") 49 | .font(.headline) 50 | } 51 | #if os(iOS) 52 | Section { 53 | Button("Contact Our Privacy Team") { 54 | if MailComposeView.canSendMail { 55 | self.sheetStack.push( 56 | .mailCompose( 57 | subject: "Privacy Inquiry", 58 | toRecipients: ["privacy@shuttletracker.app"] 59 | ) 60 | ) 61 | } else { 62 | Task { 63 | await self.sendMail() 64 | } 65 | } 66 | } 67 | .padding(.bottom) 68 | } 69 | #endif // os(iOS) 70 | } 71 | .padding(.horizontal) 72 | } 73 | .navigationTitle("Privacy") 74 | .alert("Mail Failed", isPresented: self.$doShowMailFailedAlert) { 75 | Button("Retry") { 76 | Task { 77 | await self.sendMail() 78 | } 79 | } 80 | Button("Cancel", role: .cancel) { } 81 | } message: { 82 | Text("The mail compose interface couldn’t be shown.") 83 | } 84 | .toolbar { 85 | #if os(iOS) 86 | ToolbarItem { 87 | CloseButton() 88 | } 89 | #elseif os(macOS) // os(iOS) 90 | ToolbarItem { 91 | Button("Contact Our Privacy Team") { 92 | Task { 93 | await self.sendMail() 94 | } 95 | } 96 | } 97 | ToolbarItem(placement: .confirmationAction) { 98 | Button("Close") { 99 | self.sheetStack.pop() 100 | } 101 | } 102 | #endif // os(macOS) 103 | } 104 | .sheetPresentation( 105 | provider: ShuttleTrackerSheetPresentationProvider(sheetStack: self.sheetStack), 106 | sheetStack: self.sheetStack 107 | ) 108 | } 109 | 110 | private func sendMail() async { 111 | let url = URL(string: "mailto:privacy@shuttletracker.app")! 112 | #if canImport(UIKit) 113 | guard UIApplication.shared.canOpenURL(url) else { 114 | self.doShowMailFailedAlert = true 115 | #log(system: Logging.system, category: .mailCompose, level: .error, doUpload: true, "Can’t open URL to send privacy mail") 116 | return 117 | } 118 | let success = await UIApplication.shared.open(url) 119 | #elseif canImport(AppKit) // canImport(UIKit) 120 | let success = NSWorkspace.shared.open(url) 121 | #endif // canImport(AppKit) 122 | if !success { 123 | self.doShowMailFailedAlert = true 124 | #log(system: Logging.system, category: .mailCompose, level: .error, doUpload: true, "Failed to open URL to send privacy mail") 125 | return 126 | } 127 | } 128 | 129 | } 130 | 131 | #Preview { 132 | PrivacyView() 133 | .environmentObject(ShuttleTrackerSheetStack()) 134 | } 135 | -------------------------------------------------------------------------------- /Shared/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/7/21. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | 11 | struct SettingsView: View { 12 | 13 | #if os(macOS) 14 | @State 15 | private var didResetServerBaseURL = false 16 | #endif // os(macOS) 17 | 18 | @EnvironmentObject 19 | private var viewState: ViewState 20 | 21 | @EnvironmentObject 22 | private var appStorageManager: AppStorageManager 23 | 24 | @EnvironmentObject 25 | private var sheetStack: ShuttleTrackerSheetStack 26 | 27 | var body: some View { 28 | #if os(iOS) 29 | Form { 30 | Section { 31 | HStack { 32 | ZStack { 33 | Circle() 34 | .fill(.green) 35 | Image(systemName: self.appStorageManager.colorBlindMode ? SFSymbol.colorBlindHighQualityLocation.systemName : SFSymbol.bus.systemName) 36 | .resizable() 37 | .frame(width: 15, height: 15) 38 | .foregroundColor(.white) 39 | } 40 | .frame(width: 30) 41 | .animation(.default, value: self.appStorageManager.colorBlindMode) 42 | Toggle("Color-Blind Mode", isOn: self.appStorageManager.$colorBlindMode) 43 | .accessibilityShowsLargeContentViewer() 44 | } 45 | .frame(height: 30) 46 | } footer: { 47 | Text("Modifies bus markers so that they’re distinguishable by icon in addition to color.") 48 | } 49 | #if !APPCLIP 50 | Section { 51 | Button("Show Permissions") { 52 | self.sheetStack.push(.permissions) 53 | } 54 | } 55 | #endif // !APPCLIP 56 | Section { 57 | NavigationLink("Logging & Analytics") { 58 | LoggingAnalyticsSettingsView() 59 | } 60 | if #available(iOS 17, *) { 61 | NavigationLink("Advanced") { 62 | AdvancedSettingsView() 63 | } 64 | } 65 | } 66 | Section { 67 | NavigationLink("About") { 68 | AboutView() 69 | } 70 | } 71 | } 72 | .onChange(of: self.appStorageManager.colorBlindMode) { (enabled) in 73 | withAnimation { 74 | self.viewState.toastType = .legend 75 | self.viewState.legendToastHeadlineText = nil 76 | } 77 | 78 | Task { 79 | do { 80 | try await Analytics.upload(eventType: .colorBlindModeToggled(enabled: enabled)) 81 | } catch { 82 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 83 | } 84 | } 85 | } 86 | .sheetPresentation( 87 | provider: ShuttleTrackerSheetPresentationProvider(sheetStack: self.sheetStack), 88 | sheetStack: self.sheetStack 89 | ) 90 | #elseif os(macOS) // os(iOS) 91 | TabView { 92 | Form { 93 | Section { 94 | Toggle("Distinguish bus markers by icon", isOn: self.appStorageManager.$colorBlindMode) 95 | } 96 | Divider() 97 | Section { 98 | HStack { 99 | // URL.FormatStyle’s integration with TextField seems to be broken currently, so we fall back on our custom URL format style 100 | TextField("Server Base URL", value: self.appStorageManager.$baseURL, format: .compatibilityURL) 101 | .labelsHidden() 102 | Button(role: .destructive) { 103 | self.appStorageManager.baseURL = AppStorageManager.Defaults.baseURL 104 | self.didResetServerBaseURL = true 105 | } label: { 106 | HStack { 107 | Text("Reset") 108 | if self.didResetServerBaseURL { 109 | Text("✓") 110 | } 111 | } 112 | .frame(minWidth: 50) 113 | } 114 | .disabled(self.appStorageManager.baseURL == AppStorageManager.Defaults.baseURL) 115 | .onChange(of: self.appStorageManager.baseURL) { (url) in 116 | if self.appStorageManager.baseURL != AppStorageManager.Defaults.baseURL { 117 | self.didResetServerBaseURL = false 118 | } 119 | 120 | Task { 121 | do { 122 | try await Analytics.upload(eventType: .serverBaseURLChanged(url: url)) 123 | } catch { 124 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 125 | } 126 | } 127 | } 128 | } 129 | } header: { 130 | Text("Server Base URL") 131 | .bold() 132 | } footer: { 133 | Text("Changing this setting could make the rest of the app stop working properly.") 134 | } 135 | Spacer() 136 | } 137 | .tabItem { 138 | Label("General", systemImage: SFSymbol.settings.systemName) 139 | } 140 | LoggingAnalyticsSettingsView() 141 | .tabItem { 142 | Label("Logging & Analytics", systemImage: SFSymbol.loggingAnalytics.systemName) 143 | } 144 | } 145 | .padding() 146 | .onChange(of: self.appStorageManager.colorBlindMode) { (enabled) in 147 | withAnimation { 148 | self.viewState.toastType = .legend 149 | self.viewState.legendToastHeadlineText = nil 150 | } 151 | 152 | Task { 153 | do { 154 | try await Analytics.upload(eventType: .colorBlindModeToggled(enabled: enabled)) 155 | } catch { 156 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 157 | } 158 | } 159 | } 160 | #endif // os(macOS) 161 | } 162 | 163 | } 164 | 165 | #Preview { 166 | SettingsView() 167 | .environmentObject(ViewState.shared) 168 | .environmentObject(AppStorageManager.shared) 169 | .environmentObject(ShuttleTrackerSheetStack()) 170 | } 171 | -------------------------------------------------------------------------------- /Shared/Views/Toast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Toast.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/8/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Toast: View where StringType: StringProtocol, Item: Equatable, Content: View { 11 | 12 | private var headlineString: StringType 13 | 14 | @Binding 15 | private var item: Item? 16 | 17 | private var onDismiss: (() -> Void)? 18 | 19 | private var content: (Item) -> Content 20 | 21 | @EnvironmentObject 22 | private var viewState: ViewState 23 | 24 | var body: some View { 25 | if let item = self.item { 26 | VStack(alignment: .leading) { 27 | HStack { 28 | Text(self.headlineString) 29 | .font(.headline) 30 | .accessibilityShowsLargeContentViewer() 31 | Spacer() 32 | #if os(iOS) 33 | Button { 34 | withAnimation { 35 | self.item = nil 36 | } 37 | } label: { 38 | Image(systemName: SFSymbol.close.systemName) 39 | .resizable() 40 | .frame(width: ViewConstants.toastCloseButtonDimension, height: ViewConstants.toastCloseButtonDimension) 41 | } 42 | .tint(.primary) 43 | #else // os(iOS) 44 | Button { 45 | withAnimation { 46 | self.item = nil 47 | } 48 | } label: { 49 | Image(systemName: SFSymbol.close.systemName) 50 | .resizable() 51 | .frame(width: ViewConstants.toastCloseButtonDimension, height: ViewConstants.toastCloseButtonDimension) 52 | } 53 | .buttonStyle(.plain) 54 | #endif 55 | } 56 | self.content(item) 57 | } 58 | .layoutPriority(0) 59 | .padding() 60 | .background(VisualEffectView.standard) 61 | .cornerRadius(ViewConstants.toastCornerRadius) 62 | .shadow(radius: 5) 63 | .onChange(of: self.item) { (newValue) in 64 | if newValue == nil { 65 | self.onDismiss?() 66 | } 67 | } 68 | } 69 | } 70 | 71 | init( 72 | _ headlineString: StringType, 73 | item: Binding, 74 | @ViewBuilder content: @escaping (_ item: Item, _ dismiss: @escaping () -> Void) -> Content, 75 | onDismiss: (() -> Void)? = nil 76 | ) { 77 | self.headlineString = headlineString 78 | self._item = item 79 | self.onDismiss = onDismiss 80 | self.content = { (unwrappedItem) in 81 | return content(unwrappedItem) { 82 | withAnimation { 83 | item.wrappedValue = nil 84 | } 85 | } 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Shared/Views/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/21/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VisualEffectView: UIViewRepresentable { 11 | 12 | let effect: UIVisualEffect? 13 | 14 | init(_ effect: UIVisualEffect) { 15 | self.effect = effect 16 | } 17 | 18 | init(_ style: UIBlurEffect.Style) { 19 | self.init(UIBlurEffect(style: style)) 20 | } 21 | 22 | func makeUIView(context: Context) -> UIVisualEffectView { 23 | return UIVisualEffectView() 24 | } 25 | 26 | func updateUIView(_ uiView: UIVisualEffectView, context: Context) { 27 | uiView.effect = self.effect 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Shared/Views/WhatsNewItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhatsNewItem.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/22/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WhatsNewItem: View { 11 | 12 | let title: String 13 | 14 | let description: String 15 | 16 | let icon: SFSymbol 17 | 18 | let symbolRenderingMode: SymbolRenderingMode 19 | 20 | var body: some View { 21 | HStack(alignment: .top) { 22 | Image(systemName: self.icon.systemName) 23 | .symbolRenderingMode(self.symbolRenderingMode) 24 | .resizable() 25 | .scaledToFit() 26 | .frame(width: 40, height: 40) 27 | .padding(.trailing, 20) 28 | VStack(alignment: .leading) { 29 | Text(self.title) 30 | .font(.headline) 31 | Text(self.description) 32 | } 33 | } 34 | } 35 | 36 | init( 37 | title: String, 38 | description: String, 39 | icon: SFSymbol, 40 | symbolRenderingMode: SymbolRenderingMode = .multicolor 41 | ) { 42 | self.title = title 43 | self.description = description 44 | self.icon = icon 45 | self.symbolRenderingMode = symbolRenderingMode 46 | } 47 | 48 | } 49 | 50 | #Preview { 51 | WhatsNewItem( 52 | title: "Shuttle Tracker Network", 53 | description: "The Shuttle Tracker app uses the Shuttle Tracker Network to connect to Shuttle Tracker Node, our custom bus-tracking device, to unlock Automatic Board Bus. Shuttle Tracker never collects your location when you’re not physically riding a bus.", 54 | icon: .whatsNewNetwork, 55 | symbolRenderingMode: .hierarchical 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /Shared/Views/WhatsNewView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhatsNewView.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/21/21. 6 | // 7 | 8 | import StoreKit 9 | import SwiftUI 10 | 11 | struct WhatsNewView: View { 12 | 13 | let onboarding: Bool 14 | 15 | @EnvironmentObject 16 | private var viewState: ViewState 17 | 18 | @EnvironmentObject 19 | private var sheetStack: ShuttleTrackerSheetStack 20 | 21 | var body: some View { 22 | VStack { 23 | ScrollView { 24 | VStack(alignment: .leading) { 25 | HStack { 26 | Spacer() 27 | VStack { 28 | Text("What’s New") 29 | .font(.largeTitle) 30 | .bold() 31 | .multilineTextAlignment(.center) 32 | Text("Version 2.0") 33 | .font( 34 | .system( 35 | .callout, 36 | design: .monospaced 37 | ) 38 | ) 39 | .bold() 40 | .padding(5) 41 | .background( 42 | .tertiary, 43 | in: RoundedRectangle( 44 | cornerRadius: 10, 45 | style: .continuous 46 | ) 47 | ) 48 | } 49 | Spacer() 50 | } 51 | .padding(.vertical) 52 | VStack(alignment: .leading, spacing: 20) { 53 | #if os(iOS) 54 | WhatsNewItem( 55 | title: "Automatic Board Bus", 56 | description: "Use Board Bus without taking your phone out.", 57 | icon: .whatsNewAutomaticBoardBus 58 | ) 59 | WhatsNewItem( 60 | title: "Shuttle Tracker Network", 61 | description: "Connect to our custom tracking devices on the buses.", 62 | icon: .whatsNewNetwork 63 | ) 64 | #endif // os(iOS) 65 | WhatsNewItem( 66 | title: "Notifications", 67 | description: "Receive push notification for new announcements.", 68 | icon: .whatsNewNotifications 69 | ) 70 | WhatsNewItem( 71 | title: "Design", 72 | description: "See a new logo, app icon, and color scheme.", 73 | icon: .whatsNewDesign 74 | ) 75 | WhatsNewItem( 76 | title: "Analytics", 77 | description: "Opt in to analytics sharing to help improve the app.", 78 | icon: .whatsNewAnalytics 79 | ) 80 | } 81 | } 82 | .padding(.horizontal) 83 | .padding(.bottom) 84 | #if os(iOS) 85 | .padding(.top) 86 | #endif // os(iOS) 87 | } 88 | #if os(iOS) 89 | Group { 90 | if self.onboarding { 91 | NavigationLink { 92 | NetworkOnboardingView() 93 | } label: { 94 | Text("Continue") 95 | .bold() 96 | .padding(5) 97 | .frame(maxWidth: .infinity) 98 | } 99 | } else { 100 | Button { 101 | self.sheetStack.pop() 102 | } label: { 103 | Text("Continue") 104 | .bold() 105 | .padding(5) 106 | .frame(maxWidth: .infinity) 107 | } 108 | } 109 | } 110 | .buttonStyle(.borderedProminent) 111 | .padding(.horizontal) 112 | .padding(.bottom) 113 | #endif // os(iOS) 114 | } 115 | .toolbar { 116 | #if os(macOS) 117 | ToolbarItem(placement: .confirmationAction) { 118 | Button(self.onboarding ? "Continue" : "Close") { 119 | self.sheetStack.pop() 120 | if self.onboarding { 121 | self.sheetStack.push(.analyticsOnboarding) 122 | } else { 123 | // TODO: Switch to SwiftUI’s requestReview environment value when we drop support for macOS 12 124 | // Request a review on the App Store 125 | // This logic uses the legacy SKStoreReviewController class because the newer SwiftUI requestReview environment value requires macOS 13 or newer, and stored properties can’t be gated on OS version. 126 | SKStoreReviewController.requestReview() 127 | } 128 | } 129 | } 130 | #endif // os(macOS) 131 | } 132 | .onAppear { 133 | if self.onboarding { 134 | self.viewState.handles.whatsNew?.increment() 135 | } 136 | } 137 | } 138 | 139 | } 140 | 141 | #Preview { 142 | WhatsNewView(onboarding: false) 143 | .environmentObject(ViewState.shared) 144 | .environmentObject(ShuttleTrackerSheetStack()) 145 | } 146 | -------------------------------------------------------------------------------- /Shared/WrappedError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WrappedError.swift 3 | // Shuttle Tracker 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 11/18/22. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WrappedError: LocalizedError { 11 | 12 | let error: (any Error)? 13 | 14 | var errorDescription: String? { 15 | get { 16 | return self.error?.localizedDescription 17 | } 18 | } 19 | 20 | init(_ error: (any Error)? = nil) { 21 | self.error = error 22 | } 23 | 24 | } 25 | 26 | extension Optional where Wrapped == WrappedError { 27 | 28 | var isNotNil: Bool { 29 | get { 30 | return self != nil 31 | } 32 | set { 33 | if newValue { 34 | if self == nil { 35 | self = WrappedError() 36 | } 37 | } else { 38 | self = nil 39 | } 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Shuttle Tracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Shuttle Tracker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Shuttle Tracker.xcodeproj/xcshareddata/xcschemes/Shuttle Tracker (App Clip).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 67 | 69 | 75 | 76 | 77 | 78 | 80 | 81 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /Shuttle Tracker.xcodeproj/xcshareddata/xcschemes/Shuttle Tracker (iOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Shuttle Tracker.xcodeproj/xcshareddata/xcschemes/Shuttle Tracker (macOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Shuttle Tracker.xcodeproj/xcshareddata/xcschemes/ShuttleTracker Watch App.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 56 | 58 | 64 | 65 | 66 | 69 | 70 | 71 | 77 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/AnnouncementDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnnouncementDetailView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 3/1/24. 6 | // 7 | 8 | import SwiftUI 9 | import UserNotifications 10 | import STLogging 11 | 12 | struct AnnouncementDetailView: View { 13 | 14 | let announcement: Announcement 15 | 16 | @Binding 17 | private(set) var didResetViewedAnnouncements: Bool 18 | 19 | @EnvironmentObject 20 | private var appStorageManager: AppStorageManager 21 | 22 | var body: some View { 23 | VStack(alignment: .leading) { 24 | Text(announcement.subject) 25 | .font(.headline) 26 | Text(announcement.body) 27 | HStack { 28 | switch self.announcement.scheduleType { 29 | case .none: 30 | EmptyView() 31 | case .startOnly: 32 | Text("Posted \(self.announcement.startString)") 33 | case .endOnly: 34 | Text("Expires \(self.announcement.endString)") 35 | case .startAndEnd: 36 | Text("Posted \(self.announcement.startString); expires \(self.announcement.endString)") 37 | } 38 | Spacer() 39 | } 40 | .font(.footnote) 41 | .foregroundColor(.secondary) 42 | .padding(.bottom) 43 | } 44 | .task { 45 | self.didResetViewedAnnouncements = false 46 | self.appStorageManager.viewedAnnouncementIDs.insert(self.announcement.id) 47 | 48 | do { 49 | try await UNUserNotificationCenter.updateBadge() 50 | } catch { 51 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 52 | } 53 | 54 | do { 55 | try await Analytics.upload(eventType: .announcementViewed(id: self.announcement.id)) 56 | } catch { 57 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics entry: \(error, privacy: .public)") 58 | } 59 | } 60 | } 61 | 62 | init(announcement: Announcement, didResetViewedAnnouncements: Binding = .constant(false)) { 63 | self.announcement = announcement 64 | self._didResetViewedAnnouncements = didResetViewedAnnouncements 65 | } 66 | 67 | } 68 | 69 | //#Preview { 70 | // AnnouncementDetailView(announcement: Anno, 71 | // didResetViewedAnnouncements: .constant(true)) 72 | //} 73 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/AnnouncementsSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnnouncementsSheet.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 3/1/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AnnouncementsSheet: View { 11 | 12 | @State 13 | private var announcements: [Announcement]? 14 | 15 | @State 16 | private var didResetViewedAnnouncements = false 17 | 18 | @EnvironmentObject 19 | private var appStorageManager: AppStorageManager 20 | 21 | var body: some View { 22 | NavigationView { 23 | Group { 24 | if let announcements = self.announcements { 25 | if announcements.count > 0 { 26 | List(announcements) { (announcement) in 27 | NavigationLink { 28 | AnnouncementDetailView( 29 | announcement: announcement, 30 | didResetViewedAnnouncements: self.$didResetViewedAnnouncements 31 | ) 32 | } label: { 33 | let isUnviewed = !self.appStorageManager.viewedAnnouncementIDs.contains(announcement.id) 34 | VStack(alignment: .leading) { 35 | HStack { 36 | if isUnviewed { 37 | Circle() 38 | .fill(.blue) 39 | .frame(width: 10, height: 10) 40 | } 41 | Text(announcement.subject) 42 | .font(.headline) 43 | .lineLimit(1) 44 | } 45 | Text(announcement.body) 46 | .lineLimit(2) 47 | } 48 | .bold(isUnviewed) 49 | .padding() 50 | } 51 | } 52 | } else { 53 | Text("There are no announcements.") 54 | .multilineTextAlignment(.center) 55 | .foregroundColor(.secondary) 56 | .padding() 57 | } 58 | } else { 59 | ProgressView("Loading") 60 | .font(.callout) 61 | .textCase(.uppercase) 62 | .foregroundColor(.secondary) 63 | .padding() 64 | } 65 | } 66 | .navigationTitle("Announcements") 67 | } 68 | .task { 69 | self.announcements = await [Announcement].download() 70 | } 71 | } 72 | } 73 | 74 | #Preview { 75 | AnnouncementsSheet() 76 | } 77 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/AnnouncementsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnnouncementsView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/27/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AnnouncementsView: View { 11 | 12 | @State 13 | private var announcements: [Announcement]? 14 | 15 | @State 16 | private var didResetViewedAnnouncements = false 17 | 18 | @EnvironmentObject 19 | private var appStorageManager: AppStorageManager 20 | 21 | var body: some View { 22 | NavigationView { 23 | Group { 24 | if let announcements = self.announcements { 25 | if announcements.count > 0 { 26 | List(announcements) { (announcement) in 27 | NavigationLink { 28 | AnnouncementDetailView( 29 | announcement: announcement, 30 | didResetViewedAnnouncements: self.$didResetViewedAnnouncements 31 | ) 32 | } label: { 33 | HStack { 34 | let isUnviewed = !self.appStorageManager.viewedAnnouncementIDs.contains(announcement.id) 35 | Circle() 36 | .fill(isUnviewed ? .blue : .clear) 37 | .frame(width: 10, height: 10) 38 | Text(announcement.subject) 39 | .bold(isUnviewed) 40 | } 41 | } 42 | } 43 | } else { 44 | Text("There are no announcement.") 45 | .multilineTextAlignment(.center) 46 | .foregroundColor(.secondary) 47 | .padding() 48 | } 49 | } else { 50 | ProgressView("Loading") 51 | .font(.callout) 52 | .textCase(.uppercase) 53 | .foregroundColor(.secondary) 54 | .padding() 55 | } 56 | Text("No announcement received") 57 | .multilineTextAlignment(.center) 58 | .foregroundColor(.secondary) 59 | .padding() 60 | } 61 | .navigationTitle("Announcements") 62 | } 63 | .task { 64 | self.announcements = await [Announcement].download() 65 | } 66 | } 67 | } 68 | 69 | #Preview { 70 | AnnouncementsView() 71 | } 72 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "watchos", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/3/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | 12 | @EnvironmentObject 13 | private var mapState: MapState 14 | 15 | @State 16 | private var announcements: [Announcement] = [] 17 | 18 | @State 19 | private var didResetViewedAnnouncements = false 20 | 21 | @State 22 | private var showInfoSheet = false 23 | 24 | @Binding 25 | private var mapCameraPosition: MapCameraPositionWrapper 26 | 27 | var body: some View { 28 | NavigationView { 29 | MapContainer(position: self.$mapCameraPosition) 30 | .toolbar { 31 | ToolbarItem(placement: .bottomBar) { 32 | Button { 33 | Task { 34 | await self.mapState.recenter(position: self.$mapCameraPosition) 35 | } 36 | } label: { 37 | Label("Re-Center Map", systemImage: SFSymbol.recenter.systemName) 38 | } 39 | } 40 | ToolbarItem(placement: .bottomBar) { 41 | Button(action: { 42 | self.showInfoSheet.toggle() 43 | }, label: { 44 | Label("Informations Tab", systemImage: SFSymbol.info.systemName) 45 | }) 46 | } 47 | } 48 | } 49 | .task { 50 | await self.mapState.refreshAll() 51 | } 52 | .sheet(isPresented: self.$showInfoSheet, content: { 53 | InfoView() 54 | }) 55 | } 56 | 57 | init(mapCameraPosition: Binding) { 58 | self._mapCameraPosition = mapCameraPosition 59 | } 60 | } 61 | 62 | #Preview { 63 | ContentView(mapCameraPosition: .constant(MapCameraPositionWrapper(MapConstants.defaultCameraPosition))) 64 | } 65 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/InfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/9/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InfoView: View { 11 | 12 | @EnvironmentObject 13 | private var appStorageManager : AppStorageManager 14 | 15 | var body: some View { 16 | NavigationStack { 17 | ScrollView { 18 | Text("Shuttle Tracker shows you the real-time locations of the Rensselaer campus shuttles.") 19 | .font(.footnote) 20 | NavigationLink { 21 | AnnouncementsSheet() 22 | } label: { 23 | InformationTypeView(SFSymbol: .announcements, 24 | primaryColor: .white, 25 | secondaryColor: .red, 26 | name: "Announcements") 27 | } 28 | NavigationLink { 29 | ScheduleView() 30 | } label: { 31 | InformationTypeView(SFSymbol: .schedule, 32 | primaryColor: .white, 33 | secondaryColor: .orange, 34 | name: "Schedule") 35 | } 36 | NavigationLink { 37 | SettingsView() 38 | .environmentObject(self.appStorageManager) 39 | 40 | } label: { 41 | InformationTypeView(SFSymbol: .settings, 42 | primaryColor: .white, 43 | secondaryColor: .gray, 44 | name: "Settings") 45 | } 46 | NavigationLink { 47 | PrivacyView() 48 | } label: { 49 | InformationTypeView(SFSymbol: .privacy, 50 | primaryColor: .white, 51 | secondaryColor: .blue, 52 | name: "Privacy") 53 | } 54 | if CalendarUtilities.isAprilFools { 55 | NavigationLink { 56 | PlusSheet() 57 | } label: { 58 | InformationTypeView(SFSymbol: .shuttleTrackerPlus, 59 | primaryColor: .white, 60 | secondaryColor: .clear, 61 | name: "Shuttle Tracker+") 62 | .rainbow() 63 | } 64 | } 65 | } 66 | .navigationTitle("Shuttle Tracker 🚐") 67 | .navigationBarTitleDisplayMode(.inline) 68 | .buttonStyle(.borderless) 69 | } 70 | } 71 | } 72 | 73 | #Preview { 74 | InfoView() 75 | } 76 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/InformationTypeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InformationTypeView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/16/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InformationTypeView: View { 11 | var SFSymbol : SFSymbol 12 | let primaryColor: Color 13 | let secondaryColor: Color 14 | var name : String 15 | var body: some View { 16 | HStack(alignment:.center, spacing: 8) { 17 | Image(systemName: SFSymbol.systemName) 18 | .resizable() 19 | .clipShape(Circle()) 20 | .scaledToFit() 21 | .frame(height: 18) 22 | .foregroundStyle(primaryColor, secondaryColor) 23 | .font(.title3) 24 | Text(self.name) 25 | .fontWeight(.semibold) 26 | .lineLimit(1) 27 | Spacer() 28 | } 29 | .padding(10) 30 | .background(.gray.opacity(0.2), in: .buttonBorder) 31 | .foregroundStyle(.white) 32 | } 33 | } 34 | 35 | #Preview { 36 | InformationTypeView(SFSymbol: .shuttleTrackerPlus, 37 | primaryColor: .white, 38 | secondaryColor: .clear, 39 | name: "Shuttle Tracker Plus") 40 | .rainbow() 41 | } 42 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/PlusSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlusSheet.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/19/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlusSheet: View { 11 | 12 | @State 13 | private var doShowAlert = false 14 | 15 | @EnvironmentObject 16 | private var sheetStack: ShuttleTrackerSheetStack 17 | 18 | var body: some View { 19 | ScrollView { 20 | VStack(alignment: .leading) { 21 | HStack { 22 | Text("Shuttle Tracker+") 23 | .font(.largeTitle) 24 | .bold() 25 | .rainbow() 26 | Spacer() 27 | } 28 | .padding(.bottom) 29 | Text("Subscribe to Shuttle Tracker+ today to get the best Shuttle Tracker experience. It’s just $9.99 per week. That’s cheap!") 30 | .padding(.bottom) 31 | Spacer() 32 | Button { 33 | self.doShowAlert = true 34 | } label: { 35 | Text("Subscribe") 36 | .bold() 37 | } 38 | .buttonStyle(.automatic) 39 | } 40 | .padding([.horizontal, .bottom]) 41 | .alert("April Fools!", isPresented: self.$doShowAlert) { 42 | Button("Dismiss") { 43 | self.doShowAlert = false 44 | } 45 | } 46 | } 47 | } 48 | 49 | } 50 | 51 | #Preview { 52 | PlusSheet() 53 | .environmentObject(ShuttleTrackerSheetStack()) 54 | } 55 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/PrivacyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivacyView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/23/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PrivacyView: View { 11 | var body: some View { 12 | ScrollView { 13 | VStack(alignment: .leading) { 14 | Section { 15 | Text("Shuttle Tracker sends your location data to our server only when Board Bus is activated and stops sending these data when Board Bus is deactivated. You can activate Board Bus manually by tapping “Board Bus” or automatically by positioning your device within Bluetooth range of a Shuttle Tracker Node device on a bus if you opted in to the Shuttle Tracker Network. You can deactivate Board Bus manually by tapping “Leave Bus” or automatically by positioning your device out of Bluetooth range of a Shuttle Tracker Node device on a bus if you opted in to the Shuttle Tracker Network. Your location data are associated with an anonymous, random identifier that rotates every time you start a new shuttle trip. These data aren’t associated with your name, Apple ID, RCS ID, RIN, or any other information that might identify you or your device. We continuously purge location data that are more than 30 seconds old from our server. We may retain resolved location data that are calculated using a combination of system- and user-reported data indefinitely, but these resolved data don’t correspond with any specific user-reported coordinates. Even if you opt in to the Shuttle Tracker Network, we never track your location unless you manually activate Board Bus or physically board a bus. Your device might alert you to Shuttle Tracker’s location monitoring in the background even when Shuttle Tracker isn’t actually tracking your location. This is due to a system limitation; Shuttle Tracker occasionally scans for Shuttle Tracker Node devices in the background, and your device might show that activity as location tracking. The results of these scans never leave your device, and we only start collecting location data if a scan indicates that you’re physically on a bus.") 16 | .font(.footnote) 17 | .padding(.bottom) 18 | } header: { 19 | Text("Location") 20 | .font(.headline) 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | #Preview { 28 | PrivacyView() 29 | } 30 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/ScheduleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScheduleView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/9/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ScheduleView: View { 11 | 12 | @State private var schedule : Schedule? 13 | 14 | var body: some View { 15 | ScrollView { 16 | if let schedule = self.schedule { 17 | Group { 18 | HStack { 19 | let weekday = Calendar.current.component(.weekday, from: .now) 20 | VStack(alignment: .leading, spacing: 0) { 21 | Text("M").bold(weekday == 2) 22 | Text("T").bold(weekday == 3) 23 | Text("W").bold(weekday == 4) 24 | Text("T").bold(weekday == 5) 25 | Text("F").bold(weekday == 6) 26 | Text("S").bold(weekday == 7) 27 | Text("S").bold(weekday == 1) 28 | } 29 | VStack(alignment: .leading, spacing: 0) { 30 | Text("\(schedule.content.monday.start) to \(schedule.content.monday.end)") 31 | .bold(weekday == 2) 32 | Text("\(schedule.content.tuesday.start) to \(schedule.content.tuesday.end)") 33 | .bold(weekday == 3) 34 | Text("\(schedule.content.wednesday.start) to \(schedule.content.wednesday.end)") 35 | .bold(weekday == 4) 36 | Text("\(schedule.content.thursday.start) to \(schedule.content.thursday.end)") 37 | .bold(weekday == 5) 38 | Text("\(schedule.content.friday.start) to \(schedule.content.friday.end)") 39 | .bold(weekday == 6) 40 | Text("\(schedule.content.saturday.start) to \(schedule.content.saturday.end)") 41 | .bold(weekday == 7) 42 | Text("\(schedule.content.sunday.start) to \(schedule.content.sunday.end)") 43 | .bold(weekday == 1) 44 | } 45 | Spacer() 46 | } 47 | } 48 | .navigationTitle("Schedule") 49 | } 50 | } 51 | .onAppear { 52 | Task { 53 | self.schedule = await Schedule.download() 54 | } 55 | } 56 | } 57 | } 58 | 59 | #Preview { 60 | ScheduleView() 61 | } 62 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/19/24. 6 | // 7 | 8 | import SwiftUI 9 | import UserNotifications 10 | import STLogging 11 | 12 | struct SettingsView: View { 13 | 14 | @EnvironmentObject 15 | private var appStorageManager: AppStorageManager 16 | 17 | @State 18 | private var didResetViewedAnnouncements = false 19 | 20 | var body: some View { 21 | Form { 22 | Section { 23 | HStack { 24 | ZStack { 25 | Circle() 26 | .fill(.green) 27 | Image(systemName: self.appStorageManager.colorBlindMode ? SFSymbol.colorBlindHighQualityLocation.systemName : SFSymbol.bus.systemName) 28 | .resizable() 29 | .frame(width: 15, height: 15) 30 | .foregroundColor(.white) 31 | } 32 | .frame(width: 30) 33 | .animation(.default, value: self.appStorageManager.colorBlindMode) 34 | Toggle("Color-Blind Mode", isOn: self.appStorageManager.$colorBlindMode) 35 | } 36 | .frame(height: 30) 37 | } footer: { 38 | Text("Modifies bus markers so that they’re distinguishable by icon in addition to color.") 39 | } 40 | 41 | Section { 42 | Button(role: .destructive) { 43 | Task { 44 | do { 45 | try await UNUserNotificationCenter.updateBadge() 46 | } catch { 47 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 48 | } 49 | } 50 | withAnimation { 51 | self.appStorageManager.viewedAnnouncementIDs.removeAll() 52 | self.didResetViewedAnnouncements = true 53 | } 54 | } label: { 55 | HStack { 56 | Text("Reset Viewed Announcements") 57 | if self.didResetViewedAnnouncements { 58 | Spacer() 59 | Text("✓") 60 | } 61 | } 62 | } 63 | } footer: { 64 | Text("Pressing this button resets all current live announcements to unviewed.") 65 | } 66 | .disabled(self.appStorageManager.viewedAnnouncementIDs.isEmpty) 67 | 68 | Section { 69 | // URL.FormatStyle’s integration with TextField seems to be broken currently, so we fall back on our custom URL format style 70 | TextField("Server Base URL", value: self.appStorageManager.$baseURL, format: .compatibilityURL) 71 | .labelsHidden() 72 | .scrollDismissesKeyboard(.interactively) 73 | } header: { 74 | Text("Server Base URL") 75 | } footer: { 76 | Text("The base URL for the API server. Changing this setting could make the rest of the app stop working properly.") 77 | } 78 | .navigationTitle("Settings") 79 | } 80 | } 81 | } 82 | 83 | #Preview { 84 | SettingsView() 85 | } 86 | -------------------------------------------------------------------------------- /ShuttleTracker Watch App/ShuttleTrackerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShuttleTrackerApp.swift 3 | // ShuttleTracker Watch App 4 | // 5 | // Created by Tommy Truong on 2/3/24. 6 | // 7 | 8 | import SwiftUI 9 | import CoreLocation 10 | import UserNotifications 11 | import STLogging 12 | 13 | @main 14 | struct ShuttleTrackerApp: App { 15 | 16 | @State 17 | private var mapCameraPosition: MapCameraPositionWrapper = .default 18 | 19 | @ObservedObject 20 | private var mapState = MapState.shared 21 | 22 | @ObservedObject 23 | private var appStorageManager = AppStorageManager.shared 24 | 25 | var body: some Scene { 26 | WindowGroup { 27 | ContentView(mapCameraPosition: self.$mapCameraPosition) 28 | .environmentObject(self.mapState) 29 | .environmentObject(self.appStorageManager) 30 | } 31 | } 32 | 33 | init() { 34 | CLLocationManager.default = CLLocationManager() 35 | CLLocationManager.default.requestWhenInUseAuthorization() 36 | CLLocationManager.default.activityType = .automotiveNavigation 37 | Task { 38 | do { 39 | try await UNUserNotificationCenter.requestDefaultAuthorization() 40 | } catch let error { 41 | #log(system: Logging.system , "[\(#fileID):\(#line) \(#function, privacy: .public)] Failed to request notification authorization: \(error, privacy: .public)") 42 | throw error 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ShuttleTracker-Watch-App-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSLocationAlwaysUsageDescription 6 | Shuttle Tracker connects to the Shuttle Tracker Network to track bus locations in the background and shows where you are on the map. 7 | NSLocationAlwaysAndWhenInUseUsageDescription 8 | Shuttle Tracker connects to the Shuttle Tracker Network to track bus locations in the background and shows where you are on the map. 9 | NSLocationUsageDescription 10 | Shuttle Tracker shows where you are on the map. 11 | 12 | 13 | -------------------------------------------------------------------------------- /iOS/Delegates/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 2/25/23. 6 | // 7 | 8 | import STLogging 9 | import UIKit 10 | 11 | @MainActor 12 | final class AppDelegate: NSObject, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 15 | if let launchOptions { 16 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did finish launching with options \(launchOptions, privacy: .public)") 17 | } else { 18 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did finish launching with options") 19 | } 20 | UNUserNotificationCenter.current().delegate = UserNotificationCenterDelegate.default 21 | application.registerForRemoteNotifications() 22 | return true 23 | } 24 | 25 | func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 26 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did register for remote notifications with device token \(deviceToken, privacy: .public)") 27 | let tokenString = deviceToken 28 | .map { (byte) in 29 | return String(format: "%02.2hhx", byte) 30 | } 31 | .joined() 32 | Task { 33 | do { 34 | try await API.uploadAPNSToken(token: tokenString).perform() 35 | } catch { 36 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload APNS device token: \(error, privacy: .public)") 37 | } 38 | } 39 | } 40 | 41 | func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { 42 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did fail to register for remote notifications with error \(error, privacy: .public)") 43 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to register for remote notifications: \(error, privacy: .public)") 44 | } 45 | 46 | func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { 47 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did receive remote notification \(userInfo, privacy: .public)") 48 | await UNUserNotificationCenter.handleNotification(userInfo: userInfo) 49 | return .newData 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Shuttle Tracker 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSApplicationCategoryType 24 | public.app-category.navigation 25 | LSRequiresIPhoneOS 26 | 27 | NSAppTransportSecurity 28 | 29 | NSLocationAlwaysAndWhenInUseUsageDescription 30 | Shuttle Tracker connects to the Shuttle Tracker Network to track bus locations in the background and shows where you are on the map. 31 | NSLocationTemporaryUsageDescriptionDictionary 32 | 33 | BoardBus 34 | Shuttle Tracker requires precise location access to improve data accuracy for everyone. 35 | 36 | NSLocationWhenInUseUsageDescription 37 | Shuttle Tracker connects to the Shuttle Tracker Network to track bus locations in the background and shows where you are on the map. 38 | UIApplicationSceneManifest 39 | 40 | UIApplicationSupportsMultipleScenes 41 | 42 | 43 | UIApplicationSupportsIndirectInputEvents 44 | 45 | UIBackgroundModes 46 | 47 | location 48 | remote-notification 49 | 50 | UILaunchScreen 51 | 52 | UIRequiredDeviceCapabilities 53 | 54 | armv7 55 | location-services 56 | 57 | UIRequiresFullScreen 58 | 59 | UISupportedInterfaceOrientations 60 | 61 | UIInterfaceOrientationPortrait 62 | 63 | UISupportedInterfaceOrientations~ipad 64 | 65 | UIInterfaceOrientationPortrait 66 | UIInterfaceOrientationPortraitUpsideDown 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /iOS/Views/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutView.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/4/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AboutView: View { 11 | 12 | @EnvironmentObject 13 | private var sheetStack: ShuttleTrackerSheetStack 14 | 15 | var body: some View { 16 | Form { 17 | Section { 18 | NavigationLink("App Information") { 19 | InfoView() 20 | } 21 | NavigationLink("Privacy Information") { 22 | PrivacyView() 23 | } 24 | Button("Show What’s New") { 25 | self.sheetStack.push(.whatsNew(onboarding: false)) 26 | } 27 | } 28 | } 29 | .navigationTitle("About") 30 | .toolbar { 31 | ToolbarItem { 32 | CloseButton() 33 | } 34 | } 35 | } 36 | 37 | } 38 | 39 | #Preview { 40 | AboutView() 41 | .environmentObject(ShuttleTrackerSheetStack()) 42 | } 43 | -------------------------------------------------------------------------------- /iOS/Views/AdvancedSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AdvancedSettingsView.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 1/23/22. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | 11 | @available(iOS 17, *) 12 | struct AdvancedSettingsView: View { 13 | 14 | @State 15 | private var didResetViewedAnnouncements = false 16 | 17 | @State 18 | private var didResetAdvancedSettings = false 19 | 20 | @EnvironmentObject 21 | private var appStorageManager: AppStorageManager 22 | 23 | var body: some View { 24 | Form { 25 | Section { 26 | HStack { 27 | Text("^[\(self.appStorageManager.maximumStopDistance) meter](inflect: true)") 28 | Spacer() 29 | Stepper("Maximum Stop Distance", value: self.appStorageManager.$maximumStopDistance, in: 1 ... 100) 30 | .labelsHidden() 31 | } 32 | } header: { 33 | Text("Maximum Stop Distance") 34 | } footer: { 35 | Text("The maximum distance in meters from the nearest stop at which you can board a bus.") 36 | } 37 | Section { 38 | HStack { 39 | Text("^[\(self.appStorageManager.routeTolerance) meter](inflect: true)") 40 | Spacer() 41 | Stepper("Route Tolerance", value: self.appStorageManager.$routeTolerance, in: 1 ... 50) 42 | .labelsHidden() 43 | } 44 | } header: { 45 | Text("Route Tolerance") 46 | } footer: { 47 | Text("The distance in meters from a route at which Board Bus is automatically deactivated.") 48 | } 49 | Section { 50 | // URL.FormatStyle’s integration with TextField seems to be broken currently, so we fall back on our custom URL format style 51 | TextField("Server Base URL", value: self.appStorageManager.$baseURL, format: .compatibilityURL) 52 | .labelsHidden() 53 | .keyboardType(.url) 54 | } header: { 55 | Text("Server Base URL") 56 | } footer: { 57 | Text("The base URL for the API server. Changing this setting could make the rest of the app stop working properly.") 58 | } 59 | Section { 60 | Button(role: .destructive) { 61 | Task { 62 | do { 63 | try await UNUserNotificationCenter.updateBadge() 64 | } catch { 65 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 66 | } 67 | } 68 | withAnimation { 69 | self.appStorageManager.viewedAnnouncementIDs.removeAll() 70 | self.didResetViewedAnnouncements = true 71 | } 72 | } label: { 73 | HStack { 74 | Text("Reset Viewed Announcements") 75 | if self.didResetViewedAnnouncements { 76 | Spacer() 77 | Text("✓") 78 | } 79 | } 80 | } 81 | .disabled(self.appStorageManager.viewedAnnouncementIDs.isEmpty) 82 | Button(role: .destructive) { 83 | self.appStorageManager.baseURL = AppStorageManager.Defaults.baseURL 84 | self.appStorageManager.maximumStopDistance = AppStorageManager.Defaults.maximumStopDistance 85 | self.appStorageManager.routeTolerance = AppStorageManager.Defaults.routeTolerance 86 | withAnimation { 87 | self.didResetAdvancedSettings = true 88 | } 89 | } label: { 90 | HStack { 91 | Text("Reset Advanced Settings") 92 | if self.didResetAdvancedSettings { 93 | Spacer() 94 | Text("✓") 95 | } 96 | } 97 | } 98 | .disabled(self.appStorageManager.baseURL == AppStorageManager.Defaults.baseURL && self.appStorageManager.maximumStopDistance == AppStorageManager.Defaults.maximumStopDistance && self.appStorageManager.routeTolerance == AppStorageManager.Defaults.routeTolerance) 99 | .onChange(of: self.appStorageManager.baseURL) { 100 | if self.appStorageManager.baseURL != AppStorageManager.Defaults.baseURL { 101 | self.didResetAdvancedSettings = false 102 | } 103 | } 104 | .onChange(of: self.appStorageManager.maximumStopDistance) { 105 | if self.appStorageManager.maximumStopDistance != AppStorageManager.Defaults.maximumStopDistance { 106 | self.didResetAdvancedSettings = false 107 | } 108 | } 109 | .onChange(of: self.appStorageManager.routeTolerance) { 110 | if self.appStorageManager.routeTolerance != AppStorageManager.Defaults.routeTolerance { 111 | self.didResetAdvancedSettings = false 112 | } 113 | } 114 | } 115 | } 116 | .navigationTitle("Advanced") 117 | .toolbar { 118 | ToolbarItem { 119 | CloseButton() 120 | } 121 | } 122 | } 123 | 124 | } 125 | 126 | @available(iOS 17, *) 127 | #Preview { 128 | AdvancedSettingsView() 129 | .environmentObject(AppStorageManager.shared) 130 | } 131 | -------------------------------------------------------------------------------- /iOS/Views/BoardBusToast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BoardBusToast.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Andrew Emanuel on 11/16/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BoardBusToast: View { 11 | 12 | @EnvironmentObject 13 | private var viewState: ViewState 14 | 15 | var body: some View { 16 | Toast("You can help!", item: self.$viewState.toastType) { (_ ,_) in 17 | Text("Tap “Board Bus” whenever you board a bus to help make Shuttle Tracker more accurate for everyone.") 18 | .accessibilityShowsLargeContentViewer() 19 | } 20 | } 21 | 22 | } 23 | 24 | #Preview { 25 | BoardBusToast() 26 | .environmentObject(ViewState.shared) 27 | } 28 | -------------------------------------------------------------------------------- /iOS/Views/NetworkTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkTextView.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/21/23. 6 | // 7 | 8 | import CoreLocation 9 | import SwiftUI 10 | 11 | struct NetworkTextView: View { 12 | 13 | var body: some View { 14 | VStack(alignment: .leading) { 15 | Group { 16 | // The AttributedString(markdown:) initializer detects the list-item prefixes as Markdown syntax and drops them because it doesn’t natively support numbered lists, so we must instead concatenate them as plain text outside of the initializer. 17 | switch (CLLocationManager.default.authorizationStatus, CLLocationManager.default.accuracyAuthorization) { 18 | case (.authorizedAlways, .fullAccuracy): 19 | EmptyView() 20 | case (.notDetermined, _): 21 | Group { 22 | Text(try! "1.\t" + AttributedString(markdown: "Tap the red button below.")) 23 | Text(try! "2.\t" + AttributedString(markdown: "Select ***Allow While Using App***.")) 24 | Text(try! "3.\t" + AttributedString(markdown: "Select ***Change to Always Allow***.")) 25 | } 26 | .padding(.bottom) 27 | default: 28 | Group { 29 | Text(try! "1.\t" + AttributedString(markdown: "Tap the red button below.")) 30 | Text(try! "2.\t" + AttributedString(markdown: "Select ***Always*** location access.")) 31 | Text(try! "3.\t" + AttributedString(markdown: "Enable ***Precise Location***.")) 32 | } 33 | .padding(.bottom) 34 | } 35 | } 36 | .font(.system(size: 20)) 37 | DisclosureGroup("Privacy & Battery Information") { 38 | HStack { 39 | Text(try! AttributedString(markdown: "Your location is **never shared** unless you’re physically on a bus. Location services are activated only when an ultra-low-power signal from Shuttle Tracker Node is detected, so daily background **battery usage is minimal**.")) 40 | Spacer() 41 | } 42 | } 43 | } 44 | } 45 | 46 | } 47 | 48 | #Preview { 49 | NetworkTextView() 50 | } 51 | -------------------------------------------------------------------------------- /iOS/Views/NetworkToast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkToast.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by John Foster on 12/1/22. 6 | // 7 | 8 | import CoreLocation 9 | import STLogging 10 | import SwiftUI 11 | 12 | struct NetworkToast: View { 13 | 14 | @EnvironmentObject 15 | private var viewState: ViewState 16 | 17 | @EnvironmentObject 18 | private var sheetStack: ShuttleTrackerSheetStack 19 | 20 | var body: some View { 21 | Toast("Join the Network!", item: self.$viewState.toastType) { (_, dismiss) in 22 | VStack(alignment: .leading) { 23 | Text(try! AttributedString(markdown: "The Shuttle Tracker Network unlocks **dramatically improved tracking coverage**. Enable location access to join the Network!")) 24 | Button { 25 | switch (CLLocationManager.default.authorizationStatus, CLLocationManager.default.accuracyAuthorization) { 26 | case (.authorizedAlways, .fullAccuracy): 27 | dismiss() 28 | default: 29 | self.sheetStack.push(.permissions) 30 | } 31 | 32 | Task { 33 | do { 34 | try await Analytics.upload(eventType: .networkToastPermissionsTapped) 35 | } catch { 36 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 37 | } 38 | } 39 | } label: { 40 | Text("Join the Network") 41 | .bold() 42 | .padding(5) 43 | .frame(maxWidth: .infinity) 44 | } 45 | .buttonStyle(.borderedProminent) 46 | .buttonBorderShape(.roundedRectangle(radius: 15)) 47 | } 48 | .onChange(of: CLLocationManager.default.authorizationStatus) { (authorizationStatus) in 49 | Task { 50 | do { 51 | try await Analytics.upload(eventType: .locationAuthorizationStatusDidChange(authorizationStatus: Int(authorizationStatus.rawValue))) 52 | } catch { 53 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 54 | } 55 | } 56 | } 57 | .onChange(of: CLLocationManager.default.accuracyAuthorization) { (accuracyAuthorization) in 58 | Task { 59 | do { 60 | try await Analytics.upload(eventType: .locationAccuracyAuthorizationDidChange(accuracyAuthorization: Int(accuracyAuthorization.rawValue))) 61 | } catch { 62 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload analytics: \(error, privacy: .public)") 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /iOS/Views/SecondaryOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecondaryOverlay.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/7/21. 6 | // 7 | 8 | import STLogging 9 | import SwiftUI 10 | 11 | struct SecondaryOverlay: View { 12 | 13 | @State 14 | private var announcements: [Announcement] = [] 15 | 16 | @Binding 17 | private var mapCameraPosition: MapCameraPositionWrapper 18 | 19 | @EnvironmentObject 20 | private var mapState: MapState 21 | 22 | @EnvironmentObject 23 | private var viewState: ViewState 24 | 25 | var body: some View { 26 | VStack { 27 | VStack(spacing: 0) { 28 | SecondaryOverlayButton(icon: .settings, sheetType: .settings) 29 | Divider() 30 | .frame(width: 45, height: 0) 31 | SecondaryOverlayButton(icon: .info, sheetType: .info) 32 | Divider() 33 | .frame(width: 45, height: 0) 34 | SecondaryOverlayButton(icon: .announcements, sheetType: .announcements, badgeNumber: self.viewState.badgeNumber) 35 | .task { 36 | do { 37 | try await UNUserNotificationCenter.updateBadge() 38 | } catch { 39 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to update badge: \(error, privacy: .public)") 40 | } 41 | } 42 | } 43 | .background( 44 | VisualEffectView(.systemThickMaterial) 45 | .cornerRadius(10) 46 | .shadow(radius: 5) 47 | ) 48 | VStack(spacing: 0) { 49 | SecondaryOverlayButton(icon: .recenter) { 50 | Task { 51 | await self.mapState.recenter(position: self.$mapCameraPosition) 52 | } 53 | } 54 | } 55 | .background( 56 | VisualEffectView(.systemThickMaterial) 57 | .cornerRadius(10) 58 | .shadow(radius: 5) 59 | ) 60 | } 61 | } 62 | 63 | init(mapCameraPosition: Binding) { 64 | self._mapCameraPosition = mapCameraPosition 65 | } 66 | 67 | } 68 | 69 | @available(iOS 17, *) 70 | #Preview { 71 | SecondaryOverlay(mapCameraPosition: .constant(MapCameraPositionWrapper(MapConstants.defaultCameraPosition))) 72 | .environmentObject(MapState.shared) 73 | .environmentObject(ViewState.shared) 74 | } 75 | -------------------------------------------------------------------------------- /iOS/Views/SecondaryOverlayButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecondaryOverlayButton.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SecondaryOverlayButton: View { 11 | 12 | let icon: SFSymbol 13 | 14 | let sheetType: ShuttleTrackerSheetPresentationProvider.SheetType? 15 | 16 | let action: (() -> Void)? 17 | 18 | let badgeNumber: Int 19 | 20 | @EnvironmentObject 21 | private var viewState: ViewState 22 | 23 | @EnvironmentObject 24 | private var sheetStack: ShuttleTrackerSheetStack 25 | 26 | var body: some View { 27 | Button { 28 | if let sheetType = self.sheetType { 29 | self.sheetStack.push(sheetType) 30 | } else { 31 | self.action?() 32 | } 33 | } label: { 34 | Group { 35 | Image(systemName: self.icon.systemName) 36 | .resizable() 37 | .aspectRatio(1, contentMode: .fit) 38 | .opacity(0.5) 39 | .frame(width: 20) 40 | .symbolVariant(.fill) 41 | } 42 | .frame(width: 45, height: 45) 43 | .overlay { 44 | if self.badgeNumber > 0 { 45 | ZStack { 46 | Circle() 47 | .foregroundColor(.red) 48 | Text("\(self.badgeNumber)") 49 | .foregroundColor(.white) 50 | .font(.caption) 51 | .dynamicTypeSize(...DynamicTypeSize.accessibility1) 52 | } 53 | .frame(width: 20, height: 20) 54 | .offset(x: 20, y: -20) 55 | } 56 | } 57 | } 58 | .tint(.primary) 59 | } 60 | 61 | init(icon: SFSymbol, sheetType: ShuttleTrackerSheetPresentationProvider.SheetType, badgeNumber: Int = 0) { 62 | self.icon = icon 63 | self.sheetType = sheetType 64 | self.action = nil 65 | self.badgeNumber = badgeNumber 66 | } 67 | 68 | init(icon: SFSymbol, badgeNumber: Int = 0, _ action: @escaping () -> Void) { 69 | self.icon = icon 70 | self.sheetType = nil 71 | self.action = action 72 | self.badgeNumber = badgeNumber 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /iOS/Views/SettingsSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsSheet.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 10/7/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsSheet: View { 11 | 12 | var body: some View { 13 | NavigationView { 14 | SettingsView() 15 | .navigationTitle("Settings") 16 | .toolbar { 17 | ToolbarItem { 18 | CloseButton() 19 | } 20 | } 21 | } 22 | } 23 | 24 | } 25 | 26 | #Preview { 27 | SettingsSheet() 28 | } 29 | -------------------------------------------------------------------------------- /iOS/Views/WhatsNewSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhatsNewSheet.swift 3 | // Shuttle Tracker (iOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 3/19/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WhatsNewSheet: View { 11 | 12 | let onboarding: Bool 13 | 14 | var body: some View { 15 | NavigationView { 16 | if #available(iOS 16, *) { 17 | WhatsNewView(onboarding: self.onboarding) 18 | .toolbar(.hidden, for: .navigationBar) 19 | } else { 20 | WhatsNewView(onboarding: self.onboarding) 21 | .navigationBarHidden(true) 22 | } 23 | } 24 | } 25 | 26 | } 27 | 28 | #Preview { 29 | WhatsNewSheet(onboarding: false) 30 | .environmentObject(ViewState.shared) 31 | .environmentObject(ShuttleTrackerSheetStack()) 32 | } 33 | -------------------------------------------------------------------------------- /iOS/iOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | production 7 | com.apple.developer.associated-domains 8 | 9 | appclips:shuttletracker.app 10 | appclips:web.shuttletracker.app 11 | 12 | com.apple.developer.usernotifications.time-sensitive 13 | 14 | com.apple.security.app-sandbox 15 | 16 | com.apple.security.network.client 17 | 18 | com.apple.security.personal-information.location 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /macOS/Delegates/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Shuttle Tracker (macOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 2/22/22. 6 | // 7 | 8 | import AppKit 9 | import STLogging 10 | import UserNotifications 11 | 12 | @MainActor 13 | final class AppDelegate: NSObject, NSApplicationDelegate { 14 | 15 | func applicationDidFinishLaunching(_ notification: Notification) { 16 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did finish launching") 17 | UNUserNotificationCenter.current().delegate = UserNotificationCenterDelegate.default 18 | NSApplication.shared.registerForRemoteNotifications() 19 | } 20 | 21 | func application(_: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 22 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did register for remote notifications with device token \(deviceToken, privacy: .public)") 23 | let tokenString = deviceToken 24 | .map { (byte) in 25 | return String(format: "%02.2hhx", byte) 26 | } 27 | .joined() 28 | Task { 29 | do { 30 | try await API.uploadAPNSToken(token: tokenString).perform() 31 | } catch { 32 | #log(system: Logging.system, category: .api, level: .error, doUpload: true, "Failed to upload APNS device token: \(error, privacy: .public)") 33 | } 34 | } 35 | } 36 | 37 | func application(_: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { 38 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did fail to register for remote notifications with error \(error, privacy: .public)") 39 | #log(system: Logging.system, category: .apns, level: .error, doUpload: true, "Failed to register for remote notifications: \(error, privacy: .public)") 40 | } 41 | 42 | func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { 43 | #log(system: Logging.system, category: .appDelegate, level: .info, "Should terminate after last window closed") 44 | return true 45 | } 46 | 47 | func application(_: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) { 48 | #log(system: Logging.system, category: .appDelegate, level: .info, "Did receive remote notification \(userInfo, privacy: .public)") 49 | Task { 50 | await UNUserNotificationCenter.handleNotification(userInfo: userInfo) 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSApplicationCategoryType 24 | public.app-category.navigation 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSAppTransportSecurity 28 | 29 | NSLocationUsageDescription 30 | Shuttle Tracker shows where you are on the map. 31 | 32 | 33 | -------------------------------------------------------------------------------- /macOS/Views/LegacyMapView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LegacyMapView.swift 3 | // Shuttle Tracker (macOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/20/20. 6 | // 7 | 8 | import MapKit 9 | import SwiftUI 10 | 11 | struct LegacyMapView: NSViewRepresentable { 12 | 13 | @Binding 14 | private var position: MapCameraPositionWrapper 15 | 16 | @EnvironmentObject 17 | private var mapState: MapState 18 | 19 | init(position: Binding) { 20 | self._position = position 21 | } 22 | 23 | func makeNSView(context: Context) -> MKMapView { 24 | let nsView = MKMapView(frame: .zero) 25 | Task { 26 | MapState.mapView = nsView 27 | await self.mapState.refreshAll() 28 | await self.mapState.recenter(position: self.$position) 29 | } 30 | nsView.delegate = context.coordinator 31 | nsView.showsUserLocation = true 32 | nsView.showsCompass = true 33 | if #available(macOS 13, *) { 34 | // Set a custom preferred map configuration 35 | let configuration = MKStandardMapConfiguration(emphasisStyle: .muted) 36 | configuration.pointOfInterestFilter = .excludingAll 37 | nsView.preferredConfiguration = configuration 38 | } 39 | return nsView 40 | } 41 | 42 | func updateNSView(_ nsView: MKMapView, context: Context) { 43 | nsView.delegate = context.coordinator 44 | Task { 45 | let buses = await self.mapState.buses 46 | let stops = await self.mapState.stops 47 | let routes = await self.mapState.routes 48 | await MainActor.run { 49 | let allRoutesOnMap = routes.allSatisfy { (route) in 50 | return nsView.overlays.contains { (overlay) in 51 | if let existingRoute = overlay as? Route, existingRoute == route { 52 | return true 53 | } 54 | return false 55 | } 56 | } 57 | nsView.removeAnnotations(nsView.annotations) 58 | nsView.addAnnotations(buses) 59 | nsView.addAnnotations(stops) 60 | if !allRoutesOnMap { 61 | nsView.removeOverlays(nsView.overlays) 62 | nsView.addOverlays(routes) 63 | } 64 | } 65 | } 66 | } 67 | 68 | func makeCoordinator() -> MapViewDelegate { 69 | return MapViewDelegate() 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /macOS/Views/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // Shuttle Tracker (macOS) 4 | // 5 | // Created by Gabriel Jacoby-Cooper on 9/21/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VisualEffectView: NSViewRepresentable { 11 | 12 | let material: NSVisualEffectView.Material 13 | 14 | let blendingMode: NSVisualEffectView.BlendingMode 15 | 16 | func makeNSView(context: Context) -> NSVisualEffectView { 17 | return NSVisualEffectView() 18 | } 19 | 20 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 21 | nsView.material = self.material 22 | nsView.blendingMode = self.blendingMode 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /macOS/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.aps-environment 6 | development 7 | com.apple.developer.usernotifications.time-sensitive 8 | 9 | com.apple.security.app-sandbox 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | com.apple.security.network.client 14 | 15 | com.apple.security.personal-information.location 16 | 17 | 18 | 19 | --------------------------------------------------------------------------------