Skip to content

Commit

Permalink
feat(widgets): prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
krystxf committed Dec 3, 2024
1 parent be0726d commit 5ca40b9
Show file tree
Hide file tree
Showing 16 changed files with 505 additions and 241 deletions.
10 changes: 10 additions & 0 deletions apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
2DD9D1792CF3B8A70037CB95 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD9D1782CF3B8A70037CB95 /* WidgetKit.framework */; };
2DD9D17B2CF3B8A70037CB95 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD9D17A2CF3B8A70037CB95 /* SwiftUI.framework */; };
2DD9D1862CF3B8A90037CB95 /* widgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2DD9D1762CF3B8A70037CB95 /* widgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
2DEE771C2CFF5CD000F24AAD /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 2DEE771B2CFF5CD000F24AAD /* Alamofire */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -128,6 +129,8 @@
"components/route-label-view/get-color-by-route-name.utils.swift",
"components/route-label-view/route-name.view.swift",
"components/route-label-view/route-type.enum.swift",
const/api.const.swift,
"types/api-types.swift",
utils/color.utils.swift,
);
target = 2DD9D1752CF3B8A70037CB95 /* widgetsExtension */;
Expand Down Expand Up @@ -198,6 +201,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2DEE771C2CFF5CD000F24AAD /* Alamofire in Frameworks */,
2DD9D17B2CF3B8A70037CB95 /* SwiftUI.framework in Frameworks */,
2DD9D1792CF3B8A70037CB95 /* WidgetKit.framework in Frameworks */,
);
Expand Down Expand Up @@ -333,6 +337,7 @@
);
name = widgetsExtension;
packageProductDependencies = (
2DEE771B2CFF5CD000F24AAD /* Alamofire */,
);
productName = widgetsExtension;
productReference = 2DD9D1762CF3B8A70037CB95 /* widgetsExtension.appex */;
Expand Down Expand Up @@ -893,6 +898,11 @@
package = 2D87C85D2CE8BACA00209DE6 /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire;
};
2DEE771B2CFF5CD000F24AAD /* Alamofire */ = {
isa = XCSwiftPackageProductDependency;
package = 2D87C85D2CE8BACA00209DE6 /* XCRemoteSwiftPackageReference "Alamofire" */;
productName = Alamofire;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 2D001BA02CC8099B00C6B4F8 /* Project object */;
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/metro-now/metro-now/metro_nowApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
// https://github.com/krystxf/metro-now

import SwiftUI
import WidgetKit

@main
struct metro_nowApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
WidgetCenter.shared.reloadAllTimelines()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ struct SettingsAboutPageView: View {
.frame(maxWidth: .infinity, alignment: .center)
Text("""
The app is still in development. Stay tuned to see what's next!
""")
""")
.multilineTextAlignment(.center)
}

}

if let appStoreUrl {
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/metro-now/widgets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSWidgetWantsLocation</key>
<true/>
<key>NSLocationUsageDescription</key>
<string>Show the closest metro stop</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
Expand Down
40 changes: 40 additions & 0 deletions apps/mobile/metro-now/widgets/departures/DeparturesWidget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// metro-now
// https://github.com/krystxf/metro-now

import CoreLocation
import SwiftUI
import WidgetKit

struct DeparturesWidget: Widget {
let kind: String = "Widgets"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: DeparturesWidgetTimelineProvider()) { entry in
if #available(iOS 17.0, *) {
DeparturesWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} else {
DeparturesWidgetView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("Metro Departures")
.description("Show the closest metro stop dynamically.")
.supportedFamilies([.systemLarge])
}
}

#Preview("large", as: .systemLarge) {
DeparturesWidget()
} timeline: {
DeparturesWidgetTimelineEntry(
date: .now,
closestStop: "Muzeum",
departures: [
"Route A to Station 1 at 12:45 PM",
"Route B to Station 2 at 12:50 PM",
],
location: CLLocation(latitude: 50.08, longitude: 14.43)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// metro-now
// https://github.com/krystxf/metro-now

import Alamofire
import CoreLocation

class DeparturesWidgetManager: NSObject, ObservableObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
@Published var location: CLLocation?
@Published var metroStops: [ApiStop]?
@Published var closestMetroStop: ApiStop?
@Published var nearestDepartures: [ApiDeparture] = []

override init() {
super.init()
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
fetchMetroStops()
}

func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
self.location = location
updateClosestMetroStop()
fetchDeparturesForClosestStop()
}

private func fetchMetroStops() {
let request = AF.request(
"\(API_URL)/v1/stop/all",
method: .get,
parameters: ["metroOnly": "true"]
)

request.validate().responseDecodable(of: [ApiStop].self) { response in
switch response.result {
case let .success(stops):
DispatchQueue.main.async {
self.metroStops = stops
self.updateClosestMetroStop()
}
case let .failure(error):
print("Failed to fetch metro stops: \(error)")
}
}
}

private func updateClosestMetroStop() {
guard let location, let metroStops else { return }
closestMetroStop = metroStops.min(by: {
let distance1 = location.distance(from: CLLocation(latitude: $0.avgLatitude, longitude: $0.avgLongitude))
let distance2 = location.distance(from: CLLocation(latitude: $1.avgLatitude, longitude: $1.avgLongitude))
return distance1 < distance2
})
}

private func fetchDeparturesForClosestStop() {
guard let closestStop = closestMetroStop else { return }

// Collecting stop and platform ids for the request
let stopIds = closestStop.id
let platformIds = closestStop.platforms.map(\.id)

// Construct the API request URL with array-like syntax for `stop[]` and `platform[]`
let stopQuery = stopIds.map { "stop[]=\($0)" }.joined(separator: "&")
let platformQuery = platformIds.map { "platform[]=\($0)" }.joined(separator: "&")

let url = "\(API_URL)/v2/departure?\(stopQuery)&\(platformQuery)&limit=4&minutesBefore=1&minutesAfter=\(3 * 60)"

// Print the full API request URL for debugging
print("API Request URL for Departures: \(url)")

// API request to fetch departures
let request = AF.request(url, method: .get)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

request.validate().responseDecodable(of: [ApiDeparture].self, decoder: decoder) { response in
switch response.result {
case let .success(departures):
DispatchQueue.main.async {
self.nearestDepartures = departures
}
case let .failure(error):
print("Failed to fetch departures: \(error)")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// metro-now
// https://github.com/krystxf/metro-now

import CoreLocation
import WidgetKit

struct DeparturesWidgetTimelineEntry: TimelineEntry {
let date: Date
let closestStop: String
let departures: [String]
let location: CLLocation?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// metro-now
// https://github.com/krystxf/metro-now

import WidgetKit

struct DeparturesWidgetTimelineProvider: TimelineProvider {
private let stopManager = DeparturesWidgetManager()

func placeholder(in _: Context) -> DeparturesWidgetTimelineEntry {
DeparturesWidgetTimelineEntry(date: Date(), closestStop: "Loading...", departures: [], location: nil)
}

func getSnapshot(in _: Context, completion: @escaping (DeparturesWidgetTimelineEntry) -> Void) {
let entry = DeparturesWidgetTimelineEntry(date: Date(), closestStop: "Loading...", departures: [], location: nil)
completion(entry)
}

func getTimeline(in _: Context, completion: @escaping (Timeline<DeparturesWidgetTimelineEntry>) -> Void) {
// Wait for data to be fetched
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak stopManager] in
guard let stopManager,
let closestStop = stopManager.closestMetroStop,
let location = stopManager.location
else {
let entry = DeparturesWidgetTimelineEntry(date: Date(), closestStop: "Unknown", departures: [], location: nil)
let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
return
}

let departures = stopManager.nearestDepartures.map { departure in
"\(departure.route) to \(departure.headsign) at \(formattedDepartureTime(departure.departure.predicted))"
}

// Create a timeline entry
let entry = DeparturesWidgetTimelineEntry(
date: Date(),
closestStop: closestStop.name,
departures: departures,
location: location
)

let timeline = Timeline(entries: [entry], policy: .atEnd)
completion(timeline)
}
}

private func formattedDepartureTime(_ departureDate: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: departureDate)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// metro-now
// https://github.com/krystxf/metro-now

import SwiftUI

struct DeparturesWidgetView: View {
var entry: DeparturesWidgetTimelineProvider.Entry
@Environment(\.widgetFamily) var widgetFamily

var body: some View {
VStack(alignment: .leading) {
Text("Closest Metro Stop:")
.font(.headline)
Text(entry.closestStop)
.font(.title2)
.fontWeight(.bold)

if let location = entry.location {
Text("Current Location:")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Lat: \(location.coordinate.latitude, specifier: "%.4f"), Lon: \(location.coordinate.longitude, specifier: "%.4f")")
.font(.footnote)
} else {
Text("Location not available")
.font(.subheadline)
.foregroundColor(.red)
}

Divider()

Text("Next Departures:")
.font(.subheadline)
.foregroundColor(.secondary)

ForEach(entry.departures, id: \.self) { departure in
Text(departure)
.font(.footnote)
}

Spacer()

Text("Last refreshed: \(entry.date, style: .time)")
.foregroundStyle(.tertiary)
.font(.footnote)
}
.padding()
}
}
35 changes: 35 additions & 0 deletions apps/mobile/metro-now/widgets/frequency/FrequencyWidget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// metro-now
// https://github.com/krystxf/metro-now

import SwiftUI
import WidgetKit

struct FrequencyWidget: Widget {
let kind: String = "Widgets"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FrequencyWidgetTimelineProvider()) { entry in
if #available(iOS 17.0, *) {
FrequencyWidgetView(entry: entry)
.containerBackground(.background, for: .widget)
} else {
FrequencyWidgetView(entry: entry)
.padding()
.background()
}
}
.configurationDisplayName("Metro Frequency")
.description("Show the frequency of metro departures.")
.supportedFamilies([.systemSmall])
}
}

#Preview("One metro line", as: .systemSmall) {
FrequencyWidget()
} timeline: {
FrequencyWidgetTimelineEntry(
date: .now,
stopName: "Muzeum",
frequency: 2 * 60 + 50
)
}
Loading

0 comments on commit 5ca40b9

Please sign in to comment.