Skip to content

Commit

Permalink
feat(widgets): frequency widget
Browse files Browse the repository at this point in the history
  • Loading branch information
krystxf committed Dec 3, 2024
1 parent 16e2812 commit 17b1a38
Show file tree
Hide file tree
Showing 17 changed files with 562 additions and 249 deletions.
26 changes: 18 additions & 8 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 @@ -605,7 +610,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.5;
MARKETING_VERSION = 0.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
Expand Down Expand Up @@ -637,7 +642,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.5;
MARKETING_VERSION = 0.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
Expand Down Expand Up @@ -675,7 +680,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.5;
MARKETING_VERSION = 0.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -710,7 +715,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.5;
MARKETING_VERSION = 0.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down Expand Up @@ -768,7 +773,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = R6WU5ABNG2;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = widgets/Info.plist;
Expand All @@ -780,7 +785,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.3.4;
MARKETING_VERSION = 0.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand All @@ -797,7 +802,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = R6WU5ABNG2;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = widgets/Info.plist;
Expand All @@ -809,7 +814,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.3.4;
MARKETING_VERSION = 0.3.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.widgets";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ struct SettingsChangelogPageView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
Divider()
ChangelogItem(
version: "v0.3.6",
changes: [
"frequency widget",
]
)
Divider()
ChangelogItem(
version: "v0.3.5",
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)
}
}
Loading

0 comments on commit 17b1a38

Please sign in to comment.