Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS 18 compatible OBA Widget Extension #753

Merged
merged 13 commits into from
Nov 27, 2024
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion Apps/OneBusAway/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -105,3 +125,4 @@ include:
- path: OBAKitTests/project.yml
- path: OBAKitUITests/project.yml
- path: TodayView/project.yml
- path: OBAWidget/project.yml
1 change: 1 addition & 0 deletions Apps/Shared/app_shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ targets:
- target: OBAKitCore
- target: OBAKit
- target: TodayView
- target: OBAWidget
info:
properties:
CFBundleShortVersionString: "$(MARKETING_VERSION)"
Expand Down
10 changes: 9 additions & 1 deletion OBAKit/Bookmarks/BookmarksViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 0 additions & 2 deletions OBAKit/Controls/Buttons/TaskButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,13 @@ struct TaskButton<Label: View>: View {

await action()
progressViewTask?.cancel()

isDisabled = false
showProgressView = false
}
},
label: {
ZStack {
label().opacity(showProgressView ? 0 : 1)

if showProgressView {
ProgressView()
}
Expand Down
Binary file added OBAWidget/.DS_Store
Binary file not shown.
47 changes: 47 additions & 0 deletions OBAWidget/Components/RefreshButton.swift
Original file line number Diff line number Diff line change
@@ -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)

}
}

27 changes: 27 additions & 0 deletions OBAWidget/Entries/BookmarkEntry.swift
Original file line number Diff line number Diff line change
@@ -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)
}

}

19 changes: 19 additions & 0 deletions OBAWidget/Main/OBAAppIntents.swift
Original file line number Diff line number Diff line change
@@ -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("")



}

16 changes: 16 additions & 0 deletions OBAWidget/Main/OBAWidgetBundle.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
12 changes: 12 additions & 0 deletions OBAWidget/OBAWidget-Bridging-Header.h
Original file line number Diff line number Diff line change
@@ -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 */
67 changes: 67 additions & 0 deletions OBAWidget/Provider/BookmarkTimelineProvider.swift
Original file line number Diff line number Diff line change
@@ -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<BookmarkEntry> {
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)
}
}
90 changes: 90 additions & 0 deletions OBAWidget/Provider/WidgetDataProvider.swift
Original file line number Diff line number Diff line change
@@ -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]]()
}
Loading
Loading