diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..ebf1e4e7c Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 8cd61890c..53064502c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,12 @@ DerivedData/ Apps/**/Info.plist Apps/**/*.entitlements TodayView/**/*.entitlements +OBAWidget/**/*.entitlements OBAKit/Info.plist OBAKitCore/Info.plist OBAKitTests/Info.plist TodayView/Info.plist +OBAWidget/Info.plist /project.yml ## SwiftPM diff --git a/Apps/OneBusAway/project.yml b/Apps/OneBusAway/project.yml index d60eb16da..c5aaa918a 100644 --- a/Apps/OneBusAway/project.yml +++ b/Apps/OneBusAway/project.yml @@ -94,7 +94,27 @@ targets: base: DEVELOPMENT_TEAM: 4ZQCMA634J PRODUCT_BUNDLE_IDENTIFIER: org.onebusaway.iphone.TodayView - + OBAWidget: + sources: ["Apps/OneBusAway/Assets.xcassets"] + entitlements: + properties: + com.apple.security.application-groups: + - group.org.onebusaway.iphone + info: + properties: + CFBundleDisplayName: OneBusAway + OBAKitConfig: + AppGroup: group.org.onebusaway.iphone + BundledRegionsFileName: regions.json + DeepLinkServerBaseAddress: https://onebusaway.co + ExtensionURLScheme: onebusaway + RESTServerAPIKey: org.onebusaway.iphone + RegionsServerBaseAddress: https://regions.onebusaway.org + RegionsServerAPIPath: /regions-v3.json + settings: + base: + DEVELOPMENT_TEAM: 4ZQCMA634J + PRODUCT_BUNDLE_IDENTIFIER: org.onebusaway.iphone.OBAWidget include: - path: Apps/Shared/app_shared.yml - path: Apps/Shared/analytics_fx_config.yml @@ -105,3 +125,4 @@ include: - path: OBAKitTests/project.yml - path: OBAKitUITests/project.yml - path: TodayView/project.yml + - path: OBAWidget/project.yml diff --git a/Apps/Shared/app_shared.yml b/Apps/Shared/app_shared.yml index 41a41122f..d2990927a 100644 --- a/Apps/Shared/app_shared.yml +++ b/Apps/Shared/app_shared.yml @@ -29,6 +29,7 @@ targets: - target: OBAKitCore - target: OBAKit - target: TodayView + - target: OBAWidget info: properties: CFBundleShortVersionString: "$(MARKETING_VERSION)" diff --git a/OBAKit/Bookmarks/BookmarksViewController.swift b/OBAKit/Bookmarks/BookmarksViewController.swift index a84691548..dd07a68c8 100644 --- a/OBAKit/Bookmarks/BookmarksViewController.swift +++ b/OBAKit/Bookmarks/BookmarksViewController.swift @@ -10,6 +10,7 @@ import UIKit import CoreLocation import OBAKitCore +import WidgetKit /// The view controller that powers the Bookmarks tab of the app. @objc(OBABookmarksViewController) @@ -157,12 +158,19 @@ public class BookmarksViewController: UIViewController, let sortMenu = UIMenu(title: Strings.sort, options: .displayInline, children: [groupSortAction, distanceSortAction]) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "MORE", image: UIImage(systemName: "arrow.up.arrow.down.circle"), menu: sortMenu) } - + + // MARK: Refresh Widget + func reloadWidget() { + print("Reloading the widget") + WidgetCenter.shared.reloadTimelines(ofKind: "OBAWidget") + } + // MARK: - Refresh Control @objc private func refreshControlPulled() { dataLoader.loadData() refreshControl.beginRefreshing() + reloadWidget() Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in guard let self = self else { return } diff --git a/OBAKit/Controls/Buttons/TaskButton.swift b/OBAKit/Controls/Buttons/TaskButton.swift index fbd52fb0c..af1fce17b 100644 --- a/OBAKit/Controls/Buttons/TaskButton.swift +++ b/OBAKit/Controls/Buttons/TaskButton.swift @@ -39,7 +39,6 @@ struct TaskButton: View { await action() progressViewTask?.cancel() - isDisabled = false showProgressView = false } @@ -47,7 +46,6 @@ struct TaskButton: View { label: { ZStack { label().opacity(showProgressView ? 0 : 1) - if showProgressView { ProgressView() } diff --git a/OBAWidget/.DS_Store b/OBAWidget/.DS_Store new file mode 100644 index 000000000..d630dd680 Binary files /dev/null and b/OBAWidget/.DS_Store differ diff --git a/OBAWidget/Components/RefreshButton.swift b/OBAWidget/Components/RefreshButton.swift new file mode 100644 index 000000000..e17290cb2 --- /dev/null +++ b/OBAWidget/Components/RefreshButton.swift @@ -0,0 +1,47 @@ +// +// RefreshButton.swift +// OBAWidget +// +// Created by Manu on 2024-10-18. +// + +import SwiftUI +import AppIntents +import WidgetKit + +// MARK: - RefreshWidgetIntent +/// this intent serves as a way to refresh the widget and its timelines. +struct RefreshWidgetIntent: AppIntent { + static var title: LocalizedStringResource = "Refresh Widget" + + func perform() async throws -> some IntentResult { + WidgetCenter.shared.reloadAllTimelines() + return .result() + } +} + + +/// A button to manually trigger the widget refresh. +struct RefreshButton: View { + var body: some View { + + Button(intent: RefreshWidgetIntent()) { + HStack(spacing: 2){ + Image(systemName: "arrow.trianglehead.clockwise") + .imageScale(.small) + .foregroundStyle(.white) + + Text("Refresh") + .font(.system(size: 12)) + .foregroundColor(.white) + } + .padding(.horizontal,6) + .padding(.vertical, 4) + .background(Color(.brand)) + .cornerRadius(8) + } + .buttonStyle(.plain) + + } +} + diff --git a/OBAWidget/Entries/BookmarkEntry.swift b/OBAWidget/Entries/BookmarkEntry.swift new file mode 100644 index 000000000..b74eb3391 --- /dev/null +++ b/OBAWidget/Entries/BookmarkEntry.swift @@ -0,0 +1,27 @@ +// +// BookmarkEntry.swift +// OBAWidget +// +// Created by Manu on 2024-10-18. +// + +import OBAKitCore +import WidgetKit + +/// A struct representing a timeline entry for bookmarks in a widget. +/// +/// for displaying bookmarks in the widget context. +struct BookmarkEntry: TimelineEntry { + + let date: Date + + /// bookmarks associated with this `BookmarkEntry`. + let bookmarks: [Bookmark] + + /// Returns a formatted string representing the last updated time. + public func lastUpdatedAt(with formatters: Formatters) -> String { + bookmarks.isEmpty ? "--" : formatters.timeFormatter.string(from: date) + } + +} + diff --git a/OBAWidget/Main/OBAAppIntents.swift b/OBAWidget/Main/OBAAppIntents.swift new file mode 100644 index 000000000..6a991c33f --- /dev/null +++ b/OBAWidget/Main/OBAAppIntents.swift @@ -0,0 +1,19 @@ +// +// AppIntents.swift +// OBAWidget +// +// Created by Manu on 2024-10-12. +// + +import WidgetKit +import AppIntents +import OBAKitCore + +struct ConfigurationAppIntent: AppIntent, WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Bookmarks" + static var description: IntentDescription = IntentDescription("") + + + +} + diff --git a/OBAWidget/Main/OBAWidgetBundle.swift b/OBAWidget/Main/OBAWidgetBundle.swift new file mode 100644 index 000000000..3e845e07f --- /dev/null +++ b/OBAWidget/Main/OBAWidgetBundle.swift @@ -0,0 +1,16 @@ +// +// OBAWidgetBundle.swift +// OBAWidget +// +// Created by Manu on 2024-10-12. +// + +import Foundation +import SwiftUI + +@main +struct CBWidgetBundle: WidgetBundle { + var body: some Widget { + OBAWidget() + } +} diff --git a/OBAWidget/OBAWidget-Bridging-Header.h b/OBAWidget/OBAWidget-Bridging-Header.h new file mode 100644 index 000000000..378a09155 --- /dev/null +++ b/OBAWidget/OBAWidget-Bridging-Header.h @@ -0,0 +1,12 @@ +// +// OBAWidget-Bridging-Header.h +// OBAWidget +// +// Created by Manu on 2024-10-12. +// + +#ifndef OBAWidget_Bridging_Header_h +#define OBAWidget_Bridging_Header_h + + +#endif /* OBAWidget_Bridging_Header_h */ diff --git a/OBAWidget/Provider/BookmarkTimelineProvider.swift b/OBAWidget/Provider/BookmarkTimelineProvider.swift new file mode 100644 index 000000000..14492498e --- /dev/null +++ b/OBAWidget/Provider/BookmarkTimelineProvider.swift @@ -0,0 +1,67 @@ +// +// BookmarkProvider.swift +// OBAWidget +// +// Created by Manu on 2024-10-14. +// + +import Foundation +import WidgetKit + +/// Timeline provider for generating widget updates based on bookmark data. +struct BookmarkTimelineProvider: AppIntentTimelineProvider { + + let dataProvider: WidgetDataProvider + + init(dataProvider: WidgetDataProvider) { + self.dataProvider = dataProvider + } + + // MARK: Placeholder + func placeholder(in context: Context) -> BookmarkEntry { + BookmarkEntry(date: .now, bookmarks: []) + } + + // MARK: Snapshot + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> BookmarkEntry { + + await dataProvider.loadData() + let data = dataProvider.getBookmarks() + + let entry = BookmarkEntry(date: .now, bookmarks: data) + + return entry + } + + // MARK: Actual Timelines + /// Generates timeline entries for the next 6 hours, starting from the current time. + /// + /// - **Current Time**: Let's say it's 12:00 PM. + /// - **End Time**: 6 hours later, resulting in 6:00 PM. + /// - **Entry Interval**: Creates entries every 30 minutes. + /// - **Generated Entries**: + /// - 12:00 PM + /// - 12:30 PM + /// - 1:00 PM + /// - 1:30 PM + /// - so on ...... + func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { + await dataProvider.loadData() + let data = dataProvider.getBookmarks() + + let currentDate = Date() + let endDate = Calendar.current.date(byAdding: .hour, value: 6, to: currentDate)! + + // Generate entries for every 30 minutes within the defined time range. + var entries: [BookmarkEntry] = [] + var date = currentDate + while date < endDate { + let entry = BookmarkEntry(date: date, bookmarks: data) + entries.append(entry) + date = Calendar.current.date(byAdding: .minute, value: 30, to: date)! + } + + + return Timeline(entries: entries, policy: .atEnd) + } +} diff --git a/OBAWidget/Provider/WidgetDataProvider.swift b/OBAWidget/Provider/WidgetDataProvider.swift new file mode 100644 index 000000000..3c5c8d221 --- /dev/null +++ b/OBAWidget/Provider/WidgetDataProvider.swift @@ -0,0 +1,90 @@ +// WidgetDataProvider.swift +// OBAWidget +// +// Created by Manu on 2024-10-15. +// + +import Foundation +import OBAKitCore +import CoreLocation + +/// `WidgetDataProvider` is responsible for fetching and providing relevant data to the widget timeline provider. +class WidgetDataProvider: NSObject, ObservableObject { + + public let formatters = Formatters( + locale: Locale.autoupdatingCurrent, + calendar: Calendar.autoupdatingCurrent, + themeColors: ThemeColors.shared + ) + + static let shared = WidgetDataProvider() + private let userDefaults = UserDefaults(suiteName: Bundle.main.appGroup!)! + + private lazy var locationManager = CLLocationManager() + private lazy var locationService = LocationService( + userDefaults: userDefaults, + locationManager: locationManager + ) + + private lazy var app: CoreApplication = { + let bundledRegions = Bundle.main.path(forResource: "regions", ofType: "json")! + let config = CoreAppConfig(appBundle: Bundle.main, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegions) + return CoreApplication(config: config) + }() + + private var bestAvailableBookmarks: [Bookmark] { + var bookmarks = app.userDataStore.favoritedBookmarks + if bookmarks.isEmpty { + bookmarks = app.userDataStore.bookmarks + } + return bookmarks + } + + /// Loads arrivals and departures for all favorited bookmarks for the widget. + public func loadData() async { + guard let apiService = app.apiService else { return } + + let bookmarks = getBookmarks() + .filter { $0.isTripBookmark && $0.regionIdentifier == app.regionsService.currentRegion?.id } + + for bookmark in bookmarks { + await fetchArrivalData(for: bookmark, apiService: apiService) + } + } + + /// Fetch arrival data for a specific bookmark and update the dictionary. + private func fetchArrivalData(for bookmark: Bookmark, apiService: RESTAPIService) async { + do { + let stopArrivals = try await apiService.getArrivalsAndDeparturesForStop( + id: bookmark.stopID, + minutesBefore: 0, + minutesAfter: 60 + ).entry + + await MainActor.run { + let keysAndDeps = stopArrivals.arrivalsAndDepartures.tripKeyGroupedElements + for (key, deps) in keysAndDeps { + self.arrDepDic[key] = deps + } + } + } catch { + Logger + .error( + "Error fetching data for bookmark \(bookmark.name) with bookmark id: \(bookmark.id): \(error)" + ) + } + } + + /// Looks up arrival and departure data for a given trip key. + public func lookupArrivalDeparture(with key: TripBookmarkKey) -> [ArrivalDeparture] { + return arrDepDic[key, default: []] + } + + /// Retrieves the best available bookmarks. + public func getBookmarks() -> [Bookmark] { + return bestAvailableBookmarks + } + + /// Dictionary to store arrival and departure data grouped by trip keys. + private var arrDepDic = [TripBookmarkKey: [ArrivalDeparture]]() +} diff --git a/OBAWidget/Resources/regions.json b/OBAWidget/Resources/regions.json new file mode 100644 index 000000000..fc17a7652 --- /dev/null +++ b/OBAWidget/Resources/regions.json @@ -0,0 +1,610 @@ +{ + "code": 200, + "text": "OK", + "version": 3, + "data": { + "list": [ + { + "siriBaseUrl": "https://tampa.onebusaway.org/onebusaway-api-webapp/siri/", + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "onebusaway@gohart.org", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://api.tampa.onebusaway.org/api/", + "id": 0, + "regionName": "Tampa Bay", + "obaVersionInfo": "1.1.11-SNAPSHOT|1|1|11|SNAPSHOT|6950d86123a7a9e5f12065bcbec0c516f35d86d9", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": true, + "otpContactEmail": "otp-tampa@onebusaway.org", + "otpBaseUrl": "https://otp.prod.obahart.org/otp/", + "paymentWarningBody": null, + "supportsSiriRealtimeApis": true, + "twitterUrl": "https://mobile.twitter.com/OBA_tampa", + "paymentAndroidAppId": "co.bytemark.hart", + "paymentiOSAppStoreIdentifier": "1140553099", + "paymentiOSAppUrlScheme": "fb313213768708402HART", + "active": true, + "open311Servers": [ + { + "juridisctionId": null, + "apiKey": "937033cad3054ec58a1a8156dcdd6ad8a416af2f", + "baseUrl": "https://seeclickfix.com/open311/v2/" + } + ], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 27.976910500000002, + "latSpan": 0.5424609999999994, + "lon": -82.445851, + "lonSpan": 0.576357999999999 + }, + { + "lat": 27.919249999999998, + "latSpan": 0.47208000000000183, + "lon": -82.652145, + "lonSpan": 0.3967700000000036 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": "https://pugetsound.onebusaway.org/onebusaway-api-webapp/siri/", + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "onebusaway@soundtransit.org", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://api.pugetsound.onebusaway.org/", + "id": 1, + "regionName": "Puget Sound", + "obaVersionInfo": "1.1.7|1|1|7||c8ee3d4906dd55ecafdd124f31f39c0f54a37b52", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": "otp-pugetsound@onebusaway.org", + "otpBaseUrl": "https://tpng.api.soundtransit.org/tripplanner/st/", + "paymentWarningBody": "The mobile fare payment app for Puget Sound does not support all transit service shown in OneBusAway. Please check that a ticket is eligible for your agency and route before you purchase!", + "supportsSiriRealtimeApis": true, + "twitterUrl": "https://mobile.twitter.com/oba_pugetsound", + "paymentAndroidAppId": "co.bytemark.tgt", + "paymentiOSAppStoreIdentifier": "1131345078", + "paymentiOSAppUrlScheme": "co.bytemark.tgt", + "active": true, + "open311Servers": [], + "paymentWarningTitle": "Check before you buy!", + "language": "en_US", + "bounds": [ + { + "lat": 47.221315, + "latSpan": 0.33704, + "lon": -122.4051325, + "lonSpan": 0.440483 + }, + { + "lat": 47.5607395, + "latSpan": 0.743251, + "lon": -122.1462785, + "lonSpan": 0.720901 + }, + { + "lat": 47.556288, + "latSpan": 0.090694, + "lon": -122.4013255, + "lonSpan": 0.126793 + }, + { + "lat": 47.093563, + "latSpan": 0.320892, + "lon": -122.701637, + "lonSpan": 0.55098 + }, + { + "lat": 47.5346090123, + "latSpan": 0.889378024643, + "lon": -122.3294835, + "lonSpan": 0.621109 + }, + { + "lat": 47.9747595, + "latSpan": 1.336481, + "lon": -122.8512, + "lonSpan": 1.0904 + }, + { + "lat": 47.6204755, + "latSpan": 0.014397, + "lon": -122.335392, + "lonSpan": 0.00635600000001 + }, + { + "lat": 47.64585, + "latSpan": 0.0669, + "lon": -122.2963, + "lonSpan": 0.0802 + }, + { + "lat": 47.9347358907, + "latSpan": 0.68796117128, + "lon": -121.993246104, + "lonSpan": 0.784555996061 + } + ], + "facebookUrl": "https://www.facebook.com/pages/OneBusAway/216091804930", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": "https://bustime.mta.info/api/", + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": false, + "contactEmail": "MTABusTime@mtahq.org", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://bustime.mta.info/", + "id": 2, + "regionName": "MTA New York", + "obaVersionInfo": "", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": true, + "twitterUrl": "https://mobile.twitter.com/nyctbusstop", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 40.707678, + "latSpan": 0.4093900000000019, + "lon": -74.01768100000001, + "lonSpan": 0.4686659999999989 + }, + { + "lat": 40.8192825, + "latSpan": 0.228707, + "lon": -73.89908199999999, + "lonSpan": 0.23146799999999246 + } + ], + "facebookUrl": "https://www.facebook.com/pages/MTA-New-York-City-Transit/232635164606", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "onebusaway@atlantaregional.com", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://atlanta.onebusaway.org/api/", + "id": 3, + "regionName": "Atlanta", + "obaVersionInfo": "1.1.14-SNAPSHOT|1|1|14|SNAPSHOT|440e5cb692a1ed195de7f0686d69f5ceecbe9a41", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": "onebusaway@atlantaregional.com", + "otpBaseUrl": "https://opentrip.atlantaregion.com/otp", + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "https://mobile.twitter.com/OBA_atlanta", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": false, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 33.7901797681045, + "latSpan": 0.002537628406997783, + "lon": -84.39483216212469, + "lonSpan": 0.016058977126604645 + }, + { + "lat": 33.84859251766493, + "latSpan": 0.006806584025866869, + "lon": -84.36189486914657, + "lonSpan": 0.035245473959491846 + }, + { + "lat": 34.22444908498577, + "latSpan": 0.06626826841780087, + "lon": -84.48419886031866, + "lonSpan": 0.051677923063323306 + }, + { + "lat": 33.78784752284655, + "latSpan": 0.026695928046905237, + "lon": -84.31082746240949, + "lonSpan": 0.028927748099008 + }, + { + "lat": 33.8079649176, + "latSpan": 0.8443565223999983, + "lon": -84.34070523855, + "lonSpan": 0.8666740198999889 + }, + { + "lat": 33.7842061834795, + "latSpan": 0.030916824823002287, + "lon": -84.36385552465549, + "lonSpan": 0.08416295068897739 + }, + { + "lat": 33.8105225, + "latSpan": 0.5874290000000002, + "lon": -84.37966800000001, + "lonSpan": 0.5820399999999921 + } + ], + "facebookUrl": "https://www.facebook.com/pages/ObaAtlanta/136662306506627", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": "https://buseta.wmata.com/onebusaway-api-webapp/siri/", + "supportsEmbeddedSocial": false, + "supportsObaRealtimeApis": true, + "contactEmail": "feedback@wmata.com", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://buseta.wmata.com/onebusaway-api-webapp/", + "id": 4, + "regionName": "Washington, D.C.", + "obaVersionInfo": "", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": true, + "twitterUrl": "https://mobile.twitter.com/Metrobusinfo", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 38.895092500000004, + "latSpan": 0.5927969999999974, + "lon": -77.059196, + "lonSpan": 0.7805199999999957 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "transitinfo@york.ca", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://oba.yrt.ca/", + "id": 5, + "regionName": "York", + "obaVersionInfo": "1.1.7|1|1|7||c8ee3d4906dd55ecafdd124f31f39c0f54a37b52", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "https://mobile.twitter.com/YRTViva", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_CA", + "bounds": [ + { + "lat": 44.0248945, + "latSpan": 0.6089630000000028, + "lon": -79.43752, + "lonSpan": 0.49329600000000084 + } + ], + "facebookUrl": "https://www.facebook.com/198178906967045", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": false, + "supportsObaRealtimeApis": true, + "contactEmail": "bear-transit@v-a.io", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://bt.v-a.io/onebusaway/", + "id": 6, + "regionName": "Bear Transit (beta)", + "obaVersionInfo": "1.1.7|1|1|7|d3bbb9109a652359845bdee516dc2cbd1ba35e49", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "https://mobile.twitter.com/CalParking", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": false, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 37.8917275, + "latSpan": 0.0492929999999987, + "lon": -122.28957750000001, + "lonSpan": 0.10181500000000199 + } + ], + "facebookUrl": "https://www.facebook.com/pages/Bear-Transit/109669175726418", + "stopInfoUrl": null, + "experimental": true + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": false, + "supportsObaRealtimeApis": true, + "contactEmail": "sbrown@camsys.com", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://app.dev.mbta.obaweb.org/onebusaway-api-webapp/", + "id": 7, + "regionName": "Boston (beta)", + "obaVersionInfo": "", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 42.367244, + "latSpan": 0.014910000000000423, + "lon": -71.023894, + "lonSpan": 0.011874000000005935 + }, + { + "lat": 42.1893185, + "latSpan": 1.2170369999999977, + "lon": -71.210252, + "lonSpan": 1.1692720000000065 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": true + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": false, + "supportsObaRealtimeApis": true, + "contactEmail": "obasupport@octo3.fi", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://194.89.230.196:8080/", + "id": 8, + "regionName": "Lappeenranta (beta)", + "obaVersionInfo": "1.1.13|1|1|13||ef9f836500eafee955381b17799b3105b525e93b", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "fi_FI", + "bounds": [ + { + "lat": 61.05999415916385, + "latSpan": 0.07303466470629871, + "lon": 28.197898391353952, + "lonSpan": 0.2674852336517013 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": true + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "info@rvtd.org", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://oba.rvtd.org/onebusaway-api-webapp/", + "id": 9, + "regionName": "Rogue Valley, Oregon", + "obaVersionInfo": "", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 42.309802394046, + "latSpan": 0.2679752119080021, + "lon": -122.82026350000001, + "lonSpan": 0.2974530000000044 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "jespejo@sanjoaquinrtd.com", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://www.obartd.com/onebusaway-api-webapp/", + "id": 10, + "regionName": "San Joaquin RTD (beta)", + "obaVersionInfo": "1.1.12-SNAPSHOT|1|1|12|SNAPSHOT|", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": false, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 37.9337345, + "latSpan": 0.39840300000000184, + "lon": -121.3456095, + "lonSpan": 0.4220969999999937 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": true + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": true, + "supportsObaRealtimeApis": true, + "contactEmail": "customerfeedback@sdmts.com", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://realtime.sdmts.com/api/", + "id": 11, + "regionName": "San Diego", + "obaVersionInfo": "1.1.14|1|1|14|73aea0d ", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": "customerfeedback@sdmts.com", + "otpBaseUrl": "http://realtime.sdmts.com:9090/otp", + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "", + "paymentAndroidAppId": "org.sdmts.riderapp", + "paymentiOSAppStoreIdentifier": "1212568295", + "paymentiOSAppUrlScheme": "org.sdmts.riderapp.compassmobile.payments", + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 32.731591, + "latSpan": 0.0011640000000028294, + "lon": -117.1896335, + "lonSpan": 0.027426999999988766 + }, + { + "lat": 33.0727675231135, + "latSpan": 0.7105120739810005, + "lon": -117.2316382462345, + "lonSpan": 0.7237252935990028 + }, + { + "lat": 32.8998529712665, + "latSpan": 0.7140676589169956, + "lon": -116.73142875280399, + "lonSpan": 1.093941754416008 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": false + }, + { + "siriBaseUrl": null, + "supportsEmbeddedSocial": false, + "supportsObaRealtimeApis": true, + "contactEmail": "oba4spokanetransit@gmail.com", + "enrollParticipantsInStudy": true, + "obaBaseUrl": "https://www.oba4spokane.com/api/", + "id": 12, + "regionName": "Spokane", + "obaVersionInfo": "2.0.0-SNAPSHOT|2|0|0|SNAPSHOT|", + "travelBehaviorDataCollectionEnabled": true, + "supportsObaDiscoveryApis": true, + "supportsOtpBikeshare": false, + "otpContactEmail": null, + "otpBaseUrl": null, + "paymentWarningBody": null, + "supportsSiriRealtimeApis": false, + "twitterUrl": "", + "paymentAndroidAppId": null, + "paymentiOSAppStoreIdentifier": null, + "paymentiOSAppUrlScheme": null, + "active": true, + "open311Servers": [], + "paymentWarningTitle": null, + "language": "en_US", + "bounds": [ + { + "lat": 47.622654, + "latSpan": 0.29196599999999506, + "lon": -117.397625, + "lonSpan": 0.6162599999999969 + } + ], + "facebookUrl": "", + "stopInfoUrl": null, + "experimental": false + } + ] + } +} \ No newline at end of file diff --git a/OBAWidget/Utils/Localization.swift b/OBAWidget/Utils/Localization.swift new file mode 100644 index 000000000..5a3e23484 --- /dev/null +++ b/OBAWidget/Utils/Localization.swift @@ -0,0 +1,14 @@ +// +// Localization.swift +// OBAWidget +// +// Created by Manu on 2024-10-16. +// + +import Foundation + +fileprivate class OBAWidgetLocalization: NSObject {} + +internal func OBALoc(_ key: String, value: String, comment: String) -> String { + return NSLocalizedString(key, tableName: nil, bundle: Bundle(for: OBAWidgetLocalization.self), value: value, comment: comment) +} diff --git a/OBAWidget/Utils/LocalizationKeys.swift b/OBAWidget/Utils/LocalizationKeys.swift new file mode 100644 index 000000000..ac016c928 --- /dev/null +++ b/OBAWidget/Utils/LocalizationKeys.swift @@ -0,0 +1,24 @@ +// +// LocalizationKeys.swift +// OBAWidget +// +// Created by Manu on 2024-10-23. +// + +// MARK: - LocalizationKeys Enum + +internal enum LocalizationKeys { + + static let tapForMoreInformation = OBALoc("today_screen.tap_for_more_information", + value: "Tap for more information", + comment: "Tap for more information subheading on Today view") + + static let noDeparturesInNextNMinutes = OBALoc("today_view.no_departures_in_next_n_minutes_fmt", + value: "No departures in the next %@ minutes", + comment: "") + + static let emptyStateString = OBALoc("today_screen.no_data_description", + value: "Add bookmarks to Today View Bookmarks to see them here.", + comment: "") + +} diff --git a/OBAWidget/Views/DepartureTimeBadgeView.swift b/OBAWidget/Views/DepartureTimeBadgeView.swift new file mode 100644 index 000000000..a296c52ae --- /dev/null +++ b/OBAWidget/Views/DepartureTimeBadgeView.swift @@ -0,0 +1,75 @@ +// +// DepartureTimeBadgeView.swift +// OBAWidget +// +// Created by Manu on 2024-10-14. +// + +import SwiftUI +import WidgetKit +import OBAKitCore + + +struct DepartureTimeBadgeView: View { + + let arrivalDeparture: ArrivalDeparture + let formatters: Formatters + + + private var displayText: String { + formatters.shortFormattedTime( + untilMinutes: arrivalDeparture.arrivalDepartureMinutes, + temporalState: arrivalDeparture.temporalState + ) + } + + + private var accessibilityLabel: String { + formatters.explanationForArrivalDeparture( + tempuraState: arrivalDeparture.temporalState, + arrivalDepartureStatus: arrivalDeparture.arrivalDepartureStatus, + arrivalDepartureMinutes: arrivalDeparture.arrivalDepartureMinutes + ) + } + + private var backgroundColor: Color { + Color(formatters.backgroundColorForScheduleStatus(arrivalDeparture.scheduleStatus)) + + } + + + var body: some View { + VStack{ + Text("\(displayText)") + .badgeStyle( + backgroundColor: backgroundColor, + accessibilityLabel: accessibilityLabel + ) + } + + } +} + +extension View { + func badgeStyle(backgroundColor: Color, accessibilityLabel: String) -> some View { + self.modifier(BadgeStyle(backgroundColor: backgroundColor, accessibilityLabel: accessibilityLabel)) + } +} + +struct BadgeStyle: ViewModifier { + let backgroundColor: Color + let accessibilityLabel: String + func body(content: Content) -> some View { + content + .font(.system(size: 13)) + .padding(.horizontal, 3) + .padding(.vertical, 4) + .frame(width: 40, height: 25) + .background(backgroundColor) + .foregroundColor(.white) + .cornerRadius(8) + .accessibilityLabel(accessibilityLabel) + .lineLimit(1) + .minimumScaleFactor(0.8) + } +} diff --git a/OBAWidget/Views/WidgetRowView.swift b/OBAWidget/Views/WidgetRowView.swift new file mode 100644 index 000000000..1f3679c3c --- /dev/null +++ b/OBAWidget/Views/WidgetRowView.swift @@ -0,0 +1,101 @@ +// +// WidgetMediumView.swift +// OBAWidget +// +// Created by Manu on 2024-10-16. +// + +import SwiftUI +import WidgetKit +import OBAKitCore + +// MARK: - Constants +private enum Constants { + static let minutes: UInt = 60 + static let maxDeparturesToShow = 3 + static let rowWidth: CGFloat = 180 + static let fontSize: CGFloat = 13 +} + + +// MARK: - WidgetRowView +struct WidgetRowView: View { + let bookmark: Bookmark? + let formatters: Formatters + let departures: [ArrivalDeparture]? + + private var bookmarkTitle: String { + bookmark?.name ?? " " + } + + private var nextDepartureLabel: String { + if departures != nil { + return updateNextDepartureLabel() + } else { + return LocalizationKeys.tapForMoreInformation + } + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(bookmarkTitle) + .font(.system(size: Constants.fontSize, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + + Text(nextDepartureLabel) + .font(.system(size: Constants.fontSize)) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + // if the badge is hidden take up the full width otherwise use constant + .frame(maxWidth: departures?.isEmpty == false ? Constants.rowWidth : .infinity, alignment: .leading) + + Spacer() + + if departures?.isEmpty == false { + departureTimeBadges + } + } + } + + // MARK: - Departure Time Badges + + private var departureTimeBadges: some View { + HStack(spacing: 5) { + ForEach(departures?.prefix(Constants.maxDeparturesToShow) ?? [], id: \.self) { departure in + DepartureTimeBadgeView( + arrivalDeparture: departure, + formatters: formatters + ) + } + } + } + + // MARK: - Helper Functions + + private func updateNextDepartureLabel() -> String { + guard let departures = departures else { + return LocalizationKeys.tapForMoreInformation + } + + if let firstDeparture = departures.first { + return formatters.formattedScheduleDeviation(for: firstDeparture) + } else { + return String(format: LocalizationKeys.noDeparturesInNextNMinutes, String(Constants.minutes)) + } + } +} + +// MARK: - Preview +struct Widget_Previews: PreviewProvider { + static var previews: some View { + WidgetRowView(bookmark: nil, + formatters: WidgetDataProvider.shared.formatters, + departures: []) + .containerBackground(.ultraThinMaterial.quaternary, for: .widget) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + } +} diff --git a/OBAWidget/Widgets/OBAWidget.swift b/OBAWidget/Widgets/OBAWidget.swift new file mode 100644 index 000000000..bb0cfb3f4 --- /dev/null +++ b/OBAWidget/Widgets/OBAWidget.swift @@ -0,0 +1,34 @@ +// +// OBAWidget.swift +// OBAWidget +// +// Created by Manu on 2024-10-15. +// + +import Foundation +import WidgetKit +import SwiftUI + +struct OBAWidget: Widget { + let kind: String = "OBAWidget" + let dataProvider = WidgetDataProvider.shared + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + provider: BookmarkTimelineProvider(dataProvider: dataProvider) + ) { entry in + OBAWidgetEntryView(entry: entry, dataProvider: dataProvider) + .containerBackground(.fill.quaternary, for: .widget) + } + .supportedFamilies([.systemMedium, .systemLarge]) + + } +} + +#Preview(as: .systemMedium) { + OBAWidget() +} timeline: { + BookmarkEntry(date: .now, bookmarks: []) + BookmarkEntry(date: .distantFuture, bookmarks: []) +} diff --git a/OBAWidget/Widgets/OBAWidgetEntryView.swift b/OBAWidget/Widgets/OBAWidgetEntryView.swift new file mode 100644 index 000000000..bad4294cd --- /dev/null +++ b/OBAWidget/Widgets/OBAWidgetEntryView.swift @@ -0,0 +1,92 @@ +// +// OBAWidgetEntryView.swift +// OBAWidgetEntryView +// +// Created by Manu on 2024-10-12. +// + +import OBAKitCore +import SwiftUI +import WidgetKit + +struct OBAWidgetEntryView: View { + + var entry: BookmarkTimelineProvider.Entry + + let dataProvider: WidgetDataProvider + + @Environment(\.widgetFamily) var widgetFamily + + private var maxBookmarkCount: Int { + widgetFamily == .systemLarge ? 7 : 2 + } + + var body: some View { + VStack(alignment: .leading) { + // MARK: Header View + HStack { + Text( + "Last updated at: \(entry.lastUpdatedAt(with: dataProvider.formatters))" + ) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fontWeight(.medium) + + Spacer() + RefreshButton().invalidatableContent() + } + .padding(.bottom, 5) + + // MARK: Bookmark Row View + if !entry.bookmarks.isEmpty { + VStack(spacing: 10) { + ForEach(entry.bookmarks.prefix(maxBookmarkCount), id: \.self) { bookmark in + Link(destination: constructDeepLink(for: bookmark)) { + WidgetRowView( + bookmark: bookmark, + formatters: dataProvider.formatters, + departures: loadArrivalDeparture(with: bookmark) + ) + } + } + } + } else { + emptyStateView + .multilineTextAlignment(.center) + } + + Spacer() + } + } + + + // MARK: Helper functions + private func loadArrivalDeparture(with bookmark: Bookmark) -> [ArrivalDeparture]? { + TripBookmarkKey(bookmark: bookmark).flatMap { + dataProvider.lookupArrivalDeparture(with: $0) + } + } + + private func constructDeepLink(for bookmark: Bookmark) -> URL { + let router = URLSchemeRouter(scheme: Bundle.main.extensionURLScheme!) + return router.encodeViewStop(stopID: bookmark.stopID, regionID: bookmark.regionIdentifier) + } + + + // MARK: Empty state view + private var emptyStateView: some View { + VStack { + Spacer() + Text(LocalizationKeys.emptyStateString) + Spacer() + } + .frame(maxWidth: .infinity) + } +} + +#Preview(as: .systemMedium) { + OBAWidget() +} timeline: { + BookmarkEntry(date: .now, bookmarks: []) + BookmarkEntry(date: .distantFuture, bookmarks: []) +} diff --git a/OBAWidget/project.yml b/OBAWidget/project.yml new file mode 100644 index 000000000..1e9c04d7e --- /dev/null +++ b/OBAWidget/project.yml @@ -0,0 +1,17 @@ +targets: + OBAWidget: + type: app-extension + platform: iOS + sources: ["."] + dependencies: + - target: OBAKitCore + - sdk: SwiftUI.framework + - sdk: WidgetKit.framework + entitlements: + path: OBAWidget.entitlements + info: + path: Info.plist + properties: + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension +