diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 56ae3fa70b..71de4c35f8 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -934,6 +934,9 @@ public protocol SharedAccountContext: AnyObject { func makeArchiveSettingsController(context: AccountContext) -> ViewController func makeBusinessSetupScreen(context: AccountContext) -> ViewController func makeChatbotSetupScreen(context: AccountContext) -> ViewController + func makeBusinessLocationSetupScreen(context: AccountContext) -> ViewController + func makeBusinessHoursSetupScreen(context: AccountContext) -> ViewController + func makeGreetingMessageSetupScreen(context: AccountContext) -> ViewController func navigateToChatController(_ params: NavigateToChatControllerParams) func navigateToForumChannel(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) func navigateToForumThread(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, messageId: EngineMessage.Id?, navigationController: NavigationController, activateInput: ChatControllerActivateInput?, keepStack: NavigateToChatKeepStack) -> Signal diff --git a/submodules/AccountContext/Sources/ContactMultiselectionController.swift b/submodules/AccountContext/Sources/ContactMultiselectionController.swift index 6d60174528..45cbfa89ff 100644 --- a/submodules/AccountContext/Sources/ContactMultiselectionController.swift +++ b/submodules/AccountContext/Sources/ContactMultiselectionController.swift @@ -45,6 +45,7 @@ public enum ContactMultiselectionControllerMode { public var chatListFilters: [ChatListFilter]? public var displayAutoremoveTimeout: Bool public var displayPresence: Bool + public var onlyUsers: Bool public init( title: String, @@ -53,7 +54,8 @@ public enum ContactMultiselectionControllerMode { additionalCategories: ContactMultiselectionControllerAdditionalCategories?, chatListFilters: [ChatListFilter]?, displayAutoremoveTimeout: Bool = false, - displayPresence: Bool = false + displayPresence: Bool = false, + onlyUsers: Bool = false ) { self.title = title self.searchPlaceholder = searchPlaceholder @@ -62,6 +64,7 @@ public enum ContactMultiselectionControllerMode { self.chatListFilters = chatListFilters self.displayAutoremoveTimeout = displayAutoremoveTimeout self.displayPresence = displayPresence + self.onlyUsers = onlyUsers } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 3eafa32fae..b638996c25 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -3185,7 +3185,7 @@ public final class ChatListNode: ListView { } private func resetFilter() { - if let chatListFilter = self.chatListFilter { + if let chatListFilter = self.chatListFilter, chatListFilter.id != Int32.max { self.updatedFilterDisposable.set((self.context.engine.peers.updatedChatListFilters() |> map { filters -> ChatListFilter? in for filter in filters { @@ -4113,14 +4113,16 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres if isContact { return (strings.ChatList_PeerTypeContact, false, false, nil) } else { - return (strings.ChatList_PeerTypeNonContact, false, false, nil) + //TODO:localize + return ("non-contact", false, false, nil) } } } else if case .secretChat = peer { if isContact { return (strings.ChatList_PeerTypeContact, false, false, nil) } else { - return (strings.ChatList_PeerTypeNonContact, false, false, nil) + //TODO:localize + return ("non-contact", false, false, nil) } } else if case .legacyGroup = peer { return (strings.ChatList_PeerTypeGroup, false, false, nil) diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift index c452612645..7b67cae1b2 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift @@ -67,7 +67,7 @@ public func chatListFilterPredicate(filter: ChatListFilterData, accountPeerId: E } if !filter.categories.contains(.contacts) && isContact { if let user = peer as? TelegramUser { - if user.botInfo == nil { + if user.botInfo == nil && !user.flags.contains(.isSupport) { return false } } else if let _ = peer as? TelegramSecretChat { @@ -88,7 +88,7 @@ public func chatListFilterPredicate(filter: ChatListFilterData, accountPeerId: E } if !filter.categories.contains(.bots) { if let user = peer as? TelegramUser { - if user.botInfo != nil { + if user.botInfo != nil || user.flags.contains(.isSupport) { return false } } diff --git a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift index b4f27b849e..04df0fd8ce 100644 --- a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift +++ b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift @@ -722,11 +722,11 @@ public extension CombinedComponent { updatedChild.view.layer.shadowRadius = 0.0 updatedChild.view.layer.shadowOpacity = 0.0 } - updatedChild.view.context(typeErasedComponent: updatedChild.component).erasedState._updated = { [weak viewContext] transition in + updatedChild.view.context(typeErasedComponent: updatedChild.component).erasedState._updated = { [weak viewContext] transition, isLocal in guard let viewContext = viewContext else { return } - viewContext.state.updated(transition: transition) + viewContext.state.updated(transition: transition, isLocal: isLocal) } if let transitionAppearWithGuide = updatedChild.transitionAppearWithGuide { diff --git a/submodules/ComponentFlow/Source/Base/Component.swift b/submodules/ComponentFlow/Source/Base/Component.swift index 7716349577..fabce5cf5c 100644 --- a/submodules/ComponentFlow/Source/Base/Component.swift +++ b/submodules/ComponentFlow/Source/Base/Component.swift @@ -89,15 +89,15 @@ extension UIView { } open class ComponentState { - open var _updated: ((Transition) -> Void)? + open var _updated: ((Transition, Bool) -> Void)? var isUpdated: Bool = false public init() { } - public final func updated(transition: Transition = .immediate) { + public final func updated(transition: Transition = .immediate, isLocal: Bool = false) { self.isUpdated = true - self._updated?(transition) + self._updated?(transition, isLocal) } } diff --git a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift index 32e0c7edfe..275772854a 100644 --- a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift +++ b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift @@ -8,16 +8,16 @@ public final class RoundedRectangle: Component { } public let colors: [UIColor] - public let cornerRadius: CGFloat + public let cornerRadius: CGFloat? public let gradientDirection: GradientDirection public let stroke: CGFloat? public let strokeColor: UIColor? - public convenience init(color: UIColor, cornerRadius: CGFloat, stroke: CGFloat? = nil, strokeColor: UIColor? = nil) { + public convenience init(color: UIColor, cornerRadius: CGFloat?, stroke: CGFloat? = nil, strokeColor: UIColor? = nil) { self.init(colors: [color], cornerRadius: cornerRadius, stroke: stroke, strokeColor: strokeColor) } - public init(colors: [UIColor], cornerRadius: CGFloat, gradientDirection: GradientDirection = .horizontal, stroke: CGFloat? = nil, strokeColor: UIColor? = nil) { + public init(colors: [UIColor], cornerRadius: CGFloat?, gradientDirection: GradientDirection = .horizontal, stroke: CGFloat? = nil, strokeColor: UIColor? = nil) { self.colors = colors self.cornerRadius = cornerRadius self.gradientDirection = gradientDirection @@ -49,8 +49,10 @@ public final class RoundedRectangle: Component { func update(component: RoundedRectangle, availableSize: CGSize, transition: Transition) -> CGSize { if self.component != component { + let cornerRadius = component.cornerRadius ?? min(availableSize.width, availableSize.height) * 0.5 + if component.colors.count == 1, let color = component.colors.first { - let imageSize = CGSize(width: max(component.stroke ?? 0.0, component.cornerRadius) * 2.0, height: max(component.stroke ?? 0.0, component.cornerRadius) * 2.0) + let imageSize = CGSize(width: max(component.stroke ?? 0.0, cornerRadius) * 2.0, height: max(component.stroke ?? 0.0, cornerRadius) * 2.0) UIGraphicsBeginImageContextWithOptions(imageSize, false, 0.0) if let context = UIGraphicsGetCurrentContext() { if let strokeColor = component.strokeColor { @@ -69,13 +71,13 @@ public final class RoundedRectangle: Component { context.fillEllipse(in: CGRect(origin: CGPoint(), size: imageSize).insetBy(dx: stroke, dy: stroke)) } } - self.image = UIGraphicsGetImageFromCurrentImageContext()?.stretchableImage(withLeftCapWidth: Int(component.cornerRadius), topCapHeight: Int(component.cornerRadius)) + self.image = UIGraphicsGetImageFromCurrentImageContext()?.stretchableImage(withLeftCapWidth: Int(cornerRadius), topCapHeight: Int(cornerRadius)) UIGraphicsEndImageContext() } else if component.colors.count > 1 { let imageSize = availableSize UIGraphicsBeginImageContextWithOptions(imageSize, false, 0.0) if let context = UIGraphicsGetCurrentContext() { - context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: imageSize), cornerRadius: component.cornerRadius).cgPath) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: imageSize), cornerRadius: cornerRadius).cgPath) context.clip() let colors = component.colors @@ -93,12 +95,12 @@ public final class RoundedRectangle: Component { if let stroke = component.stroke, stroke > 0.0 { context.resetClip() - context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: imageSize).insetBy(dx: stroke, dy: stroke), cornerRadius: component.cornerRadius).cgPath) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: imageSize).insetBy(dx: stroke, dy: stroke), cornerRadius: cornerRadius).cgPath) context.setBlendMode(.clear) context.fill(CGRect(origin: .zero, size: imageSize)) } } - self.image = UIGraphicsGetImageFromCurrentImageContext()?.stretchableImage(withLeftCapWidth: Int(component.cornerRadius), topCapHeight: Int(component.cornerRadius)) + self.image = UIGraphicsGetImageFromCurrentImageContext()?.stretchableImage(withLeftCapWidth: Int(cornerRadius), topCapHeight: Int(cornerRadius)) UIGraphicsEndImageContext() } } diff --git a/submodules/ComponentFlow/Source/Host/ComponentHostView.swift b/submodules/ComponentFlow/Source/Host/ComponentHostView.swift index 2c8a3d87b6..cdc4a7c3ab 100644 --- a/submodules/ComponentFlow/Source/Host/ComponentHostView.swift +++ b/submodules/ComponentFlow/Source/Host/ComponentHostView.swift @@ -82,7 +82,7 @@ public final class ComponentHostView: UIView { self.currentComponent = component self.currentContainerSize = containerSize - componentState._updated = { [weak self] transition in + componentState._updated = { [weak self] transition, _ in guard let strongSelf = self else { return } @@ -208,11 +208,11 @@ public final class ComponentView { self.currentComponent = component self.currentContainerSize = containerSize - componentState._updated = { [weak self] transition in + componentState._updated = { [weak self] transition, isLocal in guard let strongSelf = self else { return } - if let parentState = strongSelf.parentState { + if !isLocal, let parentState = strongSelf.parentState { parentState.updated(transition: transition) } else { let _ = strongSelf._update(transition: transition, component: component, maybeEnvironment: { diff --git a/submodules/LegacyComponents/Sources/TGMediaAssetsController.m b/submodules/LegacyComponents/Sources/TGMediaAssetsController.m index 90541a5d09..994ddbf39e 100644 --- a/submodules/LegacyComponents/Sources/TGMediaAssetsController.m +++ b/submodules/LegacyComponents/Sources/TGMediaAssetsController.m @@ -567,6 +567,9 @@ updateGroupingButtonVisibility(); } file:__FILE_NAME__ line:__LINE__]]; + if (_adjustmentsChangedDisposable) { + [_adjustmentsChangedDisposable dispose]; + } _adjustmentsChangedDisposable = [[SMetaDisposable alloc] init]; [_adjustmentsChangedDisposable setDisposable:[_editingContext.adjustmentsUpdatedSignal startStrictWithNext:^(__unused NSNumber *next) { @@ -583,6 +586,7 @@ self.delegate = nil; [_selectionChangedDisposable dispose]; [_tooltipDismissDisposable dispose]; + [_adjustmentsChangedDisposable dispose]; } - (void)loadView diff --git a/submodules/LocationUI/Sources/LocationPickerController.swift b/submodules/LocationUI/Sources/LocationPickerController.swift index c557339e3a..6d73920913 100644 --- a/submodules/LocationUI/Sources/LocationPickerController.swift +++ b/submodules/LocationUI/Sources/LocationPickerController.swift @@ -187,7 +187,7 @@ public final class LocationPickerController: ViewController, AttachmentContainab if ["home", "work"].contains(venueType) { completion(TelegramMediaMap(latitude: venue.latitude, longitude: venue.longitude, heading: nil, accuracyRadius: nil, geoPlace: nil, venue: nil, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil), nil, nil, nil, nil) } else { - completion(venue, queryId, resultId, nil, nil) + completion(venue, queryId, resultId, venue.venue?.address, nil) } strongSelf.dismiss() }, toggleMapModeSelection: { [weak self] in diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift index 904405d181..94585d6ff9 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift @@ -11,10 +11,41 @@ private func drawBorder(context: CGContext, rect: CGRect) { context.strokePath() } -private func renderIcon(name: String) -> UIImage? { +private func addRoundedRectPath(context: CGContext, rect: CGRect, radius: CGFloat) { + context.saveGState() + context.translateBy(x: rect.minX, y: rect.minY) + context.scaleBy(x: radius, y: radius) + let fw = rect.width / radius + let fh = rect.height / radius + context.move(to: CGPoint(x: fw, y: fh / 2.0)) + context.addArc(tangent1End: CGPoint(x: fw, y: fh), tangent2End: CGPoint(x: fw/2, y: fh), radius: 1.0) + context.addArc(tangent1End: CGPoint(x: 0, y: fh), tangent2End: CGPoint(x: 0, y: fh/2), radius: 1) + context.addArc(tangent1End: CGPoint(x: 0, y: 0), tangent2End: CGPoint(x: fw/2, y: 0), radius: 1) + context.addArc(tangent1End: CGPoint(x: fw, y: 0), tangent2End: CGPoint(x: fw, y: fh/2), radius: 1) + context.closePath() + context.restoreGState() +} + +private func renderIcon(name: String, backgroundColors: [UIColor]? = nil) -> UIImage? { return generateImage(CGSize(width: 29.0, height: 29.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) + + if let backgroundColors { + addRoundedRectPath(context: context, rect: CGRect(origin: CGPoint(), size: size), radius: 7.0) + context.clip() + + var locations: [CGFloat] = [0.0, 1.0] + let colors: [CGColor] = backgroundColors.map(\.cgColor) + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: size.width, y: size.height), end: CGPoint(x: 0.0, y: 0.0), options: CGGradientDrawingOptions()) + + context.resetClip() + } + if let image = UIImage(bundleImageName: name)?.cgImage { context.draw(image, in: bounds) } @@ -38,6 +69,7 @@ public struct PresentationResourcesSettings { public static let powerSaving = renderIcon(name: "Settings/Menu/PowerSaving") public static let stories = renderIcon(name: "Settings/Menu/Stories") public static let premiumGift = renderIcon(name: "Settings/Menu/Gift") + public static let business = renderIcon(name: "Settings/Menu/Business", backgroundColors: [UIColor(rgb: 0xA95CE3), UIColor(rgb: 0xF16B80)]) public static let premium = generateImage(CGSize(width: 29.0, height: 29.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) diff --git a/submodules/TelegramStringFormatting/Sources/DateFormat.swift b/submodules/TelegramStringFormatting/Sources/DateFormat.swift index 27479dca88..54759b97c7 100644 --- a/submodules/TelegramStringFormatting/Sources/DateFormat.swift +++ b/submodules/TelegramStringFormatting/Sources/DateFormat.swift @@ -21,9 +21,9 @@ public func stringForShortTimestamp(hours: Int32, minutes: Int32, dateTimeFormat periodString = "AM" } if minutes >= 10 { - return "\(hourString):\(minutes) \(periodString)" + return "\(hourString):\(minutes)\u{00a0}\(periodString)" } else { - return "\(hourString):0\(minutes) \(periodString)" + return "\(hourString):0\(minutes)\u{00a0}\(periodString)" } case .military: return String(format: "%02d:%02d", arguments: [Int(hours), Int(minutes)]) diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index e5fca19f8a..d4068def28 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -434,6 +434,9 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent", "//submodules/TelegramUI/Components/Settings/BusinessSetupScreen", "//submodules/TelegramUI/Components/Settings/ChatbotSetupScreen", + "//submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen", + "//submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen", + "//submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/LegacyMessageInputPanel/Sources/LegacyMessageInputPanel.swift b/submodules/TelegramUI/Components/LegacyMessageInputPanel/Sources/LegacyMessageInputPanel.swift index 2df3092fd4..2472f96dfc 100644 --- a/submodules/TelegramUI/Components/LegacyMessageInputPanel/Sources/LegacyMessageInputPanel.swift +++ b/submodules/TelegramUI/Components/LegacyMessageInputPanel/Sources/LegacyMessageInputPanel.swift @@ -57,7 +57,7 @@ public class LegacyMessageInputPanelNode: ASDisplayNode, TGCaptionPanelView { super.init() - self.state._updated = { [weak self] transition in + self.state._updated = { [weak self] transition, _ in if let self { self.update(transition: transition.containedViewLayoutTransition) } diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index b6c169f388..d767dd3491 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -7,15 +7,67 @@ import ListSectionComponent import SwitchNode public final class ListActionItemComponent: Component { + public enum ToggleStyle { + case regular + case icons + } + + public struct Toggle: Equatable { + public var style: ToggleStyle + public var isOn: Bool + public var isInteractive: Bool + public var action: ((Bool) -> Void)? + + public init(style: ToggleStyle, isOn: Bool, isInteractive: Bool = true, action: ((Bool) -> Void)? = nil) { + self.style = style + self.isOn = isOn + self.isInteractive = isInteractive + self.action = action + } + + public static func ==(lhs: Toggle, rhs: Toggle) -> Bool { + if lhs.style != rhs.style { + return false + } + if lhs.isOn != rhs.isOn { + return false + } + if lhs.isInteractive != rhs.isInteractive { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + return true + } + } + public enum Accessory: Equatable { case arrow - case toggle(Bool) + case toggle(Toggle) + } + + public enum IconInsets: Equatable { + case `default` + case custom(UIEdgeInsets) + } + + public struct Icon: Equatable { + public var component: AnyComponentWithIdentity + public var insets: IconInsets + public var allowUserInteraction: Bool + + public init(component: AnyComponentWithIdentity, insets: IconInsets = .default, allowUserInteraction: Bool = false) { + self.component = component + self.insets = insets + self.allowUserInteraction = allowUserInteraction + } } public let theme: PresentationTheme public let title: AnyComponent public let leftIcon: AnyComponentWithIdentity? - public let icon: AnyComponentWithIdentity? + public let icon: Icon? public let accessory: Accessory? public let action: ((UIView) -> Void)? @@ -23,7 +75,7 @@ public final class ListActionItemComponent: Component { theme: PresentationTheme, title: AnyComponent, leftIcon: AnyComponentWithIdentity? = nil, - icon: AnyComponentWithIdentity? = nil, + icon: Icon? = nil, accessory: Accessory? = .arrow, action: ((UIView) -> Void)? ) { @@ -63,7 +115,8 @@ public final class ListActionItemComponent: Component { private var icon: ComponentView? private var arrowView: UIImageView? - private var switchNode: IconSwitchNode? + private var switchNode: SwitchNode? + private var iconSwitchNode: IconSwitchNode? private var component: ListActionItemComponent? @@ -83,7 +136,10 @@ public final class ListActionItemComponent: Component { self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) self.internalHighligthedChanged = { [weak self] isHighlighted in - guard let self else { + guard let self, let component = self.component, component.action != nil else { + return + } + if case .toggle = component.accessory, component.action == nil { return } if let customUpdateIsHighlighted = self.customUpdateIsHighlighted { @@ -97,15 +153,23 @@ public final class ListActionItemComponent: Component { } @objc private func pressed() { - self.component?.action?(self) + guard let component, let action = component.action else { + return + } + action(self) + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = super.hitTest(point, with: event) else { + return nil + } + return result } func update(component: ListActionItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let previousComponent = self.component self.component = component - self.isEnabled = component.action != nil - let themeUpdated = component.theme !== previousComponent?.theme let verticalInset: CGFloat = 12.0 @@ -118,7 +182,7 @@ public final class ListActionItemComponent: Component { case .arrow: contentRightInset = 30.0 case .toggle: - contentRightInset = 42.0 + contentRightInset = 76.0 } var contentHeight: CGFloat = 0.0 @@ -147,7 +211,7 @@ public final class ListActionItemComponent: Component { contentHeight += verticalInset if let iconValue = component.icon { - if previousComponent?.icon?.id != iconValue.id, let icon = self.icon { + if previousComponent?.icon?.component.id != iconValue.component.id, let icon = self.icon { self.icon = nil if let iconView = icon.view { transition.setAlpha(view: iconView, alpha: 0.0, completion: { [weak iconView] _ in @@ -168,17 +232,17 @@ public final class ListActionItemComponent: Component { let iconSize = icon.update( transition: iconTransition, - component: iconValue.component, + component: iconValue.component.component, environment: {}, containerSize: CGSize(width: availableSize.width, height: availableSize.height) ) let iconFrame = CGRect(origin: CGPoint(x: availableSize.width - contentRightInset - iconSize.width, y: floor((contentHeight - iconSize.height) * 0.5)), size: iconSize) if let iconView = icon.view { if iconView.superview == nil { - iconView.isUserInteractionEnabled = false self.addSubview(iconView) transition.animateAlpha(view: iconView, from: 0.0, to: 1.0) } + iconView.isUserInteractionEnabled = iconValue.allowUserInteraction iconTransition.setFrame(view: iconView, frame: iconFrame) } } else { @@ -263,32 +327,85 @@ public final class ListActionItemComponent: Component { } } - if case let .toggle(isOn) = component.accessory { - let switchNode: IconSwitchNode - var switchTransition = transition - var updateSwitchTheme = themeUpdated - if let current = self.switchNode { - switchNode = current - switchNode.setOn(isOn, animated: !transition.animation.isImmediate) - } else { - switchTransition = switchTransition.withAnimation(.none) - updateSwitchTheme = true - switchNode = IconSwitchNode() - switchNode.setOn(isOn, animated: false) - self.addSubview(switchNode.view) + if case let .toggle(toggle) = component.accessory { + switch toggle.style { + case .regular: + let switchNode: SwitchNode + var switchTransition = transition + var updateSwitchTheme = themeUpdated + if let current = self.switchNode { + switchNode = current + switchNode.setOn(toggle.isOn, animated: !transition.animation.isImmediate) + } else { + switchTransition = switchTransition.withAnimation(.none) + updateSwitchTheme = true + switchNode = SwitchNode() + switchNode.setOn(toggle.isOn, animated: false) + self.switchNode = switchNode + self.addSubview(switchNode.view) + + switchNode.valueUpdated = { [weak self] value in + guard let self, let component = self.component else { + return + } + if case let .toggle(toggle) = component.accessory, let action = toggle.action { + action(value) + } else { + component.action?(self) + } + } + } + switchNode.isUserInteractionEnabled = toggle.isInteractive + + if updateSwitchTheme { + switchNode.frameColor = component.theme.list.itemSwitchColors.frameColor + switchNode.contentColor = component.theme.list.itemSwitchColors.contentColor + switchNode.handleColor = component.theme.list.itemSwitchColors.handleColor + } + + let switchSize = CGSize(width: 51.0, height: 31.0) + let switchFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - switchSize.width, y: floor((min(60.0, contentHeight) - switchSize.height) * 0.5)), size: switchSize) + switchTransition.setFrame(view: switchNode.view, frame: switchFrame) + case .icons: + let switchNode: IconSwitchNode + var switchTransition = transition + var updateSwitchTheme = themeUpdated + if let current = self.iconSwitchNode { + switchNode = current + switchNode.setOn(toggle.isOn, animated: !transition.animation.isImmediate) + } else { + switchTransition = switchTransition.withAnimation(.none) + updateSwitchTheme = true + switchNode = IconSwitchNode() + switchNode.setOn(toggle.isOn, animated: false) + self.iconSwitchNode = switchNode + self.addSubview(switchNode.view) + + switchNode.valueUpdated = { [weak self] value in + guard let self, let component = self.component else { + return + } + if case let .toggle(toggle) = component.accessory, let action = toggle.action { + action(value) + } else { + component.action?(self) + } + } + } + switchNode.isUserInteractionEnabled = toggle.isInteractive + + if updateSwitchTheme { + switchNode.frameColor = component.theme.list.itemSwitchColors.frameColor + switchNode.contentColor = component.theme.list.itemSwitchColors.contentColor + switchNode.handleColor = component.theme.list.itemSwitchColors.handleColor + switchNode.positiveContentColor = component.theme.list.itemSwitchColors.positiveColor + switchNode.negativeContentColor = component.theme.list.itemSwitchColors.negativeColor + } + + let switchSize = CGSize(width: 51.0, height: 31.0) + let switchFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - switchSize.width, y: floor((min(60.0, contentHeight) - switchSize.height) * 0.5)), size: switchSize) + switchTransition.setFrame(view: switchNode.view, frame: switchFrame) } - - if updateSwitchTheme { - switchNode.frameColor = component.theme.list.itemSwitchColors.frameColor - switchNode.contentColor = component.theme.list.itemSwitchColors.contentColor - switchNode.handleColor = component.theme.list.itemSwitchColors.handleColor - switchNode.positiveContentColor = component.theme.list.itemSwitchColors.positiveColor - switchNode.negativeContentColor = component.theme.list.itemSwitchColors.negativeColor - } - - let switchSize = CGSize(width: 51.0, height: 31.0) - let switchFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - switchSize.width, y: floor((min(60.0, contentHeight) - switchSize.height) * 0.5)), size: switchSize) - switchTransition.setFrame(view: switchNode.view, frame: switchFrame) } else { if let switchNode = self.switchNode { self.switchNode = nil diff --git a/submodules/TelegramUI/Components/ListItemSliderSelectorComponent/BUILD b/submodules/TelegramUI/Components/ListItemSliderSelectorComponent/BUILD new file mode 100644 index 0000000000..53ead5eba6 --- /dev/null +++ b/submodules/TelegramUI/Components/ListItemSliderSelectorComponent/BUILD @@ -0,0 +1,22 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ListItemSliderSelectorComponent", + module_name = "ListItemSliderSelectorComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/TelegramPresentationData", + "//submodules/ComponentFlow", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/SliderComponent, + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ListItemSliderSelectorComponent/Sources/ListItemSliderSelectorComponent.swift b/submodules/TelegramUI/Components/ListItemSliderSelectorComponent/Sources/ListItemSliderSelectorComponent.swift new file mode 100644 index 0000000000..6a39820831 --- /dev/null +++ b/submodules/TelegramUI/Components/ListItemSliderSelectorComponent/Sources/ListItemSliderSelectorComponent.swift @@ -0,0 +1,460 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import TelegramPresentationData +import LegacyUI +import ComponentFlow +import MultilineTextComponent +import SliderComponent + +final class ListItemSliderSelectorComponent: Component { + typealias EnvironmentType = Empty + + let title: String + let value: Float + let minValue: Float + let maxValue: Float + let startValue: Float + let isEnabled: Bool + let trackColor: UIColor? + let displayValue: Bool + let valueUpdated: (Float) -> Void + let isTrackingUpdated: ((Bool) -> Void)? + + init( + title: String, + value: Float, + minValue: Float, + maxValue: Float, + startValue: Float, + isEnabled: Bool, + trackColor: UIColor?, + displayValue: Bool, + valueUpdated: @escaping (Float) -> Void, + isTrackingUpdated: ((Bool) -> Void)? = nil + ) { + self.title = title + self.value = value + self.minValue = minValue + self.maxValue = maxValue + self.startValue = startValue + self.isEnabled = isEnabled + self.trackColor = trackColor + self.displayValue = displayValue + self.valueUpdated = valueUpdated + self.isTrackingUpdated = isTrackingUpdated + } + + static func ==(lhs: ListItemSliderSelectorComponent, rhs: ListItemSliderSelectorComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.value != rhs.value { + return false + } + if lhs.minValue != rhs.minValue { + return false + } + if lhs.maxValue != rhs.maxValue { + return false + } + if lhs.startValue != rhs.startValue { + return false + } + if lhs.isEnabled != rhs.isEnabled { + return false + } + if lhs.trackColor != rhs.trackColor { + return false + } + if lhs.displayValue != rhs.displayValue { + return false + } + return true + } + + final class View: UIView, UITextFieldDelegate { + private let title = ComponentView() + private let value = ComponentView() + private var sliderView: TGPhotoEditorSliderView? + + private var component: ListItemSliderSelectorComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ListItemSliderSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + var internalIsTrackingUpdated: ((Bool) -> Void)? + if let isTrackingUpdated = component.isTrackingUpdated { + internalIsTrackingUpdated = { [weak self] isTracking in + if let self { + if isTracking { + self.sliderView?.bordered = true + } else { + Queue.mainQueue().after(0.1) { + self.sliderView?.bordered = false + } + } + isTrackingUpdated(isTracking) + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + if let titleView = self.title.view { + transition.setAlpha(view: titleView, alpha: isTracking ? 0.0 : 1.0) + } + if let valueView = self.value.view { + transition.setAlpha(view: valueView, alpha: isTracking ? 0.0 : 1.0) + } + } + } + } + + let sliderView: TGPhotoEditorSliderView + if let current = self.sliderView { + sliderView = current + sliderView.value = CGFloat(component.value) + } else { + sliderView = TGPhotoEditorSliderView() + sliderView.backgroundColor = .clear + sliderView.startColor = UIColor(rgb: 0xffffff) + sliderView.enablePanHandling = true + sliderView.trackCornerRadius = 1.0 + sliderView.lineSize = 2.0 + sliderView.minimumValue = CGFloat(component.minValue) + sliderView.maximumValue = CGFloat(component.maxValue) + sliderView.startValue = CGFloat(component.startValue) + sliderView.value = CGFloat(component.value) + sliderView.disablesInteractiveTransitionGestureRecognizer = true + sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged) + sliderView.layer.allowsGroupOpacity = true + self.sliderView = sliderView + self.addSubview(sliderView) + } + sliderView.interactionBegan = { + internalIsTrackingUpdated?(true) + } + sliderView.interactionEnded = { + internalIsTrackingUpdated?(false) + } + + if component.isEnabled { + sliderView.alpha = 1.3 + sliderView.trackColor = component.trackColor ?? UIColor(rgb: 0xffffff) + sliderView.isUserInteractionEnabled = true + } else { + sliderView.trackColor = UIColor(rgb: 0xffffff) + sliderView.alpha = 0.3 + sliderView.isUserInteractionEnabled = false + } + + transition.setFrame(view: sliderView, frame: CGRect(origin: CGPoint(x: 22.0, y: 7.0), size: CGSize(width: availableSize.width - 22.0 * 2.0, height: 44.0))) + sliderView.hitTestEdgeInsets = UIEdgeInsets(top: -sliderView.frame.minX, left: 0.0, bottom: 0.0, right: -sliderView.frame.minX) + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent( + Text(text: component.title, font: Font.regular(14.0), color: UIColor(rgb: 0x808080)) + ), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: 21.0, y: 0.0), size: titleSize)) + } + + let valueText: String + if component.displayValue { + if component.value > 0.005 { + valueText = String(format: "+%.2f", component.value) + } else if component.value < -0.005 { + valueText = String(format: "%.2f", component.value) + } else { + valueText = "" + } + } else { + valueText = "" + } + + let valueSize = self.value.update( + transition: .immediate, + component: AnyComponent( + Text(text: valueText, font: Font.with(size: 14.0, traits: .monospacedNumbers), color: UIColor(rgb: 0xf8d74a)) + ), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let valueView = self.value.view { + if valueView.superview == nil { + self.addSubview(valueView) + } + transition.setFrame(view: valueView, frame: CGRect(origin: CGPoint(x: availableSize.width - 21.0 - valueSize.width, y: 0.0), size: valueSize)) + } + + return CGSize(width: availableSize.width, height: 52.0) + } + + @objc private func sliderValueChanged() { + guard let component = self.component, let sliderView = self.sliderView else { + return + } + component.valueUpdated(Float(sliderView.value)) + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +struct AdjustmentTool: Equatable { + let key: EditorToolKey + let title: String + let value: Float + let minValue: Float + let maxValue: Float + let startValue: Float +} + +final class AdjustmentsComponent: Component { + typealias EnvironmentType = Empty + + let tools: [AdjustmentTool] + let valueUpdated: (EditorToolKey, Float) -> Void + let isTrackingUpdated: (Bool) -> Void + + init( + tools: [AdjustmentTool], + valueUpdated: @escaping (EditorToolKey, Float) -> Void, + isTrackingUpdated: @escaping (Bool) -> Void + ) { + self.tools = tools + self.valueUpdated = valueUpdated + self.isTrackingUpdated = isTrackingUpdated + } + + static func ==(lhs: AdjustmentsComponent, rhs: AdjustmentsComponent) -> Bool { + if lhs.tools != rhs.tools { + return false + } + return true + } + + final class View: UIView { + private let scrollView = UIScrollView() + private var toolViews: [ComponentView] = [] + + private var component: AdjustmentsComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.scrollView.showsVerticalScrollIndicator = false + + super.init(frame: frame) + + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: AdjustmentsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + let valueUpdated = component.valueUpdated + let isTrackingUpdated: (EditorToolKey, Bool) -> Void = { [weak self] trackingTool, isTracking in + component.isTrackingUpdated(isTracking) + + if let self { + for i in 0 ..< component.tools.count { + let tool = component.tools[i] + if tool.key != trackingTool && i < self.toolViews.count { + if let view = self.toolViews[i].view { + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + transition.setAlpha(view: view, alpha: isTracking ? 0.0 : 1.0) + } + } + } + } + } + + var sizes: [CGSize] = [] + for i in 0 ..< component.tools.count { + let tool = component.tools[i] + let componentView: ComponentView + if i >= self.toolViews.count { + componentView = ComponentView() + self.toolViews.append(componentView) + } else { + componentView = self.toolViews[i] + } + + var valueIsNegative = false + var value = tool.value + if case .enhance = tool.key { + if value < 0.0 { + valueIsNegative = true + } + value = abs(value) + } + + let size = componentView.update( + transition: transition, + component: AnyComponent( + ListItemSliderSelectorComponent( + title: tool.title, + value: value, + minValue: tool.minValue, + maxValue: tool.maxValue, + startValue: tool.startValue, + isEnabled: true, + trackColor: nil, + displayValue: true, + valueUpdated: { value in + var updatedValue = value + if valueIsNegative { + updatedValue *= -1.0 + } + valueUpdated(tool.key, updatedValue) + }, + isTrackingUpdated: { isTracking in + isTrackingUpdated(tool.key, isTracking) + } + ) + ), + environment: {}, + containerSize: availableSize + ) + sizes.append(size) + } + + var origin: CGPoint = CGPoint(x: 0.0, y: 11.0) + for i in 0 ..< component.tools.count { + let size = sizes[i] + let componentView = self.toolViews[i] + + if let view = componentView.view { + if view.superview == nil { + self.scrollView.addSubview(view) + } + transition.setFrame(view: view, frame: CGRect(origin: origin, size: size)) + } + origin = origin.offsetBy(dx: 0.0, dy: size.height) + } + + let size = CGSize(width: availableSize.width, height: 180.0) + let contentSize = CGSize(width: availableSize.width, height: origin.y) + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: size)) + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class AdjustmentsScreenComponent: Component { + typealias EnvironmentType = Empty + + let toggleUneditedPreview: (Bool) -> Void + + init( + toggleUneditedPreview: @escaping (Bool) -> Void + ) { + self.toggleUneditedPreview = toggleUneditedPreview + } + + static func ==(lhs: AdjustmentsScreenComponent, rhs: AdjustmentsScreenComponent) -> Bool { + return true + } + + final class View: UIView { + enum Field { + case blacks + case shadows + case midtones + case highlights + case whites + } + + private var component: AdjustmentsScreenComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + + let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongPress(_:))) + longPressGestureRecognizer.minimumPressDuration = 0.05 + self.addGestureRecognizer(longPressGestureRecognizer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func handleLongPress(_ gestureRecognizer: UIPanGestureRecognizer) { + guard let component = self.component else { + return + } + + switch gestureRecognizer.state { + case .began: + component.toggleUneditedPreview(true) + case .ended, .cancelled: + component.toggleUneditedPreview(false) + default: + break + } + } + + func update(component: AdjustmentsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD new file mode 100644 index 0000000000..cab3565215 --- /dev/null +++ b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/BUILD @@ -0,0 +1,24 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ListMultilineTextFieldItemComponent", + module_name = "ListMultilineTextFieldItemComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/TelegramPresentationData", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/TextFieldComponent", + "//submodules/AccountContext", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift new file mode 100644 index 0000000000..5b1a14776e --- /dev/null +++ b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift @@ -0,0 +1,249 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import MultilineTextComponent +import ListSectionComponent +import TextFieldComponent +import AccountContext + +public final class ListMultilineTextFieldItemComponent: Component { + public final class ResetText: Equatable { + public let value: String + + public init(value: String) { + self.value = value + } + + public static func ==(lhs: ResetText, rhs: ResetText) -> Bool { + return lhs === rhs + } + } + + public let context: AccountContext + public let theme: PresentationTheme + public let strings: PresentationStrings + public let initialText: String + public let resetText: ResetText? + public let placeholder: String + public let autocapitalizationType: UITextAutocapitalizationType + public let autocorrectionType: UITextAutocorrectionType + public let updated: ((String) -> Void)? + public let tag: AnyObject? + + public init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + initialText: String, + resetText: ResetText? = nil, + placeholder: String, + autocapitalizationType: UITextAutocapitalizationType = .sentences, + autocorrectionType: UITextAutocorrectionType = .default, + updated: ((String) -> Void)?, + tag: AnyObject? = nil + ) { + self.context = context + self.theme = theme + self.strings = strings + self.initialText = initialText + self.resetText = resetText + self.placeholder = placeholder + self.autocapitalizationType = autocapitalizationType + self.autocorrectionType = autocorrectionType + self.updated = updated + self.tag = tag + } + + public static func ==(lhs: ListMultilineTextFieldItemComponent, rhs: ListMultilineTextFieldItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.initialText != rhs.initialText { + return false + } + if lhs.resetText != rhs.resetText { + return false + } + if lhs.placeholder != rhs.placeholder { + return false + } + if lhs.autocapitalizationType != rhs.autocapitalizationType { + return false + } + if lhs.autocorrectionType != rhs.autocorrectionType { + return false + } + if (lhs.updated == nil) != (rhs.updated == nil) { + return false + } + return true + } + + private final class TextField: UITextField { + var sideInset: CGFloat = 0.0 + + override func textRect(forBounds bounds: CGRect) -> CGRect { + return CGRect(origin: CGPoint(x: self.sideInset, y: 0.0), size: CGSize(width: bounds.width - self.sideInset * 2.0, height: bounds.height)) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + return CGRect(origin: CGPoint(x: self.sideInset, y: 0.0), size: CGSize(width: bounds.width - self.sideInset * 2.0, height: bounds.height)) + } + } + + public final class View: UIView, UITextFieldDelegate, ListSectionComponent.ChildView, ComponentTaggedView { + private let textField = ComponentView() + private let textFieldExternalState = TextFieldComponent.ExternalState() + + private let placeholder = ComponentView() + + private var component: ListMultilineTextFieldItemComponent? + private weak var state: EmptyComponentState? + private var isUpdating: Bool = false + + public var currentText: String { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + return textFieldView.inputState.inputText.string + } else { + return "" + } + } + + public var customUpdateIsHighlighted: ((Bool) -> Void)? + public private(set) var separatorInset: CGFloat = 0.0 + + public override init(frame: CGRect) { + super.init(frame: CGRect()) + } + + required public init?(coder: NSCoder) { + preconditionFailure() + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + return true + } + + @objc private func textDidChange() { + if !self.isUpdating { + self.state?.updated(transition: .immediate) + } + self.component?.updated?(self.currentText) + } + + public func setText(text: String, updateState: Bool) { + if let textFieldView = self.textField.view as? TextFieldComponent.View { + //TODO + let _ = textFieldView + } + + if updateState { + self.component?.updated?(self.currentText) + } else { + self.state?.updated(transition: .immediate, isLocal: true) + } + } + + public func matches(tag: Any) -> Bool { + if let component = self.component, let componentTag = component.tag { + let tag = tag as AnyObject + if componentTag === tag { + return true + } + } + return false + } + + func update(component: ListMultilineTextFieldItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + self.state = state + + let verticalInset: CGFloat = 12.0 + let sideInset: CGFloat = 16.0 + + let textFieldSize = self.textField.update( + transition: transition, + component: AnyComponent(TextFieldComponent( + context: component.context, + strings: component.strings, + externalState: self.textFieldExternalState, + fontSize: 17.0, + textColor: component.theme.list.itemPrimaryTextColor, + insets: UIEdgeInsets(top: verticalInset, left: sideInset - 8.0, bottom: verticalInset, right: sideInset - 8.0), + hideKeyboard: false, + customInputView: nil, + resetText: component.resetText.flatMap { resetText in + return NSAttributedString(string: resetText.value, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor) + }, + isOneLineWhenUnfocused: false, + formatMenuAvailability: .none, + lockedFormatAction: { + }, + present: { _ in + }, + paste: { _ in + } + )), + environment: {}, + containerSize: availableSize + ) + + let size = textFieldSize + let textFieldFrame = CGRect(origin: CGPoint(), size: textFieldSize) + + if let textFieldView = self.textField.view { + if textFieldView.superview == nil { + self.addSubview(textFieldView) + self.textField.parentState = state + } + transition.setFrame(view: textFieldView, frame: textFieldFrame) + } + + let placeholderSize = self.placeholder.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.placeholder.isEmpty ? " " : component.placeholder, font: Font.regular(17.0), textColor: component.theme.list.itemPlaceholderTextColor)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + ) + let placeholderFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: placeholderSize) + if let placeholderView = self.placeholder.view { + if placeholderView.superview == nil { + placeholderView.layer.anchorPoint = CGPoint() + placeholderView.isUserInteractionEnabled = false + self.insertSubview(placeholderView, at: 0) + } + transition.setPosition(view: placeholderView, position: placeholderFrame.origin) + placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) + + placeholderView.isHidden = self.textFieldExternalState.hasText + } + + self.separatorInset = 16.0 + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift b/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift index ce6b71a1ef..714250a551 100644 --- a/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift +++ b/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift @@ -217,6 +217,7 @@ public final class ListSectionComponent: Component { itemTransition = itemTransition.withAnimation(.none) itemView = ItemView() self.itemViews[itemId] = itemView + itemView.contents.parentState = state } let itemSize = itemView.contents.update( diff --git a/submodules/TelegramUI/Components/ListTextFieldItemComponent/BUILD b/submodules/TelegramUI/Components/ListTextFieldItemComponent/BUILD index c0256f3093..bbb2f2118f 100644 --- a/submodules/TelegramUI/Components/ListTextFieldItemComponent/BUILD +++ b/submodules/TelegramUI/Components/ListTextFieldItemComponent/BUILD @@ -14,6 +14,9 @@ swift_library( "//submodules/ComponentFlow", "//submodules/TelegramPresentationData", "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/Components/BundleIconComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ListTextFieldItemComponent/Sources/ListTextFieldItemComponent.swift b/submodules/TelegramUI/Components/ListTextFieldItemComponent/Sources/ListTextFieldItemComponent.swift index c91f9b9414..72cd2487a1 100644 --- a/submodules/TelegramUI/Components/ListTextFieldItemComponent/Sources/ListTextFieldItemComponent.swift +++ b/submodules/TelegramUI/Components/ListTextFieldItemComponent/Sources/ListTextFieldItemComponent.swift @@ -4,23 +4,35 @@ import Display import ComponentFlow import TelegramPresentationData import MultilineTextComponent +import ListSectionComponent +import PlainButtonComponent +import BundleIconComponent public final class ListTextFieldItemComponent: Component { public let theme: PresentationTheme public let initialText: String public let placeholder: String + public let autocapitalizationType: UITextAutocapitalizationType + public let autocorrectionType: UITextAutocorrectionType public let updated: ((String) -> Void)? + public let tag: AnyObject? public init( theme: PresentationTheme, initialText: String, placeholder: String, - updated: ((String) -> Void)? + autocapitalizationType: UITextAutocapitalizationType = .sentences, + autocorrectionType: UITextAutocorrectionType = .default, + updated: ((String) -> Void)?, + tag: AnyObject? = nil ) { self.theme = theme self.initialText = initialText self.placeholder = placeholder + self.autocapitalizationType = autocapitalizationType + self.autocorrectionType = autocorrectionType self.updated = updated + self.tag = tag } public static func ==(lhs: ListTextFieldItemComponent, rhs: ListTextFieldItemComponent) -> Bool { @@ -33,6 +45,12 @@ public final class ListTextFieldItemComponent: Component { if lhs.placeholder != rhs.placeholder { return false } + if lhs.autocapitalizationType != rhs.autocapitalizationType { + return false + } + if lhs.autocorrectionType != rhs.autocorrectionType { + return false + } if (lhs.updated == nil) != (rhs.updated == nil) { return false } @@ -51,9 +69,10 @@ public final class ListTextFieldItemComponent: Component { } } - public final class View: UIView, UITextFieldDelegate { + public final class View: UIView, UITextFieldDelegate, ListSectionComponent.ChildView, ComponentTaggedView { private let textField: TextField private let placeholder = ComponentView() + private let clearButton = ComponentView() private var component: ListTextFieldItemComponent? private weak var state: EmptyComponentState? @@ -63,6 +82,9 @@ public final class ListTextFieldItemComponent: Component { return self.textField.text ?? "" } + public var customUpdateIsHighlighted: ((Bool) -> Void)? + public private(set) var separatorInset: CGFloat = 0.0 + public override init(frame: CGRect) { self.textField = TextField() @@ -81,6 +103,27 @@ public final class ListTextFieldItemComponent: Component { if !self.isUpdating { self.state?.updated(transition: .immediate) } + self.component?.updated?(self.currentText) + } + + public func setText(text: String, updateState: Bool) { + self.textField.text = text + if updateState { + self.state?.updated(transition: .immediate, isLocal: true) + self.component?.updated?(self.currentText) + } else { + self.state?.updated(transition: .immediate, isLocal: true) + } + } + + public func matches(tag: Any) -> Bool { + if let component = self.component, let componentTag = component.tag { + let tag = tag as AnyObject + if componentTag === tag { + return true + } + } + return false } func update(component: ListTextFieldItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { @@ -102,6 +145,13 @@ public final class ListTextFieldItemComponent: Component { self.textField.addTarget(self, action: #selector(self.textDidChange), for: .editingChanged) } + if self.textField.autocapitalizationType != component.autocapitalizationType { + self.textField.autocapitalizationType = component.autocapitalizationType + } + if self.textField.autocorrectionType != component.autocorrectionType { + self.textField.autocorrectionType = component.autocorrectionType + } + let themeUpdated = component.theme !== previousComponent?.theme if themeUpdated { @@ -120,7 +170,7 @@ public final class ListTextFieldItemComponent: Component { text: .plain(NSAttributedString(string: component.placeholder.isEmpty ? " " : component.placeholder, font: Font.regular(17.0), textColor: component.theme.list.itemPlaceholderTextColor)) )), environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 30.0, height: 100.0) ) let contentHeight: CGFloat = placeholderSize.height + verticalInset * 2.0 let placeholderFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((contentHeight - placeholderSize.height) * 0.5)), size: placeholderSize) @@ -138,6 +188,37 @@ public final class ListTextFieldItemComponent: Component { transition.setFrame(view: self.textField, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: contentHeight))) + let clearButtonSize = self.clearButton.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(BundleIconComponent( + name: "Components/Search Bar/Clear", + tintColor: component.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.4) + )), + effectAlignment: .center, + minSize: CGSize(width: 44.0, height: 44.0), + action: { [weak self] in + guard let self else { + return + } + self.setText(text: "", updateState: true) + }, + animateAlpha: false, + animateScale: true + )), + environment: {}, + containerSize: CGSize(width: 44.0, height: 44.0) + ) + if let clearButtonView = self.clearButton.view { + if clearButtonView.superview == nil { + self.addSubview(clearButtonView) + } + transition.setFrame(view: clearButtonView, frame: CGRect(origin: CGPoint(x: availableSize.width - 0.0 - clearButtonSize.width, y: floor((contentHeight - clearButtonSize.height) * 0.5)), size: clearButtonSize)) + clearButtonView.isHidden = self.currentText.isEmpty + } + + self.separatorInset = 16.0 + return CGSize(width: availableSize.width, height: contentHeight) } } diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 91476b1fee..d73a8be9d6 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -1098,7 +1098,8 @@ public final class MessageInputPanelComponent: Component { contents = AnyComponent(PlainButtonComponent( content: AnyComponent(VStack([ AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: title, font: Font.regular(13.0), textColor: UIColor(rgb: 0xffffff, alpha: 0.3))) + text: .plain(NSAttributedString(string: title, font: Font.regular(13.0), textColor: UIColor(rgb: 0xffffff, alpha: 0.3))), + maximumNumberOfLines: 2 ))), AnyComponentWithIdentity(id: 1, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: subtitle, font: Font.regular(13.0), textColor: component.theme.list.itemAccentColor)) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 32e2d8050f..4a7aa3cf5d 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -927,7 +927,7 @@ private func settingsItems(data: PeerInfoScreenData?, context: AccountContext, p interaction.openSettings(.premium) })) //TODO:localize - items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 101, label: .text(""), additionalBadgeLabel: presentationData.strings.Settings_New, text: "Telegram Business", icon: PresentationResourcesSettings.chatFolders, action: { + items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 101, label: .text(""), additionalBadgeLabel: presentationData.strings.Settings_New, text: "Telegram Business", icon: PresentationResourcesSettings.business, action: { interaction.openSettings(.businessSetup) })) items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 102, label: .text(""), additionalBadgeLabel: presentationData.strings.Settings_New, text: presentationData.strings.Settings_PremiumGift, icon: PresentationResourcesSettings.premiumGift, action: { diff --git a/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift b/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift index cfce4784d2..cb3ee96e76 100644 --- a/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift +++ b/submodules/TelegramUI/Components/PlainButtonComponent/Sources/PlainButtonComponent.swift @@ -11,6 +11,7 @@ public final class PlainButtonComponent: Component { } public let content: AnyComponent + public let background: AnyComponent? public let effectAlignment: EffectAlignment public let minSize: CGSize? public let contentInsets: UIEdgeInsets @@ -23,6 +24,7 @@ public final class PlainButtonComponent: Component { public init( content: AnyComponent, + background: AnyComponent? = nil, effectAlignment: EffectAlignment, minSize: CGSize? = nil, contentInsets: UIEdgeInsets = UIEdgeInsets(), @@ -34,6 +36,7 @@ public final class PlainButtonComponent: Component { tag: AnyObject? = nil ) { self.content = content + self.background = background self.effectAlignment = effectAlignment self.minSize = minSize self.contentInsets = contentInsets @@ -49,6 +52,9 @@ public final class PlainButtonComponent: Component { if lhs.content != rhs.content { return false } + if lhs.background != rhs.background { + return false + } if lhs.effectAlignment != rhs.effectAlignment { return false } @@ -92,6 +98,7 @@ public final class PlainButtonComponent: Component { private let contentContainer = UIView() private let content = ComponentView() + private var background: ComponentView? public var contentView: UIView? { return self.content.view @@ -243,6 +250,33 @@ public final class PlainButtonComponent: Component { transition.setBounds(view: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: size)) transition.setPosition(view: self.contentContainer, position: CGPoint(x: size.width * anchorX, y: size.height * 0.5)) + if let backgroundValue = component.background { + var backgroundTransition = transition + let background: ComponentView + if let current = self.background { + background = current + } else { + backgroundTransition = .immediate + background = ComponentView() + self.background = background + } + let _ = background.update( + transition: backgroundTransition, + component: backgroundValue, + environment: {}, + containerSize: size + ) + if let backgroundView = background.view { + if backgroundView.superview == nil { + self.contentContainer.insertSubview(backgroundView, at: 0) + } + backgroundTransition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: size)) + } + } else if let background = self.background { + self.background = nil + background.view?.removeFromSuperview() + } + return size } } diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD new file mode 100644 index 0000000000..419a284c2b --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/BUILD @@ -0,0 +1,41 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "BusinessHoursSetupScreen", + module_name = "BusinessHoursSetupScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/TelegramUIPreferences", + "//submodules/AccountContext", + "//submodules/PresentationDataUtils", + "//submodules/Markdown", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/ListTextFieldItemComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/LocationUI", + "//submodules/AppBundle", + "//submodules/TelegramStringFormatting", + "//submodules/UIKitRuntimeUtils", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift new file mode 100644 index 0000000000..c061de51b5 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessDaySetupScreen.swift @@ -0,0 +1,631 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import BalancedTextComponent +import ListSectionComponent +import ListActionItemComponent +import BundleIconComponent +import LottieComponent +import Markdown +import LocationUI +import TelegramStringFormatting +import PlainButtonComponent + +final class BusinessDaySetupScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let dayIndex: Int + let day: BusinessHoursSetupScreenComponent.Day + + init( + context: AccountContext, + dayIndex: Int, + day: BusinessHoursSetupScreenComponent.Day + ) { + self.context = context + self.dayIndex = dayIndex + self.day = day + } + + static func ==(lhs: BusinessDaySetupScreenComponent, rhs: BusinessDaySetupScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.dayIndex != rhs.dayIndex { + return false + } + if lhs.day != rhs.day { + return false + } + + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class View: UIView, UIScrollViewDelegate { + private let topOverscrollLayer = SimpleLayer() + private let scrollView: ScrollView + + private let navigationTitle = ComponentView() + private let generalSection = ComponentView() + private var rangeSections: [Int: ComponentView] = [:] + private let addSection = ComponentView() + + private var isUpdating: Bool = false + + private var component: BusinessDaySetupScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private(set) var isOpen: Bool = false + private(set) var ranges: [BusinessHoursSetupScreenComponent.WorkingHourRange] = [] + private var nextRangeId: Int = 0 + + override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + + self.scrollView.layer.addSublayer(self.topOverscrollLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + return true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(transition: .immediate) + } + + var scrolledUp = true + private func updateScrolling(transition: Transition) { + let navigationRevealOffsetY: CGFloat = 0.0 + + let navigationAlphaDistance: CGFloat = 16.0 + let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance)) + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) + transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) + } + + var scrolledUp = false + if navigationAlpha < 0.5 { + scrolledUp = true + } else if navigationAlpha > 0.5 { + scrolledUp = false + } + + if self.scrolledUp != scrolledUp { + self.scrolledUp = scrolledUp + if !self.isUpdating { + self.state?.updated() + } + } + + if let navigationTitleView = self.navigationTitle.view { + transition.setAlpha(view: navigationTitleView, alpha: 1.0) + } + } + + private func openRangeDateSetup(rangeId: Int, isStartTime: Bool) { + guard let component = self.component else { + return + } + guard let range = self.ranges.first(where: { $0.id == rangeId }) else { + return + } + + let controller = TimeSelectionActionSheet(context: component.context, currentValue: Int32(isStartTime ? range.startTime : range.endTime), applyValue: { [weak self] value in + guard let self else { + return + } + guard let value else { + return + } + if let index = self.ranges.firstIndex(where: { $0.id == rangeId }) { + if isStartTime { + self.ranges[index].startTime = Int(value) + } else { + self.ranges[index].endTime = Int(value) + } + self.state?.updated(transition: .immediate) + } + }) + self.environment?.controller()?.present(controller, in: .window(.root)) + } + + func update(component: BusinessDaySetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + if self.component == nil { + self.isOpen = component.day.ranges != nil + self.ranges = component.day.ranges ?? [] + self.nextRangeId = (self.ranges.map(\.id).max() ?? 0) + 1 + } + + self.component = component + self.state = state + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + //TODO:localize + let title: String + switch component.dayIndex { + case 0: + title = "Monday" + case 1: + title = "Tuesday" + case 2: + title = "Wednesday" + case 3: + title = "Thursday" + case 4: + title = "Friday" + case 5: + title = "Saturday" + case 6: + title = "Sunday" + default: + title = " " + } + let navigationTitleSize = self.navigationTitle.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + navigationBar.view.addSubview(navigationTitleView) + } + } + transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame) + } + + let bottomContentInset: CGFloat = 24.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let sectionSpacing: CGFloat = 32.0 + + let _ = bottomContentInset + let _ = sectionSpacing + + var contentHeight: CGFloat = 0.0 + + contentHeight += environment.navigationHeight + contentHeight += 16.0 + + //TODO:localize + let generalSectionSize = self.generalSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Open On This Day", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isOpen, action: { [weak self] _ in + guard let self else { + return + } + self.isOpen = !self.isOpen + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let generalSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: generalSectionSize) + if let generalSectionView = self.generalSection.view { + if generalSectionView.superview == nil { + self.scrollView.addSubview(generalSectionView) + } + transition.setFrame(view: generalSectionView, frame: generalSectionFrame) + } + contentHeight += generalSectionSize.height + contentHeight += sectionSpacing + + var rangesSectionsHeight: CGFloat = 0.0 + for range in self.ranges { + let rangeId = range.id + var rangeSectionTransition = transition + let rangeSection: ComponentView + if let current = self.rangeSections[range.id] { + rangeSection = current + } else { + rangeSection = ComponentView() + self.rangeSections[range.id] = rangeSection + rangeSectionTransition = rangeSectionTransition.withAnimation(.none) + } + + let startHours = range.startTime / (60 * 60) + let startMinutes = range.startTime % (60 * 60) + let startText = stringForShortTimestamp(hours: Int32(startHours), minutes: Int32(startMinutes), dateTimeFormat: PresentationDateTimeFormat()) + let endHours = range.endTime / (60 * 60) + let endMinutes = range.endTime % (60 * 60) + let endText = stringForShortTimestamp(hours: Int32(endHours), minutes: Int32(endMinutes), dateTimeFormat: PresentationDateTimeFormat()) + + var rangeSectionItems: [AnyComponentWithIdentity] = [] + rangeSectionItems.append(AnyComponentWithIdentity(id: rangeSectionItems.count, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Opening time", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( + content: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: startText, font: Font.regular(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + background: AnyComponent(RoundedRectangle(color: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1), cornerRadius: 6.0)), + effectAlignment: .center, + minSize: nil, + contentInsets: UIEdgeInsets(top: 7.0, left: 8.0, bottom: 7.0, right: 8.0), + action: { [weak self] in + guard let self else { + return + } + self.openRangeDateSetup(rangeId: rangeId, isStartTime: true) + }, + animateAlpha: true, + animateScale: false + ))), insets: .custom(UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0)), allowUserInteraction: true), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + self.openRangeDateSetup(rangeId: rangeId, isStartTime: true) + } + )))) + rangeSectionItems.append(AnyComponentWithIdentity(id: rangeSectionItems.count, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Closing time", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( + content: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: endText, font: Font.regular(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) + )), + background: AnyComponent(RoundedRectangle(color: environment.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1), cornerRadius: 6.0)), + effectAlignment: .center, + minSize: nil, + contentInsets: UIEdgeInsets(top: 7.0, left: 8.0, bottom: 7.0, right: 8.0), + action: { [weak self] in + guard let self else { + return + } + self.openRangeDateSetup(rangeId: rangeId, isStartTime: false) + }, + animateAlpha: true, + animateScale: false + ))), insets: .custom(UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0)), allowUserInteraction: true), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + self.openRangeDateSetup(rangeId: rangeId, isStartTime: false) + } + )))) + rangeSectionItems.append(AnyComponentWithIdentity(id: rangeSectionItems.count, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Remove", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemDestructiveColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + self.ranges.removeAll(where: { $0.id == rangeId }) + self.state?.updated(transition: .spring(duration: 0.4)) + } + )))) + + let rangeSectionSize = rangeSection.update( + transition: rangeSectionTransition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: rangeSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let rangeSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + rangesSectionsHeight), size: rangeSectionSize) + if let rangeSectionView = rangeSection.view { + var animateIn = false + if rangeSectionView.superview == nil { + animateIn = true + rangeSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(rangeSectionView) + } + rangeSectionTransition.setFrame(view: rangeSectionView, frame: rangeSectionFrame) + + let alphaTransition = transition.animation.isImmediate ? transition : .easeInOut(duration: 0.25) + if self.isOpen { + if animateIn { + if !transition.animation.isImmediate { + alphaTransition.animateAlpha(view: rangeSectionView, from: 0.0, to: 1.0) + transition.animateScale(view: rangeSectionView, from: 0.001, to: 1.0) + } + } else { + alphaTransition.setAlpha(view: rangeSectionView, alpha: 1.0) + } + } else { + alphaTransition.setAlpha(view: rangeSectionView, alpha: 0.0) + } + } + + rangesSectionsHeight += rangeSectionSize.height + rangesSectionsHeight += sectionSpacing + } + var removeRangeSectionIds: [Int] = [] + for (id, rangeSection) in self.rangeSections { + if !self.ranges.contains(where: { $0.id == id }) { + removeRangeSectionIds.append(id) + + if let rangeSectionView = rangeSection.view { + if !transition.animation.isImmediate { + Transition.easeInOut(duration: 0.2).setAlpha(view: rangeSectionView, alpha: 0.0, completion: { [weak rangeSectionView] _ in + rangeSectionView?.removeFromSuperview() + }) + transition.setScale(view: rangeSectionView, scale: 0.001) + } else { + rangeSectionView.removeFromSuperview() + } + } + } + } + for id in removeRangeSectionIds { + self.rangeSections.removeValue(forKey: id) + } + + //TODO:localize + let addSectionSize = self.addSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Specify your working hours during the day.", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Add a Set of Hours", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + name: "Chat List/AddIcon", + tintColor: environment.theme.list.itemAccentColor + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + let rangeId = self.nextRangeId + self.nextRangeId += 1 + self.ranges.append(BusinessHoursSetupScreenComponent.WorkingHourRange( + id: rangeId, startTime: 9 * (60 * 60), endTime: 18 * (60 * 60))) + self.state?.updated(transition: .spring(duration: 0.4)) + } + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let addSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + rangesSectionsHeight), size: addSectionSize) + if let addSectionView = self.addSection.view { + if addSectionView.superview == nil { + self.scrollView.addSubview(addSectionView) + } + transition.setFrame(view: addSectionView, frame: addSectionFrame) + + let alphaTransition = transition.animation.isImmediate ? transition : .easeInOut(duration: 0.25) + alphaTransition.setAlpha(view: addSectionView, alpha: self.isOpen ? 1.0 : 0.0) + } + rangesSectionsHeight += addSectionSize.height + + if self.isOpen { + contentHeight += rangesSectionsHeight + } + + contentHeight += bottomContentInset + contentHeight += environment.safeInsets.bottom + + let previousBounds = self.scrollView.bounds + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + + 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.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class BusinessDaySetupScreen: ViewControllerComponentContainer { + private let context: AccountContext + private let updateDay: (BusinessHoursSetupScreenComponent.Day) -> Void + + init(context: AccountContext, dayIndex: Int, day: BusinessHoursSetupScreenComponent.Day, updateDay: @escaping (BusinessHoursSetupScreenComponent.Day) -> Void) { + self.context = context + self.updateDay = updateDay + + super.init(context: context, component: BusinessDaySetupScreenComponent( + context: context, + dayIndex: dayIndex, + day: day + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = "" + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? BusinessDaySetupScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.attemptNavigation = { [weak self] complete in + guard let self, let componentView = self.node.hostView.componentView as? BusinessDaySetupScreenComponent.View else { + return true + } + + self.updateDay(BusinessHoursSetupScreenComponent.Day(ranges: componentView.isOpen ? componentView.ranges : nil)) + + return componentView.attemptNavigation(complete: complete) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift new file mode 100644 index 0000000000..7aa0f33984 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/BusinessHoursSetupScreen.swift @@ -0,0 +1,545 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import BalancedTextComponent +import ListSectionComponent +import ListActionItemComponent +import BundleIconComponent +import LottieComponent +import Markdown +import LocationUI +import TelegramStringFormatting + +final class BusinessHoursSetupScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + + init( + context: AccountContext + ) { + self.context = context + } + + static func ==(lhs: BusinessHoursSetupScreenComponent, rhs: BusinessHoursSetupScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + struct WorkingHourRange: Equatable { + var id: Int + var startTime: Int + var endTime: Int + + init(id: Int, startTime: Int, endTime: Int) { + self.id = id + self.startTime = startTime + self.endTime = endTime + } + } + + struct Day: Equatable { + var ranges: [WorkingHourRange]? + + init(ranges: [WorkingHourRange]?) { + self.ranges = ranges + } + } + + final class View: UIView, UIScrollViewDelegate { + private let topOverscrollLayer = SimpleLayer() + private let scrollView: ScrollView + + private let navigationTitle = ComponentView() + private let icon = ComponentView() + private let subtitle = ComponentView() + private let generalSection = ComponentView() + private let daysSection = ComponentView() + + private var isUpdating: Bool = false + + private var component: BusinessHoursSetupScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var showHours: Bool = false + private var days: [Day] = [] + + override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + + self.scrollView.layer.addSublayer(self.topOverscrollLayer) + + self.days = (0 ..< 7).map { _ in + return Day(ranges: []) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + return true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(transition: .immediate) + } + + var scrolledUp = true + private func updateScrolling(transition: Transition) { + let navigationRevealOffsetY: CGFloat = 0.0 + + let navigationAlphaDistance: CGFloat = 16.0 + let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance)) + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) + transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) + } + + var scrolledUp = false + if navigationAlpha < 0.5 { + scrolledUp = true + } else if navigationAlpha > 0.5 { + scrolledUp = false + } + + if self.scrolledUp != scrolledUp { + self.scrolledUp = scrolledUp + if !self.isUpdating { + self.state?.updated() + } + } + + if let navigationTitleView = self.navigationTitle.view { + transition.setAlpha(view: navigationTitleView, alpha: 1.0) + } + } + + func update(component: BusinessHoursSetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + //TODO:localize + let navigationTitleSize = self.navigationTitle.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Business Hours", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + navigationBar.view.addSubview(navigationTitleView) + } + } + transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame) + } + + let bottomContentInset: CGFloat = 24.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let sectionSpacing: CGFloat = 32.0 + + let _ = bottomContentInset + let _ = sectionSpacing + + var contentHeight: CGFloat = 0.0 + + contentHeight += environment.navigationHeight + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "⏰", font: Font.semibold(90.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 2.0), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + self.scrollView.addSubview(iconView) + } + transition.setPosition(view: iconView, position: iconFrame.center) + iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size) + } + + contentHeight += 129.0 + + //TODO:localize + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Turn this on to show your opening hours schedule to your customers.", attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { attributes in + return ("URL", "") + }), textAlignment: .center + )) + + //TODO:localize + let subtitleSize = self.subtitle.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(subtitleString), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.25, + 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] _, _ in + guard let self, let component = self.component else { + return + } + let _ = component + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize) + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + self.scrollView.addSubview(subtitleView) + } + transition.setPosition(view: subtitleView, position: subtitleFrame.center) + subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) + } + contentHeight += subtitleSize.height + contentHeight += 27.0 + + //TODO:localize + let generalSectionSize = self.generalSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Show Business Hours", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.showHours, action: { [weak self] _ in + guard let self else { + return + } + self.showHours = !self.showHours + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let generalSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: generalSectionSize) + if let generalSectionView = self.generalSection.view { + if generalSectionView.superview == nil { + self.scrollView.addSubview(generalSectionView) + } + transition.setFrame(view: generalSectionView, frame: generalSectionFrame) + } + contentHeight += generalSectionSize.height + contentHeight += sectionSpacing + + var daysSectionItems: [AnyComponentWithIdentity] = [] + for day in self.days { + let dayIndex = daysSectionItems.count + + let title: String + //TODO:localize + switch dayIndex { + case 0: + title = "Monday" + case 1: + title = "Tuesday" + case 2: + title = "Wednesday" + case 3: + title = "Thursday" + case 4: + title = "Friday" + case 5: + title = "Saturday" + case 6: + title = "Sunday" + default: + title = " " + } + + let subtitle: String + if let ranges = self.days[dayIndex].ranges { + if ranges.isEmpty { + subtitle = "Open 24 Hours" + } else { + var resultText: String = "" + for range in ranges { + if !resultText.isEmpty { + resultText.append(", ") + } + let startHours = range.startTime / (60 * 60) + let startMinutes = range.startTime % (60 * 60) + let startText = stringForShortTimestamp(hours: Int32(startHours), minutes: Int32(startMinutes), dateTimeFormat: PresentationDateTimeFormat()) + let endHours = range.endTime / (60 * 60) + let endMinutes = range.endTime % (60 * 60) + let endText = stringForShortTimestamp(hours: Int32(endHours), minutes: Int32(endMinutes), dateTimeFormat: PresentationDateTimeFormat()) + resultText.append("\(startText)\u{00a0}- \(endText)") + } + subtitle = resultText + } + } else { + subtitle = "Closed" + } + + daysSectionItems.append(AnyComponentWithIdentity(id: dayIndex, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: title, + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: subtitle, + font: Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 15.0 / 17.0)), + textColor: environment.theme.list.itemAccentColor + )), + maximumNumberOfLines: 5 + ))) + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: day.ranges != nil, action: { [weak self] _ in + guard let self else { + return + } + if dayIndex < self.days.count { + if self.days[dayIndex].ranges == nil { + self.days[dayIndex].ranges = [] + } else { + self.days[dayIndex].ranges = nil + } + } + self.state?.updated(transition: .immediate) + })), + action: { [weak self] _ in + guard let self, let component = self.component else { + return + } + self.environment?.controller()?.push(BusinessDaySetupScreen( + context: component.context, + dayIndex: dayIndex, + day: self.days[dayIndex], + updateDay: { [weak self] day in + guard let self else { + return + } + if self.days[dayIndex] != day { + self.days[dayIndex] = day + self.state?.updated(transition: .immediate) + } + } + )) + } + )))) + } + let daysSectionSize = self.daysSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "BUSINESS HOURS", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: daysSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let daysSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: daysSectionSize) + if let daysSectionView = self.daysSection.view { + if daysSectionView.superview == nil { + daysSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(daysSectionView) + } + transition.setFrame(view: daysSectionView, frame: daysSectionFrame) + + let alphaTransition = transition.animation.isImmediate ? transition : .easeInOut(duration: 0.25) + alphaTransition.setAlpha(view: daysSectionView, alpha: self.showHours ? 1.0 : 0.0) + } + if self.showHours { + contentHeight += daysSectionSize.height + } + + contentHeight += bottomContentInset + contentHeight += environment.safeInsets.bottom + + let previousBounds = self.scrollView.bounds + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + + 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.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class BusinessHoursSetupScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init(context: AccountContext) { + self.context = context + + super.init(context: context, component: BusinessHoursSetupScreenComponent( + context: context + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = "" + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? BusinessHoursSetupScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.attemptNavigation = { [weak self] complete in + guard let self, let componentView = self.node.hostView.componentView as? BusinessHoursSetupScreenComponent.View else { + return true + } + + return componentView.attemptNavigation(complete: complete) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/TimeSelectionActionSheet.swift b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/TimeSelectionActionSheet.swift new file mode 100644 index 0000000000..048838633b --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessHoursSetupScreen/Sources/TimeSelectionActionSheet.swift @@ -0,0 +1,132 @@ +import Foundation +import Display +import AsyncDisplayKit +import UIKit +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import TelegramStringFormatting +import AccountContext +import UIKitRuntimeUtils + +final class TimeSelectionActionSheet: ActionSheetController { + private var presentationDisposable: Disposable? + + private let _ready = Promise() + override var ready: Promise { + return self._ready + } + + init(context: AccountContext, currentValue: Int32, emptyTitle: String? = nil, applyValue: @escaping (Int32?) -> Void) { + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let strings = presentationData.strings + + super.init(theme: ActionSheetControllerTheme(presentationData: presentationData)) + + self.presentationDisposable = context.sharedContext.presentationData.start(next: { [weak self] presentationData in + if let strongSelf = self { + strongSelf.theme = ActionSheetControllerTheme(presentationData: presentationData) + } + }) + + self._ready.set(.single(true)) + + var updatedValue = currentValue + var items: [ActionSheetItem] = [] + items.append(TimeSelectionActionSheetItem(strings: strings, currentValue: currentValue, valueChanged: { value in + updatedValue = value + })) + if let emptyTitle = emptyTitle { + items.append(ActionSheetButtonItem(title: emptyTitle, action: { [weak self] in + self?.dismissAnimated() + applyValue(nil) + })) + } + items.append(ActionSheetButtonItem(title: strings.Wallpaper_Set, action: { [weak self] in + self?.dismissAnimated() + applyValue(updatedValue) + })) + self.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strings.Common_Cancel, action: { [weak self] in + self?.dismissAnimated() + }), + ]) + ]) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.presentationDisposable?.dispose() + } +} + +private final class TimeSelectionActionSheetItem: ActionSheetItem { + let strings: PresentationStrings + + let currentValue: Int32 + let valueChanged: (Int32) -> Void + + init(strings: PresentationStrings, currentValue: Int32, valueChanged: @escaping (Int32) -> Void) { + self.strings = strings + self.currentValue = currentValue + self.valueChanged = valueChanged + } + + func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode { + return TimeSelectionActionSheetItemNode(theme: theme, strings: self.strings, currentValue: self.currentValue, valueChanged: self.valueChanged) + } + + func updateNode(_ node: ActionSheetItemNode) { + } +} + +private final class TimeSelectionActionSheetItemNode: ActionSheetItemNode { + private let theme: ActionSheetControllerTheme + private let strings: PresentationStrings + + private let valueChanged: (Int32) -> Void + private let pickerView: UIDatePicker + + init(theme: ActionSheetControllerTheme, strings: PresentationStrings, currentValue: Int32, valueChanged: @escaping (Int32) -> Void) { + self.theme = theme + self.strings = strings + self.valueChanged = valueChanged + + UILabel.setDateLabel(theme.primaryTextColor) + + self.pickerView = UIDatePicker() + self.pickerView.datePickerMode = .countDownTimer + self.pickerView.datePickerMode = .time + self.pickerView.timeZone = TimeZone(secondsFromGMT: 0) + self.pickerView.date = Date(timeIntervalSince1970: Double(currentValue)) + self.pickerView.locale = Locale.current + if #available(iOS 13.4, *) { + self.pickerView.preferredDatePickerStyle = .wheels + } + self.pickerView.setValue(theme.primaryTextColor, forKey: "textColor") + + super.init(theme: theme) + + self.view.addSubview(self.pickerView) + self.pickerView.addTarget(self, action: #selector(self.datePickerUpdated), for: .valueChanged) + } + + public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { + let size = CGSize(width: constrainedSize.width, height: 216.0) + + self.pickerView.frame = CGRect(origin: CGPoint(), size: size) + + self.updateInternalLayout(size, constrainedSize: constrainedSize) + return size + } + + @objc private func datePickerUpdated() { + self.valueChanged(Int32(self.pickerView.date.timeIntervalSince1970)) + } +} + diff --git a/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/BUILD new file mode 100644 index 0000000000..a689556122 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/BUILD @@ -0,0 +1,38 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "BusinessLocationSetupScreen", + module_name = "BusinessLocationSetupScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/TelegramUIPreferences", + "//submodules/AccountContext", + "//submodules/PresentationDataUtils", + "//submodules/Markdown", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/LocationUI", + "//submodules/AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/Sources/BusinessLocationSetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/Sources/BusinessLocationSetupScreen.swift new file mode 100644 index 0000000000..d61e642db3 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/Sources/BusinessLocationSetupScreen.swift @@ -0,0 +1,474 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import BalancedTextComponent +import ListSectionComponent +import ListActionItemComponent +import ListMultilineTextFieldItemComponent +import BundleIconComponent +import LottieComponent +import Markdown +import LocationUI + +final class BusinessLocationSetupScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + + init( + context: AccountContext + ) { + self.context = context + } + + static func ==(lhs: BusinessLocationSetupScreenComponent, rhs: BusinessLocationSetupScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class View: UIView, UIScrollViewDelegate { + private let topOverscrollLayer = SimpleLayer() + private let scrollView: ScrollView + + private let navigationTitle = ComponentView() + private let icon = ComponentView() + private let subtitle = ComponentView() + private let addressSection = ComponentView() + private let mapSection = ComponentView() + + private var isUpdating: Bool = false + + private var component: BusinessLocationSetupScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private let textFieldTag = NSObject() + private var resetAddressText: String? + + private var mapCoordinates: (Double, Double)? + + override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + + self.scrollView.layer.addSublayer(self.topOverscrollLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + return true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(transition: .immediate) + } + + var scrolledUp = true + private func updateScrolling(transition: Transition) { + let navigationRevealOffsetY: CGFloat = 0.0 + + let navigationAlphaDistance: CGFloat = 16.0 + let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance)) + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) + transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) + } + + var scrolledUp = false + if navigationAlpha < 0.5 { + scrolledUp = true + } else if navigationAlpha > 0.5 { + scrolledUp = false + } + + if self.scrolledUp != scrolledUp { + self.scrolledUp = scrolledUp + if !self.isUpdating { + self.state?.updated() + } + } + + if let navigationTitleView = self.navigationTitle.view { + transition.setAlpha(view: navigationTitleView, alpha: 1.0) + } + } + + private func openLocationPicker() { + guard let component = self.component else { + return + } + + let controller = LocationPickerController(context: component.context, updatedPresentationData: nil, mode: .pick, completion: { [weak self] location, _, _, address, _ in + guard let self else { + return + } + + self.mapCoordinates = (location.latitude, location.longitude) + if let textView = self.addressSection.findTaggedView(tag: self.textFieldTag) as? ListMultilineTextFieldItemComponent.View, textView.currentText.isEmpty { + self.resetAddressText = address + } + + self.state?.updated(transition: .immediate) + }) + self.environment?.controller()?.push(controller) + } + + func update(component: BusinessLocationSetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + //TODO:localize + let navigationTitleSize = self.navigationTitle.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Location", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + navigationBar.view.addSubview(navigationTitleView) + } + } + transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame) + } + + let bottomContentInset: CGFloat = 24.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let sectionSpacing: CGFloat = 32.0 + + let _ = bottomContentInset + let _ = sectionSpacing + + var contentHeight: CGFloat = 0.0 + + contentHeight += environment.navigationHeight + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "🗺", font: Font.semibold(90.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 2.0), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + self.scrollView.addSubview(iconView) + } + transition.setPosition(view: iconView, position: iconFrame.center) + iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size) + } + + contentHeight += 129.0 + + //TODO:localize + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Display the location of your business on your account.", attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { attributes in + return ("URL", "") + }), textAlignment: .center + )) + + //TODO:localize + let subtitleSize = self.subtitle.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(subtitleString), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.25, + 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] _, _ in + guard let self, let component = self.component else { + return + } + let _ = component + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize) + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + self.scrollView.addSubview(subtitleView) + } + transition.setPosition(view: subtitleView, position: subtitleFrame.center) + subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) + } + contentHeight += subtitleSize.height + contentHeight += 27.0 + + var addressSectionItems: [AnyComponentWithIdentity] = [] + addressSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + initialText: "", + resetText: self.resetAddressText.flatMap { resetAddressText in + return ListMultilineTextFieldItemComponent.ResetText(value: resetAddressText) + }, + placeholder: "Enter Address", + autocapitalizationType: .none, + autocorrectionType: .no, + updated: { [weak self] value in + guard let self else { + return + } + let _ = self + let _ = value + }, + tag: self.textFieldTag + )))) + self.resetAddressText = nil + + //TODO:localize + let addressSectionSize = self.addressSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: addressSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let addressSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: addressSectionSize) + if let addressSectionView = self.addressSection.view { + if addressSectionView.superview == nil { + self.scrollView.addSubview(addressSectionView) + self.addressSection.parentState = state + } + transition.setFrame(view: addressSectionView, frame: addressSectionFrame) + } + contentHeight += addressSectionSize.height + contentHeight += sectionSpacing + + var mapSectionItems: [AnyComponentWithIdentity] = [] + //TODO:localize + mapSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Set Location on Map", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.mapCoordinates != nil, isInteractive: self.mapCoordinates != nil)), + action: { [weak self] _ in + guard let self else { + return + } + if self.mapCoordinates == nil { + self.openLocationPicker() + } else { + self.mapCoordinates = nil + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + )))) + if let mapCoordinates = self.mapCoordinates { + mapSectionItems.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(MapPreviewComponent( + theme: environment.theme, + location: MapPreviewComponent.Location( + latitude: mapCoordinates.0, + longitude: mapCoordinates.1 + ), + action: { [weak self] in + guard let self else { + return + } + self.openLocationPicker() + } + )))) + } + + //TODO:localize + let mapSectionSize = self.mapSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: mapSectionItems, + displaySeparators: false + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let mapSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: mapSectionSize) + if let mapSectionView = self.mapSection.view { + if mapSectionView.superview == nil { + self.scrollView.addSubview(mapSectionView) + } + transition.setFrame(view: mapSectionView, frame: mapSectionFrame) + } + contentHeight += mapSectionSize.height + + contentHeight += bottomContentInset + contentHeight += environment.safeInsets.bottom + + let previousBounds = self.scrollView.bounds + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + + 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.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class BusinessLocationSetupScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init(context: AccountContext) { + self.context = context + + super.init(context: context, component: BusinessLocationSetupScreenComponent( + context: context + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = "" + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? BusinessLocationSetupScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.attemptNavigation = { [weak self] complete in + guard let self, let componentView = self.node.hostView.componentView as? BusinessLocationSetupScreenComponent.View else { + return true + } + + return componentView.attemptNavigation(complete: complete) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/Sources/MapPreviewComponent.swift b/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/Sources/MapPreviewComponent.swift new file mode 100644 index 0000000000..9e887c6279 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/BusinessLocationSetupScreen/Sources/MapPreviewComponent.swift @@ -0,0 +1,139 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import ListSectionComponent +import MapKit +import TelegramPresentationData +import AppBundle + +final class MapPreviewComponent: Component { + struct Location: Equatable { + var latitude: Double + var longitude: Double + + init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + } + + let theme: PresentationTheme + let location: Location + let action: (() -> Void)? + + init( + theme: PresentationTheme, + location: Location, + action: (() -> Void)? = nil + ) { + self.theme = theme + self.location = location + self.action = action + } + + static func ==(lhs: MapPreviewComponent, rhs: MapPreviewComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.location != rhs.location { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + return true + } + + final class View: HighlightTrackingButton, ListSectionComponent.ChildView { + private var component: MapPreviewComponent? + private weak var componentState: EmptyComponentState? + + private var mapView: MKMapView? + + private let pinShadowView: UIImageView + private let pinView: UIImageView + private let pinForegroundView: UIImageView + + var customUpdateIsHighlighted: ((Bool) -> Void)? + private(set) var separatorInset: CGFloat = 0.0 + + override init(frame: CGRect) { + self.pinShadowView = UIImageView() + self.pinView = UIImageView() + self.pinForegroundView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.pinShadowView) + self.addSubview(self.pinView) + self.addSubview(self.pinForegroundView) + + self.pinShadowView.image = UIImage(bundleImageName: "Chat/Message/LocationPinShadow") + self.pinView.image = UIImage(bundleImageName: "Chat/Message/LocationPinBackground")?.withRenderingMode(.alwaysTemplate) + self.pinForegroundView.image = UIImage(bundleImageName: "Chat/Message/LocationPinForeground")?.withRenderingMode(.alwaysTemplate) + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + self.component?.action?() + } + + func update(component: MapPreviewComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let previousComponent = self.component + self.component = component + self.componentState = state + + self.isEnabled = component.action != nil + + let size = CGSize(width: availableSize.width, height: 160.0) + + let mapView: MKMapView + if let current = self.mapView { + mapView = current + } else { + mapView = MKMapView() + mapView.isUserInteractionEnabled = false + self.mapView = mapView + self.insertSubview(mapView, at: 0) + } + transition.setFrame(view: mapView, frame: CGRect(origin: CGPoint(), size: size)) + + let defaultMapSpan = MKCoordinateSpan(latitudeDelta: 0.016, longitudeDelta: 0.016) + + let region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: component.location.latitude, longitude: component.location.longitude), span: defaultMapSpan) + if previousComponent?.location != component.location { + mapView.setRegion(region, animated: false) + mapView.setVisibleMapRect(mapView.visibleMapRect, edgePadding: UIEdgeInsets(top: 70.0, left: 0.0, bottom: 0.0, right: 0.0), animated: true) + } + + let pinImageSize = self.pinView.image?.size ?? CGSize(width: 62.0, height: 74.0) + let pinFrame = CGRect(origin: CGPoint(x: floor((size.width - pinImageSize.width) * 0.5), y: floor((size.height - pinImageSize.height) * 0.5)), size: pinImageSize) + transition.setFrame(view: self.pinShadowView, frame: pinFrame) + + transition.setFrame(view: self.pinView, frame: pinFrame) + self.pinView.tintColor = component.theme.list.itemCheckColors.fillColor + + if let image = pinForegroundView.image { + let pinIconFrame = CGRect(origin: CGPoint(x: pinFrame.minX + floor((pinFrame.width - image.size.width) * 0.5), y: pinFrame.minY + 15.0), size: image.size) + transition.setFrame(view: self.pinForegroundView, frame: pinIconFrame) + self.pinForegroundView.tintColor = component.theme.list.itemCheckColors.foregroundColor + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift b/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift index 26e1df5d66..01b5e170c9 100644 --- a/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/BusinessSetupScreen/Sources/BusinessSetupScreen.swift @@ -182,7 +182,7 @@ final class BusinessSetupScreenComponent: Component { var contentHeight: CGFloat = 0.0 contentHeight += environment.navigationHeight - contentHeight += 81.0 + contentHeight += 16.0 //TODO:localize let titleSize = self.title.update( @@ -241,14 +241,22 @@ final class BusinessSetupScreenComponent: Component { icon: "Settings/Menu/AddAccount", title: "Location", subtitle: "Display the location of your business on your account.", - action: { + action: { [weak self] in + guard let self else { + return + } + self.environment?.controller()?.push(component.context.sharedContext.makeBusinessLocationSetupScreen(context: component.context)) } )) items.append(Item( icon: "Settings/Menu/DataVoice", title: "Opening Hours", subtitle: "Show to your customers when you are open for business.", - action: { + action: { [weak self] in + guard let self else { + return + } + self.environment?.controller()?.push(component.context.sharedContext.makeBusinessHoursSetupScreen(context: component.context)) } )) items.append(Item( @@ -262,7 +270,11 @@ final class BusinessSetupScreenComponent: Component { icon: "Settings/Menu/Stories", title: "Greeting Messages", subtitle: "Create greetings that will be automatically sent to new customers.", - action: { + action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + environment.controller()?.push(component.context.sharedContext.makeGreetingMessageSetupScreen(context: component.context)) } )) items.append(Item( diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD index 9cc671937c..c83d5538a0 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD @@ -31,6 +31,10 @@ swift_library( "//submodules/TelegramUI/Components/ListActionItemComponent", "//submodules/TelegramUI/Components/ListTextFieldItemComponent", "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/AvatarNode", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", + "//submodules/ShimmerEffect", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift new file mode 100644 index 0000000000..e0df758b4a --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift @@ -0,0 +1,405 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent +import AvatarNode +import BundleIconComponent +import TelegramPresentationData +import TelegramCore +import AccountContext +import ListSectionComponent +import PlainButtonComponent +import ShimmerEffect + +final class ChatbotSearchResultItemComponent: Component { + enum Content: Equatable { + case searching + case found(peer: EnginePeer, isInstalled: Bool) + case notFound + } + + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let content: Content + let installAction: () -> Void + let removeAction: () -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + content: Content, + installAction: @escaping () -> Void, + removeAction: @escaping () -> Void + ) { + self.context = context + self.theme = theme + self.strings = strings + self.content = content + self.installAction = installAction + self.removeAction = removeAction + } + + static func ==(lhs: ChatbotSearchResultItemComponent, rhs: ChatbotSearchResultItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.content != rhs.content { + return false + } + return true + } + + final class View: UIView, ListSectionComponent.ChildView { + private var notFoundLabel: ComponentView? + private let titleLabel = ComponentView() + private let subtitleLabel = ComponentView() + + private var shimmerEffectNode: ShimmerEffectNode? + + private var avatarNode: AvatarNode? + + private var addButton: ComponentView? + private var removeButton: ComponentView? + + private var component: ChatbotSearchResultItemComponent? + private weak var state: EmptyComponentState? + + var customUpdateIsHighlighted: ((Bool) -> Void)? + private(set) var separatorInset: CGFloat = 0.0 + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ChatbotSearchResultItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + let sideInset: CGFloat = 10.0 + let avatarDiameter: CGFloat = 40.0 + let avatarTextSpacing: CGFloat = 12.0 + let titleSubtitleSpacing: CGFloat = 1.0 + let verticalInset: CGFloat = 11.0 + + let maxTextWidth: CGFloat = availableSize.width - sideInset * 2.0 - avatarDiameter - avatarTextSpacing + + var addButtonSize: CGSize? + if case .found(_, false) = component.content { + let addButton: ComponentView + var addButtonTransition = transition + if let current = self.addButton { + addButton = current + } else { + addButtonTransition = addButtonTransition.withAnimation(.none) + addButton = ComponentView() + self.addButton = addButton + } + + //TODO:localize + addButtonSize = addButton.update( + transition: addButtonTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "ADD", font: Font.semibold(15.0), textColor: component.theme.list.itemCheckColors.foregroundColor)) + )), + background: AnyComponent(RoundedRectangle(color: component.theme.list.itemCheckColors.fillColor, cornerRadius: nil)), + effectAlignment: .center, + minSize: nil, + contentInsets: UIEdgeInsets(top: 4.0, left: 8.0, bottom: 4.0, right: 8.0), + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.installAction() + }, + animateAlpha: true, + animateScale: false + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + } else { + if let addButton = self.addButton { + self.addButton = nil + if let addButtonView = addButton.view { + if !transition.animation.isImmediate { + transition.setScale(view: addButtonView, scale: 0.001) + Transition.easeInOut(duration: 0.2).setAlpha(view: addButtonView, alpha: 0.0, completion: { [weak addButtonView] _ in + addButtonView?.removeFromSuperview() + }) + } else { + addButtonView.removeFromSuperview() + } + } + } + } + + var removeButtonSize: CGSize? + if case .found(_, true) = component.content { + let removeButton: ComponentView + var removeButtonTransition = transition + if let current = self.removeButton { + removeButton = current + } else { + removeButtonTransition = removeButtonTransition.withAnimation(.none) + removeButton = ComponentView() + self.removeButton = removeButton + } + + //TODO:localize + removeButtonSize = removeButton.update( + transition: removeButtonTransition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(BundleIconComponent( + name: "Chat/Message/SideCloseIcon", + tintColor: component.theme.list.controlSecondaryColor + )), + effectAlignment: .center, + minSize: nil, + contentInsets: UIEdgeInsets(top: 4.0, left: 4.0, bottom: 4.0, right: 4.0), + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.removeAction() + }, + animateAlpha: true, + animateScale: false + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + } else { + if let removeButton = self.removeButton { + self.removeButton = nil + if let removeButtonView = removeButton.view { + if !transition.animation.isImmediate { + transition.setScale(view: removeButtonView, scale: 0.001) + Transition.easeInOut(duration: 0.2).setAlpha(view: removeButtonView, alpha: 0.0, completion: { [weak removeButtonView] _ in + removeButtonView?.removeFromSuperview() + }) + } else { + removeButtonView.removeFromSuperview() + } + } + } + } + + let titleValue: String + let subtitleValue: String + let isTextVisible: Bool + switch component.content { + case .searching, .notFound: + isTextVisible = false + titleValue = "AAAAAAAAA" + subtitleValue = "bot" //TODO:localize + case let .found(peer, _): + isTextVisible = true + titleValue = peer.displayTitle(strings: component.strings, displayOrder: .firstLast) + subtitleValue = "bot" + } + + let titleSize = self.titleLabel.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleValue, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: CGSize(width: maxTextWidth, height: 100.0) + ) + let subtitleSize = self.subtitleLabel.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: subtitleValue, font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)), + maximumNumberOfLines: 1 + )), + environment: {}, + containerSize: CGSize(width: maxTextWidth, height: 100.0) + ) + + let size = CGSize(width: availableSize.width, height: verticalInset * 2.0 + titleSize.height + titleSubtitleSpacing + subtitleSize.height) + + let titleFrame = CGRect(origin: CGPoint(x: sideInset + avatarDiameter + avatarTextSpacing, y: verticalInset), size: titleSize) + if let titleView = self.titleLabel.view { + var titleTransition = transition + if titleView.superview == nil { + titleTransition = .immediate + titleView.layer.anchorPoint = CGPoint() + self.addSubview(titleView) + } + if titleView.isHidden != !isTextVisible { + titleTransition = .immediate + } + + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + titleTransition.setPosition(view: titleView, position: titleFrame.origin) + titleView.isHidden = !isTextVisible + } + + let subtitleFrame = CGRect(origin: CGPoint(x: sideInset + avatarDiameter + avatarTextSpacing, y: verticalInset + titleSize.height + titleSubtitleSpacing), size: subtitleSize) + if let subtitleView = self.subtitleLabel.view { + var subtitleTransition = transition + if subtitleView.superview == nil { + subtitleTransition = .immediate + subtitleView.layer.anchorPoint = CGPoint() + self.addSubview(subtitleView) + } + if subtitleView.isHidden != !isTextVisible { + subtitleTransition = .immediate + } + + subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) + subtitleTransition.setPosition(view: subtitleView, position: subtitleFrame.origin) + subtitleView.isHidden = !isTextVisible + } + + let avatarFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - avatarDiameter) * 0.5)), size: CGSize(width: avatarDiameter, height: avatarDiameter)) + + if case let .found(peer, _) = component.content { + var avatarTransition = transition + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarTransition = .immediate + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0)) + self.avatarNode = avatarNode + self.addSubview(avatarNode.view) + } + avatarTransition.setFrame(view: avatarNode.view, frame: avatarFrame) + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, synchronousLoad: true, displayDimensions: avatarFrame.size) + avatarNode.updateSize(size: avatarFrame.size) + } else { + if let avatarNode = self.avatarNode { + self.avatarNode = nil + avatarNode.view.removeFromSuperview() + } + } + + if case .notFound = component.content { + let notFoundLabel: ComponentView + if let current = self.notFoundLabel { + notFoundLabel = current + } else { + notFoundLabel = ComponentView() + self.notFoundLabel = notFoundLabel + } + //TODO:localize + let notFoundLabelSize = notFoundLabel.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Chatbot not found", font: Font.regular(13.0), textColor: component.theme.list.itemSecondaryTextColor)) + )), + environment: {}, + containerSize: CGSize(width: maxTextWidth, height: 100.0) + ) + let notFoundLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - notFoundLabelSize.width) * 0.5), y: floor((size.height - notFoundLabelSize.height) * 0.5)), size: notFoundLabelSize) + if let notFoundLabelView = notFoundLabel.view { + var notFoundLabelTransition = transition + if notFoundLabelView.superview == nil { + notFoundLabelTransition = .immediate + self.addSubview(notFoundLabelView) + } + notFoundLabelTransition.setPosition(view: notFoundLabelView, position: notFoundLabelFrame.center) + notFoundLabelView.bounds = CGRect(origin: CGPoint(), size: notFoundLabelFrame.size) + } + } else { + if let notFoundLabel = self.notFoundLabel { + self.notFoundLabel = nil + notFoundLabel.view?.removeFromSuperview() + } + } + + if let addButton = self.addButton, let addButtonSize { + var addButtonTransition = transition + let addButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - addButtonSize.width, y: floor((size.height - addButtonSize.height) * 0.5)), size: addButtonSize) + if let addButtonView = addButton.view { + if addButtonView.superview == nil { + addButtonTransition = addButtonTransition.withAnimation(.none) + self.addSubview(addButtonView) + if !transition.animation.isImmediate { + transition.animateScale(view: addButtonView, from: 0.001, to: 1.0) + Transition.easeInOut(duration: 0.2).animateAlpha(view: addButtonView, from: 0.0, to: 1.0) + } + } + addButtonTransition.setFrame(view: addButtonView, frame: addButtonFrame) + } + } + + if let removeButton = self.removeButton, let removeButtonSize { + var removeButtonTransition = transition + let removeButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - removeButtonSize.width, y: floor((size.height - removeButtonSize.height) * 0.5)), size: removeButtonSize) + if let removeButtonView = removeButton.view { + if removeButtonView.superview == nil { + removeButtonTransition = removeButtonTransition.withAnimation(.none) + self.addSubview(removeButtonView) + if !transition.animation.isImmediate { + transition.animateScale(view: removeButtonView, from: 0.001, to: 1.0) + Transition.easeInOut(duration: 0.2).animateAlpha(view: removeButtonView, from: 0.0, to: 1.0) + } + } + removeButtonTransition.setFrame(view: removeButtonView, frame: removeButtonFrame) + } + } + + if case .searching = component.content { + let shimmerEffectNode: ShimmerEffectNode + if let current = self.shimmerEffectNode { + shimmerEffectNode = current + } else { + shimmerEffectNode = ShimmerEffectNode() + self.shimmerEffectNode = shimmerEffectNode + self.addSubview(shimmerEffectNode.view) + } + + shimmerEffectNode.frame = CGRect(origin: CGPoint(), size: size) + shimmerEffectNode.updateAbsoluteRect(CGRect(origin: CGPoint(), size: size), within: size) + + var shapes: [ShimmerEffectNode.Shape] = [] + + let titleLineWidth: CGFloat = titleFrame.width + let subtitleLineWidth: CGFloat = subtitleFrame.width + let lineDiameter: CGFloat = 10.0 + + shapes.append(.circle(avatarFrame)) + + shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floor((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter)) + + shapes.append(.roundedRectLine(startPoint: CGPoint(x: subtitleFrame.minX, y: subtitleFrame.minY + floor((subtitleFrame.height - lineDiameter) / 2.0)), width: subtitleLineWidth, diameter: lineDiameter)) + + shimmerEffectNode.update(backgroundColor: component.theme.list.itemBlocksBackgroundColor, foregroundColor: component.theme.list.mediaPlaceholderColor, shimmeringColor: component.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: size) + } else { + if let shimmerEffectNode = self.shimmerEffectNode { + self.shimmerEffectNode = nil + shimmerEffectNode.view.removeFromSuperview() + } + } + + self.separatorInset = 16.0 + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift index 44b119f895..ee471bd9d6 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift @@ -21,6 +21,8 @@ import ListTextFieldItemComponent import BundleIconComponent import LottieComponent import Markdown +import PeerListItemComponent +import AvatarNode private let checkIcon: UIImage = { return generateImage(CGSize(width: 12.0, height: 10.0), rotatedContext: { size, context in @@ -60,6 +62,49 @@ final class ChatbotSetupScreenComponent: Component { } } + private struct BotResolutionState: Equatable { + enum State: Equatable { + case searching + case notFound + case found(peer: EnginePeer, isInstalled: Bool) + } + + var query: String + var state: State + + init(query: String, state: State) { + self.query = query + self.state = state + } + } + + private struct AdditionalPeerList { + enum Category: Int { + case newChats = 0 + case existingChats = 1 + case contacts = 2 + case nonContacts = 3 + } + + struct Peer { + var peer: EnginePeer + var isContact: Bool + + init(peer: EnginePeer, isContact: Bool) { + self.peer = peer + self.isContact = isContact + } + } + + var categories: Set + var peers: [Peer] + + init(categories: Set, peers: [Peer]) { + self.categories = categories + self.peers = peers + } + } + final class View: UIView, UIScrollViewDelegate { private let topOverscrollLayer = SimpleLayer() private let scrollView: ScrollView @@ -79,6 +124,18 @@ final class ChatbotSetupScreenComponent: Component { private var environment: EnvironmentType? private var chevronImage: UIImage? + private let textFieldTag = NSObject() + + private var botResolutionState: BotResolutionState? + private var botResolutionDisposable: Disposable? + + private var hasAccessToAllChatsByDefault: Bool = true + private var additionalPeerList = AdditionalPeerList( + categories: Set(), + peers: [] + ) + + private var replyToMessages: Bool = true override init(frame: CGRect) { self.scrollView = ScrollView() @@ -150,6 +207,184 @@ final class ChatbotSetupScreenComponent: Component { } } + private func updateBotQuery(query: String) { + guard let component = self.component else { + return + } + + if !query.isEmpty { + if self.botResolutionState?.query != query { + let previousState = self.botResolutionState?.state + self.botResolutionState = BotResolutionState( + query: query, + state: self.botResolutionState?.state ?? .searching + ) + self.botResolutionDisposable?.dispose() + + if previousState != self.botResolutionState?.state { + self.state?.updated(transition: .spring(duration: 0.35)) + } + + self.botResolutionDisposable = (component.context.engine.peers.resolvePeerByName(name: query) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .progress: + break + case let .result(peer): + let previousState = self.botResolutionState?.state + if let peer { + self.botResolutionState?.state = .found(peer: peer, isInstalled: false) + } else { + self.botResolutionState?.state = .notFound + } + if previousState != self.botResolutionState?.state { + self.state?.updated(transition: .spring(duration: 0.35)) + } + } + }) + } + } else { + if let botResolutionDisposable = self.botResolutionDisposable { + self.botResolutionDisposable = nil + botResolutionDisposable.dispose() + } + if self.botResolutionState != nil { + self.botResolutionState = nil + self.state?.updated(transition: .spring(duration: 0.35)) + } + } + } + + private func openAdditionalPeerListSetup() { + guard let component = self.component else { + return + } + + enum AdditionalCategoryId: Int { + case existingChats + case newChats + case contacts + case nonContacts + } + + let additionalCategories: [ChatListNodeAdditionalCategory] = [ + ChatListNodeAdditionalCategory( + id: self.hasAccessToAllChatsByDefault ? AdditionalCategoryId.existingChats.rawValue : AdditionalCategoryId.newChats.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .purple), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple), + title: self.hasAccessToAllChatsByDefault ? "Existing Chats" : "New Chats" + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.contacts.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .blue), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue), + title: "Contacts" + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.nonContacts.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .yellow), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .yellow), + title: "Non-Contacts" + ) + ] + var selectedCategories = Set() + for category in self.additionalPeerList.categories { + switch category { + case .existingChats: + selectedCategories.insert(AdditionalCategoryId.existingChats.rawValue) + case .newChats: + selectedCategories.insert(AdditionalCategoryId.newChats.rawValue) + case .contacts: + selectedCategories.insert(AdditionalCategoryId.contacts.rawValue) + case .nonContacts: + selectedCategories.insert(AdditionalCategoryId.nonContacts.rawValue) + } + } + + //TODO:localize + let controller = component.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: component.context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection( + title: self.hasAccessToAllChatsByDefault ? "Exclude Chats" : "Include Chats", + searchPlaceholder: "Search chats", + selectedChats: Set(self.additionalPeerList.peers.map(\.peer.id)), + additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: selectedCategories), + chatListFilters: nil, + onlyUsers: true + )), options: [], filters: [], alwaysEnabled: true, limit: 100, reachedLimit: { _ in + })) + controller.navigationPresentation = .modal + + let _ = (controller.result + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self, weak controller] result in + guard let self, let component = self.component, case let .result(rawPeerIds, additionalCategoryIds) = result else { + controller?.dismiss() + return + } + + let peerIds = rawPeerIds.compactMap { id -> EnginePeer.Id? in + switch id { + case let .peer(id): + return id + case .deviceContact: + return nil + } + } + + let _ = (component.context.engine.data.get( + EngineDataMap( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)) + ), + EngineDataMap( + peerIds.map(TelegramEngine.EngineData.Item.Peer.IsContact.init(id:)) + ) + ) + |> deliverOnMainQueue).start(next: { [weak self] peerMap, isContactMap in + guard let self else { + return + } + + let mappedCategories = additionalCategoryIds.compactMap { item -> AdditionalPeerList.Category? in + switch item { + case AdditionalCategoryId.existingChats.rawValue: + return .existingChats + case AdditionalCategoryId.newChats.rawValue: + return .newChats + case AdditionalCategoryId.contacts.rawValue: + return .contacts + case AdditionalCategoryId.nonContacts.rawValue: + return .nonContacts + default: + return nil + } + } + + self.additionalPeerList.categories = Set(mappedCategories) + + self.additionalPeerList.peers.removeAll() + for id in peerIds { + guard let maybePeer = peerMap[id], let peer = maybePeer else { + continue + } + self.additionalPeerList.peers.append(AdditionalPeerList.Peer( + peer: peer, + isContact: isContactMap[id] ?? false + )) + } + self.additionalPeerList.peers.sort(by: { lhs, rhs in + return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle + }) + self.state?.updated(transition: .immediate) + + controller?.dismiss() + }) + }) + + self.environment?.controller()?.push(controller) + } + func update(component: ChatbotSetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.isUpdating = true defer { @@ -221,7 +456,7 @@ final class ChatbotSetupScreenComponent: Component { contentHeight += 129.0 //TODO:localize - let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Add a bot to your account to help you automatically process and respond to the messages you receive. [Learn More>]()", attributes: MarkdownAttributes( + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Add a bot to your account to help you automatically process and respond to the messages you receive. [Learn More]()", attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), @@ -239,7 +474,7 @@ final class ChatbotSetupScreenComponent: Component { //TODO:localize let subtitleSize = self.subtitle.update( transition: .immediate, - component: AnyComponent(MultilineTextComponent( + component: AnyComponent(BalancedTextComponent( text: .plain(subtitleString), horizontalAlignment: .center, maximumNumberOfLines: 0, @@ -273,6 +508,66 @@ final class ChatbotSetupScreenComponent: Component { contentHeight += subtitleSize.height contentHeight += 27.0 + var nameSectionItems: [AnyComponentWithIdentity] = [] + nameSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListTextFieldItemComponent( + theme: environment.theme, + initialText: "", + placeholder: "Bot Username", + autocapitalizationType: .none, + autocorrectionType: .no, + updated: { [weak self] value in + guard let self else { + return + } + self.updateBotQuery(query: value) + }, + tag: self.textFieldTag + )))) + if let botResolutionState = self.botResolutionState { + let mappedContent: ChatbotSearchResultItemComponent.Content + switch botResolutionState.state { + case .searching: + mappedContent = .searching + case .notFound: + mappedContent = .notFound + case let .found(peer, isInstalled): + mappedContent = .found(peer: peer, isInstalled: isInstalled) + } + nameSectionItems.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(ChatbotSearchResultItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + content: mappedContent, + installAction: { [weak self] in + guard let self else { + return + } + if var botResolutionState = self.botResolutionState, case let .found(peer, isInstalled) = botResolutionState.state, !isInstalled { + botResolutionState.state = .found(peer: peer, isInstalled: true) + self.botResolutionState = botResolutionState + self.state?.updated(transition: .spring(duration: 0.3)) + } + }, + removeAction: { [weak self] in + guard let self else { + return + } + if let botResolutionState = self.botResolutionState, case let .found(_, isInstalled) = botResolutionState.state, isInstalled { + self.botResolutionState = nil + if let botResolutionDisposable = self.botResolutionDisposable { + self.botResolutionDisposable = nil + botResolutionDisposable.dispose() + } + + if let textFieldView = self.nameSection.findTaggedView(tag: self.textFieldTag) as? ListTextFieldItemComponent.View { + textFieldView.setText(text: "", updateState: false) + } + self.state?.updated(transition: .spring(duration: 0.3)) + } + } + )))) + } + //TODO:localize let nameSectionSize = self.nameSection.update( transition: transition, @@ -287,15 +582,7 @@ final class ChatbotSetupScreenComponent: Component { )), maximumNumberOfLines: 0 )), - items: [ - AnyComponentWithIdentity(id: 0, component: AnyComponent(ListTextFieldItemComponent( - theme: environment.theme, - initialText: "", - placeholder: "Bot Username", - updated: { value in - } - ))) - ] + items: nameSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) @@ -339,11 +626,20 @@ final class ChatbotSetupScreenComponent: Component { ], alignment: .left, spacing: 2.0)), leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, - tintColor: environment.theme.list.itemAccentColor, + tintColor: !self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, contentMode: .center ))), accessory: nil, - action: { _ in + action: { [weak self] _ in + guard let self else { + return + } + if !self.hasAccessToAllChatsByDefault { + self.hasAccessToAllChatsByDefault = true + self.additionalPeerList.categories.removeAll() + self.additionalPeerList.peers.removeAll() + self.state?.updated(transition: .immediate) + } } ))), AnyComponentWithIdentity(id: 1, component: AnyComponent(ListActionItemComponent( @@ -360,11 +656,20 @@ final class ChatbotSetupScreenComponent: Component { ], alignment: .left, spacing: 2.0)), leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( image: checkIcon, - tintColor: .clear, + tintColor: self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, contentMode: .center ))), accessory: nil, - action: { _ in + action: { [weak self] _ in + guard let self else { + return + } + if self.hasAccessToAllChatsByDefault { + self.hasAccessToAllChatsByDefault = false + self.additionalPeerList.categories.removeAll() + self.additionalPeerList.peers.removeAll() + self.state?.updated(transition: .immediate) + } } ))) ] @@ -382,6 +687,95 @@ final class ChatbotSetupScreenComponent: Component { contentHeight += accessSectionSize.height contentHeight += sectionSpacing + var excludedSectionItems: [AnyComponentWithIdentity] = [] + excludedSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: self.hasAccessToAllChatsByDefault ? "Exclude Chats..." : "Select Chats...", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemAccentColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + name: "Chat List/AddIcon", + tintColor: environment.theme.list.itemAccentColor + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + self.openAdditionalPeerListSetup() + } + )))) + for category in self.additionalPeerList.categories.sorted(by: { $0.rawValue < $1.rawValue }) { + let title: String + let icon: String + let color: AvatarBackgroundColor + //TODO:localize + switch category { + case .newChats: + title = "New Chats" + icon = "Chat List/Filters/Contact" + color = .purple + case .existingChats: + title = "Existing Chats" + icon = "Chat List/Filters/Contact" + color = .purple + case .contacts: + title = "Contacts" + icon = "Chat List/Filters/Contact" + color = .blue + case .nonContacts: + title = "Non-Contacts" + icon = "Chat List/Filters/Contact" + color = .yellow + } + excludedSectionItems.append(AnyComponentWithIdentity(id: category, component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + style: .generic, + sideInset: 0.0, + title: title, + avatar: PeerListItemComponent.Avatar( + icon: icon, + color: color, + clipStyle: .roundedRect + ), + peer: nil, + subtitle: nil, + subtitleAccessory: .none, + presence: nil, + selectionState: .none, + hasNext: false, + action: { peer, _, _ in + } + )))) + } + for peer in self.additionalPeerList.peers { + excludedSectionItems.append(AnyComponentWithIdentity(id: peer.peer.id, component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + style: .generic, + sideInset: 0.0, + title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), + peer: peer.peer, + subtitle: peer.isContact ? "contact" : "non-contact", + subtitleAccessory: .none, + presence: nil, + selectionState: .none, + hasNext: false, + action: { peer, _, _ in + } + )))) + } + //TODO:localize let excludedSectionSize = self.excludedSection.update( transition: transition, @@ -389,42 +783,27 @@ final class ChatbotSetupScreenComponent: Component { theme: environment.theme, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "EXCLUDED CHATS", + string: self.hasAccessToAllChatsByDefault ? "EXCLUDED CHATS" : "INCLUDED CHATS", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), footer: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: "Select chats or entire chat categories which the bot WILL NOT have access to.", - font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), - textColor: environment.theme.list.freeTextColor - )), + text: .markdown( + text: self.hasAccessToAllChatsByDefault ? "Select chats or entire chat categories which the bot **WILL NOT** have access to." : "Select chats or entire chat categories which the bot **WILL** have access to.", + attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { _ in + return nil + } + ) + ), maximumNumberOfLines: 0 )), - items: [ - AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: AnyComponent(VStack([ - AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: "Exclude Chats...", - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemAccentColor - )), - maximumNumberOfLines: 1 - ))), - ], alignment: .left, spacing: 2.0)), - leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( - name: "Chat List/AddIcon", - tintColor: environment.theme.list.itemAccentColor - ))), - accessory: nil, - action: { _ in - } - ))), - ] + items: excludedSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) @@ -473,7 +852,13 @@ final class ChatbotSetupScreenComponent: Component { maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), - accessory: .toggle(true), + accessory: .toggle(ListActionItemComponent.Toggle(style: .icons, isOn: self.replyToMessages, action: { [weak self] _ in + guard let self else { + return + } + self.replyToMessages = !self.replyToMessages + self.state?.updated(transition: .spring(duration: 0.4)) + })), action: nil ))), ] diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD new file mode 100644 index 0000000000..2552d7c999 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/BUILD @@ -0,0 +1,42 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "GreetingMessageSetupScreen", + module_name = "GreetingMessageSetupScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/Postbox", + "//submodules/TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/PresentationDataUtils", + "//submodules/Markdown", + "//submodules/ComponentFlow", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/BundleIconComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BalancedTextComponent", + "//submodules/TelegramUI/Components/AnimatedTextComponent", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/TelegramUI/Components/BackButtonComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/ListTextFieldItemComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/AvatarNode", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", + "//submodules/ShimmerEffect", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift new file mode 100644 index 0000000000..aa0f455af2 --- /dev/null +++ b/submodules/TelegramUI/Components/Settings/GreetingMessageSetupScreen/Sources/GreetingMessageSetupScreen.swift @@ -0,0 +1,882 @@ +import Foundation +import UIKit +import Photos +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import BalancedTextComponent +import BackButtonComponent +import ListSectionComponent +import ListActionItemComponent +import ListTextFieldItemComponent +import BundleIconComponent +import LottieComponent +import Markdown +import PeerListItemComponent +import AvatarNode + +private let checkIcon: UIImage = { + return generateImage(CGSize(width: 12.0, height: 10.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor.white.cgColor) + context.setLineWidth(1.98) + context.setLineCap(.round) + context.setLineJoin(.round) + context.translateBy(x: 1.0, y: 1.0) + + let _ = try? drawSvgPath(context, path: "M0.215053763,4.36080467 L3.31621263,7.70466293 L3.31621263,7.70466293 C3.35339229,7.74475231 3.41603123,7.74711109 3.45612061,7.70993143 C3.45920681,7.70706923 3.46210733,7.70401312 3.46480451,7.70078171 L9.89247312,0 S ") + })!.withRenderingMode(.alwaysTemplate) +}() + +final class GreetingMessageSetupScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + + init( + context: AccountContext + ) { + self.context = context + } + + static func ==(lhs: GreetingMessageSetupScreenComponent, rhs: GreetingMessageSetupScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + + return true + } + + private final class ScrollView: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + private struct BotResolutionState: Equatable { + enum State: Equatable { + case searching + case notFound + case found(peer: EnginePeer, isInstalled: Bool) + } + + var query: String + var state: State + + init(query: String, state: State) { + self.query = query + self.state = state + } + } + + private struct AdditionalPeerList { + enum Category: Int { + case newChats = 0 + case existingChats = 1 + case contacts = 2 + case nonContacts = 3 + } + + struct Peer { + var peer: EnginePeer + var isContact: Bool + + init(peer: EnginePeer, isContact: Bool) { + self.peer = peer + self.isContact = isContact + } + } + + var categories: Set + var peers: [Peer] + + init(categories: Set, peers: [Peer]) { + self.categories = categories + self.peers = peers + } + } + + final class View: UIView, UIScrollViewDelegate { + private let topOverscrollLayer = SimpleLayer() + private let scrollView: ScrollView + + private let navigationTitle = ComponentView() + private let icon = ComponentView() + private let subtitle = ComponentView() + private let generalSection = ComponentView() + private let accessSection = ComponentView() + private let excludedSection = ComponentView() + private let permissionsSection = ComponentView() + + private var isUpdating: Bool = false + + private var component: GreetingMessageSetupScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var chevronImage: UIImage? + + private var isOn: Bool = false + + private var hasAccessToAllChatsByDefault: Bool = true + private var additionalPeerList = AdditionalPeerList( + categories: Set(), + peers: [] + ) + + private var replyToMessages: Bool = true + + override init(frame: CGRect) { + self.scrollView = ScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + + self.scrollView.layer.addSublayer(self.topOverscrollLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + return true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(transition: .immediate) + } + + var scrolledUp = true + private func updateScrolling(transition: Transition) { + let navigationRevealOffsetY: CGFloat = 0.0 + + let navigationAlphaDistance: CGFloat = 16.0 + let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance)) + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) + transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) + } + + var scrolledUp = false + if navigationAlpha < 0.5 { + scrolledUp = true + } else if navigationAlpha > 0.5 { + scrolledUp = false + } + + if self.scrolledUp != scrolledUp { + self.scrolledUp = scrolledUp + if !self.isUpdating { + self.state?.updated() + } + } + + if let navigationTitleView = self.navigationTitle.view { + transition.setAlpha(view: navigationTitleView, alpha: 1.0) + } + } + + private func openAdditionalPeerListSetup() { + guard let component = self.component else { + return + } + + enum AdditionalCategoryId: Int { + case existingChats + case newChats + case contacts + case nonContacts + } + + let additionalCategories: [ChatListNodeAdditionalCategory] = [ + ChatListNodeAdditionalCategory( + id: self.hasAccessToAllChatsByDefault ? AdditionalCategoryId.existingChats.rawValue : AdditionalCategoryId.newChats.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .purple), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple), + title: self.hasAccessToAllChatsByDefault ? "Existing Chats" : "New Chats" + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.contacts.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .blue), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue), + title: "Contacts" + ), + ChatListNodeAdditionalCategory( + id: AdditionalCategoryId.nonContacts.rawValue, + icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .yellow), + smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .yellow), + title: "Non-Contacts" + ) + ] + var selectedCategories = Set() + for category in self.additionalPeerList.categories { + switch category { + case .existingChats: + selectedCategories.insert(AdditionalCategoryId.existingChats.rawValue) + case .newChats: + selectedCategories.insert(AdditionalCategoryId.newChats.rawValue) + case .contacts: + selectedCategories.insert(AdditionalCategoryId.contacts.rawValue) + case .nonContacts: + selectedCategories.insert(AdditionalCategoryId.nonContacts.rawValue) + } + } + + //TODO:localize + let controller = component.context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: component.context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection( + title: self.hasAccessToAllChatsByDefault ? "Exclude Chats" : "Include Chats", + searchPlaceholder: "Search chats", + selectedChats: Set(self.additionalPeerList.peers.map(\.peer.id)), + additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: selectedCategories), + chatListFilters: nil, + onlyUsers: true + )), options: [], filters: [], alwaysEnabled: true, limit: 100, reachedLimit: { _ in + })) + controller.navigationPresentation = .modal + + let _ = (controller.result + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self, weak controller] result in + guard let self, let component = self.component, case let .result(rawPeerIds, additionalCategoryIds) = result else { + controller?.dismiss() + return + } + + let peerIds = rawPeerIds.compactMap { id -> EnginePeer.Id? in + switch id { + case let .peer(id): + return id + case .deviceContact: + return nil + } + } + + let _ = (component.context.engine.data.get( + EngineDataMap( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)) + ), + EngineDataMap( + peerIds.map(TelegramEngine.EngineData.Item.Peer.IsContact.init(id:)) + ) + ) + |> deliverOnMainQueue).start(next: { [weak self] peerMap, isContactMap in + guard let self else { + return + } + + let mappedCategories = additionalCategoryIds.compactMap { item -> AdditionalPeerList.Category? in + switch item { + case AdditionalCategoryId.existingChats.rawValue: + return .existingChats + case AdditionalCategoryId.newChats.rawValue: + return .newChats + case AdditionalCategoryId.contacts.rawValue: + return .contacts + case AdditionalCategoryId.nonContacts.rawValue: + return .nonContacts + default: + return nil + } + } + + self.additionalPeerList.categories = Set(mappedCategories) + + self.additionalPeerList.peers.removeAll() + for id in peerIds { + guard let maybePeer = peerMap[id], let peer = maybePeer else { + continue + } + self.additionalPeerList.peers.append(AdditionalPeerList.Peer( + peer: peer, + isContact: isContactMap[id] ?? false + )) + } + self.additionalPeerList.peers.sort(by: { lhs, rhs in + return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle + }) + self.state?.updated(transition: .immediate) + + controller?.dismiss() + }) + }) + + self.environment?.controller()?.push(controller) + } + + func update(component: GreetingMessageSetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + let alphaTransition: Transition = transition.animation.isImmediate ? transition : transition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + + //TODO:localize + let navigationTitleSize = self.navigationTitle.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: "Greeting Message", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) + if let navigationTitleView = self.navigationTitle.view { + if navigationTitleView.superview == nil { + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + navigationBar.view.addSubview(navigationTitleView) + } + } + transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame) + } + + let bottomContentInset: CGFloat = 24.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let sectionSpacing: CGFloat = 32.0 + + let _ = bottomContentInset + let _ = sectionSpacing + + var contentHeight: CGFloat = 0.0 + + contentHeight += environment.navigationHeight + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "HandWaveEmoji"), + loop: true + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight + 2.0), size: iconSize) + if let iconView = self.icon.view { + if iconView.superview == nil { + self.scrollView.addSubview(iconView) + } + transition.setPosition(view: iconView, position: iconFrame.center) + iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size) + } + + contentHeight += 129.0 + + //TODO:localize + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Greet customers when they message you the first time or after a period of no activity.", attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { attributes in + return ("URL", "") + }), textAlignment: .center + )) + if self.chevronImage == nil { + self.chevronImage = UIImage(bundleImageName: "Settings/TextArrowRight") + } + if let range = subtitleString.string.range(of: ">"), let chevronImage = self.chevronImage { + subtitleString.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: subtitleString.string)) + } + + //TODO:localize + let subtitleSize = self.subtitle.update( + transition: .immediate, + component: AnyComponent(BalancedTextComponent( + text: .plain(subtitleString), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.25, + 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] _, _ in + guard let self, let component = self.component else { + return + } + let _ = component + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize) + if let subtitleView = self.subtitle.view { + if subtitleView.superview == nil { + self.scrollView.addSubview(subtitleView) + } + transition.setPosition(view: subtitleView, position: subtitleFrame.center) + subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) + } + contentHeight += subtitleSize.height + contentHeight += 27.0 + + var generalSectionItems: [AnyComponentWithIdentity] = [] + generalSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Send Greeting Message", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isOn, action: { [weak self] _ in + guard let self else { + return + } + self.isOn = !self.isOn + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + )))) + + //TODO:localize + let generalSectionSize = self.generalSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: nil, + footer: nil, + items: generalSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let generalSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: generalSectionSize) + if let generalSectionView = self.generalSection.view { + if generalSectionView.superview == nil { + self.scrollView.addSubview(generalSectionView) + } + transition.setFrame(view: generalSectionView, frame: generalSectionFrame) + } + contentHeight += generalSectionSize.height + contentHeight += sectionSpacing + + var otherSectionsHeight: CGFloat = 0.0 + + //TODO:localize + let accessSectionSize = self.accessSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "RECIPIENTS", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "All 1-to-1 Chats Except...", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + image: checkIcon, + tintColor: !self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, + contentMode: .center + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + if !self.hasAccessToAllChatsByDefault { + self.hasAccessToAllChatsByDefault = true + self.additionalPeerList.categories.removeAll() + self.additionalPeerList.peers.removeAll() + self.state?.updated(transition: .immediate) + } + } + ))), + AnyComponentWithIdentity(id: 1, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Only Selected Chats", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(Image( + image: checkIcon, + tintColor: self.hasAccessToAllChatsByDefault ? .clear : environment.theme.list.itemAccentColor, + contentMode: .center + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + if self.hasAccessToAllChatsByDefault { + self.hasAccessToAllChatsByDefault = false + self.additionalPeerList.categories.removeAll() + self.additionalPeerList.peers.removeAll() + self.state?.updated(transition: .immediate) + } + } + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let accessSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: accessSectionSize) + if let accessSectionView = self.accessSection.view { + if accessSectionView.superview == nil { + accessSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(accessSectionView) + } + transition.setFrame(view: accessSectionView, frame: accessSectionFrame) + alphaTransition.setAlpha(view: accessSectionView, alpha: self.isOn ? 1.0 : 0.0) + } + otherSectionsHeight += accessSectionSize.height + otherSectionsHeight += sectionSpacing + + var excludedSectionItems: [AnyComponentWithIdentity] = [] + excludedSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: self.hasAccessToAllChatsByDefault ? "Exclude Chats..." : "Select Chats...", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemAccentColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + leftIcon: AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( + name: "Chat List/AddIcon", + tintColor: environment.theme.list.itemAccentColor + ))), + accessory: nil, + action: { [weak self] _ in + guard let self else { + return + } + self.openAdditionalPeerListSetup() + } + )))) + for category in self.additionalPeerList.categories.sorted(by: { $0.rawValue < $1.rawValue }) { + let title: String + let icon: String + let color: AvatarBackgroundColor + //TODO:localize + switch category { + case .newChats: + title = "New Chats" + icon = "Chat List/Filters/Contact" + color = .purple + case .existingChats: + title = "Existing Chats" + icon = "Chat List/Filters/Contact" + color = .purple + case .contacts: + title = "Contacts" + icon = "Chat List/Filters/Contact" + color = .blue + case .nonContacts: + title = "Non-Contacts" + icon = "Chat List/Filters/Contact" + color = .yellow + } + excludedSectionItems.append(AnyComponentWithIdentity(id: category, component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + style: .generic, + sideInset: 0.0, + title: title, + avatar: PeerListItemComponent.Avatar( + icon: icon, + color: color, + clipStyle: .roundedRect + ), + peer: nil, + subtitle: nil, + subtitleAccessory: .none, + presence: nil, + selectionState: .none, + hasNext: false, + action: { peer, _, _ in + } + )))) + } + for peer in self.additionalPeerList.peers { + excludedSectionItems.append(AnyComponentWithIdentity(id: peer.peer.id, component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: environment.theme, + strings: environment.strings, + style: .generic, + sideInset: 0.0, + title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), + peer: peer.peer, + subtitle: peer.isContact ? "contact" : "non-contact", + subtitleAccessory: .none, + presence: nil, + selectionState: .none, + hasNext: false, + action: { peer, _, _ in + } + )))) + } + + //TODO:localize + let excludedSectionSize = self.excludedSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: self.hasAccessToAllChatsByDefault ? "EXCLUDED CHATS" : "INCLUDED CHATS", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: AnyComponent(MultilineTextComponent( + text: .markdown( + text: self.hasAccessToAllChatsByDefault ? "Select chats or entire chat categories which the bot **WILL NOT** have access to." : "Select chats or entire chat categories which the bot **WILL** have access to.", + attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), + bold: MarkdownAttributeSet(font: Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor), + link: MarkdownAttributeSet(font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.itemAccentColor), + linkAttribute: { _ in + return nil + } + ) + ), + maximumNumberOfLines: 0 + )), + items: excludedSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let excludedSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: excludedSectionSize) + if let excludedSectionView = self.excludedSection.view { + if excludedSectionView.superview == nil { + excludedSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(excludedSectionView) + } + transition.setFrame(view: excludedSectionView, frame: excludedSectionFrame) + alphaTransition.setAlpha(view: excludedSectionView, alpha: self.isOn ? 1.0 : 0.0) + } + otherSectionsHeight += excludedSectionSize.height + otherSectionsHeight += sectionSpacing + + //TODO:localize + /*let permissionsSectionSize = self.permissionsSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: environment.theme, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "BOT PERMISSIONS", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "The bot will be able to view all new incoming messages, but not the messages that had been sent before you added the bot.", + font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + items: [ + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Reply to Messages", + font: Font.regular(presentationData.listsFontSize.baseDisplaySize), + textColor: environment.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + ], alignment: .left, spacing: 2.0)), + accessory: .toggle(ListActionItemComponent.Toggle(style: .icons, isOn: self.replyToMessages, action: { [weak self] _ in + guard let self else { + return + } + self.replyToMessages = !self.replyToMessages + self.state?.updated(transition: .spring(duration: 0.4)) + })), + action: nil + ))) + ] + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let permissionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + otherSectionsHeight), size: permissionsSectionSize) + if let permissionsSectionView = self.permissionsSection.view { + if permissionsSectionView.superview == nil { + permissionsSectionView.layer.allowsGroupOpacity = true + self.scrollView.addSubview(permissionsSectionView) + } + transition.setFrame(view: permissionsSectionView, frame: permissionsSectionFrame) + + alphaTransition.setAlpha(view: permissionsSectionView, alpha: self.isOn ? 1.0 : 0.0) + } + otherSectionsHeight += permissionsSectionSize.height*/ + + if self.isOn { + contentHeight += otherSectionsHeight + } + + contentHeight += bottomContentInset + contentHeight += environment.safeInsets.bottom + + let previousBounds = self.scrollView.bounds + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + + 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.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class GreetingMessageSetupScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init(context: AccountContext) { + self.context = context + + super.init(context: context, component: GreetingMessageSetupScreenComponent( + context: context + ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = "" + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? GreetingMessageSetupScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + self.attemptNavigation = { [weak self] complete in + guard let self, let componentView = self.node.hostView.componentView as? GreetingMessageSetupScreenComponent.View else { + return true + } + + return componentView.attemptNavigation(complete: complete) + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift index 187fa627ab..44bf0809e3 100644 --- a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift +++ b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift @@ -1264,14 +1264,14 @@ final class ChannelAppearanceScreenComponent: Component { AnyComponentWithIdentity(id: 2, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(HStack(profileLogoContents, spacing: 6.0)), - icon: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( context: component.context, color: profileColor.flatMap { profileColor in component.context.peerNameColors.getProfile(profileColor, dark: environment.theme.overallDarkAppearance, subject: .palette).main } ?? environment.theme.list.itemAccentColor, fileId: backgroundFileId, file: backgroundFileId.flatMap { self.cachedIconFiles[$0] } - ))), + )))), action: { [weak self] view in guard let self, let resolvedState = self.resolveState(), let view = view as? ListActionItemComponent.View, let iconView = view.iconView else { return @@ -1393,12 +1393,12 @@ final class ChannelAppearanceScreenComponent: Component { AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(HStack(emojiPackContents, spacing: 6.0)), - icon: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( context: component.context, color: environment.theme.list.itemAccentColor, fileId: emojiPack?.thumbnailFileId, file: emojiPackFile - ))), + )))), action: { [weak self] view in guard let self, let resolvedState = self.resolveState() else { return @@ -1457,12 +1457,12 @@ final class ChannelAppearanceScreenComponent: Component { AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(HStack(emojiStatusContents, spacing: 6.0)), - icon: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( context: component.context, color: environment.theme.list.itemAccentColor, fileId: statusFileId, file: statusFileId.flatMap { self.cachedIconFiles[$0] } - ))), + )))), action: { [weak self] view in guard let self, let resolvedState = self.resolveState(), let view = view as? ListActionItemComponent.View, let iconView = view.iconView else { return @@ -1581,12 +1581,12 @@ final class ChannelAppearanceScreenComponent: Component { AnyComponentWithIdentity(id: 2, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(HStack(replyLogoContents, spacing: 6.0)), - icon: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( + icon: ListActionItemComponent.Icon(component: AnyComponentWithIdentity(id: 0, component: AnyComponent(EmojiActionIconComponent( context: component.context, color: component.context.peerNameColors.get(resolvedState.nameColor, dark: environment.theme.overallDarkAppearance).main, fileId: replyFileId, file: replyFileId.flatMap { self.cachedIconFiles[$0] } - ))), + )))), action: { [weak self] view in guard let self, let resolvedState = self.resolveState(), let view = view as? ListActionItemComponent.View, let iconView = view.iconView else { return diff --git a/submodules/TelegramUI/Components/SliderComponent/BUILD b/submodules/TelegramUI/Components/SliderComponent/BUILD new file mode 100644 index 0000000000..2a25e8d670 --- /dev/null +++ b/submodules/TelegramUI/Components/SliderComponent/BUILD @@ -0,0 +1,23 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "SliderComponent", + module_name = "SliderComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramPresentationData", + "//submodules/LegacyUI", + "//submodules/ComponentFlow", + "//submodules/Components/MultilineTextComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift b/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift new file mode 100644 index 0000000000..2d314abc8f --- /dev/null +++ b/submodules/TelegramUI/Components/SliderComponent/Sources/SliderComponent.swift @@ -0,0 +1,459 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import TelegramPresentationData +import LegacyUI +import ComponentFlow +import MultilineTextComponent + +final class SliderComponent: Component { + typealias EnvironmentType = Empty + + let title: String + let value: Float + let minValue: Float + let maxValue: Float + let startValue: Float + let isEnabled: Bool + let trackColor: UIColor? + let displayValue: Bool + let valueUpdated: (Float) -> Void + let isTrackingUpdated: ((Bool) -> Void)? + + init( + title: String, + value: Float, + minValue: Float, + maxValue: Float, + startValue: Float, + isEnabled: Bool, + trackColor: UIColor?, + displayValue: Bool, + valueUpdated: @escaping (Float) -> Void, + isTrackingUpdated: ((Bool) -> Void)? = nil + ) { + self.title = title + self.value = value + self.minValue = minValue + self.maxValue = maxValue + self.startValue = startValue + self.isEnabled = isEnabled + self.trackColor = trackColor + self.displayValue = displayValue + self.valueUpdated = valueUpdated + self.isTrackingUpdated = isTrackingUpdated + } + + static func ==(lhs: SliderComponent, rhs: SliderComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.value != rhs.value { + return false + } + if lhs.minValue != rhs.minValue { + return false + } + if lhs.maxValue != rhs.maxValue { + return false + } + if lhs.startValue != rhs.startValue { + return false + } + if lhs.isEnabled != rhs.isEnabled { + return false + } + if lhs.trackColor != rhs.trackColor { + return false + } + if lhs.displayValue != rhs.displayValue { + return false + } + return true + } + + final class View: UIView, UITextFieldDelegate { + private let title = ComponentView() + private let value = ComponentView() + private var sliderView: TGPhotoEditorSliderView? + + private var component: SliderComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: SliderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + var internalIsTrackingUpdated: ((Bool) -> Void)? + if let isTrackingUpdated = component.isTrackingUpdated { + internalIsTrackingUpdated = { [weak self] isTracking in + if let self { + if isTracking { + self.sliderView?.bordered = true + } else { + Queue.mainQueue().after(0.1) { + self.sliderView?.bordered = false + } + } + isTrackingUpdated(isTracking) + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + if let titleView = self.title.view { + transition.setAlpha(view: titleView, alpha: isTracking ? 0.0 : 1.0) + } + if let valueView = self.value.view { + transition.setAlpha(view: valueView, alpha: isTracking ? 0.0 : 1.0) + } + } + } + } + + let sliderView: TGPhotoEditorSliderView + if let current = self.sliderView { + sliderView = current + sliderView.value = CGFloat(component.value) + } else { + sliderView = TGPhotoEditorSliderView() + sliderView.backgroundColor = .clear + sliderView.startColor = UIColor(rgb: 0xffffff) + sliderView.enablePanHandling = true + sliderView.trackCornerRadius = 1.0 + sliderView.lineSize = 2.0 + sliderView.minimumValue = CGFloat(component.minValue) + sliderView.maximumValue = CGFloat(component.maxValue) + sliderView.startValue = CGFloat(component.startValue) + sliderView.value = CGFloat(component.value) + sliderView.disablesInteractiveTransitionGestureRecognizer = true + sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged) + sliderView.layer.allowsGroupOpacity = true + self.sliderView = sliderView + self.addSubview(sliderView) + } + sliderView.interactionBegan = { + internalIsTrackingUpdated?(true) + } + sliderView.interactionEnded = { + internalIsTrackingUpdated?(false) + } + + if component.isEnabled { + sliderView.alpha = 1.3 + sliderView.trackColor = component.trackColor ?? UIColor(rgb: 0xffffff) + sliderView.isUserInteractionEnabled = true + } else { + sliderView.trackColor = UIColor(rgb: 0xffffff) + sliderView.alpha = 0.3 + sliderView.isUserInteractionEnabled = false + } + + transition.setFrame(view: sliderView, frame: CGRect(origin: CGPoint(x: 22.0, y: 7.0), size: CGSize(width: availableSize.width - 22.0 * 2.0, height: 44.0))) + sliderView.hitTestEdgeInsets = UIEdgeInsets(top: -sliderView.frame.minX, left: 0.0, bottom: 0.0, right: -sliderView.frame.minX) + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent( + Text(text: component.title, font: Font.regular(14.0), color: UIColor(rgb: 0x808080)) + ), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: 21.0, y: 0.0), size: titleSize)) + } + + let valueText: String + if component.displayValue { + if component.value > 0.005 { + valueText = String(format: "+%.2f", component.value) + } else if component.value < -0.005 { + valueText = String(format: "%.2f", component.value) + } else { + valueText = "" + } + } else { + valueText = "" + } + + let valueSize = self.value.update( + transition: .immediate, + component: AnyComponent( + Text(text: valueText, font: Font.with(size: 14.0, traits: .monospacedNumbers), color: UIColor(rgb: 0xf8d74a)) + ), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let valueView = self.value.view { + if valueView.superview == nil { + self.addSubview(valueView) + } + transition.setFrame(view: valueView, frame: CGRect(origin: CGPoint(x: availableSize.width - 21.0 - valueSize.width, y: 0.0), size: valueSize)) + } + + return CGSize(width: availableSize.width, height: 52.0) + } + + @objc private func sliderValueChanged() { + guard let component = self.component, let sliderView = self.sliderView else { + return + } + component.valueUpdated(Float(sliderView.value)) + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +struct AdjustmentTool: Equatable { + let key: EditorToolKey + let title: String + let value: Float + let minValue: Float + let maxValue: Float + let startValue: Float +} + +final class AdjustmentsComponent: Component { + typealias EnvironmentType = Empty + + let tools: [AdjustmentTool] + let valueUpdated: (EditorToolKey, Float) -> Void + let isTrackingUpdated: (Bool) -> Void + + init( + tools: [AdjustmentTool], + valueUpdated: @escaping (EditorToolKey, Float) -> Void, + isTrackingUpdated: @escaping (Bool) -> Void + ) { + self.tools = tools + self.valueUpdated = valueUpdated + self.isTrackingUpdated = isTrackingUpdated + } + + static func ==(lhs: AdjustmentsComponent, rhs: AdjustmentsComponent) -> Bool { + if lhs.tools != rhs.tools { + return false + } + return true + } + + final class View: UIView { + private let scrollView = UIScrollView() + private var toolViews: [ComponentView] = [] + + private var component: AdjustmentsComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.scrollView.showsVerticalScrollIndicator = false + + super.init(frame: frame) + + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: AdjustmentsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + let valueUpdated = component.valueUpdated + let isTrackingUpdated: (EditorToolKey, Bool) -> Void = { [weak self] trackingTool, isTracking in + component.isTrackingUpdated(isTracking) + + if let self { + for i in 0 ..< component.tools.count { + let tool = component.tools[i] + if tool.key != trackingTool && i < self.toolViews.count { + if let view = self.toolViews[i].view { + let transition: Transition + if isTracking { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + transition.setAlpha(view: view, alpha: isTracking ? 0.0 : 1.0) + } + } + } + } + } + + var sizes: [CGSize] = [] + for i in 0 ..< component.tools.count { + let tool = component.tools[i] + let componentView: ComponentView + if i >= self.toolViews.count { + componentView = ComponentView() + self.toolViews.append(componentView) + } else { + componentView = self.toolViews[i] + } + + var valueIsNegative = false + var value = tool.value + if case .enhance = tool.key { + if value < 0.0 { + valueIsNegative = true + } + value = abs(value) + } + + let size = componentView.update( + transition: transition, + component: AnyComponent( + SliderComponent( + title: tool.title, + value: value, + minValue: tool.minValue, + maxValue: tool.maxValue, + startValue: tool.startValue, + isEnabled: true, + trackColor: nil, + displayValue: true, + valueUpdated: { value in + var updatedValue = value + if valueIsNegative { + updatedValue *= -1.0 + } + valueUpdated(tool.key, updatedValue) + }, + isTrackingUpdated: { isTracking in + isTrackingUpdated(tool.key, isTracking) + } + ) + ), + environment: {}, + containerSize: availableSize + ) + sizes.append(size) + } + + var origin: CGPoint = CGPoint(x: 0.0, y: 11.0) + for i in 0 ..< component.tools.count { + let size = sizes[i] + let componentView = self.toolViews[i] + + if let view = componentView.view { + if view.superview == nil { + self.scrollView.addSubview(view) + } + transition.setFrame(view: view, frame: CGRect(origin: origin, size: size)) + } + origin = origin.offsetBy(dx: 0.0, dy: size.height) + } + + let size = CGSize(width: availableSize.width, height: 180.0) + let contentSize = CGSize(width: availableSize.width, height: origin.y) + if contentSize != self.scrollView.contentSize { + self.scrollView.contentSize = contentSize + } + transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: size)) + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class AdjustmentsScreenComponent: Component { + typealias EnvironmentType = Empty + + let toggleUneditedPreview: (Bool) -> Void + + init( + toggleUneditedPreview: @escaping (Bool) -> Void + ) { + self.toggleUneditedPreview = toggleUneditedPreview + } + + static func ==(lhs: AdjustmentsScreenComponent, rhs: AdjustmentsScreenComponent) -> Bool { + return true + } + + final class View: UIView { + enum Field { + case blacks + case shadows + case midtones + case highlights + case whites + } + + private var component: AdjustmentsScreenComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + + let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongPress(_:))) + longPressGestureRecognizer.minimumPressDuration = 0.05 + self.addGestureRecognizer(longPressGestureRecognizer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func handleLongPress(_ gestureRecognizer: UIPanGestureRecognizer) { + guard let component = self.component else { + return + } + + switch gestureRecognizer.state { + case .began: + component.toggleUneditedPreview(true) + case .ended, .cancelled: + component.toggleUneditedPreview(false) + default: + break + } + } + + func update(component: AdjustmentsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD index 0ad1d287a5..e72f30df9d 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD @@ -29,6 +29,7 @@ swift_library( "//submodules/ContextUI", "//submodules/TextFormat", "//submodules/PhotoResources", + "//submodules/TelegramUI/Components/ListSectionComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index c23e8cae70..d7be7ac19f 100644 --- a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -19,6 +19,7 @@ import ContextUI import EmojiTextAttachmentView import TextFormat import PhotoResources +import ListSectionComponent private let avatarFont = avatarPlaceholderFont(size: 15.0) private let readIconImage: UIImage? = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/MenuReadIcon"), color: .white)?.withRenderingMode(.alwaysTemplate) @@ -64,6 +65,18 @@ public final class PeerListItemComponent: Component { case check } + public struct Avatar: Equatable { + public var icon: String + public var color: AvatarBackgroundColor + public var clipStyle: AvatarNodeClipStyle + + public init(icon: String, color: AvatarBackgroundColor, clipStyle: AvatarNodeClipStyle) { + self.icon = icon + self.color = color + self.clipStyle = clipStyle + } + } + public final class Reaction: Equatable { public let reaction: MessageReaction.Reaction public let file: TelegramMediaFile? @@ -103,6 +116,7 @@ public final class PeerListItemComponent: Component { let style: Style let sideInset: CGFloat let title: String + let avatar: Avatar? let peer: EnginePeer? let storyStats: PeerStoryStats? let subtitle: String? @@ -127,6 +141,7 @@ public final class PeerListItemComponent: Component { style: Style, sideInset: CGFloat, title: String, + avatar: Avatar? = nil, peer: EnginePeer?, storyStats: PeerStoryStats? = nil, subtitle: String?, @@ -150,6 +165,7 @@ public final class PeerListItemComponent: Component { self.style = style self.sideInset = sideInset self.title = title + self.avatar = avatar self.peer = peer self.storyStats = storyStats self.subtitle = subtitle @@ -187,6 +203,9 @@ public final class PeerListItemComponent: Component { if lhs.title != rhs.title { return false } + if lhs.avatar != rhs.avatar { + return false + } if lhs.peer != rhs.peer { return false } @@ -229,7 +248,7 @@ public final class PeerListItemComponent: Component { return true } - public final class View: ContextControllerSourceView { + public final class View: ContextControllerSourceView, ListSectionComponent.ChildView { private let extractedContainerView: ContextExtractedContentContainingView private let containerButton: HighlightTrackingButton @@ -237,6 +256,7 @@ public final class PeerListItemComponent: Component { private let label = ComponentView() private let separatorLayer: SimpleLayer private let avatarNode: AvatarNode + private var avatarImageView: UIImageView? private let avatarButtonView: HighlightTrackingButton private var avatarIcon: ComponentView? @@ -278,6 +298,9 @@ public final class PeerListItemComponent: Component { private var isExtractedToContextMenu: Bool = false + public var customUpdateIsHighlighted: ((Bool) -> Void)? + public private(set) var separatorInset: CGFloat = 0.0 + override init(frame: CGRect) { self.separatorLayer = SimpleLayer() @@ -336,6 +359,15 @@ public final class PeerListItemComponent: Component { } component.contextAction?(peer, self.extractedContainerView, gesture) } + + self.containerButton.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + if let customUpdateIsHighlighted = self.customUpdateIsHighlighted { + customUpdateIsHighlighted(highlighted) + } + } } required init?(coder: NSCoder) { @@ -560,6 +592,27 @@ public final class PeerListItemComponent: Component { transition.setFrame(view: self.avatarButtonView, frame: avatarFrame) var statusIcon: EmojiStatusComponent.Content? + + if let avatar = component.avatar { + let avatarImageView: UIImageView + if let current = self.avatarImageView { + avatarImageView = current + } else { + avatarImageView = UIImageView() + self.avatarImageView = avatarImageView + self.containerButton.addSubview(avatarImageView) + } + if previousComponent?.avatar != avatar { + avatarImageView.image = generateAvatarImage(size: avatarFrame.size, icon: generateTintedImage(image: UIImage(bundleImageName: avatar.icon), color: .white), cornerRadius: 12.0, color: avatar.color) + } + transition.setFrame(view: avatarImageView, frame: avatarFrame) + } else { + if let avatarImageView = self.avatarImageView { + self.avatarImageView = nil + avatarImageView.removeFromSuperview() + } + } + if let peer = component.peer { let clipStyle: AvatarNodeClipStyle if case let .channel(channel) = peer, channel.flags.contains(.isForum) { @@ -596,6 +649,7 @@ public final class PeerListItemComponent: Component { lineWidth: 1.33, inactiveLineWidth: 1.33 ), transition: transition) + self.avatarNode.isHidden = false if peer.isScam { statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_ScamAccount.uppercased()) @@ -608,6 +662,8 @@ public final class PeerListItemComponent: Component { } else if peer.isPremium { statusIcon = .premium(color: component.theme.list.itemAccentColor) } + } else { + self.avatarNode.isHidden = true } let previousTitleFrame = self.title.view?.frame @@ -953,6 +1009,8 @@ public final class PeerListItemComponent: Component { let containerFrame = CGRect(origin: CGPoint(x: contextInset, y: verticalInset), size: CGSize(width: availableSize.width - contextInset * 2.0, height: height - verticalInset * 2.0)) transition.setFrame(view: self.containerButton, frame: containerFrame) + self.separatorInset = leftInset + return CGSize(width: availableSize.width, height: height) } } diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index a4779139ad..c839cf5eee 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -193,7 +193,7 @@ public final class TextFieldComponent: Component { private let ellipsisView = ComponentView() - private var inputState: InputState { + public var inputState: InputState { let selectionRange: Range = self.textView.selectedRange.location ..< (self.textView.selectedRange.location + self.textView.selectedRange.length) return InputState(inputText: stateAttributedStringForText(self.textView.attributedText ?? NSAttributedString()), selectionRange: selectionRange) } diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Menu/Business.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Menu/Business.imageset/Contents.json new file mode 100644 index 0000000000..d1c4d41aef --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Menu/Business.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "business_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Menu/Business.imageset/business_30.pdf b/submodules/TelegramUI/Images.xcassets/Settings/Menu/Business.imageset/business_30.pdf new file mode 100644 index 0000000000..374eb7b7a7 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Menu/Business.imageset/business_30.pdf @@ -0,0 +1,196 @@ +%PDF-1.7 + +1 0 obj + << /Type /XObject + /Length 2 0 R + /Group << /Type /Group + /S /Transparency + >> + /Subtype /Form + /Resources << >> + /BBox [ 0.000000 0.000000 30.000000 30.000000 ] + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.110840 5.913086 cm +1.000000 1.000000 1.000000 scn +4.498568 19.173828 m +3.994308 19.173828 3.585524 18.765045 3.585524 18.260784 c +3.585524 17.756525 3.994307 17.347740 4.498567 17.347740 c +17.281176 17.347740 l +17.785437 17.347740 18.194220 17.756525 18.194220 18.260784 c +18.194220 18.765045 17.785437 19.173828 17.281176 19.173828 c +4.498568 19.173828 l +h +3.730381 16.434698 m +2.984531 16.434698 2.270933 16.130550 1.754408 15.592503 c +0.442120 14.225536 l +0.115654 13.885468 -0.098236 13.421288 0.045616 12.972363 c +0.335989 12.066183 1.157089 11.412958 2.124654 11.412958 c +3.334878 11.412958 4.315958 12.434917 4.315958 13.695567 c +4.315958 12.434917 5.297039 11.412958 6.507263 11.412958 c +7.717486 11.412958 8.698567 12.434917 8.698567 13.695567 c +8.698567 12.434917 9.679648 11.412958 10.889872 11.412958 c +12.100096 11.412958 13.081176 12.434917 13.081176 13.695567 c +13.081176 12.434917 14.062256 11.412958 15.272481 11.412958 c +16.482704 11.412958 17.463785 12.434917 17.463785 13.695567 c +17.463785 12.434917 18.444864 11.412958 19.655088 11.412958 c +20.622656 11.412958 21.443754 12.066183 21.734129 12.972363 c +21.877979 13.421288 21.664089 13.885468 21.337624 14.225537 c +20.025335 15.592504 l +19.508810 16.130550 18.795212 16.434698 18.049362 16.434698 c +3.730381 16.434698 l +h +3.585524 10.043394 m +4.089784 10.043394 4.498567 9.634610 4.498567 9.130350 c +4.498567 6.370851 l +4.509398 5.875998 4.913934 5.478176 5.411387 5.478176 c +16.367910 5.478176 l +16.872171 5.478176 17.280952 5.886958 17.280952 6.391218 c +17.280952 8.217306 l +17.281176 8.237696 l +17.281176 9.130350 l +17.281176 9.634610 17.689960 10.043394 18.194220 10.043394 c +18.698479 10.043394 19.107264 9.634610 19.107264 9.130350 c +19.107264 6.391220 l +19.107264 1.826002 l +19.107264 0.817484 18.289698 -0.000084 17.281178 -0.000084 c +4.498567 -0.000084 l +3.490047 -0.000084 2.672480 0.817484 2.672480 1.826004 c +2.672480 6.355908 l +2.672257 6.391218 l +2.672257 8.217306 l +2.672480 8.237684 l +2.672480 9.130350 l +2.672480 9.634610 3.081264 10.043394 3.585524 10.043394 c +h +f* +n +Q + +endstream +endobj + +2 0 obj + 2129 +endobj + +3 0 obj + << /Type /XObject + /Length 4 0 R + /Group << /Type /Group + /S /Transparency + >> + /Subtype /Form + /Resources << >> + /BBox [ 0.000000 0.000000 30.000000 30.000000 ] + >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +0.000000 18.799999 m +0.000000 22.720367 0.000000 24.680552 0.762954 26.177933 c +1.434068 27.495068 2.504932 28.565931 3.822066 29.237045 c +5.319448 30.000000 7.279633 30.000000 11.200000 30.000000 c +18.799999 30.000000 l +22.720367 30.000000 24.680552 30.000000 26.177933 29.237045 c +27.495068 28.565931 28.565931 27.495068 29.237045 26.177933 c +30.000000 24.680552 30.000000 22.720367 30.000000 18.799999 c +30.000000 11.200001 l +30.000000 7.279633 30.000000 5.319448 29.237045 3.822067 c +28.565931 2.504932 27.495068 1.434069 26.177933 0.762955 c +24.680552 0.000000 22.720367 0.000000 18.799999 0.000000 c +11.200000 0.000000 l +7.279633 0.000000 5.319448 0.000000 3.822066 0.762955 c +2.504932 1.434069 1.434068 2.504932 0.762954 3.822067 c +0.000000 5.319448 0.000000 7.279633 0.000000 11.200001 c +0.000000 18.799999 l +h +f +n +Q + +endstream +endobj + +4 0 obj + 944 +endobj + +5 0 obj + << /XObject << /X1 1 0 R >> + /ExtGState << /E1 << /SMask << /Type /Mask + /G 3 0 R + /S /Alpha + >> + /Type /ExtGState + >> >> + >> +endobj + +6 0 obj + << /Length 7 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +/E1 gs +/X1 Do +Q + +endstream +endobj + +7 0 obj + 46 +endobj + +8 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 5 0 R + /Contents 6 0 R + /Parent 9 0 R + >> +endobj + +9 0 obj + << /Kids [ 8 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +10 0 obj + << /Pages 9 0 R + /Type /Catalog + >> +endobj + +xref +0 11 +0000000000 65535 f +0000000010 00000 n +0000002387 00000 n +0000002410 00000 n +0000003602 00000 n +0000003624 00000 n +0000003922 00000 n +0000004024 00000 n +0000004045 00000 n +0000004218 00000 n +0000004292 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 10 0 R + /Size 11 +>> +startxref +4352 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Resources/Animations/HandWaveEmoji.tgs b/submodules/TelegramUI/Resources/Animations/HandWaveEmoji.tgs new file mode 100644 index 0000000000..dd1ab78d28 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/HandWaveEmoji.tgs differ diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift index a26be36efb..8ed36ffca5 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift @@ -127,8 +127,23 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { let additionalCategories = chatSelection.additionalCategories let chatListFilters = chatSelection.chatListFilters + var chatListFilter: ChatListFilter? + if chatSelection.onlyUsers { + chatListFilter = .filter(id: Int32.max, title: "", emoticon: nil, data: ChatListFilterData( + isShared: false, + hasSharedLinks: false, + categories: [.contacts, .nonContacts], + excludeMuted: false, + excludeRead: false, + excludeArchived: false, + includePeers: ChatListFilterIncludePeers(), + excludePeers: [], + color: nil + )) + } + placeholder = placeholderValue - let chatListNode = ChatListNode(context: context, location: .chatList(groupId: .root), previewing: false, fillPreloadItems: false, mode: .peers(filter: [.excludeSecretChats], isSelecting: true, additionalCategories: additionalCategories?.categories ?? [], chatListFilters: chatListFilters, displayAutoremoveTimeout: chatSelection.displayAutoremoveTimeout, displayPresence: chatSelection.displayPresence), isPeerEnabled: isPeerEnabled, theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, animationCache: self.animationCache, animationRenderer: self.animationRenderer, disableAnimations: true, isInlineMode: false, autoSetReady: true, isMainTab: false) + let chatListNode = ChatListNode(context: context, location: .chatList(groupId: .root), chatListFilter: chatListFilter, previewing: false, fillPreloadItems: false, mode: .peers(filter: [.excludeSecretChats], isSelecting: true, additionalCategories: additionalCategories?.categories ?? [], chatListFilters: chatListFilters, displayAutoremoveTimeout: chatSelection.displayAutoremoveTimeout, displayPresence: chatSelection.displayPresence), isPeerEnabled: isPeerEnabled, theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, animationCache: self.animationCache, animationRenderer: self.animationRenderer, disableAnimations: true, isInlineMode: false, autoSetReady: true, isMainTab: false) chatListNode.passthroughPeerSelection = true chatListNode.disabledPeerSelected = { peer, _, reason in attemptDisabledItemSelection?(peer, reason) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 7cc369c123..b9f9ed2cfb 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -53,6 +53,9 @@ import UndoUI import ChatMessageNotificationItem import BusinessSetupScreen import ChatbotSetupScreen +import BusinessLocationSetupScreen +import BusinessHoursSetupScreen +import GreetingMessageSetupScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -1888,6 +1891,18 @@ public final class SharedAccountContextImpl: SharedAccountContext { return ChatbotSetupScreen(context: context) } + public func makeBusinessLocationSetupScreen(context: AccountContext) -> ViewController { + return BusinessLocationSetupScreen(context: context) + } + + public func makeBusinessHoursSetupScreen(context: AccountContext) -> ViewController { + return BusinessHoursSetupScreen(context: context) + } + + public func makeGreetingMessageSetupScreen(context: AccountContext) -> ViewController { + return GreetingMessageSetupScreen(context: context) + } + public func makePremiumIntroController(context: AccountContext, source: PremiumIntroSource, forceDark: Bool, dismissed: (() -> Void)?) -> ViewController { var modal = true let mappedSource: PremiumSource