├── .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 |
--------------------------------------------------------------------------------