Show ranking capsules and value breakdown in station details
The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type. The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed. Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched. Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic. Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
This commit is contained in:
@@ -153,3 +153,101 @@ extension Color {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays its children left to right and wraps to a new line when the next one
|
||||
/// would overflow, so two or three capsules never squeeze or truncate each other
|
||||
/// on a narrow screen or at large Dynamic Type.
|
||||
///
|
||||
/// A flow layout rather than `ViewThatFits`: a fallback would stack the capsules
|
||||
/// vertically as soon as one did not fit, which reads as three separate labels
|
||||
/// rather than one row of rankings. This keeps them on a line while they fit and
|
||||
/// only moves the overflowing one down.
|
||||
///
|
||||
/// Children are measured with `.unspecified` width so a long label takes the width
|
||||
/// it needs (the badge sets `fixedSize` horizontally) instead of being clipped to
|
||||
/// the container.
|
||||
struct CapsuleFlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
var lineSpacing: CGFloat = 6
|
||||
|
||||
private struct Row {
|
||||
var range: Range<Int>
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
let rows = rows(maxWidth: maxWidth, subviews: subviews)
|
||||
let width = rows.map(\.width).max() ?? 0
|
||||
let height = rows.reduce(0) { $0 + $1.height }
|
||||
+ CGFloat(max(0, rows.count - 1)) * lineSpacing
|
||||
return CGSize(width: min(width, maxWidth), height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var y = bounds.minY
|
||||
for row in rows(maxWidth: bounds.width, subviews: subviews) {
|
||||
var x = bounds.minX
|
||||
for index in row.range {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y),
|
||||
anchor: .topLeading,
|
||||
proposal: ProposedViewSize(size)
|
||||
)
|
||||
x += size.width + spacing
|
||||
}
|
||||
y += row.height + lineSpacing
|
||||
}
|
||||
}
|
||||
|
||||
private func rows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
|
||||
var rows: [Row] = []
|
||||
var start = 0
|
||||
var used: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
for index in subviews.indices {
|
||||
let size = subviews[index].sizeThatFits(.unspecified)
|
||||
let needed = used == 0 ? size.width : used + spacing + size.width
|
||||
// `used > 0` guards the first child: a single capsule wider than the
|
||||
// container must still be placed (and may wrap its own text) rather
|
||||
// than starting an empty line above itself.
|
||||
if used > 0, needed > maxWidth {
|
||||
rows.append(Row(range: start..<index, width: used, height: height))
|
||||
start = index
|
||||
used = size.width
|
||||
height = size.height
|
||||
} else {
|
||||
used = needed
|
||||
height = max(height, size.height)
|
||||
}
|
||||
}
|
||||
if start < subviews.count {
|
||||
rows.append(Row(range: start..<subviews.count, width: used, height: height))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
/// The More info sheet's row of ranking capsules: every label the station
|
||||
/// qualifies for, wrapped when the screen is narrow.
|
||||
///
|
||||
/// Renders as the SAME `StationLabelBadge` the listing rows use — same tone, same
|
||||
/// symbol, same sentence-case copy — so a station carrying "Best value" here and
|
||||
/// on the Stations list cannot look like two different things.
|
||||
struct StationRankingCapsules: View {
|
||||
let kinds: [StationLabelKind]
|
||||
|
||||
var body: some View {
|
||||
CapsuleFlowLayout(spacing: 6, lineSpacing: 6) {
|
||||
ForEach(kinds, id: \.self) { kind in
|
||||
StationLabelBadge(kind: kind)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// One phrase per capsule, read in the order they are shown.
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,6 +830,10 @@ struct ContentView: View {
|
||||
fuelSavingsAmount: sessionFuelSavingsAmount,
|
||||
savingsBaselineStationName: scaffold.baseline?.name,
|
||||
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
|
||||
// The same "cheapest in range" test this view gives its
|
||||
// rows, so the sheet's Cheapest capsule matches the badge on
|
||||
// the list it was opened from.
|
||||
isCheapestInRange: station.id == topStationID,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
|
||||
)
|
||||
|
||||
@@ -261,6 +261,9 @@ struct FavouritesView: View {
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
// This tab's cheapest favourite — the station its rows badge,
|
||||
// and the one the Key calls "Top".
|
||||
isTopFavourite: station.id == cheapest?.id,
|
||||
// Every station listed here IS a favourite for the tab's
|
||||
// fuel, so the star starts filled; tapping it unstars.
|
||||
isFavourite: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "How Best Value works" explanation as its own sheet.
|
||||
/// The "How does FuelBoard calculate value?" explanation as its own sheet.
|
||||
///
|
||||
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
|
||||
/// now so the Key sheet and the More info sheet can both open it, and the copy is
|
||||
@@ -69,7 +69,11 @@ struct HowBestValueSheet: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("How Best Value works")
|
||||
.navigationTitle(KeySheetContent.helpSheetTitle)
|
||||
// `.inline`, the mode this sheet has always used. The sheet's title is a
|
||||
// short noun phrase and the ROW that opens it carries the full question
|
||||
// — a nav title is clamped to the bar and even `.large` truncated the
|
||||
// full question to "How does FuelBoard…" rather than wrapping it.
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
|
||||
@@ -141,9 +141,14 @@ struct BestValueDetailCard: View {
|
||||
/// same large form the sheet used to repeat on its own line above the
|
||||
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
|
||||
/// with it, the same way the listing rows lay the pair out.
|
||||
///
|
||||
/// "Value breakdown" rather than "Best Value": the card is the working behind
|
||||
/// whatever verdict the capsule shows, and that verdict is not always a Best
|
||||
/// Value one — a Costs station shows its extra cost here. Naming the card after
|
||||
/// the feature promised the reader a Best Value result it might not get.
|
||||
private func header(_ result: FuelSavingsResult) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text("Best Value")
|
||||
Text("Value breakdown")
|
||||
.font(.headline)
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
@@ -278,6 +283,6 @@ struct BestValueDetailCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// "How Best Value works" lives in the shared `HowBestValueRow`
|
||||
/// "How does FuelBoard calculate value?" lives in the shared `HowBestValueRow`
|
||||
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
|
||||
/// the Key cannot drift.
|
||||
|
||||
@@ -26,6 +26,17 @@ struct StationDetailView: View {
|
||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
var savingsBaselineStationName: String? = nil
|
||||
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
||||
/// Ranking flags, supplied by the list that opened this sheet — the SAME flags
|
||||
/// it hands its own rows, so this sheet cannot award a capsule the row behind it
|
||||
/// did not. `isCheapestInRange` is the Stations tab's cheapest price in range;
|
||||
/// `isTopFavourite` is the cheapest of your Favourites. A host passes one or the
|
||||
/// other, never both, so one achievement is never labelled twice.
|
||||
var isCheapestInRange: Bool = false
|
||||
var isTopFavourite: Bool = false
|
||||
/// The user's Best Value alert threshold — the app's existing gate, defaulted
|
||||
/// exactly as `StationRow` defaults it, so the capsule here and the badge on the
|
||||
/// list agree to the penny.
|
||||
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||
/// Whether this station is starred for the fuel currently being viewed, and
|
||||
/// the toggle that flips it. Passed from the list that opened the sheet, so
|
||||
/// the star here and the star on the row are the same fuel-scoped
|
||||
@@ -38,8 +49,8 @@ struct StationDetailView: View {
|
||||
/// the caption fades out on its own.
|
||||
@State private var showingCopied = false
|
||||
@State private var copyResetTask: Task<Void, Never>?
|
||||
/// The "How Best Value works" sheet, opened from the row at the bottom of the
|
||||
/// Best Value card (the Key sheet opens the same shared view).
|
||||
/// The "How does FuelBoard calculate value?" sheet, opened from the row at the
|
||||
/// bottom of the value-breakdown card (the Key sheet opens the same shared view).
|
||||
@State private var showBestValueInfo = false
|
||||
|
||||
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||
@@ -66,6 +77,18 @@ struct StationDetailView: View {
|
||||
FuelPriceFreshness.assessment(for: station)
|
||||
}
|
||||
|
||||
/// Which ranking capsules this station qualifies for. All the rules live in the
|
||||
/// shared `StationRanking` / `StationRecommendationBadge` pair — nothing is
|
||||
/// recomputed here, and no threshold is invented.
|
||||
private var rankingKinds: [StationLabelKind] {
|
||||
StationRanking.kinds(
|
||||
netSavingGBP: fuelSavings?.result?.netSavingGBP,
|
||||
isCheapestInRange: isCheapestInRange,
|
||||
isTopFavourite: isTopFavourite,
|
||||
bestValueThresholdGBP: bestValueThresholdGBP
|
||||
)
|
||||
}
|
||||
|
||||
/// One-way and out-and-back distance from the current location, from the ONE
|
||||
/// shared display helper — so this sheet's figures carry the same confidence
|
||||
/// marker as the listing row, the widget and the Live Activity.
|
||||
@@ -229,10 +252,17 @@ struct StationDetailView: View {
|
||||
// clutter. The Address/Distance and Distance/actions lines
|
||||
// stay.
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
// The map, the facts row, any notices and the two station
|
||||
// actions all share ONE card (the list's own row hairlines
|
||||
// separate them), so no hand-rolled Divider here — adding one
|
||||
// would double the line.
|
||||
// Ranking capsules directly under the map, above the price and
|
||||
// distance. Nothing renders when the station qualifies for no
|
||||
// label, so an ordinary station's layout is untouched.
|
||||
//
|
||||
// No hairline under this row either: the map already suppressed
|
||||
// its bottom separator so the header and map read as one block,
|
||||
// and the facts row below keeps its own line.
|
||||
if !rankingKinds.isEmpty {
|
||||
StationRankingCapsules(kinds: rankingKinds)
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
}
|
||||
//
|
||||
// The station name AND the address live in the sheet header,
|
||||
// which is also where a tap copies the postcode — see the
|
||||
@@ -290,9 +320,12 @@ struct StationDetailView: View {
|
||||
// is not configured — so the heading cannot be left dangling
|
||||
// over an empty section.
|
||||
if let fuelSavings {
|
||||
// The card's own header row says "Best Value", so a section
|
||||
// header would repeat it; the green tint is what marks this
|
||||
// block as the savings card.
|
||||
// The card carries its own "Value breakdown" heading, so a
|
||||
// section header would repeat it. Neutral card background: the
|
||||
// green tint marked the block as the savings card, but it also
|
||||
// tinted a card whose verdict can be "Costs", and it made this
|
||||
// one card look like a different material from the station card
|
||||
// above it.
|
||||
Section {
|
||||
BestValueDetailCard(
|
||||
availability: fuelSavings,
|
||||
@@ -301,7 +334,6 @@ struct StationDetailView: View {
|
||||
distanceUnit: distanceUnit
|
||||
)
|
||||
}
|
||||
.listRowBackground(Color.green.opacity(0.08))
|
||||
.id("bestValueCard")
|
||||
|
||||
Section {
|
||||
|
||||
@@ -386,6 +386,8 @@ struct StationsView: View {
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
// The same flag the row gets from `topStationID`.
|
||||
isCheapestInRange: station.id == topStationID,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||
)
|
||||
|
||||
@@ -50,12 +50,14 @@
|
||||
"The nearest station by journey distance." = "The nearest station by journey distance.";
|
||||
"The cheapest station in your Favourites for the selected fuel." = "The cheapest station in your Favourites for the selected fuel.";
|
||||
"Star a station to save it to your Favourites." = "Star a station to save it to your Favourites.";
|
||||
"How does Best Value work?" = "How does Best Value work?";
|
||||
"How does FuelBoard calculate value?" = "How does FuelBoard calculate value?";
|
||||
/* Title of the explanation sheet the row above opens (a short form of it). */
|
||||
"How value is calculated" = "How value is calculated";
|
||||
"Value breakdown" = "Value breakdown";
|
||||
/* Star button in the More info sheet heading (accessibility labels) */
|
||||
"Add to favourites" = "Add to favourites";
|
||||
"Remove from favourites" = "Remove from favourites";
|
||||
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
|
||||
"How Best Value works" = "How Best Value works";
|
||||
"Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
|
||||
"With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back." = "With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.";
|
||||
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
|
||||
|
||||
@@ -324,6 +324,81 @@ final class KeySheetContentTests: XCTestCase {
|
||||
KeySheetContent.favourite,
|
||||
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
|
||||
)
|
||||
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
|
||||
// The link names the calculation, not the feature: the sheet explains the
|
||||
// fill-up, the return trip and the total, of which the Best Value gate is
|
||||
// only the last step.
|
||||
XCTAssertEqual(KeySheetContent.helpTitle, "How does FuelBoard calculate value?")
|
||||
// The sheet title is a short form of the question, NOT the question itself:
|
||||
// a nav title clamps to the bar, and the full question truncated to
|
||||
// "How does FuelBoard…" at both display modes.
|
||||
XCTAssertEqual(KeySheetContent.helpSheetTitle, "How value is calculated")
|
||||
XCTAssertLessThan(KeySheetContent.helpSheetTitle.count, KeySheetContent.helpTitle.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// The More info sheet's ranking capsules. Every rule must come from the shared
|
||||
/// badge logic — the sheet may show more labels than a row (which shows one), but
|
||||
/// it must not invent a qualification of its own.
|
||||
final class StationRankingTests: XCTestCase {
|
||||
private let threshold = 2.50
|
||||
|
||||
func testNoQualificationProducesNoCapsules() {
|
||||
XCTAssertEqual(
|
||||
StationRanking.kinds(netSavingGBP: 0.40, isCheapestInRange: false,
|
||||
isTopFavourite: false, bestValueThresholdGBP: threshold),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
func testBestValueUsesTheSameGateAsTheListingBadge() {
|
||||
// Exactly at the threshold qualifies, matching StationRecommendationBadge.
|
||||
XCTAssertEqual(
|
||||
StationRanking.kinds(netSavingGBP: threshold, isCheapestInRange: false,
|
||||
isTopFavourite: false, bestValueThresholdGBP: threshold),
|
||||
[.bestValue]
|
||||
)
|
||||
// A hair under does not.
|
||||
XCTAssertEqual(
|
||||
StationRanking.kinds(netSavingGBP: threshold - 0.01, isCheapestInRange: false,
|
||||
isTopFavourite: false, bestValueThresholdGBP: threshold),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
func testCheapestInRangeAndTopFavouriteAreDistinctLabels() {
|
||||
XCTAssertEqual(
|
||||
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true,
|
||||
isTopFavourite: false, bestValueThresholdGBP: threshold),
|
||||
[.cheapest]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false,
|
||||
isTopFavourite: true, bestValueThresholdGBP: threshold),
|
||||
[.top]
|
||||
)
|
||||
}
|
||||
|
||||
func testCapsulesAreOrderedBestValueTopCheapest() {
|
||||
XCTAssertEqual(
|
||||
StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true,
|
||||
isTopFavourite: true, bestValueThresholdGBP: threshold),
|
||||
[.bestValue, .top, .cheapest]
|
||||
)
|
||||
}
|
||||
|
||||
/// A row shows ONE badge; the sheet may show several. Both must agree on the
|
||||
/// Best Value decision, or the sheet would claim a label the list behind it
|
||||
/// withheld.
|
||||
func testSheetAgreesWithTheRowOnBestValue() {
|
||||
for netSaving in [-3.0, 0.0, 2.49, 2.50, 12.0, .nan] as [Double] {
|
||||
let rowSaysBestValue = StationRecommendationBadge.badge(
|
||||
netSavingGBP: netSaving, isCheapest: false, bestValueThresholdGBP: threshold
|
||||
) == .bestValue
|
||||
let sheetSaysBestValue = StationRanking.kinds(
|
||||
netSavingGBP: netSaving, isCheapestInRange: false,
|
||||
isTopFavourite: false, bestValueThresholdGBP: threshold
|
||||
).contains(.bestValue)
|
||||
XCTAssertEqual(rowSaysBestValue, sheetSaysBestValue, "disagreed on net saving \(netSaving)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +405,17 @@ struct FuelSavingsRankedStation: Equatable {
|
||||
description: "Star a station to save it to your Favourites."
|
||||
)
|
||||
|
||||
static let helpTitle = "How does Best Value work?"
|
||||
/// The link that opens the explanation sheet. Names the question the sheet
|
||||
/// answers rather than the feature ("How does Best Value work?"), because the
|
||||
/// sheet explains the whole calculation — fill-up, return trip, extra travel,
|
||||
/// total — not only the Best Value gate at the end of it.
|
||||
static let helpTitle = "How does FuelBoard calculate value?"
|
||||
/// The explanation sheet's own title: a SHORT form of the same question, not
|
||||
/// the link's words. A navigation title is clamped to the bar, and the full
|
||||
/// question truncated to "How does FuelBoard…" in BOTH inline and large
|
||||
/// display modes — so the row carries the full question and the sheet it opens
|
||||
/// names the same topic in words that render in full.
|
||||
static let helpSheetTitle = "How value is calculated"
|
||||
}
|
||||
|
||||
/// A station label's meaning, independent of its copy.
|
||||
@@ -463,6 +473,39 @@ enum StationRecommendationBadge: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every ranking label a station qualifies for, in display order.
|
||||
///
|
||||
/// A listing row shows ONE badge (Best Value outranks Cheapest, because a row has
|
||||
/// room for one). The More info sheet has room for all of them, so it shows each
|
||||
/// qualification side by side — but it must never invent a rule of its own, so this
|
||||
/// defers to `StationRecommendationBadge.badge` for the Best Value gate and takes
|
||||
/// the other two as the SAME flags the host list gives its rows.
|
||||
///
|
||||
/// Top and Cheapest are distinct on purpose: Cheapest means "lowest price in the
|
||||
/// range the Stations tab is showing", Top means "lowest price among your
|
||||
/// Favourites". A caller passes whichever context it is listing, never both, so a
|
||||
/// station is not labelled twice for the same achievement.
|
||||
enum StationRanking {
|
||||
static func kinds(netSavingGBP: Double?,
|
||||
isCheapestInRange: Bool,
|
||||
isTopFavourite: Bool,
|
||||
bestValueThresholdGBP: Double) -> [StationLabelKind] {
|
||||
var kinds: [StationLabelKind] = []
|
||||
// The same gate the listing row uses, asked without a cheapest flag so the
|
||||
// ranking is decided only by the saving.
|
||||
if StationRecommendationBadge.badge(
|
||||
netSavingGBP: netSavingGBP,
|
||||
isCheapest: false,
|
||||
bestValueThresholdGBP: bestValueThresholdGBP
|
||||
) == .bestValue {
|
||||
kinds.append(.bestValue)
|
||||
}
|
||||
if isTopFavourite { kinds.append(.top) }
|
||||
if isCheapestInRange { kinds.append(.cheapest) }
|
||||
return kinds
|
||||
}
|
||||
}
|
||||
|
||||
struct BestValueAlertCandidate: Equatable {
|
||||
let station: FuelStation
|
||||
let result: FuelSavingsResult
|
||||
|
||||
Reference in New Issue
Block a user