Skip to content

Commit

Permalink
iOS 18 compatible OBA Widget Extension (#753)
Browse files Browse the repository at this point in the history
Creates an iOS 18-compatible WidgetKit-based extension
  • Loading branch information
manu-r12 authored Nov 27, 2024
1 parent f12ca78 commit 6cdb5cb
Show file tree
Hide file tree
Showing 22 changed files with 1,279 additions and 4 deletions.
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

0 comments on commit 6cdb5cb

Please sign in to comment.