Unify disabled states and preserve station verdicts

This commit is contained in:
FuelBoard Contributor
2026-10-17 18:22:55 +01:00
parent ff6cbbe07d
commit 7111ce38af
6 changed files with 77 additions and 83 deletions
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist";
+29 -24
View File
@@ -166,9 +166,8 @@ struct AlertsView: View {
}
/// An option row that belongs to a toggle. When the toggle is off the control
/// is replaced by a dimmed, static value row: `.disabled` is not enough here
/// because an inline-style picker in a List still expands its options when
/// tapped while disabled.
/// is replaced by a static value row. The enclosing dependent group applies
/// FuelBoard's shared disabled interaction and appearance once.
@ViewBuilder
private func optionRow<Content: View>(_ title: String,
value: String,
@@ -182,7 +181,6 @@ struct AlertsView: View {
Spacer()
Text(value)
}
.foregroundStyle(.tertiary)
}
}
@@ -191,28 +189,30 @@ struct AlertsView: View {
List {
Section {
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: favouriteDropFuel.displayName,
isEnabled: favouriteDropEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
Group {
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: favouriteDropFuel.displayName,
isEnabled: favouriteDropEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
}
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
Text(String(localized: "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.", comment: "Favourite price alerts section description"))
.font(.footnote)
.foregroundStyle(.secondary)
}
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
.disabled(!favouriteDropEnabled)
.allowsHitTesting(favouriteDropEnabled)
Text(String(localized: "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.", comment: "Favourite price alerts section description"))
.font(.footnote)
.foregroundStyle(.secondary)
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
} header: {
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
}
Section {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
Group {
optionRow(String(localized: "Alert type", comment: "Proximity alert type picker title"),
value: alertMode.displayName,
isEnabled: enabled) {
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
@@ -265,9 +265,11 @@ struct AlertsView: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
.font(.footnote)
.foregroundStyle(.secondary)
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: enabled)
} header: {
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
}
@@ -281,7 +283,8 @@ struct AlertsView: View {
Section {
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
Group {
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: liveActivityFuel.displayName,
isEnabled: liveActivityEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
@@ -305,9 +308,11 @@ struct AlertsView: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description"))
.font(.footnote)
.foregroundStyle(.secondary)
Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
} header: {
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
}
+17 -31
View File
@@ -1842,26 +1842,13 @@ struct StationRow: View {
/// 22pt keeps the price the row's loudest element after the station name.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
/// The identity column's share of the ROW, for every row and both price
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
/// held per row rather than derived from the row's text — the point is that
/// the hairline, the price axis and the star cannot move as the list scrolls.
/// The price column takes the remainder, and its savings capsule scales down
/// instead of widening the column and dragging the hairline with it.
///
/// One value for both price styles: the star used to sit at the end of the
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
/// — ~20pt wider than the bare `158.7` at the same point size — did not fit on
/// a 393pt screen. With the star now under the station logo the price column
/// has that width back, so the identity column no longer has to give width up
/// in pounds & pence, and names keep the same room in both styles.
private static let leftSectionShare: CGFloat = 0.52
/// The comparison label sits beside the capsule only where the row is wide
/// enough for both (regular width). On an iPhone the mockup's canvas is
/// wider than the whole screen, so the label drops under the capsule rather
/// than starving the station name.
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
@@ -1931,13 +1918,9 @@ struct StationRow: View {
// the detail sheet instead of being repeated on every row.
HStack(alignment: .center, spacing: 12) {
// LEFT SECTION — station identity: brand logo, then name, address and
// distance. Given a FIXED SHARE of the row (~52%, the mockup's ratio)
// rather than sizing to its content: sized to content, this column
// grew and shrank with each row's badge and savings copy, which moved
// the hairline 23pt between adjacent rows (x767 on a BEST VALUE row,
// x699 on a "costs more" row) — so the divider and the price axis
// both jumped sideways as the list scrolled. One share for every row
// puts them on a single vertical line down the whole list.
// distance. This section yields horizontal space to the financial
// column first: a truncated station identity is recoverable from the
// detail sheet, while a truncated saving is materially misleading.
HStack(alignment: .top, spacing: 12) {
// The logo, with the favourite star directly beneath it. Moving
// the star out of the price line is what gives the price column
@@ -1972,16 +1955,15 @@ struct StationRow: View {
// Name, full address, distance
VStack(alignment: .leading, spacing: 2) {
// The name WRAPS instead of truncating: the row is tall
// enough now, and a name cut to "Z A Service Stat…" cannot
// be told apart from the station next door. Baseline
// alignment keeps the notice symbols on the name's FIRST
// line instead of floating beside a two-line name.
// Keep the established compact row. Long names yield space
// to the complete price/verdict column rather than wrapping
// and forcing a financial amount to truncate.
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name)
.font(.headline)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.truncationMode(.tail)
ForEach(StationNotice.notices(for: station)) { notice in
Image(systemName: notice.symbolName)
.font(.caption2.weight(.semibold))
@@ -2019,11 +2001,10 @@ struct StationRow: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in
length * Self.leftSectionShare
}
.layoutPriority(0)
// 1pt hairline between the two sections. Inset top and bottom so it
// spans the row's content without touching the row edges, and
@@ -2075,7 +2056,12 @@ struct StationRow: View {
savingsCapsule(result)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// The verdict owns its natural one-line width at normal Dynamic Type
// sizes. The identity column compresses first, preventing "Approx.
// Save £…" while keeping the same two-column visual design.
.fixedSize(horizontal: !dynamicTypeSize.isAccessibilitySize,
vertical: false)
.layoutPriority(1)
// The spec's ~20-24pt of air between the divider and the price
// column: the row's own 12pt spacing plus this 10pt.
.padding(.leading, 10)
+12
View File
@@ -0,0 +1,12 @@
import SwiftUI
/// Applies FuelBoard's shared unavailable appearance and native interaction state
/// to a complete dependent row or group. Apply it once at the highest dependent
/// container so labels, values, icons, descriptions and controls fade together.
extension View {
func fuelBoardDisabledState(isEnabled: Bool) -> some View {
disabled(!isEnabled)
.allowsHitTesting(isEnabled)
.opacity(isEnabled ? 1 : 0.5)
}
}
+9 -8
View File
@@ -105,25 +105,26 @@ struct FuelSavingsCapsule: View {
let result: FuelSavingsResult
var compact: Bool = true
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
HStack(spacing: compact ? 3 : 5) {
Image(systemName: Self.symbol(for: result.status))
.accessibilityHidden(true)
Text(result.capsuleHeadline())
.lineLimit(1)
// Measured on a 393pt screen: the capsule is handed ~101pt, of
// which the icon, its spacing and the 8pt padding either side
// take 32pt, leaving ~69pt for the text. "Closest station" needs
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
// which clears it at ~10pt type: scaled, never truncated.
.minimumScaleFactor(0.7)
// Financial verdicts stay complete and single-line at ordinary
// text sizes. Accessibility sizes may use a second line rather
// than clipping, shrinking or losing the amount to an ellipsis.
.lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1)
.fixedSize(horizontal: !dynamicTypeSize.isAccessibilitySize,
vertical: true)
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(Self.color(for: result.status))
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 4 : 7)
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
.accessibilityElement(children: .combine)
}
/// Verdict colour: green save, red worse off, grey break even, blue
+2 -12
View File
@@ -198,7 +198,7 @@ struct SettingsView: View {
.foregroundStyle(.orange)
}
}
.settingsDependencyEnabled(fuelSavingsSettings.enabled)
.fuelBoardDisabledState(isEnabled: fuelSavingsSettings.enabled)
} header: {
Text("Fuel Savings")
} footer: {
@@ -582,7 +582,7 @@ struct SettingsView: View {
)
}
.buttonStyle(.plain)
.settingsDependencyEnabled(!tipStore.purchaseInProgress)
.fuelBoardDisabledState(isEnabled: !tipStore.purchaseInProgress)
}
}
.listRowBackground(Color.clear)
@@ -925,16 +925,6 @@ struct SettingsView: View {
}
}
/// Applies the native unavailable state to a complete dependent settings group
/// and also dims non-control content (read-outs, icons and explanatory text),
/// which does not automatically adopt a disabled appearance from SwiftUI.
private extension View {
func settingsDependencyEnabled(_ isEnabled: Bool) -> some View {
disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.65)
}
}
/// Loads the three tip tiers and drives their purchases.
@MainActor
final class TipStore: ObservableObject {