From a0500153842e32a30c5b2033bfc13d9684aca879 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 08:05:26 -0400 Subject: [PATCH 1/8] fix(chat): redesign the unsupported-message bubble A message this build can't read showed a dashed "This message can't be displayed" bubble with "Update Flipcash to see it" hanging under it. The `.updateApp` hint now draws a plain received bubble with no outline: the title "This Message Needs a Newer Version of Flipcash" over the body "Update Flipcash to see messages like this. This one stays hidden." (component 11420:13397), set in the `appTextMedium`, `appTextCaption` and `appTextSmall` tokens rather than Figma's 15/13/14 sizes. Title and body are separate labels; as one attributed string with paragraph spacing, the cell measured short and clipped the body. The decrypt-failure hints (`.askToResend`, `.resend`) keep the dashed bubble. `ChatMessageCell.configure` takes an optional `ChatBubbleAction` that adds an "Update Flipcash" capsule. Nothing passes one yet, so no button shows until the update check exists. A message whose content type this build doesn't know was dropped by `ConversationMessage.init?`. It now maps to `.widget(.unrecognized)`, which already reads back from the store as unsupported, so the schema is unchanged. A message with no content entry is still dropped. --- .../Models/Chat/ChatMessage.swift | 18 +- .../Conversation/ConversationMessage.swift | 17 +- .../ChatPreviewDecryptionTests.swift | 2 +- .../ConversationModelMappingTests.swift | 20 ++ .../Chat/ChatMessageMappingTests.swift | 13 ++ .../FlipcashUI/Chat/ChatBubbleView.swift | 172 +++++++++++++++--- .../FlipcashUI/Chat/ChatMessageCell.swift | 6 +- 7 files changed, 213 insertions(+), 35 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 101c28c22..9a23604ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -26,8 +26,8 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// A deleted message's placeholder copy, already resolved for the viewer — "You deleted this /// message" or "This message was deleted". The mapper decides which; the view just draws it. case deleted(String) - /// A message this client cannot display — today, an encrypted one it cannot decrypt. Drawn - /// as a dashed "This message can't be displayed" bubble with `hint` beneath it. + /// A message this client cannot display: one it cannot decrypt, or a type it does not know. + /// `hint` decides its copy, and whether the bubble draws dashed. case unavailable(UnavailableHint) /// A shared profile, drawn as a card with a Share button. The card carries only the /// handle's link; the name and picture are looked up by the view, as a link card's are. @@ -36,19 +36,20 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { case media(ChatMediaContent) } - /// The line under an unavailable message's bubble, chosen by why it can't be shown. + /// Why an unavailable message can't be shown, which picks the bubble's copy. public enum UnavailableHint: Hashable, Sendable, Codable { - /// A newer client can read it: "Update Flipcash to see it". + /// A newer client can read it. Drawn as its own bubble, titled ``ChatMessage/updateAppTitle`` + /// with this hint's `text` as the body. case updateApp /// It failed to decrypt and only a resend can fix it: "Ask {first name} to send it again". case askToResend(firstName: String) /// The viewer's own message failed to decrypt: "Try sending it again". case resend - /// The hint's copy. + /// The hint's copy: the body inside the bubble for `.updateApp`, the line under it otherwise. public var text: String { switch self { - case .updateApp: "Update Flipcash to see it" + case .updateApp: "Update Flipcash to see messages like this. This one stays hidden." case .askToResend(let firstName): "Ask \(firstName) to send it again" case .resend: "Try sending it again" } @@ -72,7 +73,10 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { } } - /// The bubble copy for an unavailable message. + /// The title of the bubble for a message a newer client can read. + public static let updateAppTitle = "This Message Needs a Newer Version of Flipcash" + + /// The bubble copy for an unavailable message whose hint hangs beneath it. public static let unavailableCopy = "This message can\u{2019}t be displayed" public let id: String diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 690a1f13f..0acaac823 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -68,8 +68,9 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// media and reply, so a widget decrypted out of `EncryptedContent` is still unsupported. public enum Widget: Hashable, Sendable { case shareProfile(ShareProfileWidget) - /// A variant this client doesn't recognize, or one missing what it needs (a malformed - /// username). Drawn as an unsupported message. + /// A variant this client doesn't recognize, one missing what it needs (a malformed + /// username), or a message content type this client doesn't know. Drawn as an unsupported + /// message. case unrecognized } @@ -310,7 +311,9 @@ extension ConversationMessage { extension ConversationMessage { /// Builds a message from its proto, returning nil for content the client - /// can't represent (unknown type, or a cash amount that fails to parse). A + /// can't represent (no content, a system message, or a cash amount that + /// fails to parse). A content type this build doesn't know is kept as an + /// unrecognized widget so it draws as unsupported. A /// deleted message materializes as a `.deleted` tombstone — never nil — so /// it converges the same way across the stream, `GetMessages`, and `GetDelta` /// and can't leave the pre-delete content on screen. @@ -383,7 +386,13 @@ extension ConversationMessage { self.cashAction = nil repliedTo = nil case .none: - return nil + // A present entry with no known type is a variant added after this build. It reuses the + // unrecognized widget, whose stored shape (kind 4, no text) already reads back as + // unsupported, so keeping it needs no schema change. + guard !proto.content.isEmpty else { return nil } + self.content = .widget(.unrecognized) + self.cashAction = nil + repliedTo = nil } self.id = MessageID(proto.messageID) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift index e0ec458c6..5335862cb 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift @@ -75,7 +75,7 @@ struct ChatPreviewDecryptionTests { @Test("The hint's copy") func hintCopy() { - #expect(ChatMessage.UnavailableHint.updateApp.text == "Update Flipcash to see it") + #expect(ChatMessage.UnavailableHint.updateApp.text == "Update Flipcash to see messages like this. This one stays hidden.") #expect(ChatMessage.UnavailableHint.askToResend(firstName: "Ada").text == "Ask Ada to send it again") #expect(ChatMessage.UnavailableHint.resend.text == "Try sending it again") } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 520f27eb1..c8928d621 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -852,6 +852,26 @@ struct ConversationMessageContentEncodingTests { #expect(message.content == .widget(.unrecognized)) } + @Test("A content type this build doesn't know is kept as an unrecognized widget, not dropped") + func unknownContentTypeIsUnrecognized() throws { + // Field 99 is not a member of `Content.type` in this build, so it decodes into + // `unknownFields` and leaves the oneof unset, as a newer content type would. + let content = try Flipcash_Messaging_V1_Content(serializedBytes: Data([0x98, 0x06, 0x01])) + #expect(content.type == nil) + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [content] + } + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .widget(.unrecognized)) + } + + @Test("A message with no content entry is still dropped") + func emptyContentIsDropped() { + let proto = Flipcash_Messaging_V1_Message.with { $0.messageID = .with { $0.value = 9 } } + #expect(ConversationMessage(proto) == nil) + } + @Test("A widget can't be sent through the content encoder") func widgetIsNotEncodable() throws { let widget = ConversationMessage.Content.widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice"))))) diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index 1205a8427..31214007d 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -113,6 +113,19 @@ struct ChatMessageMappingTests { #expect(hint(me, .authentication) == [.unavailable(.resend)]) } + @Test("an unrecognized widget draws as the update bubble") + func unrecognizedWidgetIsUpdateApp() { + let widget = ConversationMessage( + id: MessageID(value: 1), + senderID: them, + content: .widget(.unrecognized), + date: base, + unreadSeq: 1 + ) + let items = ChatItem.from([widget], selfUserID: me, counterpartName: "Ada Lovelace") + #expect(messageRows(items).map(\.content) == [.unavailable(.updateApp)]) + } + @Test("a group's resend hint names the message's author, not the empty DM counterpart") func decryptFailureHintNamesGroupAuthor() { let items = ChatItem.from( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 265dfc244..6176ab01b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -10,6 +10,17 @@ import UIKit import SwiftUI import FlipcashCore +/// A button drawn inside a chat bubble: its label and what a tap does. +public struct ChatBubbleAction { + public let title: String + public let handler: @MainActor () -> Void + + public init(title: String, handler: @escaping @MainActor () -> Void) { + self.title = title + self.handler = handler + } +} + /// A single chat bubble: a multiline label over the shared `BubbleBackgroundView`, styled to /// match the app's conversation design (white-opacity fill, hairline border, app font, flattened /// inner corners on a bubble run). Dumb — hand it a `ChatMessage` and it draws. @@ -20,6 +31,11 @@ public final class ChatBubbleView: UIView { private let editedLabel = EditedMarker.makeLabel() /// The line under an unavailable message's bubble; hidden for every other row. private let hintLabel = UILabel() + /// The update bubble's optional capsule; hidden for every other row and when no action is given. + private let actionButton = UIButton(type: .system) + /// The update bubble's body, under the title the main label carries; empty on every other row. + private let detailLabel = UILabel() + private var action: ChatBubbleAction? private(set) var quotePanel = ChatQuotePanelView() /// Forwarded from the panel: the stable id of the message to jump to. @@ -42,6 +58,12 @@ public final class ChatBubbleView: UIView { private var backgroundBottomToBounds: NSLayoutConstraint! private var backgroundBottomToLabel: NSLayoutConstraint! private var hintBottom: NSLayoutConstraint! + /// The update bubble's body closes the bubble when it has no capsule under it. + private var detailBottom: NSLayoutConstraint! + /// The action capsule sits under the body and takes over the body's bottom inset. + private var actionTop: NSLayoutConstraint! + private var actionBottom: NSLayoutConstraint! + private var actionLeading: NSLayoutConstraint! /// Whether the row currently draws bare, so `maskingPath` can decline to clip a lift preview to /// a bubble that is not drawn. private var isBare = false @@ -50,6 +72,11 @@ public final class ChatBubbleView: UIView { private static let bodyPadding: CGFloat = 9 private static let hintTopSpacing: CGFloat = 8 private static let hintLeadingInset: CGFloat = 4 + /// The update bubble's insets and spacing, from Figma component 11420:13397. It stacks a title, + /// a body and a capsule, so it takes the roomier card padding rather than a text bubble's. + private static let updateInsetVertical: CGFloat = 12 + private static let updateInsetHorizontal: CGFloat = 14 + private static let updateSpacing: CGFloat = 10 /// A bare row's padding. Smaller than a bubble's because the emoji carries its own margin /// inside its line box, and the transcript's rhythm is what is being matched, not the bubble's. private static let barePadding: CGFloat = 4 @@ -88,6 +115,29 @@ public final class ChatBubbleView: UIView { hintLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(hintLabel) + var buttonConfig = UIButton.Configuration.filled() + buttonConfig.baseBackgroundColor = .white + buttonConfig.baseForegroundColor = .black + buttonConfig.cornerStyle = .capsule + buttonConfig.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14) + buttonConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = UIFont.appTextSmall + return attributes + } + detailLabel.font = .appTextCaption + detailLabel.textColor = UIColor.white.withAlphaComponent(0.55) + detailLabel.numberOfLines = 0 + detailLabel.isHidden = true + detailLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(detailLabel) + + actionButton.configuration = buttonConfig + actionButton.isHidden = true + actionButton.translatesAutoresizingMaskIntoConstraints = false + actionButton.addAction(UIAction { [weak self] _ in self?.action?.handler() }, for: .primaryActionTriggered) + addSubview(actionButton) + quotePanel.translatesAutoresizingMaskIntoConstraints = false addSubview(quotePanel) @@ -112,6 +162,10 @@ public final class ChatBubbleView: UIView { hintBottom = hintLabel.bottomAnchor.constraint(equalTo: bottomAnchor) labelLeading = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.bodyInset) labelTrailing = label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.bodyInset) + detailBottom = detailLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.updateInsetVertical) + actionTop = actionButton.topAnchor.constraint(equalTo: detailLabel.bottomAnchor, constant: Self.updateSpacing) + actionBottom = actionButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.updateInsetVertical) + actionLeading = actionButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.updateInsetHorizontal) NSLayoutConstraint.activate(quoteCollapse + [ background.topAnchor.constraint(equalTo: topAnchor), @@ -130,6 +184,13 @@ public final class ChatBubbleView: UIView { hintLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.hintLeadingInset), hintLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + detailLabel.topAnchor.constraint(equalTo: label.bottomAnchor, constant: Self.updateSpacing), + detailLabel.leadingAnchor.constraint(equalTo: label.leadingAnchor), + detailLabel.trailingAnchor.constraint(equalTo: label.trailingAnchor), + + actionLeading, + actionButton.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Self.updateInsetHorizontal), + // Bottom-trailing corner: the body's reservation run keeps the space clear, so the // marker lands on the last line where it fits and on the wrapped line where it doesn't. editedLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -EditedMarker.trailingInset), @@ -148,35 +209,59 @@ public final class ChatBubbleView: UIView { /// Whether this bubble is currently flashing. var isFlashingAttention: Bool { background.isFlashingAttention } - /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from. - public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = nil) { + /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from, and + /// `updateAction` is the capsule an `.updateApp` bubble draws, or nil for none. + public func configure( + with message: ChatMessage, + quoteThumbnail: ChatMediaLocation? = nil, + updateAction: ChatBubbleAction? = nil + ) { label.attributedText = Self.displayText(for: message) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji - // Deactivate before activating, as with the quote's top constraints below. let hint: String? + let isUpdateBubble: Bool switch message.content { - case .unavailable(let unavailableHint): hint = unavailableHint.text - case .text, .cash, .deleted, .shareProfile, .media: hint = nil + case .unavailable(.updateApp): + hint = nil + isUpdateBubble = true + case .unavailable(let unavailableHint): + hint = unavailableHint.text + isUpdateBubble = false + case .text, .cash, .deleted, .shareProfile, .media: + hint = nil + isUpdateBubble = false } + action = isUpdateBubble ? updateAction : nil + let showsAction = action != nil + actionButton.configuration?.title = action?.title + actionButton.isHidden = !showsAction + + // Deactivate before activating, as with the quote's top constraints below. hintLabel.text = hint hintLabel.isHidden = hint == nil + // Cleared rather than only hidden: a hidden label's text would still widen the bubble. + detailLabel.text = isUpdateBubble ? ChatMessage.UnavailableHint.updateApp.text : nil + detailLabel.isHidden = !isUpdateBubble + NSLayoutConstraint.deactivate([ + labelBottom, hintBottom, detailBottom, actionTop, actionBottom, backgroundBottomToLabel, backgroundBottomToBounds, + ]) if hint != nil { - labelBottom.isActive = false - backgroundBottomToBounds.isActive = false - backgroundBottomToLabel.isActive = true - hintBottom.isActive = true + NSLayoutConstraint.activate([backgroundBottomToLabel, hintBottom]) + } else if showsAction { + NSLayoutConstraint.activate([backgroundBottomToBounds, actionTop, actionBottom]) + } else if isUpdateBubble { + NSLayoutConstraint.activate([backgroundBottomToBounds, detailBottom]) } else { - hintBottom.isActive = false - backgroundBottomToLabel.isActive = false - backgroundBottomToBounds.isActive = true - labelBottom.isActive = true + NSLayoutConstraint.activate([backgroundBottomToBounds, labelBottom]) } - labelTopToBubble.constant = isBare ? Self.barePadding : Self.bodyPadding - labelBottom.constant = isBare ? -Self.barePadding : -Self.bodyPadding - labelLeading.constant = isBare ? 0 : Self.bodyInset - labelTrailing.constant = isBare ? 0 : -Self.bodyInset + let vertical = isBare ? Self.barePadding : isUpdateBubble ? Self.updateInsetVertical : Self.bodyPadding + let horizontal = isBare ? 0 : isUpdateBubble ? Self.updateInsetHorizontal : Self.bodyInset + labelTopToBubble.constant = vertical + labelBottom.constant = -vertical + labelLeading.constant = horizontal + labelTrailing.constant = -horizontal // Deactivate before activating: with both top constraints live the layout is // unsatisfiable, and UIKit resolves that by breaking one at random. @@ -204,7 +289,7 @@ public final class ChatBubbleView: UIView { groupedBelow: message.joinsBubbleBelow ), bare: isBare, - dashedBorder: Self.isUnavailable(message), + dashedBorder: Self.drawsDashedBorder(message), identity: message.id ) } @@ -220,9 +305,12 @@ public final class ChatBubbleView: UIView { } } - private static func isUnavailable(_ message: ChatMessage) -> Bool { + /// The hinted unavailable bubbles draw dashed; the update bubble draws as a plain received + /// bubble, as Figma component 11420:13397 has no outline. + private static func drawsDashedBorder(_ message: ChatMessage) -> Bool { switch message.content { - case .unavailable: true + case .unavailable(.updateApp): false + case .unavailable(.askToResend), .unavailable(.resend): true case .text, .cash, .deleted, .shareProfile, .media: false } } @@ -250,6 +338,17 @@ public final class ChatBubbleView: UIView { return result } + /// The update bubble's title; `detailLabel` carries the body beneath it. + private static func updateAppText() -> NSAttributedString { + NSAttributedString( + string: ChatMessage.updateAppTitle, + attributes: [ + .font: UIFont.appTextMedium, + .foregroundColor: UIColor.white.withAlphaComponent(0.9), + ] + ) + } + /// The bubble's rendered text: the body, a muted italic placeholder for a tombstone, and the /// "Edited" marker's reservation where the sender has revised the message — the marker itself is /// a separate label pinned to the bubble's corner. `nil` for cash rows, which use a dedicated @@ -264,7 +363,9 @@ public final class ChatBubbleView: UIView { case .deleted(let placeholder): body = placeholder isPlaceholder = true - case .unavailable: + case .unavailable(.updateApp): + return updateAppText() + case .unavailable(.askToResend), .unavailable(.resend): return unavailableText() case .cash, .shareProfile, .media: return nil @@ -320,4 +421,33 @@ public final class ChatBubbleView: UIView { ]) return container } + +#Preview("Unavailable bubbles") { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 12 + stack.alignment = .leading + stack.translatesAutoresizingMaskIntoConstraints = false + + let samples: [(ChatMessage, ChatBubbleAction?)] = [ + (ChatMessage(id: "1", content: .unavailable(.updateApp), sender: .other), nil), + (ChatMessage(id: "2", content: .unavailable(.updateApp), sender: .other), ChatBubbleAction(title: "Update Flipcash") {}), + (ChatMessage(id: "3", content: .unavailable(.askToResend(firstName: "Ada")), sender: .other), nil), + ] + for (message, action) in samples { + let bubble = ChatBubbleView() + bubble.configure(with: message, updateAction: action) + bubble.widthAnchor.constraint(lessThanOrEqualToConstant: 280).isActive = true + stack.addArrangedSubview(bubble) + } + + let container = UIView() + container.backgroundColor = UIColor(Color.backgroundMain) + container.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor), + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + ]) + return container +} #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index d02896b06..97b18e98e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -67,13 +67,15 @@ public final class ChatMessageCell: ChatColumnCell { /// - maxWidth: the widest the bubble may grow before its text wraps, in points. The owner /// derives it from the collection view's width. /// - quoteThumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. + /// - updateAction: the capsule an `.updateApp` bubble draws, or nil for none. public func configure( with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil, - quoteThumbnail: ChatMediaLocation? = nil + quoteThumbnail: ChatMediaLocation? = nil, + updateAction: ChatBubbleAction? = nil ) { - bubble.configure(with: message, quoteThumbnail: quoteThumbnail) + bubble.configure(with: message, quoteThumbnail: quoteThumbnail, updateAction: updateAction) maxWidthConstraint.constant = maxWidth reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth From 734075c8603fd72ed51176101fa93419177006fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 11:21:42 -0400 Subject: [PATCH 2/8] feat(update): show a one-time toast when a store update is out The app knew only the forced minimum build, so an optional update went unnoticed. `UpdateAvailability` (on `Container`) now asks the App Store lookup for the newest version when the app becomes active, at most every 6 hours, and caches the result. Debug, TestFlight and Xcode builds skip the check: anything whose `AppTransaction` environment isn't production. A failed lookup reads as no update. Priority comes from `.well-known/release-manifest.json`, read from `main` on GitHub. It applies only when its production version matches the lookup's, and it is seeded with the current store version, 2026.10.2, at priority 0. "New version available" with an Update action shows once per version when the update is priority (4+) or has been out 7 days: - On the Chats or Wallet home, 1s after coming back from a pushed screen or after 3s on the home, for 10s (section 11693:162). - In a chat, 1s after it opens with the keyboard down, for 6s, raised over the composer and as wide as it (section 11705:162). The keyboard coming up dismisses it. The version is recorded only once the toast is actually on screen, since the controller drops a toast while a sheet or bill covers the app. Update opens `SKStoreProductViewController`, falling back to the App Store app if the product fails to load. The unsupported-message bubble gets the same action as "Update Flipcash" whenever a newer version exists, with no priority or once-only gate. `toastClearance` takes an optional horizontal inset for the chat bar, so every toast in a chat now sits above the composer at its width. Debug builds can force the toast with `-UpdateAvailableOverride `. --- .well-known/release-manifest.json | 4 + Flipcash/Core/AppDelegate.swift | 1 + Flipcash/Core/Container.swift | 3 + .../Core/Controllers/UpdateAvailability.swift | 271 ++++++++++++++++++ Flipcash/Core/Controllers/UpdateCheck.swift | 166 +++++++++++ .../ChatScreenRepresentable.swift | 8 + .../Conversation/ConversationScreen.swift | 25 ++ .../Core/Screens/Main/Home/HomeTabView.swift | 39 ++- Flipcash/Keychain/Defaults.swift | 3 + FlipcashTests/UpdateAvailabilityTests.swift | 226 +++++++++++++++ .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 18 +- .../Views/Containers/ToastController.swift | 17 +- 13 files changed, 782 insertions(+), 5 deletions(-) create mode 100644 .well-known/release-manifest.json create mode 100644 Flipcash/Core/Controllers/UpdateAvailability.swift create mode 100644 Flipcash/Core/Controllers/UpdateCheck.swift create mode 100644 FlipcashTests/UpdateAvailabilityTests.swift diff --git a/.well-known/release-manifest.json b/.well-known/release-manifest.json new file mode 100644 index 000000000..ece165bb0 --- /dev/null +++ b/.well-known/release-manifest.json @@ -0,0 +1,4 @@ +{ + "updated": "2026-10-14T00:00:00Z", + "production": { "version": "2026.10.2", "priority": 0 } +} diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index 182616a7f..131530f70 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -144,6 +144,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { logger.info("scenePhase → active") container.client.warmUpChannel() container.flipClient.warmUpChannel() + container.updateAvailability.refresh() sessionContainer?.session.didBecomeActive() sessionContainer?.usdcSweepOperation.start() sessionContainer?.contactSyncController.didBecomeActive() diff --git a/Flipcash/Core/Container.swift b/Flipcash/Core/Container.swift index 0fc0b18f5..59bef09b9 100644 --- a/Flipcash/Core/Container.swift +++ b/Flipcash/Core/Container.swift @@ -23,6 +23,7 @@ class Container { let databaseStore: DatabaseStore let toasts: ToastController let networkPath: NetworkPathMonitor + let updateAvailability: UpdateAvailability @ObservationIgnored lazy var sessionAuthenticator = SessionAuthenticator(container: self) @ObservationIgnored lazy var deepLinkController = DeepLinkController(sessionAuthenticator: sessionAuthenticator) @@ -46,6 +47,7 @@ class Container { self.databaseStore = DatabaseStore() self.toasts = ToastController() self.networkPath = NetworkPathMonitor() + self.updateAvailability = UpdateAvailability() _ = sessionAuthenticator } @@ -62,6 +64,7 @@ class Container { .environment(notificationController) .environment(toasts) .environment(networkPath) + .environment(updateAvailability) } static func configureFirebase() { diff --git a/Flipcash/Core/Controllers/UpdateAvailability.swift b/Flipcash/Core/Controllers/UpdateAvailability.swift new file mode 100644 index 000000000..3de991a8b --- /dev/null +++ b/Flipcash/Core/Controllers/UpdateAvailability.swift @@ -0,0 +1,271 @@ +// +// UpdateAvailability.swift +// Flipcash +// + +import Foundation +import StoreKit +import UIKit +import FlipcashCore +import FlipcashUI + +nonisolated private let logger = Logger(label: "flipcash.update-availability") + +/// Whether a newer App Store version exists, and whether the one-time "New version available" toast +/// may show for it. +/// +/// Checks the App Store lookup and the repo's release manifest when the app becomes active, at most +/// once per ``UpdatePolicy/checkInterval``, and keeps the last result across launches. Debug, +/// TestFlight and other non-store builds never check, so they never report an update. +@MainActor +@Observable +final class UpdateAvailability { + + /// The newer store version, or `nil` when the installed build is current or nothing is known. + var newerVersion: AppVersion? { + UpdateEligibility.newerVersion(installed: installedVersion, release: result?.release) + } + + /// Whether the toast may show now: a newer version is priority or stale, and it hasn't shown yet. + var isToastEligible: Bool { + UpdateEligibility.isToastEligible( + installed: installedVersion, + result: result, + lastShownVersion: lastShownVersion, + now: now() + ) + } + + private var result: UpdateCheckResult? + private var lastShownVersion: AppVersion? + + @ObservationIgnored private let installedVersion: AppVersion? + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let now: () -> Date + @ObservationIgnored private let fetch: @Sendable (URL) async throws -> Data + @ObservationIgnored private let isStoreBuild: () async -> Bool + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var storeDelegate: StoreProductDelegate? + @ObservationIgnored private var shownToastID: ToastController.Toast.ID? + + // MARK: - Init - + + init( + installedVersion: String = AppMeta.version, + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init, + fetch: @escaping @Sendable (URL) async throws -> Data = UpdateAvailability.fetchFromNetwork, + isStoreBuild: @escaping () async -> Bool = UpdateAvailability.isRunningStoreBuild + ) { + self.installedVersion = AppVersion(installedVersion) + self.defaults = defaults + self.now = now + self.fetch = fetch + self.isStoreBuild = isStoreBuild + self.result = Self.load(UpdateCheckResult.self, .updateCheckResult, from: defaults) + self.lastShownVersion = Self.load(AppVersion.self, .updateToastShownVersion, from: defaults) + #if DEBUG + applyDebugOverride() + #endif + } + + // MARK: - Check - + + /// Starts a store check unless one ran within ``UpdatePolicy/checkInterval`` or one is under way. + func refresh() { + guard refreshTask == nil else { return } + #if DEBUG + if Self.debugOverride != nil { return } + #endif + if let result, now().timeIntervalSince(result.checkedAt) < UpdatePolicy.checkInterval { return } + refreshTask = Task { + await check() + refreshTask = nil + } + } + + /// Runs one store check and records what it found. A failed lookup reads as no update. + func check() async { + guard await isStoreBuild() else { + result = nil + return + } + let checkedAt = now() + let country = Locale.current.region?.identifier ?? "us" + + async let lookupData = fetch(StoreRelease.lookupURL(country: country)) + async let manifestData = fetch(ReleaseManifest.url) + + let release: StoreRelease? + do { + release = StoreRelease.parseLookup(try await lookupData) + } catch { + logger.warning("App Store lookup failed", metadata: ["error": "\(error)"]) + release = nil + } + + var manifest: ReleaseManifest? + do { + manifest = ReleaseManifest.parse(try await manifestData) + } catch { + logger.warning("Release manifest fetch failed", metadata: ["error": "\(error)"]) + } + + guard let release else { + result = nil + save(UpdateCheckResult?.none, .updateCheckResult) + return + } + let checked = UpdateCheckResult( + release: release, + priority: manifest?.priority(for: release.version) ?? 0, + checkedAt: checkedAt + ) + result = checked + save(checked, .updateCheckResult) + logger.info("Checked for update", metadata: [ + "storeVersion": "\(release.version)", + "priority": "\(checked.priority)", + "isNewer": "\(newerVersion != nil)", + ]) + } + + // MARK: - Toast - + + /// Shows the update toast on `toasts` if it is eligible, and records its version as seen once it is + /// on screen. Returns whether it showed. + @discardableResult + func showToastIfEligible(on toasts: ToastController, timeout: Duration) -> Bool { + guard isToastEligible, let version = newerVersion else { return false } + let toast = ToastController.Toast( + "New version available", + messageIdentifier: "update-available-toast", + action: .init("Update", accessibilityIdentifier: "update-available-toast-action") { [weak self] in + self?.openStore() + }, + duration: timeout + ) + toasts.show(toast) + // The controller drops a toast while something covers the app; only one that went up counts. + guard toasts.current?.id == toast.id else { return false } + lastShownVersion = version + shownToastID = toast.id + save(version, .updateToastShownVersion) + logger.info("Showed update toast", metadata: ["version": "\(version)"]) + return true + } + + /// Dismisses the update toast from `toasts` if it is still showing. It already counts as seen. + func dismissToast(on toasts: ToastController) { + guard let shownToastID else { return } + toasts.dismiss(shownToastID) + self.shownToastID = nil + } + + // MARK: - Store - + + /// Opens the app's App Store page in the app, falling back to the App Store app when the page can't load. + func openStore() { + guard let presenter = Self.topViewController else { + URL.appStoreApplicationHome.openWithApplication() + return + } + let store = SKStoreProductViewController() + let delegate = StoreProductDelegate { [weak self] in self?.storeDelegate = nil } + store.delegate = delegate + storeDelegate = delegate + presenter.present(store, animated: true) + store.loadProduct(withParameters: [SKStoreProductParameterITunesItemIdentifier: Self.appStoreID]) { loaded, error in + guard !loaded else { return } + Task { @MainActor in + logger.warning("App Store product failed to load", metadata: ["error": "\(String(describing: error))"]) + store.dismiss(animated: true) { + URL.appStoreApplicationHome.openWithApplication() + } + } + } + } + + private static let appStoreID = 6758636374 + + private static var topViewController: UIViewController? { + var top = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + while let presented = top?.presentedViewController { top = presented } + return top + } + + // MARK: - Defaults - + + /// Fetches `url` over the shared session. + nonisolated static func fetchFromNetwork(_ url: URL) async throws -> Data { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return data + } + + /// Whether this build came from the App Store. Debug builds never do; TestFlight reports sandbox and a + /// build run from Xcode reports `xcode`. + static func isRunningStoreBuild() async -> Bool { + #if DEBUG + return false + #else + guard let transaction = try? await AppTransaction.shared.payloadValue else { return false } + return transaction.environment == .production + #endif + } + + // MARK: - Persistence - + + private static func load(_ type: T.Type, _ key: DefaultsKey, from defaults: UserDefaults) -> T? { + guard let data = defaults.data(forKey: key.rawValue) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func save(_ value: T?, _ key: DefaultsKey) { + guard let value, let data = try? JSONEncoder().encode(value) else { + defaults.removeObject(forKey: key.rawValue) + return + } + defaults.set(data, forKey: key.rawValue) + } + + // MARK: - Debug - + + #if DEBUG + /// The store version a debug launch pretends to find, set by the `-UpdateAvailableOverride ` + /// launch argument. It reads as a priority update and ignores the seen-once record. + private static var debugOverride: AppVersion? { + UserDefaults.standard.string(forKey: "UpdateAvailableOverride").flatMap(AppVersion.init) + } + + private func applyDebugOverride() { + guard let version = Self.debugOverride else { return } + result = UpdateCheckResult( + release: StoreRelease(version: version, releasedAt: now()), + priority: UpdatePolicy.priorityThreshold, + checkedAt: now() + ) + lastShownVersion = nil + } + #endif +} + +/// Clears the presenter's hold on the store sheet once the user closes it. +private final class StoreProductDelegate: NSObject, SKStoreProductViewControllerDelegate { + private let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func productViewControllerDidFinish(_ viewController: SKStoreProductViewController) { + viewController.dismiss(animated: true) + onFinish() + } +} diff --git a/Flipcash/Core/Controllers/UpdateCheck.swift b/Flipcash/Core/Controllers/UpdateCheck.swift new file mode 100644 index 000000000..c77bee9f5 --- /dev/null +++ b/Flipcash/Core/Controllers/UpdateCheck.swift @@ -0,0 +1,166 @@ +// +// UpdateCheck.swift +// Flipcash +// + +import Foundation + +/// The thresholds that decide when the update toast shows, kept together so both triggers and the +/// eligibility check read the same numbers. +nonisolated enum UpdatePolicy { + /// The release manifest priority, on Play's 0–5 scale, from which an update counts as priority. + static let priorityThreshold = 4 + /// How long a newer store version has been out before an update counts without priority. + static let stalenessThreshold: TimeInterval = 7 * 24 * 60 * 60 + /// The shortest gap between two store checks. + static let checkInterval: TimeInterval = 6 * 60 * 60 + /// The pause after the user comes back to a tab home before the toast shows. + static let returnSettle: Duration = .seconds(1) + /// How long the user stays on a tab home before the toast shows. + static let homeDwell: Duration = .seconds(3) + /// The pause after a chat opens before the toast shows. + static let chatSettle: Duration = .seconds(1) + /// How long the toast stays on a tab home. + static let homeTimeout: Duration = .seconds(10) + /// How long the toast stays in a chat, where it covers the newest messages. + static let chatTimeout: Duration = .seconds(6) +} + +/// A dotted marketing version, such as `2026.10.4`, compared numerically per component. +nonisolated struct AppVersion: Comparable, Codable, Sendable, CustomStringConvertible { + + /// The numeric components, most significant first. + let components: [Int] + + /// The version `string` names, or `nil` when it is empty or any component is not a number. + init?(_ string: String) { + let parts = string.trimmingCharacters(in: .whitespaces).split(separator: ".", omittingEmptySubsequences: false) + let numbers = parts.compactMap { Int($0) } + guard !numbers.isEmpty, numbers.count == parts.count, numbers.allSatisfy({ $0 >= 0 }) else { return nil } + components = numbers + } + + var description: String { + components.map(String.init).joined(separator: ".") + } + + // A missing trailing component reads as zero, so `2026.10` equals `2026.10.0`. + static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { + for index in 0.. Bool { + !(lhs < rhs) && !(rhs < lhs) + } + + init(from decoder: any Decoder) throws { + let string = try decoder.singleValueContainer().decode(String.self) + guard let version = AppVersion(string) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Not a version")) + } + self = version + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} + +/// The newest version the App Store offers, and when it was released. +nonisolated struct StoreRelease: Codable, Equatable, Sendable { + let version: AppVersion + let releasedAt: Date + + /// The App Store lookup endpoint for the app in `country`, an ISO 3166 alpha-2 code. + static func lookupURL(country: String) -> URL { + var components = URLComponents(string: "https://itunes.apple.com/lookup")! + components.queryItems = [ + URLQueryItem(name: "id", value: "6758636374"), + URLQueryItem(name: "country", value: country.lowercased()), + ] + return components.url! + } + + /// The release the lookup response `data` describes, or `nil` when it lists no usable result. + static func parseLookup(_ data: Data) -> StoreRelease? { + struct Response: Decodable { + struct Result: Decodable { + let version: String + let currentVersionReleaseDate: String + } + let results: [Result] + } + guard let result = (try? JSONDecoder().decode(Response.self, from: data))?.results.first, + let version = AppVersion(result.version), + let releasedAt = ISO8601DateFormatter().date(from: result.currentVersionReleaseDate) + else { return nil } + return StoreRelease(version: version, releasedAt: releasedAt) + } +} + +/// The release manifest published from the repo, which marks how urgent the production version is. +nonisolated struct ReleaseManifest: Equatable, Sendable { + let productionVersion: AppVersion + let priority: Int + + /// Where the manifest is read from. + static let url = URL(string: "https://raw.githubusercontent.com/code-payments/code-ios-app/main/.well-known/release-manifest.json")! + + /// The manifest `data` holds, or `nil` when it doesn't parse. + static func parse(_ data: Data) -> ReleaseManifest? { + struct Body: Decodable { + struct Production: Decodable { + let version: String + let priority: Int + } + let production: Production + } + guard let body = try? JSONDecoder().decode(Body.self, from: data), + let version = AppVersion(body.production.version) + else { return nil } + return ReleaseManifest(productionVersion: version, priority: body.production.priority) + } + + /// The priority this manifest gives `version`: its own priority when it names that version, else 0. + func priority(for version: AppVersion) -> Int { + productionVersion == version ? priority : 0 + } +} + +/// What the last store check found, cached between launches. +nonisolated struct UpdateCheckResult: Codable, Equatable, Sendable { + let release: StoreRelease + let priority: Int + let checkedAt: Date +} + +/// Decides, from a store check, whether a newer version exists and whether it merits the toast. +nonisolated enum UpdateEligibility { + + /// The store version when it is newer than `installed`, else `nil`. + static func newerVersion(installed: AppVersion?, release: StoreRelease?) -> AppVersion? { + guard let installed, let release, release.version > installed else { return nil } + return release.version + } + + /// Whether the toast may show for `result`: the store build is newer, the update is priority or has + /// been out for the staleness threshold, and the toast hasn't shown for that version yet. + static func isToastEligible( + installed: AppVersion?, + result: UpdateCheckResult?, + lastShownVersion: AppVersion?, + now: Date + ) -> Bool { + guard let result, let newer = newerVersion(installed: installed, release: result.release) else { return false } + guard lastShownVersion != newer else { return false } + let isPriority = result.priority >= UpdatePolicy.priorityThreshold + let isStale = now.timeIntervalSince(result.release.releasedAt) >= UpdatePolicy.stalenessThreshold + return isPriority || isStale + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 4d259e9e4..981dc1a48 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -44,6 +44,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let linkCardSource: any LinkCardSource /// Whether web cards ask for their pages as they draw (DMs, members) or wait for the chip. let webPreviewMode: WebLinkPreviewMode + /// The "Update Flipcash" capsule an `.updateApp` bubble draws, or nil while no newer version is known. + let updateAction: ChatBubbleAction? + /// The app's toast, raised over the bar and narrowed to its width while the chat is open. Passed in: + /// the bar's hosting controller doesn't inherit the environment. + let toasts: ToastController /// Fired when the user taps the "Encrypted" marker above a DM's first encrypted message. let onEncryptionMarkerTap: () -> Void /// Fired when the user taps an author's face in a group's gutter, with that author's user id. @@ -165,6 +170,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.webPreviewMode = webPreviewMode + screen.updateAction = updateAction screen.onMediaTap = onMediaTap screen.acceptsPastedImages = acceptsMedia && editingStableID == nil screen.onImagesPasted = onImagesPasted @@ -224,6 +230,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.webPreviewMode = webPreviewMode + screen.updateAction = updateAction screen.onMediaTap = onMediaTap screen.acceptsPastedImages = acceptsMedia && editingStableID == nil screen.onImagesPasted = onImagesPasted @@ -347,6 +354,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } ) .environment(conversationController) + .toastClearance(toasts, horizontalInset: BarMetrics.compactInset) .modifier( MeasuredBarHeight { height, accessories in // After the update that measured it, not inside it. Resized inside an animated diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 7ae0ddf73..03b0609f1 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -98,6 +98,8 @@ struct ConversationScreen: View { /// The join-requests anchor's measured height, reserved at the transcript's top; 0 while hidden. @State private var anchorHeight: CGFloat = 0 + /// Whether the keyboard is up, which holds the update toast back. + @State private var isKeyboardUp = false /// This conversation's id when the viewer hosts it as a private group; the anchor is host-only. private var hostedConversationID: ConversationID? { @@ -217,6 +219,13 @@ struct ConversationScreen: View { /// requirement — or a rate that finally loads — opens the chat without a reopen. /// DMs and members fetch web previews as they draw; anyone else, or a chat whose record has not /// loaded yet, waits for the chip. + /// The bubble's "Update Flipcash" action, offered whenever the store has a newer version. + private var updateAction: ChatBubbleAction? { + let updates = container.updateAvailability + guard updates.newerVersion != nil else { return nil } + return ChatBubbleAction(title: "Update Flipcash") { updates.openStore() } + } + private var webPreviewMode: WebLinkPreviewMode { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID), @@ -403,6 +412,8 @@ struct ConversationScreen: View { onLinkCardTap: openLinkCard, linkCardSource: sessionContainer.linkCardFeed, webPreviewMode: webPreviewMode, + updateAction: updateAction, + toasts: container.toasts, onEncryptionMarkerTap: { isShowingEncryptionInfo = true }, onAuthorTap: openAuthorProfile, onMessageAction: handleMessageAction, @@ -689,6 +700,20 @@ struct ConversationScreen: View { /// The fetches the transcript needs: gate token names, sender names, and avatars. private func loads(_ content: some View) -> some View { content + // The update toast, once per version: a beat after the chat opens, never over a raised keyboard. + .task { + guard !openKeyboard else { return } + try? await Task.sleep(for: UpdatePolicy.chatSettle) + guard !Task.isCancelled, !isKeyboardUp else { return } + container.updateAvailability.showToastIfEligible(on: container.toasts, timeout: UpdatePolicy.chatTimeout) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + isKeyboardUp = true + container.updateAvailability.dismissToast(on: container.toasts) + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + isKeyboardUp = false + } .task { await sessionContainer.linkCardMemo.awaitLoaded() waitedForLinkPreviews = true diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 85b002c6d..7f46238f9 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -70,11 +70,31 @@ struct HomeTabView: View { /// Sheets already cover the bar, so only in-tab pushes need handling here. @State private var cardExpansion = WalletCardExpansion() + /// Set when the user comes back to a tab home from a screen pushed on it, so the update toast waits + /// only for the navigation to settle rather than the full dwell. + @State private var returnedToHome = false + + private var hasPushedScreen: Bool { + selection.pushStack.map { !router[$0].isEmpty } ?? false + } + + /// When the update toast may show on this view: the Chats or Wallet home, with nothing pushed or + /// covering it. + private var updateToastMoment: UpdateToastMoment? { + guard selection == .chat || selection == .wallet, + !hasPushedScreen, + !container.toasts.isCovered, + !sessionContainer.session.isShowingBill, + container.updateAvailability.isToastEligible + else { return nil } + return UpdateToastMoment(tab: selection, isReturn: returnedToHome) + } + private var isTabBarHidden: Bool { selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, - hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false + hasPushedScreen: hasPushedScreen ) } @@ -105,6 +125,17 @@ struct HomeTabView: View { .onChange(of: router.presentedSheet != nil || sessionContainer.session.isShowingBill, initial: true) { _, covered in container.toasts.isCovered = covered } + .onChange(of: hasPushedScreen) { wasPushed, isPushed in + if wasPushed && !isPushed { returnedToHome = true } + } + .onChange(of: selection) { _, _ in returnedToHome = false } + .task(id: updateToastMoment) { + guard let moment = updateToastMoment else { return } + try? await Task.sleep(for: moment.isReturn ? UpdatePolicy.returnSettle : UpdatePolicy.homeDwell) + guard !Task.isCancelled else { return } + returnedToHome = false + container.updateAvailability.showToastIfEligible(on: container.toasts, timeout: UpdatePolicy.homeTimeout) + } .onChange(of: sessionContainer.session.profile?.username, initial: true) { _, username in sessionContainer.chatArchive.setViewerUsername(username) } @@ -343,6 +374,12 @@ struct HomeTabView: View { } } +/// A tab home the update toast can show on, and whether the user just came back to it. +private struct UpdateToastMoment: Equatable { + let tab: HomeTab + let isReturn: Bool +} + extension HomeTab { /// The router stack this tab pushes onto, published to `AppRouter` as the /// active push target. `nil` for tabs that only present sheets (Scan) or diff --git a/Flipcash/Keychain/Defaults.swift b/Flipcash/Keychain/Defaults.swift index 197b7237e..015b2ed2a 100644 --- a/Flipcash/Keychain/Defaults.swift +++ b/Flipcash/Keychain/Defaults.swift @@ -27,6 +27,9 @@ enum DefaultsKey: String { case contactsConnected = "com.flipcash.contacts.connected" case trustedWebsites = "com.flipcash.links.trustedWebsites" + + case updateCheckResult = "com.flipcash.update.checkResult" + case updateToastShownVersion = "com.flipcash.update.toastShownVersion" } private let defaultsEncoder = JSONEncoder() diff --git a/FlipcashTests/UpdateAvailabilityTests.swift b/FlipcashTests/UpdateAvailabilityTests.swift new file mode 100644 index 000000000..4d486eea1 --- /dev/null +++ b/FlipcashTests/UpdateAvailabilityTests.swift @@ -0,0 +1,226 @@ +// +// UpdateAvailabilityTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashUI +@testable import Flipcash + +@Suite struct AppVersionTests { + + @Test func comparesEachComponentNumerically() { + #expect(AppVersion("2026.10.10")! > AppVersion("2026.10.9")!) + #expect(AppVersion("2026.9.30")! < AppVersion("2026.10.1")!) + #expect(AppVersion("2027.1.0")! > AppVersion("2026.12.31")!) + } + + @Test func missingTrailingComponentsReadAsZero() { + #expect(AppVersion("2026.10")! == AppVersion("2026.10.0")!) + #expect(AppVersion("2026.10.1")! > AppVersion("2026.10")!) + } + + @Test(arguments: ["", "2026.x.1", "2026..1", "1.-2", "v2026.10.1"]) + func rejectsMalformed(_ string: String) { + #expect(AppVersion(string) == nil) + } +} + +@Suite struct UpdateCheckParsingTests { + + @Test func lookupReadsTheFirstResult() { + let data = Data(""" + {"resultCount":1,"results":[{"version":"2026.10.4","currentVersionReleaseDate":"2026-10-08T17:02:11Z"}]} + """.utf8) + let release = StoreRelease.parseLookup(data) + #expect(release?.version == AppVersion("2026.10.4")) + #expect(release?.releasedAt == ISO8601DateFormatter().date(from: "2026-10-08T17:02:11Z")) + } + + @Test func lookupWithNoResultsIsNil() { + #expect(StoreRelease.parseLookup(Data(#"{"resultCount":0,"results":[]}"#.utf8)) == nil) + #expect(StoreRelease.parseLookup(Data("not json".utf8)) == nil) + } + + @Test func lookupURLNamesTheAppAndStorefront() { + let url = StoreRelease.lookupURL(country: "CA").absoluteString + #expect(url == "https://itunes.apple.com/lookup?id=6758636374&country=ca") + } + + @Test func manifestPriorityAppliesOnlyToItsVersion() { + let data = Data(""" + {"updated":"2026-10-14T00:00:00Z","production":{"version":"2026.10.4","priority":5}} + """.utf8) + let manifest = ReleaseManifest.parse(data) + #expect(manifest?.priority(for: AppVersion("2026.10.4")!) == 5) + #expect(manifest?.priority(for: AppVersion("2026.10.5")!) == 0) + } + + @Test func malformedManifestIsNil() { + #expect(ReleaseManifest.parse(Data(#"{"production":{"version":"x","priority":1}}"#.utf8)) == nil) + } +} + +@Suite struct UpdateEligibilityTests { + + private let now = Date(timeIntervalSince1970: 1_000_000_000) + private let installed = AppVersion("2026.10.3") + + private func result(version: String = "2026.10.4", daysOut: Double, priority: Int = 0) -> UpdateCheckResult { + UpdateCheckResult( + release: StoreRelease(version: AppVersion(version)!, releasedAt: now.addingTimeInterval(-daysOut * 86_400)), + priority: priority, + checkedAt: now + ) + } + + private func eligible(_ result: UpdateCheckResult?, shown: String? = nil) -> Bool { + UpdateEligibility.isToastEligible( + installed: installed, + result: result, + lastShownVersion: shown.flatMap(AppVersion.init), + now: now + ) + } + + @Test func freshOptionalUpdateWaits() { + #expect(!eligible(result(daysOut: 6.9))) + } + + @Test func updateOutForSevenDaysIsEligible() { + #expect(eligible(result(daysOut: 7))) + } + + @Test func priorityUpdateIsEligibleAtOnce() { + #expect(eligible(result(daysOut: 0, priority: 4))) + #expect(!eligible(result(daysOut: 0, priority: 3))) + } + + @Test func showsOncePerVersion() { + #expect(!eligible(result(daysOut: 30), shown: "2026.10.4")) + #expect(eligible(result(version: "2026.10.5", daysOut: 30), shown: "2026.10.4")) + } + + @Test func sameOrOlderStoreVersionIsNoUpdate() { + #expect(!eligible(result(version: "2026.10.3", daysOut: 30, priority: 5))) + #expect(!eligible(result(version: "2026.10.2", daysOut: 30, priority: 5))) + #expect(UpdateEligibility.newerVersion(installed: installed, release: result(version: "2026.10.2", daysOut: 1).release) == nil) + } + + @Test func noResultIsNoUpdate() { + #expect(!eligible(nil)) + } +} + +@MainActor +@Suite struct UpdateAvailabilityTests { + + private let defaults = UserDefaults(suiteName: "update-availability-\(UUID())")! + private let now = ISO8601DateFormatter().date(from: "2026-10-14T12:00:00Z")! + + private static let staleLookup = Data(""" + {"results":[{"version":"2026.10.4","currentVersionReleaseDate":"2026-10-01T00:00:00Z"}]} + """.utf8) + + private func sut( + isStoreBuild: Bool = true, + lookup: Data = staleLookup, + manifest: Data? = nil, + at date: Date? = nil + ) -> UpdateAvailability { + let time = date ?? now + return UpdateAvailability( + installedVersion: "2026.10.3", + defaults: defaults, + now: { time }, + fetch: { url in + if url == ReleaseManifest.url { + guard let manifest else { throw URLError(.fileDoesNotExist) } + return manifest + } + return lookup + }, + isStoreBuild: { isStoreBuild } + ) + } + + @Test func storeBuildFindsTheNewerVersion() async { + let updates = sut() + await updates.check() + #expect(updates.newerVersion == AppVersion("2026.10.4")) + #expect(updates.isToastEligible) + } + + @Test func nonStoreBuildNeverReportsAnUpdate() async { + let updates = sut(isStoreBuild: false) + await updates.check() + #expect(updates.newerVersion == nil) + #expect(!updates.isToastEligible) + } + + @Test func failedLookupIsNoUpdate() async { + let updates = UpdateAvailability( + installedVersion: "2026.10.3", + defaults: defaults, + now: { now }, + fetch: { _ in throw URLError(.notConnectedToInternet) }, + isStoreBuild: { true } + ) + await updates.check() + #expect(updates.newerVersion == nil) + } + + @Test func manifestPriorityMakesAFreshUpdateEligible() async { + let fresh = Data(""" + {"results":[{"version":"2026.10.4","currentVersionReleaseDate":"2026-10-13T00:00:00Z"}]} + """.utf8) + let manifest = Data(#"{"production":{"version":"2026.10.4","priority":4}}"#.utf8) + let plain = sut(lookup: fresh) + await plain.check() + #expect(plain.newerVersion != nil) + #expect(!plain.isToastEligible) + + let urgent = sut(lookup: fresh, manifest: manifest) + await urgent.check() + #expect(urgent.isToastEligible) + } + + @Test func resultSurvivesRelaunch() async { + await sut().check() + #expect(sut().newerVersion == AppVersion("2026.10.4")) + } + + @Test func showingTheToastMarksItsVersionSeen() async { + let updates = sut() + await updates.check() + let toasts = ToastController() + #expect(updates.showToastIfEligible(on: toasts, timeout: .seconds(10))) + #expect(toasts.current != nil) + #expect(!updates.isToastEligible) + #expect(!sut().isToastEligible) + #expect(!updates.showToastIfEligible(on: toasts, timeout: .seconds(10))) + } + + @Test func toastDroppedWhileCoveredIsNotSeen() async { + let updates = sut() + await updates.check() + let toasts = ToastController() + toasts.isCovered = true + #expect(!updates.showToastIfEligible(on: toasts, timeout: .seconds(10))) + #expect(updates.isToastEligible) + } + + @Test func dismissRemovesOnlyTheUpdateToast() async { + let updates = sut() + await updates.check() + let toasts = ToastController() + updates.showToastIfEligible(on: toasts, timeout: .seconds(10)) + updates.dismissToast(on: toasts) + #expect(toasts.current == nil) + + toasts.show(.init("Copied")) + updates.dismissToast(on: toasts) + #expect(toasts.current != nil) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index d2fa3bdf1..927bb63b1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -231,6 +231,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.webPreviewMode = newValue } } + /// ``ChatViewController/updateAction``. + public var updateAction: ChatBubbleAction? { + get { transcript.updateAction } + set { transcript.updateAction = newValue } + } + /// Where a photo row gets its download URL — see ``ChatViewController/mediaURLResolver``. public var mediaURLResolver: ChatMediaURLResolver? { get { transcript.mediaURLResolver } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index b32599472..511ed0aed 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -96,6 +96,21 @@ public final class ChatViewController: UICollectionViewController { } } + /// The capsule an `.updateApp` bubble draws, or nil while no newer version is known. A change + /// redraws the rows on screen. + public var updateAction: ChatBubbleAction? { + didSet { + guard (updateAction == nil) != (oldValue == nil), isViewLoaded else { return } + for cell in collectionView.visibleCells where cell is ChatMessageCell { + guard let indexPath = collectionView.indexPath(for: cell), + items.indices.contains(indexPath.item), + case .message(let message) = items[indexPath.item] else { continue } + configure(cell, with: message) + } + collectionView.collectionViewLayout.invalidateLayout() + } + } + /// The web previews this transcript's viewer asked for through the chip, shared by every row. private let webPreviewRequests = WebPreviewRequests() @@ -690,7 +705,8 @@ public final class ChatViewController: UICollectionViewController { with: message, maxWidth: maxWidth, authorImageData: authorImageData, - quoteThumbnail: quoteThumbnail(for: message) + quoteThumbnail: quoteThumbnail(for: message), + updateAction: updateAction ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onQuoteTap = { [weak self] id in self?.onQuoteTap?(id) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift index 86779f72b..798549997 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift @@ -60,6 +60,10 @@ public final class ToastController { /// through ``View/toastClearance(_:)``. public var bottomClearance: CGFloat = 0 + /// The toast's inset from each screen edge in place of the floating tab bar's, for a screen whose + /// pinned bar is narrower than the tab bar. Set through ``View/toastClearance(_:horizontalInset:)``. + public var horizontalInset: CGFloat? + /// A controller with no toast showing. public init() {} @@ -114,14 +118,16 @@ extension View { extension View { /// Raises `controller`'s toast above this view, measured, for as long as it is on screen. Put it on - /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. - public func toastClearance(_ controller: ToastController) -> some View { - modifier(ToastClearance(controller: controller)) + /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. A `horizontalInset` sets + /// the toast's inset from each screen edge, to match the bar's width, for the same span. + public func toastClearance(_ controller: ToastController, horizontalInset: CGFloat? = nil) -> some View { + modifier(ToastClearance(controller: controller, horizontalInset: horizontalInset)) } } private struct ToastClearance: ViewModifier { let controller: ToastController + let horizontalInset: CGFloat? @State private var height: CGFloat = 0 @State private var isVisible = false @@ -134,10 +140,12 @@ private struct ToastClearance: ViewModifier { .onAppear { isVisible = true controller.bottomClearance = height + controller.horizontalInset = horizontalInset } .onDisappear { isVisible = false controller.bottomClearance = 0 + controller.horizontalInset = nil } } } @@ -162,6 +170,9 @@ private struct ToastHost: View { width: toast.width, onDismiss: toast.action == nil ? nil : { controller.dismiss(toast.id) } ) + .transformEnvironment(\.floatingTabBar) { tabBar in + if let inset = controller.horizontalInset { tabBar = FloatingTabBar(horizontalInset: inset) } + } .allowsHitTesting(toast.action != nil) .padding(.bottom, bottomPadding + controller.bottomClearance) .floatingToastTransition() From 8a3774121c9b85bd1b2d5ba750bc5428584ec1c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:10:16 -0400 Subject: [PATCH 3/8] fix(update): rest the in-chat toast 8pt above the composer The chat toast sat 28pt above the composer, not the 8pt section 11705:162 asks for: the clearance counted the bar's 8pt top margin and its 8pt drop into the safe area on top of the host's 12pt padding. `toastClearance` takes an `overlap` that lowers the toast back into the bar's measured height, and the chat passes `BarMetrics.toastOverlap` (20pt). The profile screens keep the default of 0. --- .../Conversation/ChatScreenRepresentable.swift | 2 +- .../Conversation/ConversationBottomBar.swift | 5 +++++ .../Views/Containers/ToastController.swift | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 981dc1a48..06f4c962a 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -354,7 +354,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } ) .environment(conversationController) - .toastClearance(toasts, horizontalInset: BarMetrics.compactInset) + .toastClearance(toasts, horizontalInset: BarMetrics.compactInset, overlap: BarMetrics.toastOverlap) .modifier( MeasuredBarHeight { height, accessories in // After the update that measured it, not inside it. Resized inside an animated diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 1e60896bf..65e9250b9 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -250,6 +250,11 @@ enum BarMetrics { static func trailingControlCentreInset(isCompact: Bool) -> CGFloat { controlInset(isCompact: isCompact) + contentHeight / 2 } + + /// How far a toast comes back down into the compact bar's measured height, so it rests 8pt above + /// the controls: the bar's top margin, its drop into the safe area, and the 4pt the toast host's + /// 12pt padding exceeds that gap. + static let toastOverlap: CGFloat = contentPadding + compactDrop + 4 } /// What stands outside the message field, beside it. diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift index 798549997..9eca9b233 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift @@ -119,23 +119,30 @@ extension View { extension View { /// Raises `controller`'s toast above this view, measured, for as long as it is on screen. Put it on /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. A `horizontalInset` sets - /// the toast's inset from each screen edge, to match the bar's width, for the same span. - public func toastClearance(_ controller: ToastController, horizontalInset: CGFloat? = nil) -> some View { - modifier(ToastClearance(controller: controller, horizontalInset: horizontalInset)) + /// the toast's inset from each screen edge, to match the bar's width, for the same span. An `overlap` + /// lowers the toast that far back into the bar's measured height, for a bar with clear margin above + /// its controls. + public func toastClearance( + _ controller: ToastController, + horizontalInset: CGFloat? = nil, + overlap: CGFloat = 0 + ) -> some View { + modifier(ToastClearance(controller: controller, horizontalInset: horizontalInset, overlap: overlap)) } } private struct ToastClearance: ViewModifier { let controller: ToastController let horizontalInset: CGFloat? + let overlap: CGFloat @State private var height: CGFloat = 0 @State private var isVisible = false func body(content: Content) -> some View { content .onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in - height = newValue - if isVisible { controller.bottomClearance = newValue } + height = max(0, newValue - overlap) + if isVisible { controller.bottomClearance = height } } .onAppear { isVisible = true From da16cb4a36f29efe34fe8a04fa90c6d8f0b6c9a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 17:00:16 -0400 Subject: [PATCH 4/8] feat(settings): add the update dot on the gear and the version card The You tab's gear shows the attention dot while a newer store build exists. The Settings footer becomes a card: inline with the build and 'Up to date' (keeping the beta-access tap), or pinned on glass with an Update capsule when a newer version exists. Debug builds keep an inline card with a Debug tag, no dot and no Update button. --- .../Core/Screens/Main/You/YouScreen.swift | 9 ++ .../Screens/Settings/SettingsScreen.swift | 67 ++++---- .../Settings/SettingsVersionCard.swift | 144 ++++++++++++++++++ .../SettingsVersionCardStateTests.swift | 41 +++++ 4 files changed, 233 insertions(+), 28 deletions(-) create mode 100644 Flipcash/Core/Screens/Settings/SettingsVersionCard.swift create mode 100644 FlipcashTests/SettingsVersionCardStateTests.swift 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..b4bdf8181 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,9 +22,10 @@ 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() @@ -40,14 +41,23 @@ struct SettingsScreen: View { privacySection advancedSection accountSection - versionFooter - .padding(.top, 32) + if !cardState.isPinned { + versionCard + .padding(.top, 32) + } } .font(.appDisplayXS) .foregroundStyle(.textMain) .padding(.bottom, 24) } } + // Pinned over the list on glass; the inset keeps Log Out and Delete Account clear of it. + .safeAreaInset(edge: .bottom) { + if cardState.isPinned { + versionCard + .padding(.bottom, 8) + } + } .navigationTitle("Settings") .toolbarTitleDisplayMode(.inline) .dialog(item: $dialogItem) @@ -146,30 +156,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..af2b4a08c --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift @@ -0,0 +1,144 @@ +// +// 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(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("Flipcash \(AppMeta.version)") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color.textMain) + if state == .debug { + Text("Debug") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.textSecondary.opacity(0.2))) + } + } + subtitle + } + .lineLimit(1) + + Spacer(minLength: 0) + + trailing + } + .padding(.horizontal, 16) + .frame(height: 64) + .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") + } + + /// Liquid Glass on iOS 26; a material on iOS 18, which has no glass. + @ViewBuilder + private var pinnedBackground: some View { + let shape = RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous) + if #available(iOS 26.0, *) { + shape.fill(Color.clear).glassEffect(.regular, in: shape) + } else { + shape.fill(.ultraThinMaterial) + } + } + + @ViewBuilder + private var subtitle: some View { + switch state { + case .updateAvailable(let version): + Text("\(version.description) available") + .font(.system(size: 12)) + .foregroundStyle(Color.textSuccess) + .accessibilityIdentifier("settings-version-subtitle") + case .upToDate, .debug: + Text("Build \(AppMeta.build)") + .font(.system(size: 12)) + .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(.system(size: 12)) + .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) + } + } +} From fb1162fc8968cbbbe8c61346a702450541e8546e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 17:15:52 -0400 Subject: [PATCH 5/8] fix(settings): match the version card to the A8 frames Dark glass with a 10% white edge, bold title, 70pt height, 50pt above the screen bottom. Settings also takes down the update toast so it never covers the pinned card. --- .../Screens/Settings/SettingsScreen.swift | 4 ++- .../Settings/SettingsVersionCard.swift | 30 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index b4bdf8181..7b6ec4a78 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -55,12 +55,14 @@ struct SettingsScreen: View { .safeAreaInset(edge: .bottom) { if cardState.isPinned { versionCard - .padding(.bottom, 8) + .padding(.bottom, 16) } } .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 - diff --git a/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift b/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift index af2b4a08c..63a2e72b9 100644 --- a/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift +++ b/Flipcash/Core/Screens/Settings/SettingsVersionCard.swift @@ -58,15 +58,15 @@ struct SettingsVersionCard: View { let onUpdate: () -> Void var body: some View { - HStack(spacing: 12) { + HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text("Flipcash \(AppMeta.version)") - .font(.system(size: 14, weight: .medium)) + .font(.appTextSmall) .foregroundStyle(Color.textMain) if state == .debug { Text("Debug") - .font(.system(size: 11, weight: .medium)) + .font(.appTextCaption) .foregroundStyle(Color.textSecondary) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -76,13 +76,15 @@ struct SettingsVersionCard: View { subtitle } .lineLimit(1) + .padding(.top, 15) Spacer(minLength: 0) trailing + .frame(maxHeight: .infinity) } .padding(.horizontal, 16) - .frame(height: 64) + .frame(height: 70) .background { if state.isPinned { pinnedBackground @@ -100,15 +102,15 @@ struct SettingsVersionCard: View { .accessibilityIdentifier("you-version-footer") } - /// Liquid Glass on iOS 26; a material on iOS 18, which has no glass. - @ViewBuilder + /// 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) - if #available(iOS 26.0, *) { - shape.fill(Color.clear).glassEffect(.regular, in: shape) - } else { - shape.fill(.ultraThinMaterial) - } + 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 @@ -116,12 +118,12 @@ struct SettingsVersionCard: View { switch state { case .updateAvailable(let version): Text("\(version.description) available") - .font(.system(size: 12)) + .font(.appTextCaption) .foregroundStyle(Color.textSuccess) .accessibilityIdentifier("settings-version-subtitle") case .upToDate, .debug: Text("Build \(AppMeta.build)") - .font(.system(size: 12)) + .font(.appTextCaption) .foregroundStyle(Color.textSecondary) .accessibilityIdentifier("settings-version-subtitle") } @@ -135,7 +137,7 @@ struct SettingsVersionCard: View { .accessibilityIdentifier("settings-version-update") case .upToDate: Text("Up to date \u{2713}") - .font(.system(size: 12)) + .font(.appTextCaption) .foregroundStyle(Color.textSecondary) case .debug: EmptyView() From 8c0a8b26283e4679ca9c4c72e59dec31b85ffd88 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 20:55:49 -0400 Subject: [PATCH 6/8] fix(settings): pin the version card in a bottom scroll-edge bar --- Flipcash/Core/Screens/Settings/SettingsScreen.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 7b6ec4a78..d73d6fdfb 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -51,11 +51,15 @@ struct SettingsScreen: View { .padding(.bottom, 24) } } - // Pinned over the list on glass; the inset keeps Log Out and Delete Account clear of it. - .safeAreaInset(edge: .bottom) { + // A bar, not a plain inset, so the soft bottom scroll edge has something to draw from: the + // pinned card sits on glass in it, and inline the bar is empty, so rows fade at the bottom + // in both states. The bar's inset also keeps Log Out and Delete Account clear of the card. + .scrollEdgeBar(.bottom) { if cardState.isPinned { versionCard .padding(.bottom, 16) + } else { + Color.clear.frame(height: 0) } } .navigationTitle("Settings") From e2c307adc2a8547bc71188338e3b17f9eecbd6a4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 21:03:33 -0400 Subject: [PATCH 7/8] fix(settings): fade rows at the bottom when the version card is inline --- Flipcash/Core/Screens/Settings/SettingsScreen.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index d73d6fdfb..93a00a272 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -28,6 +28,8 @@ struct SettingsScreen: View { /// 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. @@ -50,16 +52,19 @@ struct SettingsScreen: View { .foregroundStyle(.textMain) .padding(.bottom, 24) } + .profileScrollFit($scrollFit) } - // A bar, not a plain inset, so the soft bottom scroll edge has something to draw from: the - // pinned card sits on glass in it, and inline the bar is empty, so rows fade at the bottom - // in both states. The bar's inset also keeps Log Out and Delete Account clear of the card. + // 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) + Color.clear + .frame(height: 0) + .profileBarlessFade(isActive: scrollFit.fadesBottom) } } .navigationTitle("Settings") From 3dd44b52b0a507c4686324e88b1eec3096e6f749 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:48:31 -0400 Subject: [PATCH 8/8] fix(settings): close the inline version card's top gap to 8pt --- Flipcash/Core/Screens/Settings/SettingsScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 93a00a272..602ab205e 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -45,7 +45,7 @@ struct SettingsScreen: View { accountSection if !cardState.isPinned { versionCard - .padding(.top, 32) + .padding(.top, 8) } } .font(.appDisplayXS)