From 7ed87d74c1d40de3419a75786b2f071b45f43930 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 11:34:50 -0400 Subject: [PATCH] fix(profile): put the status chips on the cover beside the avatar A timed mute ("Muted until 12:15 PM") in the action row leaves too little width for Edit Group and Share; on Android the same layout wrapped Edit Group onto two lines and pushed Share off screen. Below the title was ruled out because the mute state arrives after first render and would shift the header. The cover is a fixed height, so nothing placed on it moves the rest of the header. ProfileHeaderView gains a coverAccessory slot laid on the cover's bottom edge: leading at inset + avatar + 12, trailing at the inset, 8 above the bottom, matching Android. The group profile's mute chip and a person's Blocked and mute chips move there, leaving the action row to Edit Group and Share. A timed mute that doesn't fit falls back to "Muted". Android also adds a 70% background backing under the chip because its fill is warning at 10% alpha. iOS fills with warningSecondary, which is opaque, so the backing would draw nothing and is left out. --- .../Core/Screens/Main/You/YouScreen.swift | 1 + .../Screens/Profile/ChatMuteStatusLabel.swift | 30 +++++++++++-------- .../Screens/Profile/ChatProfileScreen.swift | 6 +++- .../Screens/Profile/ProfileHeaderView.swift | 14 ++++++++- .../Screens/Profile/UserProfileScreen.swift | 8 +++-- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 414b3e0e1..e52957ebc 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -150,6 +150,7 @@ struct YouScreen: View { ) }, bannerControls: { EmptyView() }, + coverAccessory: { EmptyView() }, rowActions: { ProfileEditCapsule { router.push(.editProfile) diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 90c84bc56..a67d562e3 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -9,9 +9,9 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// What the chat's mute currently is, drawn in the action row of both profile headers — -/// "Muted until 5:56 PM" for a timed mute, plain "Muted" for an indefinite one, and nothing at all -/// while the chat is audible. +/// What the chat's mute currently is, drawn on the cover of both profile headers — +/// "Muted until 5:56 PM" for a timed mute, plain "Muted" for an indefinite one or a timed one too +/// wide for the space, and nothing at all while the chat is audible. /// /// Stating the deadline is what makes a timed mute trustworthy: without it a timed mute can't be /// told apart from an indefinite one, and the user has no way to know when the chat comes back. @@ -74,16 +74,19 @@ struct ChatMuteStatusLabel: View { } if let text { - chipLabel(text) - .accessibilityIdentifier("chat-mute-status") - // Replaced rather than resized. A chip that keeps its identity across a text - // change interpolates its width, and the glyphs inside don't interpolate with - // it: they are drawn at the new string's size against a frame still growing - // from the old one's, so the label either spills out of the capsule or slides - // sideways into place. Keying identity on the string makes every change an - // insert and a remove, which has no width to interpolate. - .transition(.scale(scale: 0.85).combined(with: .opacity)) - .id(text) + ViewThatFits(in: .horizontal) { + chipLabel(text) + chipLabel("Muted") + } + .accessibilityIdentifier("chat-mute-status") + // Replaced rather than resized. A chip that keeps its identity across a text + // change interpolates its width, and the glyphs inside don't interpolate with + // it: they are drawn at the new string's size against a frame still growing + // from the old one's, so the label either spills out of the capsule or slides + // sideways into place. Keying identity on the string makes every change an + // insert and a remove, which has no width to interpolate. + .transition(.scale(scale: 0.85).combined(with: .opacity)) + .id(text) } } .padding(.top, reservesSpace ? 3 : 0) @@ -109,5 +112,6 @@ struct ChatMuteStatusLabel: View { private func chipLabel(_ string: String) -> some View { ProfileStatusChip(systemImage: "bell.slash", text: string, tint: .warning, fill: .warningSecondary) + .lineLimit(1) } } diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 3cc25e79f..a5570a85d 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -125,10 +125,14 @@ struct ChatProfileScreen: View { ) }, bannerControls: { EmptyView() }, - rowActions: { + // On the cover rather than in the action row: a timed mute's label is + // wide enough to wrap Edit Group and push Share off the screen. + coverAccessory: { if isMember { ChatMuteStatusLabel(conversationID: conversationID, reservesSpace: false) } + }, + rowActions: { if canEdit { ProfileEditCapsule(title: "Edit Group") { router.push(.editGroup(conversationID)) diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 1ea6b0331..065df1b2a 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -23,7 +23,7 @@ enum ProfileHeaderMetrics { /// /// The caller lets the view run under the status bar. A nil `title` leaves the text block out, /// for a profile that has not named itself yet. -struct ProfileHeaderView: View { +struct ProfileHeaderView: View { let cover: ProfileCover let title: String? @@ -32,6 +32,9 @@ struct ProfileHeaderView Avatar @ViewBuilder let bannerControls: () -> BannerControls + /// Sits on the cover's bottom edge beside the avatar. The cover is a fixed height, so content + /// that arrives late, such as a status chip, never moves the rest of the header. + @ViewBuilder let coverAccessory: () -> CoverAccessory @ViewBuilder let rowActions: () -> RowActions @ViewBuilder let underSubtitle: () -> UnderSubtitle @@ -41,6 +44,15 @@ struct ProfileHeaderView