import Foundation import UIKit import SwiftSignalKit import Display import TelegramCore import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import AccountContext import ViewControllerComponent import MultilineTextComponent import BalancedTextComponent import ButtonComponent import BundleIconComponent import PresentationDataUtils import PlainButtonComponent import Markdown import PremiumUI import LottieComponent private final class ProfileLevelInfoScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let peer: EnginePeer let starRating: TelegramStarRating let pendingStarRating: TelegramStarPendingRating? init( context: AccountContext, peer: EnginePeer, starRating: TelegramStarRating, pendingStarRating: TelegramStarPendingRating? ) { self.context = context self.peer = peer self.starRating = starRating self.pendingStarRating = pendingStarRating } static func ==(lhs: ProfileLevelInfoScreenComponent, rhs: ProfileLevelInfoScreenComponent) -> Bool { return true } private final class TransitionHint { let isChangingPreview: Bool init(isChangingPreview: Bool) { self.isChangingPreview = isChangingPreview } } private final class ScrollView: UIScrollView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } } private struct ItemLayout: Equatable { var containerSize: CGSize var containerInset: CGFloat var bottomInset: CGFloat var topInset: CGFloat init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat) { self.containerSize = containerSize self.containerInset = containerInset self.bottomInset = bottomInset self.topInset = topInset } } final class View: UIView, UIScrollViewDelegate { private let dimView: UIView private let backgroundLayer: SimpleLayer private let navigationBarContainer: SparseContainerView private let navigationBackgroundView: BlurredBackgroundView private let navigationBarSeparator: SimpleLayer private let scrollView: ScrollView private let scrollContentClippingView: SparseContainerView private let scrollContentView: UIView private let closeButton = ComponentView() private let peerAvatar = ComponentView() private let callIconBackground = ComponentView() private let callIcon = ComponentView() private let title = ComponentView() private let levelInfo = ComponentView() private var secondaryDescriptionText: ComponentView? private let descriptionText = ComponentView() private var items: [ComponentView] = [] private let bottomPanelContainer: UIView private let actionButton = ComponentView() private let bottomOverscrollLimit: CGFloat private var isFirstTimeApplyingModalFactor: Bool = true private var ignoreScrolling: Bool = false private var component: ProfileLevelInfoScreenComponent? private weak var state: EmptyComponentState? private var environment: ViewControllerComponentContainer.Environment? private var isUpdating: Bool = false private var isPreviewingPendingRating: Bool = false private var itemLayout: ItemLayout? private var topOffsetDistance: CGFloat? private var cachedChevronImage: UIImage? private var cachedCloseImage: UIImage? override init(frame: CGRect) { self.bottomOverscrollLimit = 200.0 self.dimView = UIView() self.backgroundLayer = SimpleLayer() self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] self.backgroundLayer.cornerRadius = 10.0 self.navigationBarContainer = SparseContainerView() self.navigationBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) self.navigationBarSeparator = SimpleLayer() self.scrollView = ScrollView() self.scrollContentClippingView = SparseContainerView() self.scrollContentClippingView.clipsToBounds = true self.scrollContentView = UIView() self.bottomPanelContainer = UIView() super.init(frame: frame) self.addSubview(self.dimView) self.layer.addSublayer(self.backgroundLayer) self.scrollView.delaysContentTouches = true self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false self.scrollView.contentInsetAdjustmentBehavior = .never if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = true self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.scrollView.clipsToBounds = true self.addSubview(self.scrollContentClippingView) self.scrollContentClippingView.addSubview(self.scrollView) self.scrollView.addSubview(self.scrollContentView) self.addSubview(self.navigationBarContainer) self.addSubview(self.bottomPanelContainer) self.navigationBarContainer.addSubview(self.navigationBackgroundView) self.navigationBarContainer.layer.addSublayer(self.navigationBarSeparator) self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) } } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil } if !self.backgroundLayer.frame.contains(point) { return self.dimView } if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) { return result } let result = super.hitTest(point, with: event) return result } @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { guard let environment = self.environment, let controller = environment.controller() else { return } controller.dismiss() } } private func updateScrolling(transition: ComponentTransition) { guard let environment = self.environment, let controller = environment.controller(), let itemLayout = self.itemLayout else { return } var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset let titleTransformFraction: CGFloat = max(0.0, min(1.0, -topOffset / 20.0)) let navigationAlpha: CGFloat = titleTransformFraction transition.setAlpha(view: self.navigationBackgroundView, alpha: navigationAlpha) transition.setAlpha(layer: self.navigationBarSeparator, alpha: navigationAlpha) topOffset = max(0.0, topOffset) transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) let topOffsetDistance: CGFloat = 80.0 self.topOffsetDistance = topOffsetDistance var topOffsetFraction = topOffset / topOffsetDistance topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) let transitionFactor: CGFloat = 1.0 - topOffsetFraction var modalOverlayTransition = transition if self.isFirstTimeApplyingModalFactor { self.isFirstTimeApplyingModalFactor = false modalOverlayTransition = .spring(duration: 0.5) } if self.isUpdating { DispatchQueue.main.async { [weak controller] in guard let controller else { return } controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: modalOverlayTransition.containedViewLayoutTransition) } } else { controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: modalOverlayTransition.containedViewLayoutTransition) } } func animateIn() { self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.bottomPanelContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } func animateOut(completion: @escaping () -> Void) { let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in completion() }) self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) self.bottomPanelContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) if let environment = self.environment, let controller = environment.controller() { controller.updateModalStyleOverlayTransitionFactor(0.0, transition: .animated(duration: 0.3, curve: .easeInOut)) } } func update(component: ProfileLevelInfoScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.16) let environment = environment[ViewControllerComponentContainer.Environment.self].value let themeUpdated = self.environment?.theme !== environment.theme let resetScrolling = self.scrollView.bounds.width != availableSize.width let sideInset: CGFloat = 16.0 + environment.safeInsets.left self.component = component self.state = state self.environment = environment if themeUpdated { self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) self.backgroundLayer.backgroundColor = environment.theme.actionSheet.opaqueItemBackgroundColor.cgColor self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) self.navigationBarSeparator.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor } transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) var contentHeight: CGFloat = 0.0 let closeImage: UIImage if let image = self.cachedCloseImage, !themeUpdated { closeImage = image } else { closeImage = generateCloseButtonImage(backgroundColor: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05), foregroundColor: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.4))! self.cachedCloseImage = closeImage } let closeButtonSize = self.closeButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Image(image: closeImage, size: closeImage.size)), action: { [weak self] in guard let self, let controller = self.environment?.controller() else { return } controller.dismiss() } ).minSize(CGSize(width: 62.0, height: 56.0))), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) let closeButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - environment.safeInsets.right - closeButtonSize.width, y: 0.0), size: closeButtonSize) if let closeButtonView = self.closeButton.view { if closeButtonView.superview == nil { self.navigationBarContainer.addSubview(closeButtonView) } transition.setFrame(view: closeButtonView, frame: closeButtonFrame) } let containerInset: CGFloat = environment.statusBarHeight + 10.0 let clippingY: CGFloat let titleString: String = environment.strings.ProfileLevelInfo_Title let descriptionTextString: String var secondaryDescriptionTextString: String? if component.peer.id == component.context.account.peerId { descriptionTextString = environment.strings.ProfileLevelInfo_MyText let timestamp = Int32(Date().timeIntervalSince1970) if let pendingStarRating = component.pendingStarRating, pendingStarRating.timestamp > timestamp { if pendingStarRating.rating.stars > component.starRating.stars { let pendingPoints = pendingStarRating.rating.stars - component.starRating.stars if self.isPreviewingPendingRating { secondaryDescriptionTextString = "This will be your rating in 21 days,\n after \(pendingPoints) points are added. [Back >]()" } else { let dayCount = (pendingStarRating.timestamp - timestamp) / (24 * 60 * 60) if dayCount == 0 { secondaryDescriptionTextString = environment.strings.ProfileLevelInfo_MyDescriptionToday(Int32(pendingPoints)) } else { secondaryDescriptionTextString = environment.strings.ProfileLevelInfo_MyDescription(environment.strings.ProfileLevelInfo_MyDescriptionDays(Int32(dayCount)), environment.strings.ProfileLevelInfo_MyDescriptionPoints(Int32(pendingPoints))).string } } } } } else { descriptionTextString = environment.strings.ProfileLevelInfo_OtherDescription(component.peer.compactDisplayTitle).string } let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) ) let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: floor((56.0 - titleSize.height) * 0.5)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.navigationBarContainer.addSubview(titleView) } titleView.frame = titleFrame } contentHeight += 56.0 let navigationBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: 54.0)) transition.setFrame(view: self.navigationBackgroundView, frame: navigationBackgroundFrame) self.navigationBackgroundView.update(size: navigationBackgroundFrame.size, cornerRadius: 10.0, maskedCorners: [.layerMinXMinYCorner, .layerMaxXMinYCorner], transition: transition.containedViewLayoutTransition) transition.setFrame(layer: self.navigationBarSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: 54.0), size: CGSize(width: availableSize.width, height: UIScreenPixel))) let gradientColors: [UIColor] gradientColors = [ environment.theme.list.itemCheckColors.fillColor, environment.theme.list.itemCheckColors.fillColor, environment.theme.list.itemCheckColors.fillColor, environment.theme.list.itemCheckColors.fillColor ] let levelFraction: CGFloat let badgeText: String var badgeTextSuffix: String? let currentLevel: Int32 let nextLevel: Int32? if let pendingStarRating = component.pendingStarRating, pendingStarRating.rating.stars > component.starRating.stars, self.isPreviewingPendingRating { badgeText = starCountString(Int64(pendingStarRating.rating.stars), decimalSeparator: ".") currentLevel = pendingStarRating.rating.level nextLevel = pendingStarRating.rating.nextLevelStars == nil ? nil : currentLevel + 1 if let nextLevelStars = pendingStarRating.rating.nextLevelStars { badgeTextSuffix = " / \(starCountString(Int64(nextLevelStars), decimalSeparator: "."))" } if let nextLevelStars = pendingStarRating.rating.nextLevelStars { levelFraction = Double(pendingStarRating.rating.stars) / Double(nextLevelStars) } else { levelFraction = 1.0 } } else { badgeText = starCountString(Int64(component.starRating.stars), decimalSeparator: ".") currentLevel = component.starRating.level nextLevel = component.starRating.nextLevelStars == nil ? nil : currentLevel + 1 if let nextLevelStars = component.starRating.nextLevelStars { badgeTextSuffix = " / \(starCountString(Int64(nextLevelStars), decimalSeparator: "."))" } if let nextLevelStars = component.starRating.nextLevelStars { levelFraction = Double(component.starRating.stars) / Double(nextLevelStars) } else { levelFraction = 1.0 } } let levelInfoSize = self.levelInfo.update( transition: .immediate, component: AnyComponent(PremiumLimitDisplayComponent( inactiveColor: environment.theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.5), activeColors: gradientColors, inactiveTitle: environment.strings.ProfileLevelInfo_LevelIndex(Int32(currentLevel)), inactiveValue: "", inactiveTitleColor: environment.theme.list.itemPrimaryTextColor, activeTitle: "", activeValue: nextLevel.flatMap { environment.strings.ProfileLevelInfo_LevelIndex(Int32($0)) } ?? "", activeTitleColor: .white, badgeIconName: "Peer Info/ProfileLevelProgressIcon", badgeText: badgeText, badgeTextSuffix: badgeTextSuffix, badgePosition: levelFraction, badgeGraphPosition: levelFraction, invertProgress: true, isPremiumDisabled: false )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 200.0) ) if let levelInfoView = self.levelInfo.view { if levelInfoView.superview == nil { self.scrollContentView.addSubview(levelInfoView) } levelInfoView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - levelInfoSize.width) * 0.5), y: contentHeight - 16.0), size: levelInfoSize) } contentHeight += 129.0 let isChangingPreview = transition.userData(TransitionHint.self)?.isChangingPreview ?? false if let secondaryDescriptionTextString { if isChangingPreview, let secondaryDescriptionTextView = self.secondaryDescriptionText?.view { self.secondaryDescriptionText = nil transition.setTransform(view: secondaryDescriptionTextView, transform: CATransform3DMakeScale(0.9, 0.9, 1.0)) alphaTransition.setAlpha(view: secondaryDescriptionTextView, alpha: 0.0, completion: { [weak secondaryDescriptionTextView] _ in secondaryDescriptionTextView?.removeFromSuperview() }) } contentHeight -= 8.0 let secondaryDescriptionText: ComponentView var secondaryDescriptionTextTransition = transition if let current = self.secondaryDescriptionText { secondaryDescriptionText = current } else { secondaryDescriptionTextTransition = .immediate secondaryDescriptionText = ComponentView() self.secondaryDescriptionText = secondaryDescriptionText } let secondaryDescriptionAttributedString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(secondaryDescriptionTextString, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemSecondaryTextColor), bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.itemSecondaryTextColor), link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemAccentColor), linkAttribute: { url in return ("URL", url) } ))) let chevronImage: UIImage? if let current = self.cachedChevronImage { chevronImage = current } else { chevronImage = generateTintedImage(image: UIImage(bundleImageName: "Item List/InlineTextRightArrow"), color: .white) self.cachedChevronImage = chevronImage } if let range = secondaryDescriptionAttributedString.string.range(of: ">"), let chevronImage { secondaryDescriptionAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: secondaryDescriptionAttributedString.string)) } let secondaryDescriptionTextSize = secondaryDescriptionText.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .plain(secondaryDescriptionAttributedString), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2, highlightColor: environment.theme.list.itemAccentColor.withMultipliedAlpha(0.1), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { return NSAttributedString.Key(rawValue: "URL") } else { return nil } }, tapAction: { [weak self] attributes, _ in guard let self else { return } self.isPreviewingPendingRating = !self.isPreviewingPendingRating var transition: ComponentTransition = .spring(duration: 0.4) transition = transition.withUserData(TransitionHint(isChangingPreview: true)) self.state?.updated(transition: transition) } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let secondaryDescriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - secondaryDescriptionTextSize.width) * 0.5), y: contentHeight), size: secondaryDescriptionTextSize) if let secondaryDescriptionTextView = secondaryDescriptionText.view { if secondaryDescriptionTextView.superview == nil { self.scrollContentView.addSubview(secondaryDescriptionTextView) if isChangingPreview { transition.animateScale(view: secondaryDescriptionTextView, from: 0.9, to: 1.0) alphaTransition.animateAlpha(view: secondaryDescriptionTextView, from: 0.0, to: 1.0) } } secondaryDescriptionTextTransition.setPosition(view: secondaryDescriptionTextView, position: secondaryDescriptionTextFrame.center) secondaryDescriptionTextView.bounds = CGRect(origin: CGPoint(), size: secondaryDescriptionTextFrame.size) } contentHeight += secondaryDescriptionTextSize.height contentHeight += 23.0 } else if let secondaryDescriptionText = self.secondaryDescriptionText { self.secondaryDescriptionText = nil secondaryDescriptionText.view?.removeFromSuperview() } let descriptionTextSize = self.descriptionText.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .markdown( text: descriptionTextString, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemPrimaryTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), linkAttribute: { url in return ("URL", url) } ) ), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.2 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let descriptionTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - descriptionTextSize.width) * 0.5), y: contentHeight), size: descriptionTextSize) if let descriptionTextView = self.descriptionText.view { if descriptionTextView.superview == nil { self.scrollContentView.addSubview(descriptionTextView) } transition.setPosition(view: descriptionTextView, position: descriptionTextFrame.center) descriptionTextView.bounds = CGRect(origin: CGPoint(), size: descriptionTextFrame.size) } contentHeight += descriptionTextSize.height contentHeight += 24.0 struct Item { let title: String let text: String let badgeText: String let isBadgeAccent: Bool let icon: String } let items: [Item] = [ Item( title: "Gifts from Telegram", text: "100% of the Stars spent on gifts purchased from Telegram.", badgeText: "ADDED", isBadgeAccent: true, icon: "Chat/Input/Accessory Panels/Gift" ), Item( title: "Gifts and Posts from Users", text: "20% of the Stars spent on gifts or posts from users and channels.", badgeText: "ADDED", isBadgeAccent: true, icon: "Peer Info/ProfileLevelInfo2" ), Item( title: "Refunds and Conversions", text: "10x of refunded Stars and 85% of bought gifts converted to Stars.", badgeText: "DEDUCTED", isBadgeAccent: false, icon: "Peer Info/ProfileLevelInfo3" ) ] let itemSpacing: CGFloat = 24.0 for i in 0 ..< items.count { if i != 0 { contentHeight += itemSpacing } let item = items[i] let itemView: ComponentView if self.items.count > i { itemView = self.items[i] } else { itemView = ComponentView() self.items.append(itemView) } let itemSize = itemView.update( transition: .immediate, component: AnyComponent(ItemComponent( theme: environment.theme, title: item.title, text: item.text, badge: item.badgeText, isBadgeAccent: item.isBadgeAccent, icon: item.icon )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let itemFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: itemSize) if let itemComponentView = itemView.view { if itemComponentView.superview == nil { self.scrollContentView.addSubview(itemComponentView) } itemComponentView.frame = itemFrame } contentHeight += itemSize.height } contentHeight += 31.0 let actionButtonTitle: String = environment.strings.ProfileLevelInfo_CloseButton var buttonTitle: [AnyComponentWithIdentity] = [] let playButtonAnimation = ActionSlot() buttonTitle.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(LottieComponent( content: LottieComponent.AppBundleContent(name: "anim_ok"), color: environment.theme.list.itemCheckColors.foregroundColor, startingPosition: .begin, size: CGSize(width: 28.0, height: 28.0), playOnce: playButtonAnimation )))) buttonTitle.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(ButtonTextContentComponent( text: actionButtonTitle, badge: 0, textColor: environment.theme.list.itemCheckColors.foregroundColor, badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, badgeForeground: environment.theme.list.itemCheckColors.fillColor )))) let actionButtonSize = self.actionButton.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( color: environment.theme.list.itemCheckColors.fillColor, foreground: environment.theme.list.itemCheckColors.foregroundColor, pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(HStack(buttonTitle, spacing: 2.0)) ), isEnabled: true, displaysProgress: false, action: { [weak self] in guard let self else { return } self.environment?.controller()?.dismiss() } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) ) let bottomPanelHeight = 10.0 + environment.safeInsets.bottom + actionButtonSize.height let bottomPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelHeight), size: CGSize(width: availableSize.width, height: bottomPanelHeight)) transition.setFrame(view: self.bottomPanelContainer, frame: bottomPanelFrame) let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: actionButtonSize) if let actionButtonView = self.actionButton.view { if actionButtonView.superview == nil { self.bottomPanelContainer.addSubview(actionButtonView) playButtonAnimation.invoke(Void()) } transition.setFrame(view: actionButtonView, frame: actionButtonFrame) } contentHeight += bottomPanelHeight clippingY = bottomPanelFrame.minY - 8.0 let topInset: CGFloat = max(0.0, availableSize.height - containerInset - contentHeight) let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset) self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, bottomInset: environment.safeInsets.bottom, topInset: topInset) transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset), size: CGSize(width: availableSize.width - sideInset * 2.0, height: clippingY - containerInset)) transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) self.ignoreScrolling = true let previousBounds = self.scrollView.bounds transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height))) let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight) if contentSize != self.scrollView.contentSize { self.scrollView.contentSize = contentSize } if resetScrolling { self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize) } else { if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds if bounds.maxY != previousBounds.maxY { let offsetY = previousBounds.maxY - bounds.maxY transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) } } } self.ignoreScrolling = false self.updateScrolling(transition: transition) return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public class ProfileLevelInfoScreen: ViewControllerComponentContainer { private let context: AccountContext private var isDismissed: Bool = false public init( context: AccountContext, peer: EnginePeer, starRating: TelegramStarRating, pendingStarRating: TelegramStarPendingRating?, customTheme: PresentationTheme? ) { self.context = context let theme: ViewControllerComponentContainer.Theme if let customTheme { theme = .custom(customTheme) } else { theme = .default } super.init(context: context, component: ProfileLevelInfoScreenComponent( context: context, peer: peer, starRating: starRating, pendingStarRating: pendingStarRating ), navigationBarAppearance: .none, theme: theme) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal self.blocksBackgroundWhenInOverlay = true } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.view.disablesInteractiveModalDismiss = true if let componentView = self.node.hostView.componentView as? ProfileLevelInfoScreenComponent.View { componentView.animateIn() } } override public func dismiss(completion: (() -> Void)? = nil) { if !self.isDismissed { self.isDismissed = true if let componentView = self.node.hostView.componentView as? ProfileLevelInfoScreenComponent.View { componentView.animateOut(completion: { [weak self] in completion?() self?.dismiss(animated: false) }) } else { self.dismiss(animated: false) } } } } private func generateCloseButtonImage(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(backgroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) context.setLineWidth(2.0) context.setLineCap(.round) context.setStrokeColor(foregroundColor.cgColor) context.beginPath() context.move(to: CGPoint(x: 10.0, y: 10.0)) context.addLine(to: CGPoint(x: 20.0, y: 20.0)) context.move(to: CGPoint(x: 20.0, y: 10.0)) context.addLine(to: CGPoint(x: 10.0, y: 20.0)) context.strokePath() }) } private final class ItemComponent: Component { let theme: PresentationTheme let title: String let text: String let badge: String let isBadgeAccent: Bool let icon: String init( theme: PresentationTheme, title: String, text: String, badge: String, isBadgeAccent: Bool, icon: String ) { self.theme = theme self.title = title self.text = text self.badge = badge self.isBadgeAccent = isBadgeAccent self.icon = icon } static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } if lhs.text != rhs.text { return false } if lhs.badge != rhs.badge { return false } if lhs.isBadgeAccent != rhs.isBadgeAccent { return false } if lhs.icon != rhs.icon { return false } return true } final class View: UIView { let title = ComponentView() let text = ComponentView() let badgeBackground = ComponentView() let badgeText = ComponentView() let icon = ComponentView() override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let leftInset: CGFloat = 44.0 let titleSpacing: CGFloat = 5.0 let badgeInsets = UIEdgeInsets(top: 2.0, left: 4.0, bottom: 2.0, right: 4.0) let badgeSpacing: CGFloat = 4.0 let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.title, font: Font.semibold(15.0), textColor: component.theme.list.itemPrimaryTextColor)), maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - leftInset, height: 10000.0) ) let badgeTextSize = self.badgeText.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.badge, font: Font.semibold(11.0), textColor: component.isBadgeAccent ? component.theme.chatList.unreadBadgeActiveTextColor : component.theme.chatList.unreadBadgeInactiveTextColor)) )), environment: {}, containerSize: CGSize(width: 1000.0, height: 10000.0) ) let textSize = self.text.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.text, font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)), maximumNumberOfLines: 0, lineSpacing: 0.2, cutout: TextNodeCutout(topLeft: CGSize(width: badgeInsets.left + badgeTextSize.width + badgeInsets.right + badgeSpacing, height: 6.0)) )), environment: {}, containerSize: CGSize(width: availableSize.width - leftInset, height: 10000.0) ) let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: titleSize) let textFrame = CGRect(origin: CGPoint(x: leftInset, y: titleFrame.maxY + titleSpacing), size: textSize) let badgeSize = CGSize(width: badgeInsets.left + badgeTextSize.width + badgeInsets.right, height: badgeInsets.top + badgeTextSize.height + badgeInsets.bottom) let badgeFrame = CGRect(origin: CGPoint(x: leftInset, y: textFrame.minY), size: badgeSize) let badgeTextFrame = CGRect(origin: CGPoint(x: badgeFrame.minX + badgeInsets.left, y: badgeFrame.minY + badgeInsets.top), size: badgeTextSize) let _ = self.badgeBackground.update( transition: .immediate, component: AnyComponent(FilledRoundedRectangleComponent( color: component.isBadgeAccent ? component.theme.chatList.unreadBadgeActiveBackgroundColor : component.theme.chatList.unreadBadgeInactiveBackgroundColor, cornerRadius: .value(6.0), smoothCorners: true )), environment: {}, containerSize: badgeSize ) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } titleView.frame = titleFrame } if let textView = self.text.view { if textView.superview == nil { self.addSubview(textView) } textView.frame = textFrame } if let badgeBackgroundView = self.badgeBackground.view { if badgeBackgroundView.superview == nil { self.addSubview(badgeBackgroundView) } badgeBackgroundView.frame = badgeFrame } if let badgeTextView = self.badgeText.view { if badgeTextView.superview == nil { self.addSubview(badgeTextView) } badgeTextView.frame = badgeTextFrame } let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( name: component.icon, tintColor: component.theme.list.itemAccentColor )), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) ) if let iconView = self.icon.view { if iconView.superview == nil { self.addSubview(iconView) } iconView.frame = CGRect(origin: CGPoint(x: floor((leftInset - iconSize.width) * 0.5) - 2.0, y: 4.0), size: iconSize) } return CGSize(width: availableSize.width, height: textFrame.maxY) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private func starCountString(_ size: Int64, forceDecimal: Bool = false, decimalSeparator: String) -> String { if size >= 1000 * 1000 { let remainder = Int64((Double(size % (1000 * 1000)) / (1000.0 * 100.0)).rounded(.down)) if remainder != 0 || forceDecimal { return "\(size / (1000 * 1000))\(decimalSeparator)\(remainder)M" } else { return "\(size / (1000 * 1000))M" } } else if size >= 100000 { let remainder = (size % (1000)) / (100) if remainder != 0 || forceDecimal { return "\(size / 1000)\(decimalSeparator)\(remainder)K" } else { return "\(size / 1000)K" } } else { return "\(size)" } }