diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/AccountPopoverView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/AccountPopoverView.swift new file mode 100644 index 00000000..1d47f881 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/AccountPopoverView.swift @@ -0,0 +1,192 @@ +import Nuke +import NukeUI +import Shimmer +import SwiftUI +import Models + +struct AccountPopoverView: View { + let account: Account + let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview + private let config: AvatarView.FrameConfig = .account + + @Binding var showPopup: Bool + @Binding var autoDismiss: Bool + @Binding var toggleTask: Task + + var body: some View { + VStack(alignment: .leading) { + LazyImage(request: ImageRequest(url: account.header) + ) { state in + if let image = state.image { + image.resizable().scaledToFill() + } + } + .frame(width: 500, height: 150) + .clipped() + .background(theme.secondaryBackgroundColor) + + VStack(alignment: .leading) { + HStack(alignment: .bottomAvatar) { + AvatarImage(account.avatar, config: adaptiveConfig) + Spacer() + makeCustomInfoLabel(title: "account.following", count: account.followingCount ?? 0) + makeCustomInfoLabel(title: "account.posts", count: account.statusesCount ?? 0) + makeCustomInfoLabel(title: "account.followers", count: account.followersCount ?? 0) + } + .frame(height: adaptiveConfig.height / 2, alignment: .bottom) + + EmojiTextApp(.init(stringValue: account.safeDisplayName ), emojis: account.emojis) + .font(.headline) + .foregroundColor(theme.labelColor) + .emojiSize(Font.scaledHeadlineFont.emojiSize) + .emojiBaselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset) + .accessibilityAddTraits(.isHeader) + .help(account.safeDisplayName) + + Text("@\(account.acct)") + .font(.callout) + .foregroundColor(.gray) + .textSelection(.enabled) + .accessibilityRespondsToUserInteraction(false) + .help("@\(account.acct)") + + HStack(spacing: 4) { + Image(systemName: "calendar") + .accessibilityHidden(true) + Text("account.joined") + Text(account.createdAt.asDate, style: .date) + } + .foregroundColor(.gray) + .font(.footnote) + .accessibilityElement(children: .combine) + + EmojiTextApp(account.note, emojis: account.emojis, lineLimit: 5) + .font(.body) + .emojiSize(Font.scaledFootnoteFont.emojiSize) + .emojiBaselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset) + .padding(.top, 3) + } + .padding([.leading, .trailing, .bottom]) + } + .frame(width: 500) + .onAppear { + toggleTask.cancel() + toggleTask = Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) + guard !Task.isCancelled else { return } + if autoDismiss { + showPopup = false + } + } + } + .onHover { hovering in + toggleTask.cancel() + toggleTask = Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2) + guard !Task.isCancelled else { return } + if hovering { + autoDismiss = false + } else { + showPopup = false + autoDismiss = true + } + } + } + } + + @MainActor + private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View { + VStack { + Text(count, format: .number.notation(.compactName)) + .font(.scaledHeadline) + .foregroundColor(theme.tintColor) + .overlay(alignment: .trailing) { + if needsBadge { + Circle() + .fill(Color.red) + .frame(width: 9, height: 9) + .offset(x: 12) + } + } + Text(title) + .font(.scaledFootnote) + .foregroundColor(.gray) + .alignmentGuide(.bottomAvatar, computeValue: { dimension in + dimension[.firstTextBaseline] + }) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityValue("\(count)") + } + + private var adaptiveConfig: AvatarView.FrameConfig { + var cornerRadius: CGFloat + if config == .badge || theme.avatarShape == .circle { + cornerRadius = config.width / 2 + } else { + cornerRadius = config.cornerRadius + } + return AvatarView.FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius) + } +} + +private enum BottomAvatarAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context.height + } +} + +extension VerticalAlignment { + static let bottomAvatar = VerticalAlignment(BottomAvatarAlignment.self) +} + +public struct AccountPopoverModifier : ViewModifier { + @Environment(Theme.self) private var theme + + @State private var showPopup = false + @State private var autoDismiss = true + @State private var toggleTask: Task = Task {} + + let account: Account + + public func body(content: Content) -> some View { + content + .onHover { hovering in + if hovering { + toggleTask.cancel() + toggleTask = Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2) + guard !Task.isCancelled else { return } + if !showPopup { + showPopup = true + } + } + } else { + if !showPopup { + toggleTask.cancel() + } + } + } + .hoverEffect(.lift) + .popover(isPresented: $showPopup) { + AccountPopoverView( + account: account, + theme: theme, + showPopup: $showPopup, + autoDismiss: $autoDismiss, + toggleTask: $toggleTask + ) + } + } + + init(_ account: Account) { + self.account = account + } +} + +extension View { + public func accountPopover(_ account: Account) -> some View { + modifier(AccountPopoverModifier(account)) + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift index 77c06c98..1153a5dc 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/AvatarView.swift @@ -148,190 +148,3 @@ struct AvatarPlaceHolder: View { .frame(width: config.width, height: config.height) } } - -struct AccountPopoverView: View { - let account: Account - let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview - private let config: AvatarView.FrameConfig = .account - - @Binding var showPopup: Bool - @Binding var autoDismiss: Bool - @Binding var toggleTask: Task - - var body: some View { - VStack(alignment: .leading) { - LazyImage(request: ImageRequest(url: account.header) - ) { state in - if let image = state.image { - image.resizable().scaledToFill() - } - } - .frame(width: 500, height: 150) - .clipped() - .background(theme.secondaryBackgroundColor) - - VStack(alignment: .leading) { - HStack(alignment: .bottomAvatar) { - AvatarImage(account.avatar, config: adaptiveConfig) - Spacer() - makeCustomInfoLabel(title: "account.following", count: account.followingCount ?? 0) - makeCustomInfoLabel(title: "account.posts", count: account.statusesCount ?? 0) - makeCustomInfoLabel(title: "account.followers", count: account.followersCount ?? 0) - } - .frame(height: adaptiveConfig.height / 2, alignment: .bottom) - - EmojiTextApp(.init(stringValue: account.safeDisplayName ), emojis: account.emojis) - .font(.headline) - .foregroundColor(theme.labelColor) - .emojiSize(Font.scaledHeadlineFont.emojiSize) - .emojiBaselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset) - .accessibilityAddTraits(.isHeader) - .help(account.safeDisplayName) - - Text("@\(account.acct)") - .font(.callout) - .foregroundColor(.gray) - .textSelection(.enabled) - .accessibilityRespondsToUserInteraction(false) - .help("@\(account.acct)") - - HStack(spacing: 4) { - Image(systemName: "calendar") - .accessibilityHidden(true) - Text("account.joined") - Text(account.createdAt.asDate, style: .date) - } - .foregroundColor(.gray) - .font(.footnote) - .accessibilityElement(children: .combine) - - EmojiTextApp(account.note, emojis: account.emojis, lineLimit: 5) - .font(.body) - .emojiSize(Font.scaledFootnoteFont.emojiSize) - .emojiBaselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset) - .padding(.top, 3) - } - .padding([.leading, .trailing, .bottom]) - } - .frame(width: 500) - .onAppear { - toggleTask.cancel() - toggleTask = Task { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2) - guard !Task.isCancelled else { return } - if autoDismiss { - showPopup = false - } - } - } - .onHover { hovering in - toggleTask.cancel() - toggleTask = Task { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2) - guard !Task.isCancelled else { return } - if hovering { - autoDismiss = false - } else { - showPopup = false - autoDismiss = true - } - } - } - } - - @MainActor - private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View { - VStack { - Text(count, format: .number.notation(.compactName)) - .font(.scaledHeadline) - .foregroundColor(theme.tintColor) - .overlay(alignment: .trailing) { - if needsBadge { - Circle() - .fill(Color.red) - .frame(width: 9, height: 9) - .offset(x: 12) - } - } - Text(title) - .font(.scaledFootnote) - .foregroundColor(.gray) - .alignmentGuide(.bottomAvatar, computeValue: { dimension in - dimension[.firstTextBaseline] - }) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(title) - .accessibilityValue("\(count)") - } - - private var adaptiveConfig: AvatarView.FrameConfig { - var cornerRadius: CGFloat - if config == .badge || theme.avatarShape == .circle { - cornerRadius = config.width / 2 - } else { - cornerRadius = config.cornerRadius - } - return AvatarView.FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius) - } -} - -private enum BottomAvatarAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context.height - } -} - -extension VerticalAlignment { - static let bottomAvatar = VerticalAlignment(BottomAvatarAlignment.self) -} - -public struct AccountPopoverModifier : ViewModifier { - @Environment(Theme.self) private var theme - - @State private var showPopup = false - @State private var autoDismiss = true - @State private var toggleTask: Task = Task {} - - let account: Account - - public func body(content: Content) -> some View { - content - .onHover { hovering in - if hovering { - toggleTask.cancel() - toggleTask = Task { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2) - guard !Task.isCancelled else { return } - if !showPopup { - showPopup = true - } - } - } else { - if !showPopup { - toggleTask.cancel() - } - } - } - .hoverEffect(.lift) - .popover(isPresented: $showPopup) { - AccountPopoverView( - account: account, - theme: theme, - showPopup: $showPopup, - autoDismiss: $autoDismiss, - toggleTask: $toggleTask - ) - } - } - - init(_ account: Account) { - self.account = account - } -} - -extension View { - public func accountPopover(_ account: Account) -> some View { - modifier(AccountPopoverModifier(account)) - } -}