diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index fb4661e..2f9f8f8 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -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"; diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index 4be101d..6b057f1 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -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(_ 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")) } diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 7d6aa89..899f535 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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) diff --git a/FuelBoard/FuelBoardDisabledState.swift b/FuelBoard/FuelBoardDisabledState.swift new file mode 100644 index 0000000..1bd6abf --- /dev/null +++ b/FuelBoard/FuelBoardDisabledState.swift @@ -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) + } +} diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 4d9d7d5..aac2bc2 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -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 diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 9aeaf86..3658722 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -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 {