import Foundation import UIKit import Display import AccountContext import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import AppBundle import ViewControllerComponent import EntityKeyboard import MultilineTextComponent import UndoUI import BundleIconComponent import AnimatedTextComponent import AudioToolbox import ListSectionComponent import PeerAllowedReactionsScreen import AttachmentUI import ListMultilineTextFieldItemComponent import ListActionItemComponent import ChatEntityKeyboardInputNode import ChatPresentationInterfaceState import EmojiSuggestionsComponent import TextFormat import TextFieldComponent import ListComposePollOptionComponent import Markdown final class ComposeTodoScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let peer: EnginePeer let initialData: ComposeTodoScreen.InitialData let completion: (TelegramMediaTodo) -> Void init( context: AccountContext, peer: EnginePeer, initialData: ComposeTodoScreen.InitialData, completion: @escaping (TelegramMediaTodo) -> Void ) { self.context = context self.peer = peer self.initialData = initialData self.completion = completion } static func ==(lhs: ComposeTodoScreenComponent, rhs: ComposeTodoScreenComponent) -> Bool { return true } private final class TodoItem { let id: Int32 let textInputState = TextFieldComponent.ExternalState() let textFieldTag = NSObject() var resetText: String? init(id: Int32) { self.id = id } } final class View: UIView, UIScrollViewDelegate { private let scrollView: UIScrollView private let todoTextSection = ComponentView() private let todoItemsSectionHeader = ComponentView() private let todoItemsSectionFooterContainer = UIView() private var todoItemsSectionFooter = ComponentView() private var todoItemsSectionContainer: ListSectionContentView private let todoSettingsSection = ComponentView() private let actionButton = ComponentView() private var isUpdating: Bool = false private var ignoreScrolling: Bool = false private var previousHadInputHeight: Bool = false private var component: ComposeTodoScreenComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? private let todoTextInputState = TextFieldComponent.ExternalState() private let todoTextFieldTag = NSObject() private var resetTodoText: String? private var nextTodoItemId: Int32 = 1 private var todoItems: [TodoItem] = [] private var currentTodoItemsLimitReached: Bool = false private var currentInputMode: ListComposePollOptionComponent.InputMode = .keyboard private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? private var inputMediaNodeDataDisposable: Disposable? private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() private var inputMediaInteraction: ChatEntityKeyboardInputNode.Interaction? private var inputMediaNode: ChatEntityKeyboardInputNode? private var inputMediaNodeBackground = SimpleLayer() private var inputMediaNodeTargetTag: AnyObject? private let inputMediaNodeDataPromise = Promise() private var currentEmojiSuggestionView: ComponentHostView? private var currentEditingTag: AnyObject? var isAppendableByOthers = false var isCompletableByOthers = false override init(frame: CGRect) { self.scrollView = UIScrollView() self.scrollView.showsVerticalScrollIndicator = true self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.scrollsToTop = false self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.contentInsetAdjustmentBehavior = .never self.scrollView.alwaysBounceVertical = true self.todoItemsSectionContainer = ListSectionContentView(frame: CGRect()) self.todoItemsSectionContainer.automaticallyLayoutExternalContentBackgroundView = false super.init(frame: frame) self.scrollView.delegate = self self.addSubview(self.scrollView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.inputMediaNodeDataDisposable?.dispose() } func scrollToTop() { self.scrollView.setContentOffset(CGPoint(), animated: true) } func validatedInput() -> TelegramMediaTodo? { if self.todoTextInputState.text.length == 0 { return nil } var mappedItems: [TelegramMediaTodo.Item] = [] for todoItem in self.todoItems { if todoItem.textInputState.text.length == 0 { continue } var entities: [MessageTextEntity] = [] for entity in generateChatInputTextEntities(todoItem.textInputState.text) { switch entity.type { case .CustomEmoji: entities.append(entity) default: break } } mappedItems.append( TelegramMediaTodo.Item( text: todoItem.textInputState.text.string, entities: entities, id: todoItem.id ) ) } if mappedItems.count < 1 { return nil } var textEntities: [MessageTextEntity] = [] for entity in generateChatInputTextEntities(self.todoTextInputState.text) { switch entity.type { case .CustomEmoji: textEntities.append(entity) default: break } } var flags: TelegramMediaTodo.Flags = [] if self.isCompletableByOthers { flags.insert(.othersCanComplete) if self.isAppendableByOthers { flags.insert(.othersCanAppend) } } return TelegramMediaTodo( flags: flags, text: self.todoTextInputState.text.string, textEntities: textEntities, items: mappedItems ) } func attemptNavigation(complete: @escaping () -> Void) -> Bool { guard let component = self.component else { return true } let _ = component return true } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) } } private func updateScrolling(transition: ComponentTransition) { let navigationAlphaDistance: CGFloat = 16.0 let navigationAlpha: CGFloat = max(0.0, min(1.0, self.scrollView.contentOffset.y / 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) } } func isPanGestureEnabled() -> Bool { if self.inputMediaNode != nil { return false } for (_, state) in self.collectTextInputStates() { if state.isEditing { return false } } return true } private func updateInputMediaNode( component: ComposeTodoScreenComponent, availableSize: CGSize, bottomInset: CGFloat, inputHeight: CGFloat, effectiveInputHeight: CGFloat, metrics: LayoutMetrics, deviceMetrics: DeviceMetrics, transition: ComponentTransition ) -> CGFloat { let bottomInset: CGFloat = bottomInset + 8.0 let bottomContainerInset: CGFloat = 0.0 let needsInputActivation: Bool = !"".isEmpty var height: CGFloat = 0.0 if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData { if let updatedTag = self.collectTextInputStates().first(where: { $1.isEditing })?.view.currentTag { self.inputMediaNodeTargetTag = updatedTag } let inputMediaNode: ChatEntityKeyboardInputNode var inputMediaNodeTransition = transition var animateIn = false if let current = self.inputMediaNode { inputMediaNode = current } else { animateIn = true inputMediaNodeTransition = inputMediaNodeTransition.withAnimation(.none) inputMediaNode = ChatEntityKeyboardInputNode( context: component.context, currentInputData: inputData, updatedInputData: self.inputMediaNodeDataPromise.get(), defaultToEmojiTab: true, opaqueTopPanelBackground: false, useOpaqueTheme: true, interaction: self.inputMediaInteraction, chatPeerId: nil, stateContext: self.inputMediaNodeStateContext ) inputMediaNode.clipsToBounds = true inputMediaNode.externalTopPanelContainerImpl = nil inputMediaNode.useExternalSearchContainer = true if inputMediaNode.view.superview == nil { self.inputMediaNodeBackground.removeAllAnimations() self.layer.addSublayer(self.inputMediaNodeBackground) self.addSubview(inputMediaNode.view) } self.inputMediaNode = inputMediaNode } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let presentationInterfaceState = ChatPresentationInterfaceState( chatWallpaper: .builtin(WallpaperSettings()), theme: presentationData.theme, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, limitsConfiguration: component.context.currentLimitsConfiguration.with { $0 }, fontSize: presentationData.chatFontSize, bubbleCorners: presentationData.chatBubbleCorners, accountPeerId: component.context.account.peerId, mode: .standard(.default), chatLocation: .peer(id: component.context.account.peerId), subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil, threadData: nil, isGeneralThreadClosed: nil, replyMessage: nil, accountPeerColor: nil, businessIntro: nil ) self.inputMediaNodeBackground.backgroundColor = presentationData.theme.rootController.navigationBar.opaqueBackgroundColor.cgColor let heightAndOverflow = inputMediaNode.updateLayout(width: availableSize.width, leftInset: 0.0, rightInset: 0.0, bottomInset: bottomInset, standardInputHeight: deviceMetrics.standardInputHeight(inLandscape: false), inputHeight: inputHeight < 100.0 ? inputHeight - bottomContainerInset : inputHeight, maximumHeight: availableSize.height, inputPanelHeight: 0.0, transition: .immediate, interfaceState: presentationInterfaceState, layoutMetrics: metrics, deviceMetrics: deviceMetrics, isVisible: true, isExpanded: false) let inputNodeHeight = heightAndOverflow.0 let inputNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputNodeHeight), size: CGSize(width: availableSize.width, height: inputNodeHeight)) let inputNodeBackgroundFrame = CGRect(origin: CGPoint(x: inputNodeFrame.minX, y: inputNodeFrame.minY - 6.0), size: CGSize(width: inputNodeFrame.width, height: inputNodeFrame.height + 6.0)) if needsInputActivation { let inputNodeFrame = inputNodeFrame.offsetBy(dx: 0.0, dy: inputNodeHeight) ComponentTransition.immediate.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) ComponentTransition.immediate.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) } if animateIn { var targetFrame = inputNodeFrame targetFrame.origin.y = availableSize.height inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: targetFrame) let inputNodeBackgroundTargetFrame = CGRect(origin: CGPoint(x: targetFrame.minX, y: targetFrame.minY - 6.0), size: CGSize(width: targetFrame.width, height: targetFrame.height + 6.0)) inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundTargetFrame) transition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) transition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) } else { inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) } height = heightAndOverflow.0 } else { self.inputMediaNodeTargetTag = nil if let inputMediaNode = self.inputMediaNode { self.inputMediaNode = nil var targetFrame = inputMediaNode.frame targetFrame.origin.y = availableSize.height transition.setFrame(view: inputMediaNode.view, frame: targetFrame, completion: { [weak inputMediaNode] _ in if let inputMediaNode { Queue.mainQueue().after(0.3) { inputMediaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak inputMediaNode] _ in inputMediaNode?.view.removeFromSuperview() }) } } }) transition.setFrame(layer: self.inputMediaNodeBackground, frame: targetFrame, completion: { [weak self] _ in Queue.mainQueue().after(0.3) { guard let self else { return } if self.currentInputMode == .keyboard { self.inputMediaNodeBackground.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak self] finished in guard let self else { return } if finished { self.inputMediaNodeBackground.removeFromSuperlayer() } self.inputMediaNodeBackground.removeAllAnimations() }) } } }) } } /*if needsInputActivation { needsInputActivation = false Queue.mainQueue().justDispatch { inputPanelView.activateInput() } }*/ if let controller = self.environment?.controller() as? ComposeTodoScreen { let isTabBarVisible = self.inputMediaNode == nil DispatchQueue.main.async { [weak controller] in controller?.updateTabBarVisibility(isTabBarVisible, transition.containedViewLayoutTransition) } } return height } private func collectTextInputStates() -> [(view: ListComposePollOptionComponent.View, state: TextFieldComponent.ExternalState)] { var textInputStates: [(view: ListComposePollOptionComponent.View, state: TextFieldComponent.ExternalState)] = [] if let textInputView = self.todoTextSection.findTaggedView(tag: self.todoTextFieldTag) as? ListComposePollOptionComponent.View { textInputStates.append((textInputView, self.todoTextInputState)) } for todoItem in self.todoItems { if let textInputView = findTaggedComponentViewImpl(view: self.todoItemsSectionContainer, tag: todoItem.textFieldTag) as? ListComposePollOptionComponent.View { textInputStates.append((textInputView, todoItem.textInputState)) } } return textInputStates } func update(component: ComposeTodoScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } var alphaTransition = transition if !transition.animation.isImmediate { alphaTransition = alphaTransition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) } let environment = environment[EnvironmentType.self].value let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment let isFirstTime = self.component == nil if self.component == nil { if let existingTodo = component.initialData.existingTodo { self.resetTodoText = existingTodo.text for item in existingTodo.items { let todoItem = ComposeTodoScreenComponent.TodoItem( id: item.id ) todoItem.resetText = item.text self.todoItems.append(todoItem) } self.nextTodoItemId = (existingTodo.items.max(by: { $0.id < $1.id })?.id ?? 0) + 1 self.isAppendableByOthers = existingTodo.flags.contains(.othersCanAppend) self.isCompletableByOthers = existingTodo.flags.contains(.othersCanComplete) } else { self.todoItems.append(ComposeTodoScreenComponent.TodoItem( id: self.nextTodoItemId )) self.nextTodoItemId += 1 self.todoItems.append(ComposeTodoScreenComponent.TodoItem( id: self.nextTodoItemId )) self.nextTodoItemId += 1 } self.inputMediaNodeDataPromise.set( ChatEntityKeyboardInputNode.inputData( context: component.context, chatPeerId: nil, areCustomEmojiEnabled: true, hasTrending: false, hasSearch: true, hasStickers: false, hasGifs: false, hideBackground: true, sendGif: nil ) ) self.inputMediaNodeDataDisposable = (self.inputMediaNodeDataPromise.get() |> deliverOnMainQueue).start(next: { [weak self] value in guard let self else { return } self.inputMediaNodeData = value }) self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in let _ = self }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _ , _, _, _, _ in return false }, updateChoosingSticker: { _ in }, switchToTextInput: { [weak self] in guard let self else { return } self.currentInputMode = .keyboard self.state?.updated(transition: .spring(duration: 0.4)) }, dismissTextInput: { }, insertText: { [weak self] text in guard let self else { return } var found = false for (textInputView, externalState) in self.collectTextInputStates() { if externalState.isEditing { textInputView.insertText(text: text) found = true break } } if !found, let inputMediaNodeTargetTag = self.inputMediaNodeTargetTag { for (textInputView, _) in self.collectTextInputStates() { if textInputView.currentTag === inputMediaNodeTargetTag { textInputView.insertText(text: text) found = true break } } } }, backwardsDeleteText: { [weak self] in guard let self else { return } var found = false for (textInputView, externalState) in self.collectTextInputStates() { if externalState.isEditing { textInputView.backwardsDeleteText() found = true break } } if !found, let inputMediaNodeTargetTag = self.inputMediaNodeTargetTag { for (textInputView, _) in self.collectTextInputStates() { if textInputView.currentTag === inputMediaNodeTargetTag { textInputView.backwardsDeleteText() found = true break } } } }, openStickerEditor: { }, presentController: { [weak self] c, a in guard let self else { return } self.environment?.controller()?.present(c, in: .window(.root), with: a) }, presentGlobalOverlayController: { [weak self] c, a in guard let self else { return } self.environment?.controller()?.presentInGlobalOverlay(c, with: a) }, getNavigationController: { [weak self] () -> NavigationController? in guard let self else { return nil } guard let controller = self.environment?.controller() as? ComposeTodoScreen else { return nil } if let navigationController = controller.navigationController as? NavigationController { return navigationController } if let parentController = controller.parentController() { return parentController.navigationController as? NavigationController } return nil }, requestLayout: { [weak self] transition in guard let self else { return } if !self.isUpdating { self.state?.updated(transition: ComponentTransition(transition)) } } ) } self.component = component self.state = state let topInset: CGFloat = 24.0 let bottomInset: CGFloat = 8.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left let sectionSpacing: CGFloat = 24.0 if themeUpdated { self.backgroundColor = environment.theme.list.blocksBackgroundColor } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } var contentHeight: CGFloat = 0.0 contentHeight += environment.navigationHeight contentHeight += topInset var canEdit = true if let _ = component.initialData.existingTodo, !component.initialData.canEdit { canEdit = false } var todoTextSectionItems: [AnyComponentWithIdentity] = [] todoTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( externalState: self.todoTextInputState, context: component.context, theme: environment.theme, strings: environment.strings, isEnabled: canEdit, resetText: self.resetTodoText.flatMap { resetText in return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText)) }, assumeIsEditing: self.inputMediaNodeTargetTag === self.todoTextFieldTag, characterLimit: component.initialData.maxTodoTextLength, canReorder: canEdit, emptyLineHandling: .allowed, returnKeyAction: { [weak self] in guard let self else { return } if !self.todoItems.isEmpty { if let todoItemView = self.todoItemsSectionContainer.itemViews[self.todoItems[0].id] { if let todoItemComponentView = todoItemView.contents.view as? ListComposePollOptionComponent.View { todoItemComponentView.activateInput() } } } }, backspaceKeyAction: nil, selection: nil, inputMode: self.currentInputMode, toggleInputMode: { [weak self] in guard let self else { return } switch self.currentInputMode { case .keyboard: self.currentInputMode = .emoji case .emoji: self.currentInputMode = .keyboard } self.state?.updated(transition: .spring(duration: 0.4)) }, tag: self.todoTextFieldTag )))) self.resetTodoText = nil let todoTextSectionSize = self.todoTextSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: nil, items: todoTextSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let todoTextSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: todoTextSectionSize) if let todoTextSectionView = self.todoTextSection.view as? ListSectionComponent.View { if todoTextSectionView.superview == nil { self.scrollView.addSubview(todoTextSectionView) self.todoTextSection.parentState = state } transition.setFrame(view: todoTextSectionView, frame: todoTextSectionFrame) if let itemView = todoTextSectionView.itemView(id: 0) as? ListComposePollOptionComponent.View { itemView.updateCustomPlaceholder(value: "Title", size: itemView.bounds.size, transition: .immediate) } } contentHeight += todoTextSectionSize.height contentHeight += sectionSpacing var todoItemsSectionItems: [AnyComponentWithIdentity] = [] var todoItemsSectionReadyItems: [ListSectionContentView.ReadyItem] = [] let processTodoItemItem: (Int) -> Void = { i in let todoItem = self.todoItems[i] let optionId = todoItem.id var isEnabled = true if !canEdit, let existingTodo = component.initialData.existingTodo, existingTodo.items.contains(where: { $0.id == todoItem.id }) { isEnabled = false } todoItemsSectionItems.append(AnyComponentWithIdentity(id: todoItem.id, component: AnyComponent(ListComposePollOptionComponent( externalState: todoItem.textInputState, context: component.context, theme: environment.theme, strings: environment.strings, isEnabled: isEnabled, resetText: todoItem.resetText.flatMap { resetText in return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText)) }, assumeIsEditing: self.inputMediaNodeTargetTag === todoItem.textFieldTag, characterLimit: component.initialData.maxTodoItemLength, emptyLineHandling: .notAllowed, returnKeyAction: { [weak self] in guard let self else { return } if let index = self.todoItems.firstIndex(where: { $0.id == optionId }) { if index == self.todoItems.count - 1 { self.endEditing(true) } else { if let todoItemView = self.todoItemsSectionContainer.itemViews[self.todoItems[index + 1].id] { if let todoItemComponentView = todoItemView.contents.view as? ListComposePollOptionComponent.View { todoItemComponentView.activateInput() } } } } }, backspaceKeyAction: { [weak self] in guard let self else { return } if let index = self.todoItems.firstIndex(where: { $0.id == optionId }) { if index == 0 { if let textInputView = self.todoTextSection.findTaggedView(tag: self.todoTextFieldTag) as? ListComposePollOptionComponent.View { textInputView.activateInput() } } else { if let todoItemView = self.todoItemsSectionContainer.itemViews[self.todoItems[index - 1].id] { if let todoItemComponentView = todoItemView.contents.view as? ListComposePollOptionComponent.View { todoItemComponentView.activateInput() } } } } }, selection: nil, inputMode: self.currentInputMode, toggleInputMode: { [weak self] in guard let self else { return } switch self.currentInputMode { case .keyboard: self.currentInputMode = .emoji case .emoji: self.currentInputMode = .keyboard } self.state?.updated(transition: .spring(duration: 0.4)) }, tag: todoItem.textFieldTag )))) let item = todoItemsSectionItems[i] let itemId = item.id let itemView: ListSectionContentView.ItemView var itemTransition = transition if let current = self.todoItemsSectionContainer.itemViews[itemId] { itemView = current } else { itemTransition = itemTransition.withAnimation(.none) itemView = ListSectionContentView.ItemView() self.todoItemsSectionContainer.itemViews[itemId] = itemView itemView.contents.parentState = state } let itemSize = itemView.contents.update( transition: itemTransition, component: item.component, environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) ) todoItemsSectionReadyItems.append(ListSectionContentView.ReadyItem( id: itemId, itemView: itemView, size: itemSize, transition: itemTransition )) } for i in 0 ..< self.todoItems.count { processTodoItemItem(i) } if self.todoItems.count > 2 { let lastOption = self.todoItems[self.todoItems.count - 1] let secondToLastOption = self.todoItems[self.todoItems.count - 2] if !lastOption.textInputState.isEditing && lastOption.textInputState.text.length == 0 && secondToLastOption.textInputState.text.length == 0 { self.todoItems.removeLast() todoItemsSectionItems.removeLast() todoItemsSectionReadyItems.removeLast() } } if self.todoItems.count < component.initialData.maxTodoItemsCount, let lastOption = self.todoItems.last { if lastOption.textInputState.text.length != 0 { self.todoItems.append(TodoItem(id: self.nextTodoItemId)) self.nextTodoItemId += 1 processTodoItemItem(self.todoItems.count - 1) } } for i in 0 ..< todoItemsSectionReadyItems.count { var activate = false let placeholder: String if i == todoItemsSectionReadyItems.count - 1 { placeholder = "Add a Task" if isFirstTime, component.initialData.append { activate = true } } else { placeholder = "Task" } if let itemView = todoItemsSectionReadyItems[i].itemView.contents.view as? ListComposePollOptionComponent.View { itemView.updateCustomPlaceholder(value: placeholder, size: todoItemsSectionReadyItems[i].size, transition: todoItemsSectionReadyItems[i].transition) if activate { itemView.activateInput() } } } let todoItemsSectionUpdateResult = self.todoItemsSectionContainer.update( configuration: ListSectionContentView.Configuration( theme: environment.theme, displaySeparators: true, extendsItemHighlightToSection: false, background: .all ), width: availableSize.width - sideInset * 2.0, leftInset: 0.0, readyItems: todoItemsSectionReadyItems, transition: transition ) let sectionHeaderSideInset: CGFloat = 16.0 let todoItemsSectionHeaderSize = self.todoItemsSectionHeader.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "TO DO LIST", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sectionHeaderSideInset * 2.0, height: 1000.0) ) let todoItemsSectionHeaderFrame = CGRect(origin: CGPoint(x: sideInset + sectionHeaderSideInset, y: contentHeight), size: todoItemsSectionHeaderSize) if let todoItemsSectionHeaderView = self.todoItemsSectionHeader.view { if todoItemsSectionHeaderView.superview == nil { todoItemsSectionHeaderView.layer.anchorPoint = CGPoint() self.scrollView.addSubview(todoItemsSectionHeaderView) } transition.setPosition(view: todoItemsSectionHeaderView, position: todoItemsSectionHeaderFrame.origin) todoItemsSectionHeaderView.bounds = CGRect(origin: CGPoint(), size: todoItemsSectionHeaderFrame.size) } contentHeight += todoItemsSectionHeaderSize.height contentHeight += 7.0 let todoItemsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: todoItemsSectionUpdateResult.size) if self.todoItemsSectionContainer.superview == nil { self.scrollView.addSubview(self.todoItemsSectionContainer.externalContentBackgroundView) self.scrollView.addSubview(self.todoItemsSectionContainer) } transition.setFrame(view: self.todoItemsSectionContainer, frame: todoItemsSectionFrame) transition.setFrame(view: self.todoItemsSectionContainer.externalContentBackgroundView, frame: todoItemsSectionUpdateResult.backgroundFrame.offsetBy(dx: todoItemsSectionFrame.minX, dy: todoItemsSectionFrame.minY)) contentHeight += todoItemsSectionUpdateResult.size.height contentHeight += 7.0 let todoItemsLimitReached = self.todoItems.count >= component.initialData.maxTodoItemsCount var animateTodoItemsFooterIn = false var todoItemsFooterTransition = transition if self.currentTodoItemsLimitReached != todoItemsLimitReached { self.currentTodoItemsLimitReached = todoItemsLimitReached if let todoItemsSectionFooterView = self.todoItemsSectionFooter.view { animateTodoItemsFooterIn = true todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none) alphaTransition.setAlpha(view: todoItemsSectionFooterView, alpha: 0.0, completion: { [weak todoItemsSectionFooterView] _ in todoItemsSectionFooterView?.removeFromSuperview() }) self.todoItemsSectionFooter = ComponentView() } } let todoItemsComponent: AnyComponent if !"".isEmpty, todoItemsLimitReached { todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none) let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize) let boldTextFont = Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize) let textColor = environment.theme.list.freeTextColor todoItemsComponent = AnyComponent(MultilineTextComponent( text: .markdown( text: "Limit of tasks reached. You can increase the limit to **20 tasks** by subscribing to [Telegram Premium]().", attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: environment.theme.list.itemAccentColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) } ) ), maximumNumberOfLines: 0, highlightColor: presentationData.theme.list.itemAccentColor.withAlphaComponent(0.2), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) } else { return nil } }, tapAction: { [weak self] _, _ in guard let self, let component = self.component else { return } let controller = component.context.sharedContext.makePremiumIntroController( context: component.context, source: .chatsPerFolder, forceDark: false, dismissed: nil ) (self.environment?.controller() as? AttachmentContainable)?.parentController()?.push(controller) } )) } else { let remainingCount = component.initialData.maxTodoItemsCount - self.todoItems.count let rawString = "You can add {count} more tasks." //environment.strings.CreatePoll_OptionCountFooterFormat(Int32(remainingCount)) var todoItemsFooterItems: [AnimatedTextComponent.Item] = [] if let range = rawString.range(of: "{count}") { if range.lowerBound != rawString.startIndex { todoItemsFooterItems.append(AnimatedTextComponent.Item( id: 0, isUnbreakable: true, content: .text(String(rawString[rawString.startIndex ..< range.lowerBound])) )) } todoItemsFooterItems.append(AnimatedTextComponent.Item( id: 1, isUnbreakable: true, content: .number(remainingCount, minDigits: 1) )) if range.upperBound != rawString.endIndex { todoItemsFooterItems.append(AnimatedTextComponent.Item( id: 2, isUnbreakable: true, content: .text(String(rawString[range.upperBound ..< rawString.endIndex])) )) } } todoItemsComponent = AnyComponent(AnimatedTextComponent( font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), color: environment.theme.list.freeTextColor, items: todoItemsFooterItems )) } let todoItemsSectionFooterSize = self.todoItemsSectionFooter.update( transition: todoItemsFooterTransition, component: todoItemsComponent, environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sectionHeaderSideInset * 2.0, height: 1000.0) ) let todoItemsSectionFooterFrame = CGRect(origin: CGPoint(x: sideInset + sectionHeaderSideInset, y: contentHeight), size: todoItemsSectionFooterSize) if self.todoItemsSectionFooterContainer.superview == nil { self.scrollView.addSubview(self.todoItemsSectionFooterContainer) } transition.setFrame(view: self.todoItemsSectionFooterContainer, frame: todoItemsSectionFooterFrame) if let todoItemsSectionFooterView = self.todoItemsSectionFooter.view { if todoItemsSectionFooterView.superview == nil { todoItemsSectionFooterView.layer.anchorPoint = CGPoint() self.todoItemsSectionFooterContainer.addSubview(todoItemsSectionFooterView) } todoItemsFooterTransition.setPosition(view: todoItemsSectionFooterView, position: CGPoint()) todoItemsSectionFooterView.bounds = CGRect(origin: CGPoint(), size: todoItemsSectionFooterFrame.size) if animateTodoItemsFooterIn && !transition.animation.isImmediate { alphaTransition.animateAlpha(view: todoItemsSectionFooterView, from: 0.0, to: 1.0) } } contentHeight += todoItemsSectionFooterSize.height contentHeight += sectionSpacing var todoSettingsSectionItems: [AnyComponentWithIdentity] = [] if canEdit && component.peer.id != component.context.account.peerId { todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "completable", component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Allow Others to Mark as Done", 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.isCompletableByOthers, action: { [weak self] _ in guard let self else { return } self.isCompletableByOthers = !self.isCompletableByOthers self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) if self.isCompletableByOthers { todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "editable", component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Allow Others to Add Tasks", 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.isAppendableByOthers, action: { [weak self] _ in guard let self else { return } self.isAppendableByOthers = !self.isAppendableByOthers self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) } } if !todoSettingsSectionItems.isEmpty { let todoSettingsSectionSize = self.todoSettingsSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: nil, items: todoSettingsSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let todoSettingsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: todoSettingsSectionSize) if let todoSettingsSectionView = self.todoSettingsSection.view { if todoSettingsSectionView.superview == nil { self.scrollView.addSubview(todoSettingsSectionView) self.todoSettingsSection.parentState = state } transition.setFrame(view: todoSettingsSectionView, frame: todoSettingsSectionFrame) } contentHeight += todoSettingsSectionSize.height } var inputHeight: CGFloat = 0.0 inputHeight += self.updateInputMediaNode( component: component, availableSize: availableSize, bottomInset: environment.safeInsets.bottom, inputHeight: 0.0, effectiveInputHeight: environment.deviceMetrics.standardInputHeight(inLandscape: false), metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, transition: transition ) if self.inputMediaNode == nil { inputHeight = environment.inputHeight } let textInputStates = self.collectTextInputStates() let previousEditingTag = self.currentEditingTag let isEditing: Bool if let index = textInputStates.firstIndex(where: { $0.state.isEditing }) { isEditing = true self.currentEditingTag = textInputStates[index].view.currentTag } else { isEditing = false self.currentEditingTag = nil } if let (_, suggestionTextInputState) = textInputStates.first(where: { $0.state.isEditing && $0.state.currentEmojiSuggestion != nil }), let emojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, emojiSuggestion.disposable == nil { emojiSuggestion.disposable = (EmojiSuggestionsComponent.suggestionData(context: component.context, isSavedMessages: false, query: emojiSuggestion.position.value) |> deliverOnMainQueue).start(next: { [weak self, weak suggestionTextInputState, weak emojiSuggestion] result in guard let self, let suggestionTextInputState, let emojiSuggestion, suggestionTextInputState.currentEmojiSuggestion === emojiSuggestion else { return } emojiSuggestion.value = result self.state?.updated() }) } for (_, suggestionTextInputState) in textInputStates { var hasTrackingView = suggestionTextInputState.hasTrackingView if let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, let value = currentEmojiSuggestion.value as? [TelegramMediaFile], value.isEmpty { hasTrackingView = false } if !suggestionTextInputState.isEditing { hasTrackingView = false } if !hasTrackingView { if let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion { suggestionTextInputState.currentEmojiSuggestion = nil currentEmojiSuggestion.disposable?.dispose() } if let currentEmojiSuggestionView = self.currentEmojiSuggestionView { self.currentEmojiSuggestionView = nil currentEmojiSuggestionView.alpha = 0.0 currentEmojiSuggestionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak currentEmojiSuggestionView] _ in currentEmojiSuggestionView?.removeFromSuperview() }) } } } if let (suggestionTextInputView, suggestionTextInputState) = textInputStates.first(where: { $0.state.isEditing && $0.state.currentEmojiSuggestion != nil }), let emojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, let value = emojiSuggestion.value as? [TelegramMediaFile] { let currentEmojiSuggestionView: ComponentHostView if let current = self.currentEmojiSuggestionView { currentEmojiSuggestionView = current } else { currentEmojiSuggestionView = ComponentHostView() self.currentEmojiSuggestionView = currentEmojiSuggestionView self.addSubview(currentEmojiSuggestionView) currentEmojiSuggestionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } let globalPosition: CGPoint if let textView = suggestionTextInputView.textFieldView { globalPosition = textView.convert(emojiSuggestion.localPosition, to: self) } else { globalPosition = .zero } let sideInset: CGFloat = 7.0 let viewSize = currentEmojiSuggestionView.update( transition: .immediate, component: AnyComponent(EmojiSuggestionsComponent( context: component.context, userLocation: .other, theme: EmojiSuggestionsComponent.Theme(theme: environment.theme, backgroundColor: environment.theme.list.itemBlocksBackgroundColor), animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, files: value, action: { [weak self, weak suggestionTextInputView, weak suggestionTextInputState] file in guard let self, let suggestionTextInputView, let suggestionTextInputState, let textView = suggestionTextInputView.textFieldView, let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion else { return } let _ = self AudioServicesPlaySystemSound(0x450) let inputState = textView.getInputState() let inputText = NSMutableAttributedString(attributedString: inputState.inputText) var text: String? var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? loop: for attribute in file.attributes { switch attribute { case let .CustomEmoji(_, _, displayText, _): text = displayText emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) break loop default: break } } if let emojiAttribute = emojiAttribute, let text = text { let replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) let range = currentEmojiSuggestion.position.range let previousText = inputText.attributedSubstring(from: range) inputText.replaceCharacters(in: range, with: replacementText) var replacedUpperBound = range.lowerBound while true { if inputText.attributedSubstring(from: NSRange(location: 0, length: replacedUpperBound)).string.hasSuffix(previousText.string) { let replaceRange = NSRange(location: replacedUpperBound - previousText.length, length: previousText.length) if replaceRange.location < 0 { break } let adjacentString = inputText.attributedSubstring(from: replaceRange) if adjacentString.string != previousText.string || adjacentString.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) != nil { break } inputText.replaceCharacters(in: replaceRange, with: NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: emojiAttribute.interactivelySelectedFromPackId, fileId: emojiAttribute.fileId, file: emojiAttribute.file)])) replacedUpperBound = replaceRange.lowerBound } else { break } } let selectionPosition = range.lowerBound + (replacementText.string as NSString).length textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) } } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) ) let viewFrame = CGRect(origin: CGPoint(x: min(availableSize.width - sideInset - viewSize.width, max(sideInset, floor(globalPosition.x - viewSize.width / 2.0))), y: globalPosition.y - 4.0 - viewSize.height), size: viewSize) currentEmojiSuggestionView.frame = viewFrame if let componentView = currentEmojiSuggestionView.componentView as? EmojiSuggestionsComponent.View { componentView.adjustBackground(relativePositionX: floor(globalPosition.x + 10.0)) } } let combinedBottomInset: CGFloat combinedBottomInset = bottomInset + max(environment.safeInsets.bottom, 8.0 + inputHeight) contentHeight += combinedBottomInset var recenterOnTag: AnyObject? if let hint = transition.userData(TextFieldComponent.AnimationHint.self), let targetView = hint.view { var matches = false switch hint.kind { case .textChanged: matches = true case let .textFocusChanged(isFocused): if isFocused { matches = true } } if matches { for (textView, _) in self.collectTextInputStates() { if targetView.isDescendant(of: textView) { recenterOnTag = textView.currentTag break } } } } if recenterOnTag == nil && self.previousHadInputHeight != (inputHeight > 0.0) { for (textView, state) in self.collectTextInputStates() { if state.isEditing { recenterOnTag = textView.currentTag break } } } self.previousHadInputHeight = (inputHeight > 0.0) self.ignoreScrolling = true 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: environment.safeInsets.bottom, right: 0.0) if self.scrollView.scrollIndicatorInsets != scrollInsets { self.scrollView.scrollIndicatorInsets = scrollInsets } if let recenterOnTag { if let targetView = self.collectTextInputStates().first(where: { $0.view.currentTag === recenterOnTag })?.view { let caretRect = targetView.convert(targetView.bounds, to: self.scrollView) var scrollViewBounds = self.scrollView.bounds let minButtonDistance: CGFloat = 16.0 if -scrollViewBounds.minY + caretRect.maxY > availableSize.height - combinedBottomInset - minButtonDistance { scrollViewBounds.origin.y = -(availableSize.height - combinedBottomInset - minButtonDistance - caretRect.maxY) if scrollViewBounds.origin.y < 0.0 { scrollViewBounds.origin.y = 0.0 } } if self.scrollView.bounds != scrollViewBounds { self.scrollView.bounds = scrollViewBounds } } } if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds if bounds.maxY != previousBounds.maxY { let offsetY = previousBounds.maxY - bounds.maxY transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) } } self.ignoreScrolling = false self.updateScrolling(transition: transition) if isEditing { if let controller = environment.controller() as? ComposeTodoScreen { DispatchQueue.main.async { [weak controller] in controller?.requestAttachmentMenuExpansion() } } } let isValid = self.validatedInput() != nil if let controller = environment.controller() as? ComposeTodoScreen, let sendButtonItem = controller.sendButtonItem { if sendButtonItem.isEnabled != isValid { sendButtonItem.isEnabled = isValid } } if let currentEditingTag = self.currentEditingTag, previousEditingTag !== currentEditingTag, self.currentInputMode != .keyboard { DispatchQueue.main.async { [weak self] in guard let self else { return } self.currentInputMode = .keyboard self.state?.updated(transition: .spring(duration: 0.4)) } } for i in 0 ..< self.todoItems.count { self.todoItems[i].resetText = nil } return availableSize } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public class ComposeTodoScreen: ViewControllerComponentContainer, AttachmentContainable { public final class InitialData { fileprivate let maxTodoTextLength: Int fileprivate let maxTodoItemLength: Int fileprivate let maxTodoItemsCount: Int fileprivate let existingTodo: TelegramMediaTodo? fileprivate let append: Bool fileprivate let canEdit: Bool fileprivate init( maxTodoTextLength: Int, maxTodoItemLength: Int, maxTodoItemsCount: Int, existingTodo: TelegramMediaTodo?, append: Bool, canEdit: Bool ) { self.maxTodoTextLength = maxTodoTextLength self.maxTodoItemLength = maxTodoItemLength self.maxTodoItemsCount = maxTodoItemsCount self.existingTodo = existingTodo self.append = append self.canEdit = canEdit } } private let context: AccountContext private let completion: (TelegramMediaTodo) -> Void private var isDismissed: Bool = false fileprivate private(set) var sendButtonItem: UIBarButtonItem? public var isMinimized: Bool = false public var requestAttachmentMenuExpansion: () -> Void = { } public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in } public var parentController: () -> ViewController? = { return nil } public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in } public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in } public var cancelPanGesture: () -> Void = { } public var isContainerPanning: () -> Bool = { return false } public var isContainerExpanded: () -> Bool = { return false } public var mediaPickerContext: AttachmentMediaPickerContext? public var isPanGestureEnabled: (() -> Bool)? { return { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else { return true } return componentView.isPanGestureEnabled() } } public init( context: AccountContext, initialData: InitialData, peer: EnginePeer, completion: @escaping (TelegramMediaTodo) -> Void ) { self.context = context self.completion = completion super.init(context: context, component: ComposeTodoScreenComponent( context: context, peer: peer, initialData: initialData, completion: completion ), navigationBarAppearance: .default, theme: .default) let presentationData = context.sharedContext.currentPresentationData.with { $0 } if !initialData.canEdit && initialData.existingTodo != nil { self.title = "Add a Task" } else { self.title = initialData.existingTodo != nil ? "Edit To Do List" : "New To Do List" } self.navigationItem.setLeftBarButton(UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)), animated: false) let sendButtonItem = UIBarButtonItem(title: initialData.existingTodo != nil ? "Save" : presentationData.strings.CreatePoll_Create, style: .done, target: self, action: #selector(self.sendPressed)) self.sendButtonItem = sendButtonItem self.navigationItem.setRightBarButton(sendButtonItem, animated: false) sendButtonItem.isEnabled = false self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else { return } componentView.scrollToTop() } self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else { return true } return componentView.attemptNavigation(complete: complete) } } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } public static func initialData(context: AccountContext, existingTodo: TelegramMediaTodo? = nil, append: Bool = false, canEdit: Bool = false) -> InitialData { var maxTodoTextLength: Int = 32 var maxTodoItemLength: Int = 64 var maxTodoItemsCount: Int = 30 if let data = context.currentAppConfiguration.with({ $0 }).data { if let value = data["todo_title_length_max"] as? Double { maxTodoTextLength = Int(value) } if let value = data["todo_item_length_max"] as? Double { maxTodoItemLength = Int(value) } if let value = data["todo_items_max"] as? Double { maxTodoItemsCount = Int(value) } } return InitialData( maxTodoTextLength: maxTodoTextLength, maxTodoItemLength: maxTodoItemLength, maxTodoItemsCount: maxTodoItemsCount, existingTodo: existingTodo, append: append, canEdit: canEdit ) } @objc private func cancelPressed() { self.dismiss() } @objc private func sendPressed() { guard let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else { return } if let input = componentView.validatedInput() { self.completion(input) } self.dismiss() } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) } public func isContainerPanningUpdated(_ panning: Bool) { } public func resetForReuse() { } public func prepareForReuse() { } public func requestDismiss(completion: @escaping () -> Void) { completion() } public func shouldDismissImmediately() -> Bool { return true } }