Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added basic arrival view #110

Merged
merged 18 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading