Skip to content
Merged
9 changes: 9 additions & 0 deletions Flipcash/Core/Screens/Main/You/YouScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
78 changes: 50 additions & 28 deletions Flipcash/Core/Screens/Settings/SettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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 -
Expand Down Expand Up @@ -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 -
Expand Down
146 changes: 146 additions & 0 deletions Flipcash/Core/Screens/Settings/SettingsVersionCard.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
41 changes: 41 additions & 0 deletions FlipcashTests/SettingsVersionCardStateTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading