diff --git a/apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj b/apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj index 74739536..358796a5 100644 --- a/apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj +++ b/apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj @@ -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 */ @@ -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 */; @@ -198,6 +201,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2DEE771C2CFF5CD000F24AAD /* Alamofire in Frameworks */, 2DD9D17B2CF3B8A70037CB95 /* SwiftUI.framework in Frameworks */, 2DD9D1792CF3B8A70037CB95 /* WidgetKit.framework in Frameworks */, ); @@ -333,6 +337,7 @@ ); name = widgetsExtension; packageProductDependencies = ( + 2DEE771B2CFF5CD000F24AAD /* Alamofire */, ); productName = widgetsExtension; productReference = 2DD9D1762CF3B8A70037CB95 /* widgetsExtension.appex */; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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 */; diff --git a/apps/mobile/metro-now/metro-now/metro_nowApp.swift b/apps/mobile/metro-now/metro-now/metro_nowApp.swift index 26076c8d..c819f0b7 100644 --- a/apps/mobile/metro-now/metro-now/metro_nowApp.swift +++ b/apps/mobile/metro-now/metro-now/metro_nowApp.swift @@ -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() + } } } } diff --git a/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-about.swift b/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-about.swift index 106a389f..0189a38b 100644 --- a/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-about.swift +++ b/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-about.swift @@ -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 { diff --git a/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-changelog.swift b/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-changelog.swift index 70bcc4a5..4ade0edf 100644 --- a/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-changelog.swift +++ b/apps/mobile/metro-now/metro-now/pages/settings/subpages/settings-changelog.swift @@ -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", diff --git a/apps/mobile/metro-now/widgets/Info.plist b/apps/mobile/metro-now/widgets/Info.plist index 0f118fb7..d987ab47 100644 --- a/apps/mobile/metro-now/widgets/Info.plist +++ b/apps/mobile/metro-now/widgets/Info.plist @@ -2,6 +2,10 @@ + NSWidgetWantsLocation + + NSLocationUsageDescription + Show the closest metro stop NSExtension NSExtensionPointIdentifier diff --git a/apps/mobile/metro-now/widgets/departures/DeparturesWidget.swift b/apps/mobile/metro-now/widgets/departures/DeparturesWidget.swift new file mode 100644 index 00000000..4fefa922 --- /dev/null +++ b/apps/mobile/metro-now/widgets/departures/DeparturesWidget.swift @@ -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) + ) +} diff --git a/apps/mobile/metro-now/widgets/departures/DeparturesWidgetManager.swift b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetManager.swift new file mode 100644 index 00000000..7c4ebd16 --- /dev/null +++ b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetManager.swift @@ -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)") + } + } + } +} diff --git a/apps/mobile/metro-now/widgets/departures/DeparturesWidgetTimelineEntry.swift b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetTimelineEntry.swift new file mode 100644 index 00000000..19d857a0 --- /dev/null +++ b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetTimelineEntry.swift @@ -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? +} diff --git a/apps/mobile/metro-now/widgets/departures/DeparturesWidgetTimelineProvider.swift b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetTimelineProvider.swift new file mode 100644 index 00000000..c5b6ad7c --- /dev/null +++ b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetTimelineProvider.swift @@ -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) -> 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) + } +} diff --git a/apps/mobile/metro-now/widgets/departures/DeparturesWidgetView.swift b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetView.swift new file mode 100644 index 00000000..5602e725 --- /dev/null +++ b/apps/mobile/metro-now/widgets/departures/DeparturesWidgetView.swift @@ -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() + } +} diff --git a/apps/mobile/metro-now/widgets/frequency/FrequencyWidget.swift b/apps/mobile/metro-now/widgets/frequency/FrequencyWidget.swift new file mode 100644 index 00000000..3adb8453 --- /dev/null +++ b/apps/mobile/metro-now/widgets/frequency/FrequencyWidget.swift @@ -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 + ) +} diff --git a/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetManager.swift b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetManager.swift new file mode 100644 index 00000000..2b2c7c90 --- /dev/null +++ b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetManager.swift @@ -0,0 +1,83 @@ +// metro-now +// https://github.com/krystxf/metro-now + +import Alamofire +import CoreLocation + +class FrequencyWidgetManager: 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 } + + let platformIds = closestStop.platforms.map(\.id) + + // Construct the API request URL with array-like syntax for `stop[]` and `platform[]` + let platformQuery = platformIds.map { "platform[]=\($0)" }.joined(separator: "&") + + // API request to fetch departures + let request = AF.request("\(API_URL)/v2/departure?\(platformQuery)&limit=\(4)&minutesBefore=0&minutesAfter=\(12 * 60)", 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)") + } + } + } +} diff --git a/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetTimelineEntry.swift b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetTimelineEntry.swift new file mode 100644 index 00000000..738b2ab6 --- /dev/null +++ b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetTimelineEntry.swift @@ -0,0 +1,10 @@ +// metro-now +// https://github.com/krystxf/metro-now + +import WidgetKit + +struct FrequencyWidgetTimelineEntry: TimelineEntry { + let date: Date + let stopName: String + let frequency: TimeInterval // in seconds +} diff --git a/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetTimelineProvider.swift b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetTimelineProvider.swift new file mode 100644 index 00000000..63bb26b5 --- /dev/null +++ b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetTimelineProvider.swift @@ -0,0 +1,111 @@ +// metro-now +// https://github.com/krystxf/metro-now + +import WidgetKit + +struct FrequencyWidgetTimelineProvider: TimelineProvider { + private let stopManager = FrequencyWidgetManager() + + func placeholder(in _: Context) -> FrequencyWidgetTimelineEntry { + FrequencyWidgetTimelineEntry(date: Date(), stopName: "Muzeum", frequency: 2 * 60) + } + + func getSnapshot(in _: Context, completion: @escaping (FrequencyWidgetTimelineEntry) -> Void) { + let entry = FrequencyWidgetTimelineEntry( + date: Date(), + stopName: "Muzeum", + frequency: 2 * 60 + ) + + completion(entry) + } + + func getTimeline( + in _: Context, + completion: @escaping (Timeline) -> Void + ) { + // Wait for data to be fetched + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak stopManager] in + guard let stopManager, + let closestStop = stopManager.closestMetroStop + else { + let entry = FrequencyWidgetTimelineEntry( + date: Date(), + stopName: "Loading", + frequency: 0 + ) + + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + return + } + + let closestStopMetroPlatforms = closestStop.platforms.filter(\.isMetro) + let metroDepartures = stopManager.nearestDepartures.filter { departure in + closestStopMetroPlatforms.contains { platform in + departure.platformId == platform.id + } + } + + // If there are fewer than two departures, return early with a default entry + guard metroDepartures.count > 1 else { + let entry = FrequencyWidgetTimelineEntry( + date: Date(), + stopName: closestStop.name, + frequency: 0 + ) + + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + return + } + + // Group departures by platformId + let groupedDepartures = Dictionary(grouping: metroDepartures, by: { $0.platformId }) + + // Calculate average frequency per platform + var platformFrequencies: [String: Double] = [:] + + for (platformId, platformDepartures) in groupedDepartures { + // Sort departures for the current platform by predicted time + let sortedDepartures = platformDepartures.sorted { + $0.departure.predicted < $1.departure.predicted + } + + // Exclude the nearest departure (first departure in sorted order) + let departuresToConsider = sortedDepartures.dropFirst() + + // Calculate the average frequency for this platform + let intervals = zip(departuresToConsider, departuresToConsider.dropFirst()).map { + $1.departure.predicted.timeIntervalSince($0.departure.predicted) + } + let averageFrequency = intervals.isEmpty ? 0 : intervals.reduce(0, +) / Double(intervals.count) + + platformFrequencies[platformId] = averageFrequency + } + + // Combine frequencies to calculate the overall average frequency across all platforms + let averageFrequencyAcrossPlatforms = platformFrequencies.values.reduce(0, +) / Double(platformFrequencies.count) + + // Create timeline entries for each departure with the calculated average frequency + var entries: [FrequencyWidgetTimelineEntry] = [] + + for departure in metroDepartures { + let frequency = averageFrequencyAcrossPlatforms + let entry = FrequencyWidgetTimelineEntry( + date: departure.departure.predicted, + stopName: closestStop.name, + frequency: frequency + ) + entries.append(entry) + } + + print("entries") + print(entries.count) + + // Finalize the timeline with all entries + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } + } +} diff --git a/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetView.swift b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetView.swift new file mode 100644 index 00000000..23b15d99 --- /dev/null +++ b/apps/mobile/metro-now/widgets/frequency/FrequencyWidgetView.swift @@ -0,0 +1,41 @@ +// metro-now +// https://github.com/krystxf/metro-now + +import SwiftUI + +struct FrequencyWidgetView: View { + private var entry: FrequencyWidgetTimelineProvider.Entry + private let formatter: DateComponentsFormatter + + init(entry: FrequencyWidgetTimelineProvider.Entry) { + self.entry = entry + formatter = DateComponentsFormatter() + + formatter.unitsStyle = .brief + formatter.allowedUnits = [.hour, .minute, .second] + formatter.maximumUnitCount = 2 + } + + var body: some View { + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { + Text(entry.stopName) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + VStack(spacing: 6) { + Text("Every") + Text( + formatter.string(from: entry.frequency)! + ) + .font(.title3) + .fontWeight(.bold) + } + .padding(.vertical) + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + } +} diff --git a/apps/mobile/metro-now/widgets/widgets.swift b/apps/mobile/metro-now/widgets/widgets.swift deleted file mode 100644 index 9c77edac..00000000 --- a/apps/mobile/metro-now/widgets/widgets.swift +++ /dev/null @@ -1,232 +0,0 @@ -// metro-now -// https://github.com/krystxf/metro-now - -import SwiftUI -import WidgetKit - -struct Provider: TimelineProvider { - func placeholder(in _: Context) -> SimpleEntry { - SimpleEntry(date: Date()) - } - - func getSnapshot(in _: Context, completion: @escaping (SimpleEntry) -> Void) { - let entry = SimpleEntry(date: Date()) - completion(entry) - } - - func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { - var entries: [SimpleEntry] = [] - - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate) - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } -} - -struct SimpleEntry: TimelineEntry { - let date: Date -} - - - - - -struct LargeWidgetListItemView: View { - let routeName: String - - let headsign: String - let departure: Date - - let nextHeadsing: String? - let nextDeparture: Date? - - var body: some View { - HStack{ - RouteNameIconView( - label: routeName, - background: getRouteColor(routeName) - ) - - VStack{ - HStack{ - Text(headsign) - Spacer() - Text(departure,style: .offset) - .fontDesign(.monospaced) - .multilineTextAlignment(.trailing) - } - - - if let nextHeadsing, let nextDeparture { - - HStack{ - Text(headsign == nextHeadsing ? "Also in" : nextHeadsing) - Spacer() - Text(nextDeparture,style: .offset) - .fontDesign(.monospaced) - .multilineTextAlignment(.trailing) - - } - .font(.caption) - } - } - } - } -} - -struct WidgetsEntryPlaceholderView : View { - var entry: Provider.Entry - - var body: some View { - Text("Muzeum") - .font(.headline) - - VStack{ - LargeWidgetListItemView( - routeName: "a", - headsign: "D. Hostivar", - departure: .now, - nextHeadsing: "Skalka", - nextDeparture: .now - ) - Divider() - LargeWidgetListItemView( - routeName: "a", - headsign: "N. Motol",departure: .now, - nextHeadsing: "N. Motol", - nextDeparture: .now - ) - Divider() - LargeWidgetListItemView( - routeName: "c", - headsign: "Letnany",departure: .now, - nextHeadsing: "Letnany", - nextDeparture: .now - ) - Divider() - LargeWidgetListItemView( - routeName: "c", - headsign: "Haje",departure: .now, - nextHeadsing: "Kacerov", - nextDeparture: .now - ) - Divider() - Spacer() - - Text("Last refreshed: \(entry.date, style: .time)") - .foregroundStyle(.tertiary) - .font(.footnote) - } - - - } -} - -struct LargeWidgetView : View { - var entry: Provider.Entry - - var body: some View { - Text("Muzeum") - .font(.headline) - - VStack{ - LargeWidgetListItemView( - routeName: "a", - headsign: "D. Hostivar", - departure: .now, - nextHeadsing: "Skalka", - nextDeparture: .now - ) - Divider() - LargeWidgetListItemView( - routeName: "a", - headsign: "N. Motol",departure: .now, - nextHeadsing: "N. Motol", - nextDeparture: .now - ) - Divider() - LargeWidgetListItemView( - routeName: "c", - headsign: "Letnany",departure: .now, - nextHeadsing: "Letnany", - nextDeparture: .now - ) - Divider() - LargeWidgetListItemView( - routeName: "c", - headsign: "Haje",departure: .now, - nextHeadsing: "Kacerov", - nextDeparture: .now - ) - Divider() - Spacer() - - Text("Last refreshed: \(entry.date, style: .time)") - .foregroundStyle(.tertiary) - .font(.footnote) - } - } -} - -struct widgetsEntryView: View { - var entry: Provider.Entry - @Environment(\.widgetFamily) var widgetFamily - - var body: some View { - switch widgetFamily { - case .systemLarge, .systemExtraLarge: - LargeWidgetView(entry: entry) - default: - LargeWidgetView(entry: entry) - } - } -} - -struct widgets: Widget { - let kind: String = "widgets" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - if #available(iOS 17.0, *) { - widgetsEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } else { - widgetsEntryView(entry: entry) - .padding() - .background() - } - } - .configurationDisplayName("Metro Departures") - .description("Show metro departures from selected stop.") - .supportedFamilies([ - .systemSmall, - .systemMedium, - .systemLarge, - .systemExtraLarge - ]) - } -} - -#Preview("small", as: .systemSmall) { - widgets() -} timeline: { - SimpleEntry(date: .now) -} - -#Preview("medium", as: .systemMedium) { - widgets() -} timeline: { - SimpleEntry(date: .now) -} - -#Preview("large", as: .systemLarge) { - widgets() -} timeline: { - SimpleEntry(date: .now) -} diff --git a/apps/mobile/metro-now/widgets/widgetsBundle.swift b/apps/mobile/metro-now/widgets/widgetsBundle.swift index 6bc7049c..1362fb7f 100644 --- a/apps/mobile/metro-now/widgets/widgetsBundle.swift +++ b/apps/mobile/metro-now/widgets/widgetsBundle.swift @@ -1,9 +1,5 @@ -// -// widgetsBundle.swift -// widgets -// -// Created by Kryštof Krátký on 24.11.2024. -// +// metro-now +// https://github.com/krystxf/metro-now import SwiftUI import WidgetKit @@ -11,6 +7,6 @@ import WidgetKit @main struct widgetsBundle: WidgetBundle { var body: some Widget { - widgets() + FrequencyWidget() } }