Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions Flipcash/Core/Controllers/ProfileAvatarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,24 @@ final class ProfileAvatarStore {
case chat(ConversationID)
/// A user's cover banner. Authorizes like the user's avatar but holds the full-size blob.
case cover(UserID)
/// A group chat's cover banner. Authorizes like the chat's picture but holds the full-size blob.
case groupCover(ConversationID)

/// The context this subject's blobs authorize through.
var accessContext: BlobAccessContext {
switch self {
case .user(let userID): .userProfile(userID)
case .chat(let conversationID): .chatProfile(conversationID)
case .cover(let userID): .userProfile(userID)
case .groupCover(let conversationID): .chatProfile(conversationID)
}
}

/// The blob of `picture` this subject renders: avatars use the thumbnail, a cover the original.
func blobID(for picture: ProfilePicture) -> BlobID {
switch self {
case .user, .chat: picture.thumbnailBlobID
case .cover: picture.blobID
case .user, .chat: picture.thumbnailBlobID
case .cover, .groupCover: picture.blobID
}
}
}
Expand Down Expand Up @@ -145,7 +148,10 @@ final class ProfileAvatarStore {

/// Covers hold the full-size blob, so they get their own budget rather than crowding out thumbnails.
private func cache(for subject: AvatarSubject) -> BlobCache {
if case .cover = subject { coverCache } else { cache }
switch subject {
case .user, .chat: cache
case .cover, .groupCover: coverCache
}
}

/// Download URLs expire, so one is minted per fetch and never stored.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ struct ChangeCoverPictureScreen: View {
Button("Choose File", systemImage: "folder") { isShowingFilePicker = true }
} label: {
ProfileCoverBanner(
userID: session.userID,
coverPicture: profile?.coverPicture,
cover: .user(session.userID, picture: profile?.coverPicture),
preview: state.selectedImage
)
.clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius))
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct EditProfileScreen: View {
fieldCards
.padding(.top, 20)
}
.padding(.horizontal, ProfileHeaderView<EmptyView, EmptyView, EmptyView>.inset)
.padding(.horizontal, ProfileHeaderMetrics.inset)
.padding(.vertical, 24)
}
}
Expand All @@ -59,7 +59,7 @@ struct EditProfileScreen: View {
Button {
router.push(.changeCoverPicture)
} label: {
ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture, bannerHeight: Self.coverHeight)
ProfileCoverBanner(cover: .user(session.userID, picture: profile?.coverPicture), bannerHeight: Self.coverHeight)
.clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous))
}
.buttonStyle(.plain)
Expand Down
23 changes: 14 additions & 9 deletions Flipcash/Core/Screens/Main/You/YouScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ struct YouScreen: View {
if displayName == nil {
setupPrompt
.padding(.top, 24)
.padding(.horizontal, ProfileHeaderView<EmptyView, EmptyView, EmptyView>.inset)
.padding(.horizontal, ProfileHeaderMetrics.inset)
} else {
ProfileStatsCard(
minimumToChat: StartChattingFee.amount(
Expand Down Expand Up @@ -115,13 +115,18 @@ struct YouScreen: View {

private var header: some View {
ProfileHeaderView(
userID: sessionContainer.session.userID,
displayName: displayName,
handle: username.map(\.handle),
bio: profile?.bio,
avatarData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID),
avatarBlurhash: profilePicture?.thumbnailBlurhash,
coverPicture: profile?.coverPicture,
cover: .user(sessionContainer.session.userID, picture: profile?.coverPicture),
title: displayName,
subtitle: username.map(\.handle),
bodyText: profile?.bio,
avatar: {
ProfileHeaderAvatar(
id: sessionContainer.session.userID.uuidString,
displayName: displayName ?? "",
imageData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID),
blurhash: profilePicture?.thumbnailBlurhash
)
},
bannerControls: { EmptyView() },
rowActions: {
ProfileEditCapsule {
Expand All @@ -133,7 +138,7 @@ struct YouScreen: View {
shareButton
}
},
underHandle: {
underSubtitle: {
if shouldPromptForUsername {
Button(action: claimUsername) {
Text("Claim your username ›")
Expand Down
41 changes: 31 additions & 10 deletions Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ import SwiftUI
import FlipcashCore
import FlipcashUI

/// What a cover banner draws: the picture, and the subject its bytes load and authorize under.
struct ProfileCover {

let subject: ProfileAvatarStore.AvatarSubject
let picture: ProfilePicture?

private init(subject: ProfileAvatarStore.AvatarSubject, picture: ProfilePicture?) {
self.subject = subject
self.picture = picture
}

/// A user's cover.
static func user(_ userID: UserID, picture: ProfilePicture?) -> ProfileCover {
ProfileCover(subject: .cover(userID), picture: picture)
}

/// A group chat's cover.
static func group(_ conversationID: ConversationID, picture: ProfilePicture?) -> ProfileCover {
ProfileCover(subject: .groupCover(conversationID), picture: picture)
}
}

/// A profile's full-bleed cover: the picture once it has loaded, the blurhash while it does, and a
/// flat gray when there is no picture.
///
Expand All @@ -15,8 +37,7 @@ struct ProfileCoverBanner<Controls: View>: View {

@Environment(SessionContainer.self) private var sessionContainer

let userID: UserID
let coverPicture: ProfilePicture?
let cover: ProfileCover
/// A picked image not uploaded yet, drawn in place of the stored cover.
var preview: UIImage? = nil
/// The banner's height; a profile's own cover is ``height``.
Expand All @@ -29,13 +50,13 @@ struct ProfileCoverBanner<Controls: View>: View {
Color.clear
.frame(height: bannerHeight)
.frame(maxWidth: .infinity)
.overlay { cover }
.overlay { image }
.clipped()
.overlay(alignment: .top) {
controls()
}
.task(id: coverPicture?.blobID) {
await sessionContainer.profileAvatars.load(.cover(userID), picture: coverPicture)
.task(id: cover.picture?.blobID) {
await sessionContainer.profileAvatars.load(cover.subject, picture: cover.picture)
}
// `.contain` keeps the banner's controls as their own elements; a bare identifier
// here would overwrite theirs.
Expand All @@ -44,11 +65,11 @@ struct ProfileCoverBanner<Controls: View>: View {
}

@ViewBuilder
private var cover: some View {
private var image: some View {
if let preview {
fill(preview)
} else if let coverPicture {
let data = sessionContainer.profileAvatars.data(for: .cover(userID))
} else if let coverPicture = cover.picture {
let data = sessionContainer.profileAvatars.data(for: cover.subject)
if let data, let image = ContactAvatarCache.shared.image(forKey: "cover-\(coverPicture.blobID)", data: data) {
fill(image)
} else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) {
Expand All @@ -71,7 +92,7 @@ struct ProfileCoverBanner<Controls: View>: View {
extension ProfileCoverBanner where Controls == EmptyView {

/// A cover with nothing laid over it.
init(userID: UserID, coverPicture: ProfilePicture?, preview: UIImage? = nil, bannerHeight: CGFloat = Self.height) {
self.init(userID: userID, coverPicture: coverPicture, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() })
init(cover: ProfileCover, preview: UIImage? = nil, bannerHeight: CGFloat = Self.height) {
self.init(cover: cover, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() })
}
}
91 changes: 54 additions & 37 deletions Flipcash/Core/Screens/Profile/ProfileHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,45 @@ import SwiftUI
import FlipcashCore
import FlipcashUI

/// A profile's top block: a full-bleed cover, the avatar overlapping it, an action row, then name,
/// handle and bio, all left-aligned on a 24pt inset.
///
/// The caller lets the view run under the status bar. A nil `displayName` leaves the name block out,
/// for a profile that has not named itself yet.
struct ProfileHeaderView<BannerControls: View, RowActions: View, UnderHandle: View>: View {

let userID: UserID
let displayName: String?
let handle: String?
let bio: String?
let avatarData: Data?
let avatarBlurhash: String?
let coverPicture: ProfilePicture?
@ViewBuilder let bannerControls: () -> BannerControls
@ViewBuilder let rowActions: () -> RowActions
@ViewBuilder let underHandle: () -> UnderHandle
/// The profile header's layout constants, shared with the sections laid out beneath it.
enum ProfileHeaderMetrics {

/// The horizontal inset everything below the banner sits on.
static var inset: CGFloat { 24 }

static var avatarSize: CGFloat { 84 }
/// How far the avatar rises over the banner.
static var avatarOverlap: CGFloat { 42 }
}

/// A profile's top block: a full-bleed cover, the caller's avatar overlapping it, an action row,
/// then title, subtitle and body text, all left-aligned on a 24pt inset.
///
/// 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<Avatar: View, BannerControls: View, RowActions: View, UnderSubtitle: View>: View {

let cover: ProfileCover
let title: String?
let subtitle: String?
let bodyText: String?
/// Drawn at ``ProfileHeaderMetrics/avatarSize``; the header adds the ring and the overlap.
@ViewBuilder let avatar: () -> Avatar
@ViewBuilder let bannerControls: () -> BannerControls
@ViewBuilder let rowActions: () -> RowActions
@ViewBuilder let underSubtitle: () -> UnderSubtitle

var body: some View {
VStack(alignment: .leading, spacing: 0) {
ProfileCoverBanner(
userID: userID,
coverPicture: coverPicture,
cover: cover,
controls: bannerControls
)

HStack(alignment: .top, spacing: 0) {
ContactAvatarView(
id: userID.uuidString,
displayName: displayName ?? "",
imageData: avatarData,
blurhash: avatarBlurhash,
size: Self.avatarSize
)
.overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) }
.padding(.top, -Self.avatarOverlap)
avatar()
.overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) }
.padding(.top, -ProfileHeaderMetrics.avatarOverlap)

Spacer(minLength: 8)

Expand All @@ -57,26 +54,26 @@ struct ProfileHeaderView<BannerControls: View, RowActions: View, UnderHandle: Vi
}
.padding(.top, 20)
}
.padding(.horizontal, Self.inset)
.padding(.horizontal, ProfileHeaderMetrics.inset)

if let displayName {
if let title {
VStack(alignment: .leading, spacing: 0) {
Text(displayName)
Text(title)
.font(.appDisplaySmall)
.foregroundStyle(Color.textMain)
.accessibilityIdentifier("profile-name")

if let handle {
Text(handle)
if let subtitle {
Text(subtitle)
.font(.appTextSmall)
.foregroundStyle(Color.textSecondary)
.accessibilityIdentifier("profile-handle")
}

underHandle()
underSubtitle()

if let bio, !bio.isEmpty {
Text(bio)
if let bodyText, !bodyText.isEmpty {
Text(bodyText)
.font(.appTextMessage)
.foregroundStyle(Color.textMain)
.multilineTextAlignment(.leading)
Expand All @@ -86,12 +83,32 @@ struct ProfileHeaderView<BannerControls: View, RowActions: View, UnderHandle: Vi
}
}
.padding(.top, 8)
.padding(.horizontal, Self.inset)
.padding(.horizontal, ProfileHeaderMetrics.inset)
}
}
}
}

/// A person's avatar for the header's avatar slot: their picture, or their monogram, at the
/// header's size.
struct ProfileHeaderAvatar: View {

let id: String
let displayName: String
let imageData: Data?
let blurhash: String?

var body: some View {
ContactAvatarView(
id: id,
displayName: displayName,
imageData: imageData,
blurhash: blurhash,
size: ProfileHeaderMetrics.avatarSize
)
}
}

/// The share button in the header's action row: an icon on a flat filled circle.
struct ProfileActionCircle: View {

Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Profile/ProfileStatsCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct ProfileStatsCard: View {
}
.frame(height: 84)
.background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous))
.padding(.horizontal, ProfileHeaderView<EmptyView, EmptyView, EmptyView>.inset)
.padding(.horizontal, ProfileHeaderMetrics.inset)
}

private func stat(title: String, value: String, valueFont: Font, identifier: String) -> some View {
Expand Down
23 changes: 14 additions & 9 deletions Flipcash/Core/Screens/Profile/UserProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,24 @@ private struct UserProfileContent: View {
ScrollView {
VStack(spacing: 0) {
ProfileHeaderView(
userID: model.userID,
displayName: model.displayName,
handle: model.handle,
bio: model.bio,
avatarData: model.imageData,
avatarBlurhash: model.blurhash,
coverPicture: model.coverPicture,
cover: .user(model.userID, picture: model.coverPicture),
title: model.displayName,
subtitle: model.handle,
bodyText: model.bio,
avatar: {
ProfileHeaderAvatar(
id: model.userID.uuidString,
displayName: model.displayName,
imageData: model.imageData,
blurhash: model.blurhash
)
},
bannerControls: { EmptyView() },
rowActions: {
statusBadges
shareButton
},
underHandle: { EmptyView() }
underSubtitle: { EmptyView() }
)

ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt)
Expand Down Expand Up @@ -291,7 +296,7 @@ private struct UserProfileContent: View {
CodeButton(style: .filled, title: title, action: tapPinned)
.accessibilityIdentifier("profile-pinned-action")
}
.padding(.horizontal, ProfileHeaderView<EmptyView, EmptyView, EmptyView>.inset)
.padding(.horizontal, ProfileHeaderMetrics.inset)
.padding(.top, 12)
.padding(.bottom, 8)
} else if showsE2eeFooter {
Expand Down
Loading
Loading