diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index e86cad023..1fdcca0b2 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -23,6 +23,7 @@ struct YouScreen: View { @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router + @Environment(UpdateAvailability.self) private var updateAvailability @Environment(RatesController.self) private var ratesController @Environment(ConversationController.self) private var conversationController @@ -173,11 +174,19 @@ struct YouScreen: View { .accessibilityIdentifier("you-share") } + private var showsUpdateDot: Bool { + SettingsVersionCardState( + newerVersion: updateAvailability.newerVersion, + isDebugBuild: SettingsVersionCardState.isDebugBuild + ).showsGearDot + } + private var settingsGear: some View { Button { router.push(.settings) } label: { Image(systemName: "gearshape") + .notificationDot(showsUpdateDot) } .accessibilityLabel("Settings") .accessibilityIdentifier("you-settings") diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 574fba6dd..602ab205e 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -8,8 +8,8 @@ import FlipcashUI import FlipcashCore /// The Settings list, pushed from the gear on the You tab: the profile editors, -/// the account-level actions, and the version footer that doubles as the -/// beta-access easter egg. +/// the account-level actions, and the version card that doubles as the +/// beta-access easter egg while it sits inline. /// /// The row icons track Android's through the nearest SF Symbol. Profile Picture /// is the exception: `familiar_face_and_zone` has no SF equivalent, so it ships @@ -22,11 +22,14 @@ struct SettingsScreen: View { @Environment(SessionAuthenticator.self) private var sessionAuthenticator @Environment(ContactSyncController.self) private var contactSyncController @Environment(ToastController.self) private var toasts + @Environment(UpdateAvailability.self) private var updateAvailability @State private var dialogItem: DialogItem? - /// The version footer's beta-access easter egg — the tap count and the line + /// The version card's beta-access easter egg — the tap count and the line /// it shows for the last few taps. @State private var versionUnlock = VersionTapUnlock() + /// How the list fits the screen, which is when the inline state's bottom fade belongs. + @State private var scrollFit = ProfileScrollFit() // Horizontal inset lives on the rows, not the list: the section headers // carry their own 20pt. @@ -40,17 +43,35 @@ struct SettingsScreen: View { privacySection advancedSection accountSection - versionFooter - .padding(.top, 32) + if !cardState.isPinned { + versionCard + .padding(.top, 8) + } } .font(.appDisplayXS) .foregroundStyle(.textMain) .padding(.bottom, 24) } + .profileScrollFit($scrollFit) + } + // Pinned, the card sits on glass in the bar and the soft scroll edge fades rows under it; its + // inset keeps Log Out and Delete Account clear of the card. Inline the bar is empty, which + // draws no edge, so it carries the barless fade the profiles use while rows run past the screen. + .scrollEdgeBar(.bottom) { + if cardState.isPinned { + versionCard + .padding(.bottom, 16) + } else { + Color.clear + .frame(height: 0) + .profileBarlessFade(isActive: scrollFit.fadesBottom) + } } .navigationTitle("Settings") .toolbarTitleDisplayMode(.inline) .dialog(item: $dialogItem) + // The update toast would sit on the pinned card, so it comes down while Settings is open. + .onAppear { updateAvailability.dismissToast(on: toasts) } } // MARK: - Sections - @@ -146,30 +167,31 @@ struct SettingsScreen: View { } } - private var versionFooter: some View { - Button { - let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { - betaFlags.setAccessGranted(!betaFlags.accessGranted) - } - if let message { - // Two seconds and swapped in place, so consecutive taps read as one countdown. Without an action the toast - // lets taps through, so it never blocks the version string at the bottom of the list. - toasts.show( - .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), - inPlace: true - ) - } - } label: { - Text("Version \(AppMeta.version) • Build \(AppMeta.build)") - .lineLimit(1) - .font(.appTextHeading) - .foregroundStyle(Color.textSecondary) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + private var cardState: SettingsVersionCardState { + SettingsVersionCardState( + newerVersion: updateAvailability.newerVersion, + isDebugBuild: SettingsVersionCardState.isDebugBuild + ) + } + + private var versionCard: some View { + SettingsVersionCard(state: cardState, onTapVersion: registerVersionTap) { + updateAvailability.openStore() + } + } + + private func registerVersionTap() { + let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { + betaFlags.setAccessGranted(!betaFlags.accessGranted) + } + if let message { + // Two seconds and swapped in place, so consecutive taps read as one countdown. Without an action the toast + // lets taps through, so it never blocks the version card. + toasts.show( + .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), + inPlace: true + ) } - .buttonStyle(.plain) - .accessibilityIdentifier("you-version-footer") } // MARK: - Actions - diff --git a/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift b/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift new file mode 100644 index 000000000..63a2e72b9 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift @@ -0,0 +1,146 @@ +// +// SettingsVersionCard.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// What the Settings version card shows. Debug is checked first, so a debug build never pins the +/// card or offers an update whatever the store lookup says. +nonisolated enum SettingsVersionCardState: Equatable { + case upToDate + case updateAvailable(AppVersion) + case debug + + init(newerVersion: AppVersion?, isDebugBuild: Bool) { + if isDebugBuild { + self = .debug + } else if let newerVersion { + self = .updateAvailable(newerVersion) + } else { + self = .upToDate + } + } + + /// Whether the card floats over the bottom of the list instead of sitting at the end of it. + var isPinned: Bool { + switch self { + case .updateAvailable: true + case .upToDate, .debug: false + } + } + + /// Whether the You tab's gear carries the attention dot. + var showsGearDot: Bool { isPinned } + + /// Whether tapping the card counts toward the beta-access easter egg. + var allowsBetaToggle: Bool { !isPinned } + + /// Whether the build the app is running is a debug build. `-SettingsCardAsRelease YES` lets a + /// debug build render the release treatments for inspection. + static var isDebugBuild: Bool { + #if DEBUG + !UserDefaults.standard.bool(forKey: "SettingsCardAsRelease") + #else + false + #endif + } +} + +/// The compact card at the bottom of Settings: the installed version, and either its build or the +/// newer version waiting in the store. +struct SettingsVersionCard: View { + + let state: SettingsVersionCardState + let onTapVersion: () -> Void + let onUpdate: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("Flipcash \(AppMeta.version)") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + if state == .debug { + Text("Debug") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.textSecondary.opacity(0.2))) + } + } + subtitle + } + .lineLimit(1) + .padding(.top, 15) + + Spacer(minLength: 0) + + trailing + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 16) + .frame(height: 70) + .background { + if state.isPinned { + pinnedBackground + } else { + RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous) + .fill(Color.backgroundRow) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if state.allowsBetaToggle { onTapVersion() } + } + .padding(.horizontal, 20) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("you-version-footer") + } + + /// Dark glass: the row tone at 62% over a 24pt-blur material, with a 10% white edge and a soft + /// shadow. The same recipe on every OS, so iOS 18 and 26 match. + private var pinnedBackground: some View { + let shape = RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous) + return shape + .fill(.ultraThinMaterial) + .overlay(shape.fill(Color(red: 28 / 255, green: 28 / 255, blue: 29 / 255).opacity(0.62))) + .overlay(shape.strokeBorder(Color.white.opacity(0.1), lineWidth: 1)) + .shadow(color: .black.opacity(0.25), radius: 16, y: 4) + } + + @ViewBuilder + private var subtitle: some View { + switch state { + case .updateAvailable(let version): + Text("\(version.description) available") + .font(.appTextCaption) + .foregroundStyle(Color.textSuccess) + .accessibilityIdentifier("settings-version-subtitle") + case .upToDate, .debug: + Text("Build \(AppMeta.build)") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("settings-version-subtitle") + } + } + + @ViewBuilder + private var trailing: some View { + switch state { + case .updateAvailable: + ProfileEditCapsule(title: "Update", action: onUpdate) + .accessibilityIdentifier("settings-version-update") + case .upToDate: + Text("Up to date \u{2713}") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + case .debug: + EmptyView() + } + } +} diff --git a/FlipcashTests/SettingsVersionCardStateTests.swift b/FlipcashTests/SettingsVersionCardStateTests.swift new file mode 100644 index 000000000..271e39973 --- /dev/null +++ b/FlipcashTests/SettingsVersionCardStateTests.swift @@ -0,0 +1,41 @@ +// +// SettingsVersionCardStateTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@Suite("SettingsVersionCardState") +struct SettingsVersionCardStateTests { + + private let newer = AppVersion("2026.10.4")! + + @Test("No newer version reads as up to date, inline, no dot") + func upToDate() { + let state = SettingsVersionCardState(newerVersion: nil, isDebugBuild: false) + #expect(state == .upToDate) + #expect(!state.isPinned) + #expect(!state.showsGearDot) + #expect(state.allowsBetaToggle) + } + + @Test("A newer version pins the card, shows the dot, and drops the beta toggle") + func updateAvailable() { + let state = SettingsVersionCardState(newerVersion: newer, isDebugBuild: false) + #expect(state == .updateAvailable(newer)) + #expect(state.isPinned) + #expect(state.showsGearDot) + #expect(!state.allowsBetaToggle) + } + + @Test("A debug build stays inline with no dot even when a newer version exists") + func debug() { + for newerVersion in [nil, newer] { + let state = SettingsVersionCardState(newerVersion: newerVersion, isDebugBuild: true) + #expect(state == .debug) + #expect(!state.isPinned) + #expect(!state.showsGearDot) + } + } +}