Skip to content

Commit

Permalink
Merge pull request #110 from stadiamaps/feat/ios-arrival-view
Browse files Browse the repository at this point in the history
feat: added basic arrival view
  • Loading branch information
ianthetechie authored Jun 14, 2024
2 parents bd5272e + baf9fad commit 32d8dcd
Show file tree
Hide file tree
Showing 19 changed files with 444 additions and 27 deletions.
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ if useLocalMapLibreSwiftUIDSL {

let package = Package(
name: "FerrostarCore",
defaultLocalization: "en",
platforms: [
.iOS(.v15),
],
Expand Down
2 changes: 1 addition & 1 deletion apple/DemoApp/Demo/DemoNavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ struct DemoNavigationView: View {
}
}
.padding()
.padding(.bottom, 32)
.padding(.bottom, 72)
.task {
await getRoutes()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import FerrostarCoreFFI
import Foundation

public extension TripProgress {
/// The estimated arrival date and time.
func estimatedArrival(from startingDate: Date = Date()) -> Date {
startingDate.addingTimeInterval(durationRemaining)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ public struct PortraitNavigationView: View {
)
}
})
.overlay(alignment: .bottom) {
if let progress = navigationState?.progress {
ArrivalView(progress: progress)
.padding(.horizontal, 16)
}
}
}
}
}
Expand Down
32 changes: 32 additions & 0 deletions apple/Sources/FerrostarSwiftUI/Library/DefaultFormatters.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation
import MapKit

/// A collection of arrival view formatters that work reasonably well for most applications.
public class DefaultFormatters {
/// An MKDistance formatter with abbreviated units for the arrival view.
///
/// E.g. 120 mi
public static var distanceFormatter: MKDistanceFormatter {
let formatter = MKDistanceFormatter()
formatter.unitStyle = .abbreviated
return formatter
}

/// A formatter for estimated time of arrival using the shortened style for the current locale.
///
/// E.g. `5:20 PM` or `17:20`
public static var estimatedArrivalFormat: Date.FormatStyle {
Date.FormatStyle(date: .omitted, time: .shortened)
}

/// A formatter for duration on the arrival view using (optional) hours and minutes.
///
/// E.g. `1h 20m`
public static var durationFormat: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute]
formatter.unitsStyle = .abbreviated
formatter.zeroFormattingBehavior = .dropAll
return formatter
}
}
42 changes: 42 additions & 0 deletions apple/Sources/FerrostarSwiftUI/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"sourceLanguage" : "en",
"strings" : {
"%@" : {

},
"Arrival" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Arrival"
}
}
}
},
"Distance" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Distance"
}
}
}
},
"Duration" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Duration"
}
}
}
}
},
"version" : "1.0"
}
40 changes: 40 additions & 0 deletions apple/Sources/FerrostarSwiftUI/Theme/ArrivalViewTheme.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import SwiftUI

public enum ArrivalViewStyle: Equatable {
/// The simplified/default which only shows actual values
case simplified

/// An expanded informational arrival view that labels each value.
case informational
}

public protocol ArrivalViewTheme: Equatable {
/// The style of the arrival view controls the general theme.
var style: ArrivalViewStyle { get }

/// The color for the measurement values (top row)
var measurementColor: Color { get }

/// The font for the measurement values (top row)
var measurementFont: Font { get }

/// The color for the secondary text.
var secondaryColor: Color { get }

/// The font for the secondary text.
var secondaryFont: Font { get }

/// The color of the background.
var backgroundColor: Color { get }
}

public struct DefaultArrivalViewTheme: ArrivalViewTheme {
public var style: ArrivalViewStyle = .simplified
public var measurementColor: Color = .primary
public var measurementFont: Font = .title2.bold()
public var secondaryColor: Color = .secondary
public var secondaryFont: Font = .subheadline
public var backgroundColor: Color = .init(.systemBackground)

public init() {}
}
138 changes: 138 additions & 0 deletions apple/Sources/FerrostarSwiftUI/Views/ArrivalView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import FerrostarCore
import FerrostarCoreFFI
import MapKit
import SwiftUI

