Swiftgram/submodules/PremiumUI/Sources/PremiumBoostLevelsScreen.swift
2024-01-26 01:53:43 +04:00

1977 lines
85 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import Postbox
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import ComponentFlow
import ViewControllerComponent
import MultilineTextComponent
import BalancedTextComponent
import BundleIconComponent
import Markdown
import TextFormat
import SolidRoundedButtonComponent
import BlurredBackgroundComponent
import UndoUI
import ConfettiEffect
func requiredBoostSubjectLevel(subject: BoostSubject, context: AccountContext, configuration: PremiumConfiguration) -> Int32 {
switch subject {
case .stories:
return 1
case let .channelReactions(reactionCount):
return reactionCount
case let .nameColors(colors):
if let value = context.peerNameColors.nameColorsChannelMinRequiredBoostLevel[colors.rawValue] {
return value
} else {
return 1
}
case .nameIcon:
return configuration.minChannelNameIconLevel
case .profileColors:
return configuration.minChannelProfileColorLevel
case .profileIcon:
return configuration.minChannelProfileIconLevel
case .emojiStatus:
return configuration.minChannelEmojiStatusLevel
case .wallpaper:
return configuration.minChannelWallpaperLevel
case .customWallpaper:
return configuration.minChannelCustomWallpaperLevel
}
}
public enum BoostSubject: Equatable {
case stories
case channelReactions(reactionCount: Int32)
case nameColors(colors: PeerNameColor)
case nameIcon
case profileColors
case profileIcon
case emojiStatus
case wallpaper
case customWallpaper
public func requiredLevel(context: AccountContext, configuration: PremiumConfiguration) -> Int32 {
return requiredBoostSubjectLevel(subject: self, context: context, configuration: configuration)
}
}
private final class LevelHeaderComponent: CombinedComponent {
let theme: PresentationTheme
let text: String
init(
theme: PresentationTheme,
text: String
) {
self.theme = theme
self.text = text
}
static func ==(lhs: LevelHeaderComponent, rhs: LevelHeaderComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
static var body: Body {
let background = Child(RoundedRectangle.self)
let text = Child(MultilineTextComponent.self)
let leftLine = Child(Rectangle.self)
let rightLine = Child(Rectangle.self)
return { context in
let component = context.component
let outerInset: CGFloat = 28.0
let innerInset: CGFloat = 9.0
let height: CGFloat = 50.0
let backgroundHeight: CGFloat = 34.0
let text = text.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: component.text, font: Font.semibold(15.0), textColor: .white)),
horizontalAlignment: .center
),
availableSize: context.availableSize,
transition: .immediate
)
let backgroundWidth: CGFloat = floor(text.size.width + 21.0)
let background = background.update(
component: RoundedRectangle(colors: [UIColor(rgb: 0x9076ff), UIColor(rgb: 0xbc6de8)], cornerRadius: backgroundHeight / 2.0, gradientDirection: .horizontal),
availableSize: CGSize(width: backgroundWidth, height: backgroundHeight),
transition: .immediate
)
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: height / 2.0))
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: height / 2.0))
)
let remainingWidth = (context.availableSize.width - background.size.width) / 2.0
let lineSize = remainingWidth - outerInset - innerInset
let lineWidth = 1.0 - UIScreenPixel
let leftLine = leftLine.update(
component: Rectangle(
color: component.theme.actionSheet.secondaryTextColor.withMultipliedAlpha(0.5)
),
availableSize: CGSize(width: lineSize, height: lineWidth),
transition: .immediate
)
context.add(leftLine
.position(CGPoint(x: outerInset + lineSize / 2.0, y: height / 2.0))
)
let rightLine = rightLine.update(
component: Rectangle(
color: component.theme.actionSheet.secondaryTextColor.withMultipliedAlpha(0.5)
),
availableSize: CGSize(width: lineSize, height: lineWidth),
transition: .immediate
)
context.add(rightLine
.position(CGPoint(x: context.availableSize.width - outerInset - lineSize / 2.0, y: height / 2.0))
)
return CGSize(width: context.availableSize.width, height: height)
}
}
}
private final class LevelPerkComponent: CombinedComponent {
let theme: PresentationTheme
let iconName: String
let text: String
init(
theme: PresentationTheme,
iconName: String,
text: String
) {
self.theme = theme
self.iconName = iconName
self.text = text
}
static func ==(lhs: LevelPerkComponent, rhs: LevelPerkComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.iconName != rhs.iconName {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
static var body: Body {
let icon = Child(BundleIconComponent.self)
let text = Child(MultilineTextComponent.self)
return { context in
let component = context.component
let outerInset: CGFloat = 28.0
let height: CGFloat = 44.0
let icon = icon.update(
component: BundleIconComponent(
name: component.iconName,
tintColor: component.theme.actionSheet.controlAccentColor
),
availableSize: context.availableSize,
transition: .immediate
)
context.add(icon
.position(CGPoint(x: outerInset + icon.size.width / 2.0, y: height / 2.0))
)
let text = text.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: component.text, font: Font.semibold(15.0), textColor: component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center
),
availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: outerInset * 2.0 + 18.0 + text.size.width / 2.0, y: height / 2.0))
)
return CGSize(width: context.availableSize.width, height: height)
}
}
}
private final class LevelSectionComponent: CombinedComponent {
enum Perk: Equatable {
case story(Int32)
case reaction(Int32)
case nameColor(Int32)
case profileColor(Int32)
case profileIcon
case linkColor(Int32)
case linkIcon
case emojiStatus
case wallpaper(Int32)
case customWallpaper
func title(strings: PresentationStrings) -> String {
switch self {
case let .story(value):
return strings.ChannelBoost_Table_StoriesPerDay(value)
case let .reaction(value):
return strings.ChannelBoost_Table_CustomReactions(value)
case let .nameColor(value):
return strings.ChannelBoost_Table_NameColor(value)
case let .profileColor(value):
return strings.ChannelBoost_Table_ProfileColor(value)
case .profileIcon:
return strings.ChannelBoost_Table_ProfileLogo
case let .linkColor(value):
return strings.ChannelBoost_Table_StyleForHeaders(value)
case .linkIcon:
return strings.ChannelBoost_Table_HeadersLogo
case .emojiStatus:
return strings.ChannelBoost_Table_EmojiStatus
case let .wallpaper(value):
return strings.ChannelBoost_Table_Wallpaper(value)
case .customWallpaper:
return strings.ChannelBoost_Table_CustomWallpaper
}
}
var iconName: String {
switch self {
case .story:
return "Premium/BoostPerk/Story"
case .reaction:
return "Premium/BoostPerk/Reaction"
case .nameColor:
return "Premium/BoostPerk/NameColor"
case .profileColor:
return "Premium/BoostPerk/CoverColor"
case .profileIcon:
return "Premium/BoostPerk/CoverLogo"
case .linkColor:
return "Premium/BoostPerk/LinkColor"
case .linkIcon:
return "Premium/BoostPerk/LinkLogo"
case .emojiStatus:
return "Premium/BoostPerk/EmojiStatus"
case .wallpaper:
return "Premium/BoostPerk/Wallpaper"
case .customWallpaper:
return "Premium/BoostPerk/CustomWallpaper"
}
}
}
let theme: PresentationTheme
let strings: PresentationStrings
let level: Int32
let isFirst: Bool
let perks: [Perk]
init(
theme: PresentationTheme,
strings: PresentationStrings,
level: Int32,
isFirst: Bool,
perks: [Perk]
) {
self.theme = theme
self.strings = strings
self.level = level
self.isFirst = isFirst
self.perks = perks
}
static func ==(lhs: LevelSectionComponent, rhs: LevelSectionComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.level != rhs.level {
return false
}
if lhs.isFirst != rhs.isFirst {
return false
}
if lhs.perks != rhs.perks {
return false
}
return true
}
static var body: Body {
let header = Child(LevelHeaderComponent.self)
let list = Child(List<Empty>.self)
return { context in
let component = context.component
let header = header.update(
component: LevelHeaderComponent(theme: component.theme, text: component.isFirst ? component.strings.ChannelBoost_Table_LevelUnlocks(component.level) : component.strings.ChannelBoost_Table_Level(component.level)),
availableSize: context.availableSize,
transition: .immediate
)
context.add(header
.position(CGPoint(x: context.availableSize.width / 2.0, y: header.size.height / 2.0)))
let items: [AnyComponentWithIdentity<Empty>] = component.perks.enumerated().map { index, value in
AnyComponentWithIdentity(
id: index, component: AnyComponent(
LevelPerkComponent(
theme: component.theme,
iconName: value.iconName,
text: value.title(strings: component.strings)
)
)
)
}
let list = list.update(
component: List(items),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: context.transition
)
context.add(list
.position(CGPoint(x: context.availableSize.width / 2.0, y: header.size.height + list.size.height / 2.0)))
return CGSize(width: context.availableSize.width, height: header.size.height + list.size.height)
}
}
}
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = (Empty, ScrollChildEnvironment)
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let peerId: EnginePeer.Id
let mode: PremiumBoostLevelsScreen.Mode
let status: ChannelBoostStatus
let copyLink: (String) -> Void
let dismiss: () -> Void
let openStats: (() -> Void)?
let openGift: (() -> Void)?
init(context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
peerId: EnginePeer.Id,
mode: PremiumBoostLevelsScreen.Mode,
status: ChannelBoostStatus,
copyLink: @escaping (String) -> Void,
dismiss: @escaping () -> Void,
openStats: (() -> Void)?,
openGift: (() -> Void)?
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.peerId = peerId
self.mode = mode
self.status = status
self.copyLink = copyLink
self.dismiss = dismiss
self.openStats = openStats
self.openGift = openGift
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.status != rhs.status {
return false
}
return true
}
final class State: ComponentState {
var cachedChevronImage: (UIImage, PresentationTheme)?
private(set) var peer: EnginePeer?
private(set) var memberPeer: EnginePeer?
private var disposable: Disposable?
private var memberDisposable: Disposable?
init(context: AccountContext, peerId: EnginePeer.Id, userId: EnginePeer.Id?) {
super.init()
self.disposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).startStrict(next: { [weak self] peer in
guard let self else {
return
}
self.peer = peer
self.updated()
})
if let userId {
self.memberDisposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: userId))
|> deliverOnMainQueue).startStrict(next: { [weak self] peer in
guard let self else {
return
}
self.memberPeer = peer
self.updated()
})
}
}
deinit {
self.disposable?.dispose()
self.memberDisposable?.dispose()
}
}
func makeState() -> State {
var userId: EnginePeer.Id?
if case let .user(mode) = mode, case let .groupPeer(peerId) = mode {
userId = peerId
}
return State(context: self.context, peerId: self.peerId, userId: userId)
}
static var body: Body {
let peerShortcut = Child(Button.self)
let text = Child(BalancedTextComponent.self)
let alternateText = Child(BalancedTextComponent.self)
let limit = Child(PremiumLimitDisplayComponent.self)
let linkButton = Child(SolidRoundedButtonComponent.self)
let boostButton = Child(SolidRoundedButtonComponent.self)
let copyButton = Child(SolidRoundedButtonComponent.self)
let orLeftLine = Child(Rectangle.self)
let orRightLine = Child(Rectangle.self)
let orText = Child(MultilineTextComponent.self)
let giftText = Child(BalancedTextComponent.self)
let levels = Child(List<Empty>.self)
return { context in
let component = context.component
let theme = component.theme
let strings = component.strings
let state = context.state
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
let sideInset: CGFloat = 16.0 // + environment.safeInsets.left
let textSideInset: CGFloat = 32.0 // + environment.safeInsets.left
let iconName = "Premium/Boost"
let badgeText = "\(component.status.boosts)"
let peerName = state.peer?.compactDisplayTitle ?? ""
var remaining: Int?
if let nextLevelBoosts = component.status.nextLevelBoosts {
remaining = nextLevelBoosts - component.status.boosts
}
var textString = ""
switch component.mode {
case let .owner(subject):
if let remaining {
var needsSecondParagraph = true
let storiesString = strings.ChannelBoost_StoriesPerDay(Int32(component.status.level) + 1)
let valueString = strings.ChannelBoost_MoreBoosts(Int32(remaining))
switch subject {
case .stories:
if component.status.level == 0 {
textString = strings.ChannelBoost_EnableStoriesText(valueString).string
} else {
textString = strings.ChannelBoost_IncreaseLimitText(valueString, storiesString).string
}
needsSecondParagraph = false
case let .channelReactions(reactionCount):
textString = strings.ChannelBoost_CustomReactionsText("\(reactionCount)", "\(reactionCount)").string
needsSecondParagraph = false
case .nameColors:
let colorLevel = subject.requiredLevel(context: context.component.context, configuration: premiumConfiguration)
textString = strings.ChannelBoost_EnableNameColorLevelText("\(colorLevel)").string
case .nameIcon:
textString = strings.ChannelBoost_EnableNameIconLevelText("\(premiumConfiguration.minChannelNameIconLevel)").string
case .profileColors:
textString = strings.ChannelBoost_EnableProfileColorLevelText("\(premiumConfiguration.minChannelProfileColorLevel)").string
case .profileIcon:
textString = strings.ChannelBoost_EnableProfileIconLevelText("\(premiumConfiguration.minChannelProfileIconLevel)").string
case .emojiStatus:
textString = strings.ChannelBoost_EnableEmojiStatusLevelText("\(premiumConfiguration.minChannelEmojiStatusLevel)").string
case .wallpaper:
textString = strings.ChannelBoost_EnableWallpaperLevelText("\(premiumConfiguration.minChannelWallpaperLevel)").string
case .customWallpaper:
textString = strings.ChannelBoost_EnableCustomWallpaperLevelText("\(premiumConfiguration.minChannelCustomWallpaperLevel)").string
}
if needsSecondParagraph {
textString += "\n\n\(strings.ChannelBoost_AskToBoost)"
}
} else {
let storiesString = strings.ChannelBoost_StoriesPerDay(Int32(component.status.level))
textString = strings.ChannelBoost_MaxLevelReachedTextAuthor("\(component.status.level)", storiesString).string
}
case let .user(mode):
if case .groupPeer = mode {
let memberName = state.memberPeer?.compactDisplayTitle ?? ""
textString = "**\(memberName)** boosted the group **\(2)** times. Boost **\(peerName)** to help it unlock new features and get a booster **badge** for your messages."
} else {
if let remaining {
let boostsString = strings.ChannelBoost_MoreBoostsNeeded_Boosts(Int32(remaining))
textString = strings.ChannelBoost_MoreBoostsNeeded_Text(peerName, boostsString).string
} else {
textString = strings.ChannelBoost_MaxLevelReached_Text(peerName, "\(component.status.level)").string
}
}
}
let defaultTitle = strings.ChannelBoost_Level("\(component.status.level)").string
let defaultValue = ""
let premiumValue = strings.ChannelBoost_Level("\(component.status.level + 1)").string
let premiumTitle = ""
let progress: CGFloat
if let nextLevelBoosts = component.status.nextLevelBoosts {
progress = CGFloat(component.status.boosts - component.status.currentLevelBoosts) / CGFloat(nextLevelBoosts - component.status.currentLevelBoosts)
} else {
progress = 1.0
}
var contentSize: CGSize = CGSize(width: context.availableSize.width, height: 44.0)
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let textColor = theme.actionSheet.primaryTextColor
let linkColor = theme.actionSheet.controlAccentColor
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
})
let gradientColors = [
UIColor(rgb: 0x0077ff),
UIColor(rgb: 0x6b93ff),
UIColor(rgb: 0x8878ff),
UIColor(rgb: 0xe46ace)
]
let buttonGradientColors = [
UIColor(rgb: 0x007afe),
UIColor(rgb: 0x5494ff)
]
if case let .user(mode) = component.mode, case .external = mode, let peer = state.peer {
contentSize.height += 10.0
let peerShortcut = peerShortcut.update(
component: Button(
content: AnyComponent(
PeerShortcutComponent(
context: component.context,
theme: component.theme,
peer: peer
)
),
action: {
// component.dismiss()
// Queue.mainQueue().after(0.35) {
// component.openPeer(peer)
// }
}
),
availableSize: CGSize(width: context.availableSize.width - 32.0, height: context.availableSize.height),
transition: .immediate
)
context.add(peerShortcut
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + peerShortcut.size.height / 2.0))
)
contentSize.height += peerShortcut.size.height + 2.0
}
let limit = limit.update(
component: PremiumLimitDisplayComponent(
inactiveColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3),
activeColors: gradientColors,
inactiveTitle: defaultTitle,
inactiveValue: defaultValue,
inactiveTitleColor: theme.list.itemPrimaryTextColor,
activeTitle: premiumTitle,
activeValue: premiumValue,
activeTitleColor: .white,
badgeIconName: iconName,
badgeText: badgeText,
badgePosition: progress,
badgeGraphPosition: progress,
invertProgress: true,
isPremiumDisabled: false
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height),
transition: context.transition
)
context.add(limit
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + limit.size.height / 2.0))
)
contentSize.height += limit.size.height + 23.0
let textChild: _ConcreteChildComponent<BalancedTextComponent>
if component.status.boosts % 2 == 0 {
textChild = text
} else {
textChild = alternateText
}
let text = textChild.update(
component: BalancedTextComponent(
text: .markdown(text: textString, attributes: markdownAttributes),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
.appear(Transition.Appear({ _, view, transition in
transition.animatePosition(view: view, from: CGPoint(x: 0.0, y: 64.0), to: .zero, additive: true)
transition.animateAlpha(view: view, from: 0.0, to: 1.0)
}))
.disappear(Transition.Disappear({ view, transition, completion in
view.superview?.sendSubviewToBack(view)
transition.animatePosition(view: view, from: .zero, to: CGPoint(x: 0.0, y: -64.0), additive: true)
transition.setAlpha(view: view, alpha: 0.0, completion: { _ in
completion()
})
}))
)
contentSize.height += text.size.height + 13.0
if case .owner = component.mode {
contentSize.height += 7.0
let linkButton = linkButton.update(
component: SolidRoundedButtonComponent(
title: component.status.url.replacingOccurrences(of: "https://", with: ""),
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3),
backgroundColors: [],
foregroundColor: theme.list.itemPrimaryTextColor
),
font: .regular,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
action: {
component.copyLink(component.status.url)
component.dismiss()
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0),
transition: context.transition
)
context.add(linkButton
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + linkButton.size.height / 2.0))
)
contentSize.height += linkButton.size.height + 16.0
//TODO:localize
let boostButton = boostButton.update(
component: SolidRoundedButtonComponent(
title: "Boost",
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: .black,
backgroundColors: buttonGradientColors,
foregroundColor: .white
),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
gloss: false,
iconName: nil,
animationName: nil,
iconPosition: .left,
action: {
component.dismiss()
}
),
availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 50.0),
transition: context.transition
)
let copyButton = copyButton.update(
component: SolidRoundedButtonComponent(
title: "Copy",
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: .black,
backgroundColors: buttonGradientColors,
foregroundColor: .white
),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
gloss: false,
iconName: nil,
animationName: nil,
iconPosition: .left,
action: {
component.copyLink(component.status.url)
component.dismiss()
}
),
availableSize: CGSize(width: (context.availableSize.width - 8.0 - sideInset * 2.0) / 2.0, height: 50.0),
transition: context.transition
)
let boostButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: contentSize.height), size: boostButton.size)
context.add(boostButton
.position(boostButtonFrame.center)
)
let copyButtonFrame = CGRect(origin: CGPoint(x: context.availableSize.width - sideInset - copyButton.size.width, y: contentSize.height), size: copyButton.size)
context.add(copyButton
.position(copyButtonFrame.center)
)
contentSize.height += boostButton.size.height
if premiumConfiguration.giveawayGiftsPurchaseAvailable {
let orText = orText.update(
component: MultilineTextComponent(text: .plain(NSAttributedString(string: strings.ChannelBoost_Or, font: Font.regular(15.0), textColor: textColor.withAlphaComponent(0.8), paragraphAlignment: .center))),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(orText
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 27.0))
)
let orLeftLine = orLeftLine.update(
component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)),
availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel),
transition: .immediate
)
context.add(orLeftLine
.position(CGPoint(x: context.availableSize.width / 2.0 - orText.size.width / 2.0 - 11.0 - 45.0, y: contentSize.height + 27.0))
)
let orRightLine = orRightLine.update(
component: Rectangle(color: theme.list.itemBlocksSeparatorColor.withAlphaComponent(0.3)),
availableSize: CGSize(width: 90.0, height: 1.0 - UIScreenPixel),
transition: .immediate
)
context.add(orRightLine
.position(CGPoint(x: context.availableSize.width / 2.0 + orText.size.width / 2.0 + 11.0 + 45.0, y: contentSize.height + 27.0))
)
if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== theme {
state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Settings/TextArrowRight"), color: linkColor)!, theme)
}
let giftString = strings.Premium_BoostByGiftDescription2
let giftAttributedString = parseMarkdownIntoAttributedString(giftString, attributes: markdownAttributes).mutableCopy() as! NSMutableAttributedString
if let range = giftAttributedString.string.range(of: ">"), let chevronImage = state.cachedChevronImage?.0 {
giftAttributedString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: giftAttributedString.string))
}
let giftText = giftText.update(
component: BalancedTextComponent(
text: .plain(giftAttributedString),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.1,
highlightColor: linkColor.withAlphaComponent(0.2),
highlightAction: { _ in
return nil
},
tapAction: { _, _ in
component.openGift?()
}
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(giftText
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + 50.0 + giftText.size.height / 2.0))
)
contentSize.height += giftText.size.height + 50.0 + 23.0
}
}
var nextLevels: ClosedRange<Int32>?
if component.status.level < 10 {
nextLevels = Int32(component.status.level) + 1 ... 10
}
var isGroup = false
if case let .user(mode) = component.mode, case .groupPeer = mode {
isGroup = true
}
var levelItems: [AnyComponentWithIdentity<Empty>] = []
var nameColorsAtLevel: [(Int32, Int32)] = []
var nameColorsCountMap: [Int32: Int32] = [:]
for color in context.component.context.peerNameColors.displayOrder {
if let level = context.component.context.peerNameColors.nameColorsChannelMinRequiredBoostLevel[color] {
if let current = nameColorsCountMap[level] {
nameColorsCountMap[level] = current + 1
} else {
nameColorsCountMap[level] = 1
}
}
}
for (key, value) in nameColorsCountMap {
nameColorsAtLevel.append((key, value))
}
if let nextLevels {
for level in nextLevels {
var perks: [LevelSectionComponent.Perk] = []
perks.append(.story(level))
if !isGroup {
perks.append(.reaction(level))
}
var nameColorsCount: Int32 = 0
for (colorLevel, count) in nameColorsAtLevel {
if level >= colorLevel && colorLevel == 1 {
nameColorsCount = count
}
}
if !isGroup && nameColorsCount > 0 {
perks.append(.nameColor(nameColorsCount))
}
if level >= premiumConfiguration.minChannelProfileColorLevel {
let delta = min(level - premiumConfiguration.minChannelProfileColorLevel + 1, 2)
perks.append(.profileColor(8 * delta))
}
if level >= premiumConfiguration.minChannelProfileIconLevel {
perks.append(.profileIcon)
}
var linkColorsCount: Int32 = 0
for (colorLevel, count) in nameColorsAtLevel {
if level >= colorLevel {
linkColorsCount += count
}
}
if !isGroup && linkColorsCount > 0 {
perks.append(.linkColor(linkColorsCount))
}
if !isGroup && level >= premiumConfiguration.minChannelNameIconLevel {
perks.append(.linkIcon)
}
if level >= premiumConfiguration.minChannelEmojiStatusLevel {
perks.append(.emojiStatus)
}
if level >= premiumConfiguration.minChannelWallpaperLevel {
perks.append(.wallpaper(8))
}
if level >= premiumConfiguration.minChannelCustomWallpaperLevel {
perks.append(.customWallpaper)
}
levelItems.append(
AnyComponentWithIdentity(
id: level, component: AnyComponent(
LevelSectionComponent(
theme: component.theme,
strings: component.strings,
level: level,
isFirst: levelItems.isEmpty,
perks: perks
)
)
)
)
}
}
if !levelItems.isEmpty {
let levels = levels.update(
component: List(levelItems),
availableSize: CGSize(width: context.availableSize.width, height: 100000.0),
transition: context.transition
)
context.add(levels
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + levels.size.height / 2.0 ))
)
contentSize.height += levels.size.height + 80.0
contentSize.height += 60.0
}
return contentSize
}
}
}
private final class BoostLevelsContainerComponent: CombinedComponent {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let peerId: EnginePeer.Id
let mode: PremiumBoostLevelsScreen.Mode
let status: ChannelBoostStatus
let copyLink: (String) -> Void
let dismiss: () -> Void
let openStats: (() -> Void)?
let openGift: (() -> Void)?
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
peerId: EnginePeer.Id,
mode: PremiumBoostLevelsScreen.Mode,
status: ChannelBoostStatus,
copyLink: @escaping (String) -> Void,
dismiss: @escaping () -> Void,
openStats: (() -> Void)?,
openGift: (() -> Void)?
) {
self.context = context
self.theme = theme
self.strings = strings
self.peerId = peerId
self.mode = mode
self.status = status
self.copyLink = copyLink
self.dismiss = dismiss
self.openStats = openStats
self.openGift = openGift
}
static func ==(lhs: BoostLevelsContainerComponent, rhs: BoostLevelsContainerComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.peerId != rhs.peerId {
return false
}
if lhs.mode != rhs.mode {
return false
}
if lhs.status != rhs.status {
return false
}
return true
}
final class State: ComponentState {
var topContentOffset: CGFloat = 0.0
var cachedStatsImage: (UIImage, PresentationTheme)?
var cachedCloseImage: (UIImage, PresentationTheme)?
}
func makeState() -> State {
return State()
}
static var body: Body {
let background = Child(Rectangle.self)
let scroll = Child(ScrollComponent<Empty>.self)
let topPanel = Child(BlurredBackgroundComponent.self)
let topSeparator = Child(Rectangle.self)
let title = Child(MultilineTextComponent.self)
let statsButton = Child(Button.self)
let closeButton = Child(Button.self)
return { context in
let state = context.state
let theme = context.component.theme
let strings = context.component.context.sharedContext.currentPresentationData.with { $0 }.strings
let topInset: CGFloat = 56.0
let component = context.component
let scroll = scroll.update(
component: ScrollComponent<Empty>(
content: AnyComponent(
SheetContent(
context: component.context,
theme: component.theme,
strings: component.strings,
insets: .zero,
peerId: component.peerId,
mode: component.mode,
status: component.status,
copyLink: component.copyLink,
dismiss: component.dismiss,
openStats: component.openStats,
openGift: component.openGift
)
),
contentInsets: UIEdgeInsets(top: topInset, left: 0.0, bottom: 34.0, right: 0.0),
contentOffsetUpdated: { [weak state] topContentOffset, _ in
state?.topContentOffset = topContentOffset
Queue.mainQueue().justDispatch {
state?.updated(transition: .immediate)
}
},
contentOffsetWillCommit: { _ in }
),
availableSize: context.availableSize,
transition: context.transition
)
let background = background.update(
component: Rectangle(color: theme.overallDarkAppearance ? theme.list.blocksBackgroundColor : theme.list.plainBackgroundColor),
availableSize: scroll.size,
transition: context.transition
)
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: background.size.height / 2.0))
)
context.add(scroll
.position(CGPoint(x: context.availableSize.width / 2.0, y: scroll.size.height / 2.0))
)
let topPanel = topPanel.update(
component: BlurredBackgroundComponent(
color: theme.rootController.navigationBar.blurredBackgroundColor
),
availableSize: CGSize(width: context.availableSize.width, height: topInset),
transition: context.transition
)
let topSeparator = topSeparator.update(
component: Rectangle(
color: theme.rootController.navigationBar.separatorColor
),
availableSize: CGSize(width: context.availableSize.width, height: UIScreenPixel),
transition: context.transition
)
let titleString: String
switch component.mode {
case let .owner(subject):
if let _ = component.status.nextLevelBoosts {
switch subject {
case .stories:
if component.status.level == 0 {
titleString = strings.ChannelBoost_EnableStories
} else {
titleString = strings.ChannelBoost_IncreaseLimit
}
case .nameColors:
titleString = strings.ChannelBoost_NameColor
case .nameIcon:
titleString = strings.ChannelBoost_NameIcon
case .profileColors:
titleString = strings.ChannelBoost_ProfileColor
case .profileIcon:
titleString = strings.ChannelBoost_ProfileIcon
case .channelReactions:
titleString = strings.ChannelBoost_CustomReactions
case .emojiStatus:
titleString = strings.ChannelBoost_EmojiStatus
case .wallpaper:
titleString = strings.ChannelBoost_Wallpaper
case .customWallpaper:
titleString = strings.ChannelBoost_CustomWallpaper
}
} else {
titleString = strings.ChannelBoost_MaxLevelReached
}
case let .user(mode):
var remaining: Int?
if let nextLevelBoosts = component.status.nextLevelBoosts {
remaining = nextLevelBoosts - component.status.boosts
}
if let _ = remaining {
if case .current = mode {
titleString = "Boost Group"
//titleString = strings.ChannelBoost_Title_Current
} else if case .groupPeer = mode {
titleString = "Boost Group"
} else {
titleString = strings.ChannelBoost_Title_Other
}
} else {
titleString = strings.ChannelBoost_MaxLevelReached
}
}
let title = title.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: theme.rootController.navigationBar.primaryTextColor)),
horizontalAlignment: .center,
truncationType: .end,
maximumNumberOfLines: 1
),
availableSize: context.availableSize,
transition: context.transition
)
let topPanelAlpha: CGFloat = min(30.0, state.topContentOffset) / 30.0
context.add(topPanel
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height / 2.0))
.opacity(topPanelAlpha)
)
context.add(topSeparator
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height))
.opacity(topPanelAlpha)
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: topPanel.size.height / 2.0))
)
if let openStats = component.openStats {
let statsButton = statsButton.update(
component: Button(
content: AnyComponent(
BundleIconComponent(
name: "Premium/Stats",
tintColor: component.theme.list.itemAccentColor
)
),
action: {
component.dismiss()
Queue.mainQueue().after(0.35) {
openStats()
}
}
).minSize(CGSize(width: 44.0, height: 44.0)),
availableSize: context.availableSize,
transition: .immediate
)
context.add(statsButton
.position(CGPoint(x: 31.0, y: 28.0))
)
}
let closeImage: UIImage
if let (image, theme) = state.cachedCloseImage, theme === component.theme {
closeImage = image
} else {
closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)!
state.cachedCloseImage = (closeImage, theme)
}
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Image(image: closeImage)),
action: {
component.dismiss()
}
),
availableSize: CGSize(width: 30.0, height: 30.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: context.availableSize.width - closeButton.size.width, y: 28.0))
)
return scroll.size
}
}
}
public class PremiumBoostLevelsScreen: ViewController {
public enum Mode: Equatable {
public enum UserMode: Equatable {
case external
case current
case groupPeer(EnginePeer.Id)
}
case user(mode: UserMode)
case owner(subject: BoostSubject)
}
final class Node: ViewControllerTracingNode, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private var presentationData: PresentationData
private weak var controller: PremiumBoostLevelsScreen?
let dim: ASDisplayNode
let wrappingView: UIView
let containerView: UIView
let contentView: ComponentHostView<Empty>
let footerContainerView: UIView
let footerView: ComponentHostView<Empty>
private(set) var isExpanded = false
private var panGestureRecognizer: UIPanGestureRecognizer?
private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?, listNode: ListView?)?
private let hapticFeedback = HapticFeedback()
private var currentIsVisible: Bool = false
private var currentLayout: ContainerViewLayout?
init(context: AccountContext, controller: PremiumBoostLevelsScreen) {
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
if controller.forceDark {
self.presentationData = self.presentationData.withUpdated(theme: defaultDarkPresentationTheme)
}
self.presentationData = self.presentationData.withUpdated(theme: self.presentationData.theme.withModalBlocksBackground())
self.controller = controller
self.dim = ASDisplayNode()
self.dim.alpha = 0.0
self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25)
self.wrappingView = UIView()
self.containerView = UIView()
self.contentView = ComponentHostView()
self.footerContainerView = UIView()
self.footerView = ComponentHostView()
super.init()
self.containerView.clipsToBounds = true
self.containerView.backgroundColor = self.presentationData.theme.overallDarkAppearance ? self.presentationData.theme.list.blocksBackgroundColor : self.presentationData.theme.list.plainBackgroundColor
self.addSubnode(self.dim)
self.view.addSubview(self.wrappingView)
self.wrappingView.addSubview(self.containerView)
self.containerView.addSubview(self.contentView)
if case .user = controller.mode {
self.containerView.addSubview(self.footerContainerView)
self.footerContainerView.addSubview(self.footerView)
}
}
override func didLoad() {
super.didLoad()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
panRecognizer.delegate = self
panRecognizer.delaysTouchesBegan = false
panRecognizer.cancelsTouchesInView = true
self.panGestureRecognizer = panRecognizer
self.wrappingView.addGestureRecognizer(panRecognizer)
self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate)
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.controller?.dismiss(animated: true)
}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let layout = self.currentLayout {
if case .regular = layout.metrics.widthClass {
return false
}
}
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer {
if let scrollView = otherGestureRecognizer.view as? UIScrollView {
if scrollView.contentSize.width > scrollView.contentSize.height {
return false
}
}
return true
}
return false
}
private var isDismissing = false
func animateIn() {
ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0)
let targetPosition = self.containerView.center
let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height)
self.containerView.center = startPosition
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
transition.animateView(allowUserInteraction: true, {
self.containerView.center = targetPosition
}, completion: { _ in
})
}
func animateOut(completion: @escaping () -> Void = {}) {
self.isDismissing = true
let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut)
positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in
self?.controller?.dismiss(animated: false, completion: completion)
})
let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut)
alphaTransition.updateAlpha(node: self.dim, alpha: 0.0)
self.controller?.updateModalStyleOverlayTransitionFactor(0.0, transition: positionTransition)
}
private var dismissOffset: CGFloat?
func containerLayoutUpdated(layout: ContainerViewLayout, transition: Transition) {
self.currentLayout = layout
self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0))
var effectiveExpanded = self.isExpanded
if case .regular = layout.metrics.widthClass {
effectiveExpanded = true
}
let isLandscape = layout.orientation == .landscape
let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset
let topInset: CGFloat
if let (panInitialTopInset, panOffset, _, _) = self.panGestureArguments {
if effectiveExpanded {
topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset))
} else {
topInset = max(0.0, panInitialTopInset + min(0.0, panOffset))
}
} else if let dismissOffset = self.dismissOffset, !dismissOffset.isZero {
topInset = edgeTopInset * dismissOffset
} else {
topInset = effectiveExpanded ? 0.0 : edgeTopInset
}
transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil)
let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset)
self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition)
let clipFrame: CGRect
if layout.metrics.widthClass == .compact {
self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25)
if isLandscape {
self.containerView.layer.cornerRadius = 0.0
} else {
self.containerView.layer.cornerRadius = 10.0
}
if #available(iOS 11.0, *) {
if layout.safeInsets.bottom.isZero {
self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
} else {
self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
}
}
if isLandscape {
clipFrame = CGRect(origin: CGPoint(), size: layout.size)
} else {
let coveredByModalTransition: CGFloat = 0.0
var containerTopInset: CGFloat = 10.0
if let statusBarHeight = layout.statusBarHeight {
containerTopInset += statusBarHeight
}
let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset))
let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width
let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition
let maxScaledTopInset: CGFloat = containerTopInset - 10.0
let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition
let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0))
clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height)
}
} else {
self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4)
self.containerView.layer.cornerRadius = 10.0
let verticalInset: CGFloat = 44.0
let maxSide = max(layout.size.width, layout.size.height)
let minSide = min(layout.size.width, layout.size.height)
let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0)
clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize)
}
transition.setFrame(view: self.containerView, frame: clipFrame)
var footerHeight: CGFloat = 8.0 + 50.0
footerHeight += layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : 8.0
let convertedFooterFrame = self.view.convert(CGRect(origin: CGPoint(x: clipFrame.minX, y: clipFrame.maxY - footerHeight), size: CGSize(width: clipFrame.width, height: footerHeight)), to: self.containerView)
transition.setFrame(view: self.footerContainerView, frame: convertedFooterFrame)
self.updated(transition: transition)
}
private var updatedStatus: ChannelBoostStatus?
func updated(transition: Transition) {
guard let controller = self.controller, let layout = self.currentLayout else {
return
}
let contentSize = self.contentView.update(
transition: transition,
component: AnyComponent(
BoostLevelsContainerComponent(
context: controller.context,
theme: self.presentationData.theme,
strings: self.presentationData.strings,
peerId: controller.peerId,
mode: controller.mode,
status: self.updatedStatus ?? controller.status,
copyLink: { [weak self, weak controller] link in
guard let self else {
return
}
UIPasteboard.general.string = link
if let previousController = controller?.navigationController?.viewControllers.reversed().first(where: { $0 !== controller }) as? ViewController {
previousController.present(UndoOverlayController(presentationData: self.presentationData, content: .linkCopied(text: self.presentationData.strings.ChannelBoost_BoostLinkCopied), elevatedLayout: true, position: .top, animateInAsReplacement: false, action: { _ in return false }), in: .current)
}
},
dismiss: { [weak controller] in
controller?.dismiss(animated: true)
},
openStats: controller.openStats,
openGift: controller.openGift
)
),
environment: {},
containerSize: self.containerView.bounds.size
)
self.contentView.frame = CGRect(origin: .zero, size: contentSize)
var footerHeight: CGFloat = 8.0 + 50.0
footerHeight += layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : 8.0
let footerSize = self.footerView.update(
transition: .immediate,
component: AnyComponent(
FooterComponent(
context: controller.context,
theme: self.presentationData.theme,
title: self.updatedStatus != nil ? "Boost Again" : "Boost Group",
action: { [weak self] in
guard let self else {
return
}
if let status = self.controller?.status {
self.updatedStatus = status.withUpdated(boosts: status.boosts + 1)
}
self.animateSuccess()
self.updated(transition: .easeInOut(duration: 0.2))
}
)
),
environment: {},
containerSize: CGSize(width: self.containerView.bounds.width, height: footerHeight)
)
self.footerView.frame = CGRect(origin: .zero, size: footerSize)
}
private var didPlayAppearAnimation = false
func updateIsVisible(isVisible: Bool) {
if self.currentIsVisible == isVisible {
return
}
self.currentIsVisible = isVisible
guard let layout = self.currentLayout else {
return
}
self.containerLayoutUpdated(layout: layout, transition: .immediate)
if !self.didPlayAppearAnimation {
self.didPlayAppearAnimation = true
self.animateIn()
}
}
private var defaultTopInset: CGFloat {
guard let layout = self.currentLayout else {
return 210.0
}
if case .compact = layout.metrics.widthClass {
let bottomPanelPadding: CGFloat = 12.0
let bottomInset: CGFloat = layout.intrinsicInsets.bottom > 0.0 ? layout.intrinsicInsets.bottom + 5.0 : bottomPanelPadding
let panelHeight: CGFloat = bottomPanelPadding + 50.0 + bottomInset + 28.0
return layout.size.height - layout.size.width - 128.0 - panelHeight
} else {
return 210.0
}
}
private func findVerticalScrollView(view: UIView?) -> (UIScrollView, ListView?)? {
if let view = view {
if let view = view as? UIScrollView, view.contentSize.height > view.contentSize.width {
return (view, nil)
}
return findVerticalScrollView(view: view.superview)
} else {
return nil
}
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
guard let layout = self.currentLayout else {
return
}
let isLandscape = layout.orientation == .landscape
let edgeTopInset = isLandscape ? 0.0 : defaultTopInset
switch recognizer.state {
case .began:
let point = recognizer.location(in: self.view)
let currentHitView = self.hitTest(point, with: nil)
var scrollViewAndListNode = self.findVerticalScrollView(view: currentHitView)
if scrollViewAndListNode?.0.frame.height == self.frame.width {
scrollViewAndListNode = nil
}
let scrollView = scrollViewAndListNode?.0
let listNode = scrollViewAndListNode?.1
let topInset: CGFloat
if self.isExpanded {
topInset = 0.0
} else {
topInset = edgeTopInset
}
self.panGestureArguments = (topInset, 0.0, scrollView, listNode)
case .changed:
guard let (topInset, panOffset, scrollView, listNode) = self.panGestureArguments else {
return
}
let visibleContentOffset = listNode?.visibleContentOffset()
let contentOffset = scrollView?.contentOffset.y ?? 0.0
var translation = recognizer.translation(in: self.view).y
var currentOffset = topInset + translation
let epsilon = 1.0
if case let .known(value) = visibleContentOffset, value <= epsilon {
if let scrollView = scrollView {
scrollView.bounces = false
scrollView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: false)
}
} else if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon {
scrollView.bounces = false
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
} else if let scrollView = scrollView {
translation = panOffset
currentOffset = topInset + translation
if self.isExpanded {
recognizer.setTranslation(CGPoint(), in: self.view)
} else if currentOffset > 0.0 {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
}
}
if scrollView == nil {
translation = max(0.0, translation)
}
self.panGestureArguments = (topInset, translation, scrollView, listNode)
if !self.isExpanded {
if currentOffset > 0.0, let scrollView = scrollView {
scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView)
}
}
var bounds = self.bounds
if self.isExpanded {
bounds.origin.y = -max(0.0, translation - edgeTopInset)
} else {
bounds.origin.y = -translation
}
bounds.origin.y = min(0.0, bounds.origin.y)
self.bounds = bounds
self.containerLayoutUpdated(layout: layout, transition: .immediate)
case .ended:
guard let (currentTopInset, panOffset, scrollView, listNode) = self.panGestureArguments else {
return
}
self.panGestureArguments = nil
let visibleContentOffset = listNode?.visibleContentOffset()
let contentOffset = scrollView?.contentOffset.y ?? 0.0
let translation = recognizer.translation(in: self.view).y
var velocity = recognizer.velocity(in: self.view)
if self.isExpanded {
if case let .known(value) = visibleContentOffset, value > 0.1 {
velocity = CGPoint()
} else if case .unknown = visibleContentOffset {
velocity = CGPoint()
} else if contentOffset > 0.1 {
velocity = CGPoint()
}
}
var bounds = self.bounds
if self.isExpanded {
bounds.origin.y = -max(0.0, translation - edgeTopInset)
} else {
bounds.origin.y = -translation
}
bounds.origin.y = min(0.0, bounds.origin.y)
scrollView?.bounces = true
let offset = currentTopInset + panOffset
let topInset: CGFloat = edgeTopInset
var dismissing = false
if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) {
self.controller?.dismiss(animated: true, completion: nil)
dismissing = true
} else if self.isExpanded {
if velocity.y > 300.0 || offset > topInset / 2.0 {
self.isExpanded = false
if let listNode = listNode {
listNode.scroller.setContentOffset(CGPoint(), animated: false)
} else if let scrollView = scrollView {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
}
let distance = topInset - offset
let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance)
let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity))
self.containerLayoutUpdated(layout: layout, transition: Transition(transition))
} else {
self.isExpanded = true
self.containerLayoutUpdated(layout: layout, transition: Transition(.animated(duration: 0.3, curve: .easeInOut)))
}
} else if scrollView != nil, (velocity.y < -300.0 || offset < topInset / 2.0) {
if velocity.y > -2200.0 && velocity.y < -300.0, let listNode = listNode {
DispatchQueue.main.async {
listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
}
let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset)
let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity))
self.isExpanded = true
self.containerLayoutUpdated(layout: layout, transition: Transition(transition))
} else {
if let listNode = listNode {
listNode.scroller.setContentOffset(CGPoint(), animated: false)
} else if let scrollView = scrollView {
scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false)
}
self.containerLayoutUpdated(layout: layout, transition: Transition(.animated(duration: 0.3, curve: .easeInOut)))
}
if !dismissing {
var bounds = self.bounds
let previousBounds = bounds
bounds.origin.y = 0.0
self.bounds = bounds
self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue)
}
case .cancelled:
self.panGestureArguments = nil
self.containerLayoutUpdated(layout: layout, transition: Transition(.animated(duration: 0.3, curve: .easeInOut)))
default:
break
}
}
func updateDismissOffset(_ offset: CGFloat) {
guard self.isExpanded, let layout = self.currentLayout else {
return
}
self.dismissOffset = offset
self.containerLayoutUpdated(layout: layout, transition: .immediate)
}
func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) {
guard isExpanded != self.isExpanded else {
return
}
self.dismissOffset = nil
self.isExpanded = isExpanded
guard let layout = self.currentLayout else {
return
}
self.containerLayoutUpdated(layout: layout, transition: Transition(transition))
}
public func animateSuccess() {
self.hapticFeedback.impact()
self.view.addSubview(ConfettiView(frame: self.view.bounds))
if self.isExpanded {
self.update(isExpanded: false, transition: .animated(duration: 0.4, curve: .spring))
}
}
}
var node: Node {
return self.displayNode as! Node
}
private let context: AccountContext
private let peerId: EnginePeer.Id
private let mode: Mode
private let status: ChannelBoostStatus
private let myBoostStatus: MyBoostStatus?
private let openStats: (() -> Void)?
private let openGift: (() -> Void)?
private let forceDark: Bool
private var currentLayout: ContainerViewLayout?
public var disposed: () -> Void = {}
public init(
context: AccountContext,
peerId: EnginePeer.Id,
mode: Mode,
status: ChannelBoostStatus,
myBoostStatus: MyBoostStatus? = nil,
openStats: (() -> Void)? = nil,
openGift: (() -> Void)? = nil,
forceDark: Bool = false
) {
self.context = context
self.peerId = peerId
self.mode = mode
self.status = status
self.myBoostStatus = myBoostStatus
self.openStats = openStats
self.openGift = openGift
self.forceDark = forceDark
super.init(navigationBarPresentationData: nil)
self.navigationPresentation = .flatModal
self.statusBar.statusBarStyle = .Ignore
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposed()
}
@objc private func cancelPressed() {
self.dismiss(animated: true, completion: nil)
}
override open func loadDisplayNode() {
self.displayNode = Node(context: self.context, controller: self)
self.displayNodeDidLoad()
self.view.disablesInteractiveModalDismiss = true
}
public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
self.view.endEditing(true)
if flag {
self.node.animateOut(completion: {
super.dismiss(animated: false, completion: {})
completion?()
})
} else {
super.dismiss(animated: false, completion: {})
completion?()
}
}
override open func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.node.updateIsVisible(isVisible: true)
}
override open func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.node.updateIsVisible(isVisible: false)
}
override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.currentLayout = layout
super.containerLayoutUpdated(layout, transition: transition)
self.node.containerLayoutUpdated(layout: layout, transition: Transition(transition))
}
}
private final class FooterComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let title: String
let action: () -> Void
init(context: AccountContext, theme: PresentationTheme, title: String, action: @escaping () -> Void) {
self.context = context
self.theme = theme
self.title = title
self.action = action
}
static func ==(lhs: FooterComponent, rhs: FooterComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
return true
}
final class View: UIView {
private let backgroundView: BlurredBackgroundView
private let separator = SimpleLayer()
private let button = ComponentView<Empty>()
private var component: FooterComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: nil)
super.init(frame: frame)
self.backgroundView.clipsToBounds = true
self.addSubview(self.backgroundView)
self.layer.addSublayer(self.separator)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: FooterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
self.component = component
self.state = state
let bounds = CGRect(origin: .zero, size: availableSize)
self.backgroundView.updateColor(color: component.theme.rootController.tabBar.backgroundColor, transition: transition.containedViewLayoutTransition)
self.backgroundView.update(size: bounds.size, transition: transition.containedViewLayoutTransition)
transition.setFrame(view: self.backgroundView, frame: bounds)
self.separator.backgroundColor = component.theme.rootController.tabBar.separatorColor.cgColor
transition.setFrame(layer: self.separator, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: UIScreenPixel)))
let gradientColors = [
UIColor(rgb: 0x0077ff),
UIColor(rgb: 0x6b93ff),
UIColor(rgb: 0x8878ff),
UIColor(rgb: 0xe46ace)
]
let buttonSize = self.button.update(
transition: .immediate,
component: AnyComponent(
SolidRoundedButtonComponent(
title: component.title,
theme: SolidRoundedButtonComponent.Theme(
backgroundColor: .black,
backgroundColors: gradientColors,
foregroundColor: .white
),
font: .bold,
fontSize: 17.0,
height: 50.0,
cornerRadius: 10.0,
gloss: true,
iconName: "Premium/BoostChannel",
animationName: nil,
iconPosition: .left,
action: {
component.action()
}
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - 32.0, height: availableSize.height)
)
if let view = self.button.view {
if view.superview == nil {
self.addSubview(view)
}
let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: 8.0), size: buttonSize)
view.frame = buttonFrame
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}