diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index fce8dcd81..7a2f44e3b 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -63,6 +63,7 @@ final class AppRouter { /// The destination `push` last appended, and onto which stack. Any other change /// to `paths` clears it, so it names the top of that stack whenever it is set. @ObservationIgnored private var lastPush: (stack: Stack, destination: Destination)? + @ObservationIgnored private var pendingCollapse: (stack: Stack, destination: Destination, count: Int)? /// The stack owned by the currently-selected tab. A tab is the active /// surface without being a *sheet*, so `presentedSheet` is nil while a tab @@ -336,6 +337,38 @@ final class AppRouter { logger.info("Set path", metadata: navigationMetadata(stack: stack, destination: destinations.last)) } + /// Pushes `destination` over the current top as a normal forward push and remembers the + /// resulting depth, so `collapseTopmostPath(toTop:)` can later drop everything beneath it. + /// Replacing the path outright would animate as a pop whenever the new path is shorter. + func pushForCollapse(_ destination: Destination) { + guard let stack = topmostStack else { + logger.warning("Push for collapse attempted with no topmost stack", metadata: [ + "destination": "\(destination)", + ]) + return + } + paths[stack, default: NavigationPath()].append(destination) + pendingCollapse = (stack, destination, paths[stack, default: NavigationPath()].count) + lastPush = (stack, destination) + logger.info("Push", metadata: navigationMetadata(stack: stack, destination: destination)) + } + + /// Once the push from `pushForCollapse(_:)` has settled, removes everything beneath + /// `destination` so the stack becomes `[destination]`. Removing views under the visible top + /// is not animated. Does nothing if the stack changed in the meantime (the user already + /// popped or something else was pushed), so it never discards a screen the user is on. + func collapseTopmostPath(toTop destination: Destination) { + guard let pending = pendingCollapse, pending.destination == destination else { return } + pendingCollapse = nil + guard paths[pending.stack, default: NavigationPath()].count == pending.count else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + setPath([destination], on: pending.stack) + } + lastPush = (pending.stack, destination) + } + /// SwiftUI binding setter — fires when the NavigationStack writes a new /// path back through the binding (system swipe-back, NavigationLink /// activation, programmatic `dismiss()`). Distinguished from explicit diff --git a/Flipcash/Core/Screens/Conversation/GroupReadyCard.swift b/Flipcash/Core/Screens/Conversation/GroupReadyCard.swift index 87733ef5d..8506be819 100644 --- a/Flipcash/Core/Screens/Conversation/GroupReadyCard.swift +++ b/Flipcash/Core/Screens/Conversation/GroupReadyCard.swift @@ -90,7 +90,7 @@ private struct GroupReadyInvitePresenter: ViewModifier { open() } .dialog(item: $dialog) - .fullScreenCover(item: $share) { share in + .sheet(item: $share) { share in switch share { case .invite(let links): ShareToChatsSheet(subject: .inviteLink(conversationID, links), isPresented: isSharing, onInvited: invited) diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift index 2937be189..89aea0808 100644 --- a/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift @@ -12,7 +12,8 @@ import FlipcashCore enum InviteLinkCopy { static let creating = "Creating link…" - static let footnote = "Anyone with this link can join and see who’s in the group. Share it only with people you want in the group" + /// Under the link pill on a group's Invite People sheet, for both kinds of link. + static let groupSheetLine = "Send this link to the people you want to join your group" static let cantShare = "Can’t share from this device" /// Under an invite link's group card QR. @@ -59,11 +60,6 @@ enum InviteLinkCopy { isOn ? "You approve each person" : "Anyone with the link joins right away" } - /// The sheet's meta line under the current link: "Expires Nov 3 · 0 joined". - static func sheetMeta(_ invite: Invite) -> String { - "\(expiry(invite)) · \(joined(invite))" - } - /// A Manage Invites row's second line. static func rowDetail(_ entry: GroupInviteLinks.Entry) -> String { let invite = entry.invite diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift b/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift index 36bd1bc38..3e57d2f98 100644 --- a/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift +++ b/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift @@ -66,7 +66,7 @@ struct InviteLinksScreen: View { ) } // Share Link opens the invite share sheet (1f) on that row's link. - .fullScreenCover(item: $sharing) { shared in + .sheet(item: $sharing) { shared in if let links { ShareToChatsSheet( subject: .inviteLink(conversationID, links, entry: shared.id), diff --git a/Flipcash/Core/Screens/Invite Links/InvitePeopleSheet.swift b/Flipcash/Core/Screens/Invite Links/InvitePeopleSheet.swift index 4e7d13d55..d0f6907c9 100644 --- a/Flipcash/Core/Screens/Invite Links/InvitePeopleSheet.swift +++ b/Flipcash/Core/Screens/Invite Links/InvitePeopleSheet.swift @@ -362,10 +362,10 @@ private struct InvitePeoplePresenter: ViewModifier { .task { try? await links.load() } } } - .fullScreenCover(isPresented: canInvite ? .constant(false) : $isPresented) { + .sheet(isPresented: canInvite ? .constant(false) : $isPresented) { ShareToChatsSheet(subject: .group(conversationID), isPresented: $isPresented, onInvited: onInvited) } - .fullScreenCover(item: $share) { share in + .sheet(item: $share) { share in switch share { case .invite(let links): ShareToChatsSheet(subject: .inviteLink(conversationID, links), isPresented: isSharing, onInvited: invited) diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift index e806b7c3c..26a554aad 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/NewPublicGroupScreen.swift @@ -305,14 +305,17 @@ struct NewPublicGroupScreen: View { guard !Task.isCancelled else { return } - // The whole create flow is replaced rather than stacked under the chat. Back from - // a group that now exists belongs at the chat list holding it, not at a filled - // form offering to create it twice or at the picker that opened the form. The - // chat list is this stack's root, so unwinding to it is the same as popping both. - router.popToRoot() - // Both privacies land in the chat. A private group's invite card is there, so the - // host's first job (inviting people) starts from it rather than from the profile. - router.push(.tipConversation(conversation.id)) + // The chat is pushed over the form so it slides in from the right; replacing the + // path with a shorter one would animate as a pop. NavigationStack has no + // push-finished callback, so the form and picker are dropped from beneath it after + // a delay sized to the push transition. Back from a group that now exists belongs + // at the chat list, not at a filled form offering to create it twice. Both + // privacies land in the chat, where a private group's invite card starts the + // host's first invite. + let chat = AppRouter.Destination.tipConversation(conversation.id) + router.pushForCollapse(chat) + try? await Task.delay(milliseconds: 600) + router.collapseTopmostPath(toTop: chat) } catch { buttonState = .normal diff --git a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift index 90c9e7010..299256677 100644 --- a/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/New Group/ShareToChatsSheet.swift @@ -37,10 +37,10 @@ enum ShareToChatsSubject { } /// "Invite People" (nodes 10330:19387, 10330:19549, 10329:12104): hand out a link by Share or Copy, -/// or post it straight into recent chats with an optional message. A group's sheet shows its link in -/// a pill above the tiles, whichever link it is. A group gets a third tile, Show QR Code, and a -/// person with a name gets Show Profile Card; either draws its card over the sheet, and closing it -/// uncovers the sheet as it was left. +/// or post it straight into recent chats with an optional message. A group's sheet is the link in a +/// pill, one line under it and three tiles (Copy, Share, QR Code), with no Recent Chats, and its +/// height wraps that content. A person with a name gets Show Profile Card; it and the group card +/// draw over the sheet, and closing one uncovers the sheet as it was left. struct ShareToChatsSheet: View { let subject: ShareToChatsSubject @@ -63,6 +63,7 @@ struct ShareToChatsSheet: View { @State private var model: InvitePeopleViewModel @State private var didCopy = false @State private var headerHeight: CGFloat = 0 + @State private var fittedHeight: CGFloat = 0 @State private var isShowingCard = false @State private var isShowingGroupCard = false @FocusState private var isMessageFocused: Bool @@ -94,10 +95,10 @@ struct ShareToChatsSheet: View { } } - /// The chat the picker leaves out: the group itself, or the DM with the person shared. + /// The chat the picker leaves out: the DM with the person shared. A group's sheet has no picker. private var excludedChatID: ConversationID? { switch subject { - case .group(let id), .inviteLink(let id, _, _): id + case .group, .inviteLink: nil case .user(_, _, _, _, let directChatID): directChatID // The message's own chat stays: a link sent there points at something further up. case .message, .currency: nil @@ -189,18 +190,29 @@ struct ShareToChatsSheet: View { private var copyTitle: String { switch subject { - case .group, .inviteLink: "Copy Invite Link" + case .group, .inviteLink: "Copy" case .user, .message, .currency: "Copy Link" } } - private var sendTitle: String { + private var sendTitle: String { "Send" } + + /// Whether the sheet is a group's link sheet: no Recent Chats, and a height that wraps its content. + nonisolated static func isGroupSheet(_ subject: ShareToChatsSubject) -> Bool { switch subject { - case .group, .inviteLink: "Invite" - case .user, .message, .currency: "Send" + case .group, .inviteLink: true + case .user, .message, .currency: false } } + /// Whether the sheet lists recent chats to send the link into. + nonisolated static func showsRecentChats(for subject: ShareToChatsSubject) -> Bool { + !isGroupSheet(subject) + } + + /// The group sheet's tiles, left to right. + nonisolated static let groupTileTitles = ["Copy", "Share", "QR Code"] + /// The group's own title, which names it in the shared message. /// /// Read from the record rather than through `displayName(for:)`: that resolves an untitled chat @@ -217,13 +229,8 @@ struct ShareToChatsSheet: View { /// The chats the Chats tab lists, newest activity first: 1:1 chats and joined groups, less /// ``excludedChatID``. Search and people outside recent chats are out of scope. - /// - /// An invite link goes only to chats the server can't read (Decision 6): the link and the - /// server's records together open the group key. private var recentChats: [Conversation] { - let chats = conversationController.chatListConversations.filter { $0.id != excludedChatID } - guard isInviteMode else { return chats } - return chats.filter(E2eePolicy.shouldEncrypt) + conversationController.chatListConversations.filter { $0.id != excludedChatID } } /// The group's picture for the share sheet's own preview card. @@ -255,20 +262,63 @@ struct ShareToChatsSheet: View { var body: some View { Background(color: .backgroundMain) { + if Self.isGroupSheet(subject) { + groupContent + } else { + listContent + } + } + .overlay { + if isShowingCard, let card { + ProfileCardScreen(card: card) { + withAnimation(ProfileCardScreen.fade) { isShowingCard = false } + } + .transition(.asymmetric(insertion: .identity, removal: .opacity)) + } else if isShowingGroupCard, let groupCard { + GroupCardScreen(card: groupCard) { + withAnimation(GroupCardScreen.fade) { isShowingGroupCard = false } + } + .transition(.asymmetric(insertion: .identity, removal: .opacity)) + } + } + .presentationBackground(Color.backgroundMain) + .interactiveDismissDisabled(model.isSending) + .task { + // Idempotent: the store returns without a round trip once the bytes are in memory or + // on disk. Loading here rather than relying on the presenting screen keeps the preview + // card's icon independent of which screen opened the sheet. + guard let groupID else { return } + await sessionContainer.profileAvatars.load(.chat(groupID), picture: conversation?.picture) + } + } + + /// A group's sheet: title bar, the link, its line and the tiles, fitted to their height. The + /// card is full height, so the sheet opens to large while it shows. + private var groupContent: some View { + VStack(spacing: 0) { + header + VStack(spacing: 16) { + linkPanel + shareTiles + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { fittedHeight = $0 } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .presentationDetents( + [isShowingGroupCard || fittedHeight == 0 ? .large : .height(fittedHeight)] + ) + } + + private var listContent: some View { VStack(spacing: 0) { // A List (not a ScrollView of Buttons) so a drag over a row scrolls and never // toggles it on release. List { - Group { - if groupID != nil { - VStack(spacing: 16) { - linkPanel - shareTiles - } - } else { - shareTiles - } - } + shareTiles .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -295,17 +345,6 @@ struct ShareToChatsSheet: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) } - - if isInviteMode { - Text(InviteLinkCopy.footnote) - .font(.default(size: 12, weight: .medium)) - .foregroundStyle(Color.textMain.opacity(0.5)) - .fixedSize(horizontal: false, vertical: true) - .listRowInsets(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .accessibilityIdentifier("invite-link-footnote") - } } .listStyle(.plain) .scrollContentBackground(.hidden) @@ -336,29 +375,6 @@ struct ShareToChatsSheet: View { .ignoresSafeArea(.container, edges: .bottom) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - .overlay { - if isShowingCard, let card { - ProfileCardScreen(card: card) { - withAnimation(ProfileCardScreen.fade) { isShowingCard = false } - } - .transition(.asymmetric(insertion: .identity, removal: .opacity)) - } else if isShowingGroupCard, let groupCard { - GroupCardScreen(card: groupCard) { - withAnimation(GroupCardScreen.fade) { isShowingGroupCard = false } - } - .transition(.asymmetric(insertion: .identity, removal: .opacity)) - } - } - .presentationBackground(Color.backgroundMain) - .interactiveDismissDisabled(model.isSending) - .task { - // Idempotent: the store returns without a round trip once the bytes are in memory or - // on disk. Loading here rather than relying on the presenting screen keeps the preview - // card's icon independent of which screen opened the sheet. - guard let groupID else { return } - await sessionContainer.profileAvatars.load(.chat(groupID), picture: conversation?.picture) - } } /// The sheet's own title bar: a centred title and a glass close button on the right. @@ -378,72 +394,92 @@ struct ShareToChatsSheet: View { .onGeometryChange(for: CGFloat.self, of: \.size.height) { headerHeight = $0 } } - /// Share and Copy Invite Link, plus Show QR Code for a group or Show Profile Card for a person - /// with a card, as tiles side by side, sized to the tallest. + /// A group's tiles are Copy, Share and QR Code; a person's are Share, Copy and, with a card, + /// Show Profile Card; a message or currency has Share and Copy. Sized to the tallest. private var shareTiles: some View { HStack(spacing: 10) { - ShareTile(title: "Share") { - Image.asset(.shareOS) + if Self.isGroupSheet(subject) { + copyTile + shareTile + groupCardTile + } else { + shareTile + copyTile + profileCardTile + } + } + .fixedSize(horizontal: false, vertical: true) + } + + private var shareTile: some View { + ShareTile(title: "Share") { + Image.asset(.shareOS) + .renderingMode(.template) + .resizable() + .scaledToFit() + } action: { + if groupID != nil { Analytics.groupInviteShared(method: .share) } + ShareSheet.present(activityItems: shareItems()) { _ in } + } + .disabled(!hasLink) + .opacity(hasLink ? 1 : 0.35) + .accessibilityIdentifier("group-invite-send") + } + + private var copyTile: some View { + ShareTile(title: didCopy ? "Copied" : copyTitle) { + if didCopy { + Image.system(.circleCheck) + .resizable() + .scaledToFit() + } else { + Image.asset(.squareBehindSquare) + .renderingMode(.template) + .resizable() + .scaledToFit() + } + } action: { + copy() + } + .disabled(!hasLink) + .opacity(hasLink ? 1 : 0.35) + .accessibilityIdentifier("group-invite-copy") + } + + @ViewBuilder + private var profileCardTile: some View { + if card != nil { + ShareTile(title: "Show Profile Card") { + // The Scan tab's outline glyph: the card is what the scanner reads. + Image(HomeTab.scan.iconName(isSelected: false)) .renderingMode(.template) .resizable() .scaledToFit() } action: { - if groupID != nil { Analytics.groupInviteShared(method: .share) } - ShareSheet.present(activityItems: shareItems()) { _ in } + isMessageFocused = false + // The card runs its own reveal on appear, so only its removal is animated. + isShowingCard = true } - .disabled(!hasLink) - .opacity(hasLink ? 1 : 0.35) - .accessibilityIdentifier("group-invite-send") - - ShareTile(title: didCopy ? "Copied" : copyTitle) { - if didCopy { - Image.system(.circleCheck) - .resizable() - .scaledToFit() - } else { - Image.asset(.squareBehindSquare) - .renderingMode(.template) - .resizable() - .scaledToFit() - } + .accessibilityIdentifier("profile-card-tile") + } + } + + @ViewBuilder + private var groupCardTile: some View { + if Self.showsGroupCardTile(for: subject) { + ShareTile(title: "QR Code") { + Image(HomeTab.scan.iconName(isSelected: false)) + .renderingMode(.template) + .resizable() + .scaledToFit() } action: { - copy() + isMessageFocused = false + isShowingGroupCard = true } .disabled(!hasLink) .opacity(hasLink ? 1 : 0.35) - .accessibilityIdentifier("group-invite-copy") - - if card != nil { - ShareTile(title: "Show Profile Card") { - // The Scan tab's outline glyph: the card is what the scanner reads. - Image(HomeTab.scan.iconName(isSelected: false)) - .renderingMode(.template) - .resizable() - .scaledToFit() - } action: { - isMessageFocused = false - // The card runs its own reveal on appear, so only its removal is animated. - isShowingCard = true - } - .accessibilityIdentifier("profile-card-tile") - } - - if Self.showsGroupCardTile(for: subject) { - ShareTile(title: "Show QR Code") { - Image(HomeTab.scan.iconName(isSelected: false)) - .renderingMode(.template) - .resizable() - .scaledToFit() - } action: { - isMessageFocused = false - isShowingGroupCard = true - } - .disabled(!hasLink) - .opacity(hasLink ? 1 : 0.35) - .accessibilityIdentifier("group-card-tile") - } + .accessibilityIdentifier("group-card-tile") } - .fixedSize(horizontal: false, vertical: true) } private var sectionHeader: some View { @@ -538,22 +574,17 @@ struct ShareToChatsSheet: View { // MARK: - Invite link - - /// The group's link over the tiles: the invite link with its expiry and uses, or the plain link, - /// which has neither. + /// The group's link over the tiles, with the line that says what to do with it. private var linkPanel: some View { InviteLinkPanel( pill: isInviteMode ? inviteURL.map(InviteLinkPanel.Pill.link) ?? .creating : .link(model.url), - meta: inviteEntry.map { InviteLinkCopy.sheetMeta($0.invite) } + meta: InviteLinkCopy.groupSheetLine ) } private func invite() { isMessageFocused = false Task { - if isInviteMode { - guard let inviteURL else { return } - model.url = inviteURL - } guard let first = await model.sendInvites(via: { text, chatID in await conversationController.send(text, to: chatID) }) else { return } diff --git a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift index 4cd2c1c5d..288ea084f 100644 --- a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift @@ -18,14 +18,6 @@ struct NewChatScreen: View { var body: some View { Background(color: .backgroundMain) { VStack(spacing: 12) { - ChatActionRow( - icon: .asset(.at), - title: "Find Someone by Username", - accessibilityIdentifier: "new-chat-find-by-username" - ) { - router.push(.usernameLookup) - } - ChatActionRow( icon: .asset(.group), title: "Create a New Group", @@ -35,6 +27,14 @@ struct NewChatScreen: View { router.push(.newPrivateGroup) } + ChatActionRow( + icon: .asset(.at), + title: "Find Someone by Username", + accessibilityIdentifier: "new-chat-find-by-username" + ) { + router.push(.usernameLookup) + } + ChatActionRow( icon: .asset(.currencyCoin), title: "Create a New Community Chat", diff --git a/FlipcashTests/Chat/GroupInviteLinksTests.swift b/FlipcashTests/Chat/GroupInviteLinksTests.swift index f0c6b4b98..9544ffb6a 100644 --- a/FlipcashTests/Chat/GroupInviteLinksTests.swift +++ b/FlipcashTests/Chat/GroupInviteLinksTests.swift @@ -299,20 +299,28 @@ struct GroupInviteLinksTests { #expect(ShareToChatsSheet.showsGroupCardTile(for: .group(group))) } - @Test("The invite share sheet's meta line describes the link itself") - func sheetMeta() { - let limited = Invite( - id: InviteID(data: Data(repeating: 1, count: 16)), - conversationID: group, - createdBy: me, - maxUses: 10, - useCount: 3, - createdAt: now, - expiresAt: nil, - status: .active, - secretEnvelope: nil - ) - #expect(InviteLinkCopy.sheetMeta(limited) == "Never expires · 3 of 10 joined") + @Test("Both group sheets drop Recent Chats and order their tiles Copy, Share, QR Code") + func groupSheetLayout() { + let links = makeLinks(Server()) + for subject in [ShareToChatsSubject.inviteLink(group, links), .group(group)] { + #expect(ShareToChatsSheet.isGroupSheet(subject)) + #expect(!ShareToChatsSheet.showsRecentChats(for: subject)) + } + #expect(ShareToChatsSheet.groupTileTitles == ["Copy", "Share", "QR Code"]) + #expect(InviteLinkCopy.groupSheetLine == "Send this link to the people you want to join your group") + } + + @Test("Profile, message and currency sheets keep Recent Chats") + func otherSheetsKeepRecentChats() { + let url = URL(string: "https://app.flipcash.com/chat/x")! + for subject in [ + ShareToChatsSubject.user(userID: UUID(), username: nil, displayName: "A", directChatID: nil), + .message(url), + .currency(url: url, name: "C"), + ] { + #expect(!ShareToChatsSheet.isGroupSheet(subject)) + #expect(ShareToChatsSheet.showsRecentChats(for: subject)) + } } // MARK: - First link and the group-ready card - diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift index 57ac1e587..55b7ea2e0 100644 --- a/FlipcashTests/Navigation/AppRouterTests.swift +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -164,6 +164,29 @@ struct AppRouterTests { #expect(router[.balance] == AppRouter.navigationPath(.discoverCurrencies, .currencyCreationSummary)) } + @Test("pushForCollapse pushes over the stack, then collapse leaves only the destination") + func pushForCollapse_thenCollapse() { + let router = AppRouter() + router.activeTabStack = .tips + router.push(.newPrivateGroup) + router.push(.newPublicGroup) + router.pushForCollapse(.currencyInfo(.usdc)) + #expect(router[.tips] == AppRouter.navigationPath(.newPrivateGroup, .newPublicGroup, .currencyInfo(.usdc))) + router.collapseTopmostPath(toTop: .currencyInfo(.usdc)) + #expect(router[.tips] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("collapse is skipped when the user already popped") + func collapse_skippedAfterPop() { + let router = AppRouter() + router.activeTabStack = .tips + router.push(.newPublicGroup) + router.pushForCollapse(.currencyInfo(.usdc)) + router.pop(on: .tips) + router.collapseTopmostPath(toTop: .currencyInfo(.usdc)) + #expect(router[.tips] == AppRouter.navigationPath(.newPublicGroup)) + } + @Test("setPath with identical path is a no-op") func setPath_identical_isNoop() { let router = AppRouter()