public struct ArrivalView: View {
let progress: TripProgress
let distanceFormatter: Formatter
let estimatedArrivalFormatter: Date.FormatStyle
let durationFormatter: DateComponentsFormatter
let theme: any ArrivalViewTheme
let fromDate: Date

/// Initialize the ArrivalView
///
/// - Parameters:
/// - progress: The current Trip Progress providing durations and distances.
/// - distanceFormatter: The distance formatter to use when displaying the remaining trip distance.
/// - estimatedArrivalFormatter: The estimated time of arrival Date-Time formatter.
/// - durationFormatter: The duration remaining formatter.
/// - theme: The arrival view theme.
/// - fromDate: The date time to estimate arrival from, primarily for testing (default is now).
public init(
progress: TripProgress,
distanceFormatter: Formatter = DefaultFormatters.distanceFormatter,
estimatedArrivalFormatter: Date.FormatStyle = DefaultFormatters.estimatedArrivalFormat,
durationFormatter: DateComponentsFormatter = DefaultFormatters.durationFormat,
theme: any ArrivalViewTheme = DefaultArrivalViewTheme(),
fromDate: Date = Date()
) {
self.progress = progress
self.distanceFormatter = distanceFormatter
self.estimatedArrivalFormatter = estimatedArrivalFormatter
self.durationFormatter = durationFormatter
self.theme = theme
self.fromDate = fromDate
}

public var body: some View {
HStack {
VStack {
Text(estimatedArrivalFormatter.format(progress.estimatedArrival(from: fromDate)))
.font(theme.measurementFont)
.foregroundStyle(theme.measurementColor)
.multilineTextAlignment(.center)

if theme.style == .informational {
Text("Arrival", bundle: .module)
.font(theme.secondaryFont)
.foregroundStyle(theme.secondaryColor)
}
}

if let formattedDuration = durationFormatter.string(from: progress.durationRemaining) {
VStack {
Text(formattedDuration)
.font(theme.measurementFont)
.foregroundStyle(theme.measurementColor)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)

if theme.style == .informational {
Text("Duration", bundle: .module)
.font(theme.secondaryFont)
.foregroundStyle(theme.secondaryColor)
}
}
}

VStack {
Text(distanceFormatter.string(for: progress.distanceRemaining) ?? "")
.font(theme.measurementFont)
.foregroundStyle(theme.measurementColor)
.multilineTextAlignment(.center)

if theme.style == .informational {
Text("Distance", bundle: .module)
.font(theme.secondaryFont)
.foregroundStyle(theme.secondaryColor)
}
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
.background(theme.backgroundColor)
.clipShape(.rect(cornerRadius: 48))
.shadow(radius: 12)
}
}

#Preview {
var informationalTheme: any ArrivalViewTheme {
var theme = DefaultArrivalViewTheme()
theme.style = .informational
return theme
}

return VStack(spacing: 16) {
ArrivalView(
progress: TripProgress(
distanceToNextManeuver: 123,
distanceRemaining: 120,
durationRemaining: 150
)
)

ArrivalView(
progress: TripProgress(
distanceToNextManeuver: 123,
distanceRemaining: 14500,
durationRemaining: 1234
)
)

ArrivalView(
progress: TripProgress(
distanceToNextManeuver: 123,
distanceRemaining: 14500,
durationRemaining: 1234
),
theme: informationalTheme
)
.environment(\.locale, .init(identifier: "de_DE"))

ArrivalView(
progress: TripProgress(
distanceToNextManeuver: 5420,
distanceRemaining: 1_420_000,
durationRemaining: 520_800
),
theme: informationalTheme
)

Spacer()
}
.padding()
.background(Color.green)
}
4 changes: 2 additions & 2 deletions apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public struct InstructionsView: View {
/// - secondaryRowTheme: The theme for the secondary instruction.
public init(
visualInstruction: VisualInstruction,
distanceFormatter: Formatter = MKDistanceFormatter(),
distanceFormatter: Formatter = DefaultFormatters.distanceFormatter,
distanceToNextManeuver: CLLocationDistance? = nil,
primaryRowTheme: InstructionRowTheme = DefaultInstructionRowTheme(),
secondaryRowTheme: InstructionRowTheme = DefaultSecondaryInstructionRowTheme(),
Expand Down Expand Up @@ -94,7 +94,7 @@ public struct InstructionsView: View {

#Preview {
let germanFormatter = MKDistanceFormatter()
germanFormatter.locale = Locale(identifier: "de-DE")
germanFormatter.locale = Locale(identifier: "de_DE")
germanFormatter.units = .metric

return VStack {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import XCTest
@testable import FerrostarSwiftUI

final class DefaultFormatterTests: XCTestCase {
let referenceDate = Date(timeIntervalSince1970: 1_718_065_239)

// MARK: Distance Formatter

func testDistanceFormatter() {
let formatter = DefaultFormatters.distanceFormatter
XCTAssertEqual(formatter.string(fromDistance: 150), "500 ft")
}

func testDistanceFormatter_de_DE() {
let formatter = DefaultFormatters.distanceFormatter
formatter.locale = .init(identifier: "de_DE")
formatter.units = .metric
XCTAssertEqual(formatter.string(fromDistance: 150), "150 m")
}

// MARK: Estimated Time of Arrival (ETA) Formatter

func testEstimatedArrivalFormatter() {
var formatter = DefaultFormatters.estimatedArrivalFormat
formatter.timeZone = .init(secondsFromGMT: 0)!

XCTAssertEqual(referenceDate.formatted(formatter), "12:20 AM")
}

func testEstimatedArrivalFormatter_de_DE() {
var formatter = DefaultFormatters.estimatedArrivalFormat
.locale(.init(identifier: "de_DE"))
formatter.timeZone = .init(secondsFromGMT: 0)!

XCTAssertEqual(referenceDate.formatted(formatter), "0:20")
}

// MARK: Duration Formatters

func testDurationFormatter() {
let formatter = DefaultFormatters.durationFormat
let duration: TimeInterval = 1200.0
XCTAssertEqual(formatter.string(from: duration), "20m")
}

func testDurationFormatter_Long() {
let formatter = DefaultFormatters.durationFormat
let duration: TimeInterval = 120_000.0
XCTAssertEqual(formatter.string(from: duration), "33h 20m")
}
}
17 changes: 17 additions & 0 deletions apple/Tests/FerrostarSwiftUITests/Support/Formatters.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import FerrostarSwiftUI
import Foundation
import MapKit

Expand All @@ -16,3 +17,19 @@ var germanDistanceFormatter: Formatter = {

return formatter
}()

var longDurationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full

return formatter
}()

var germanArrivalFormatter: Date.FormatStyle = {
var formatter = DefaultFormatters.estimatedArrivalFormat
.locale(.init(identifier: "de_DE"))

formatter.timeZone = .init(secondsFromGMT: 0)!

return formatter
}()
Loading

0 comments on commit 32d8dcd

Please sign in to comment.