import Foundation import UIKit import Display import ComponentFlow import TelegramCore import ViewControllerComponent import TelegramPresentationData import TelegramStringFormatting import AccountContext import SheetComponent import ButtonComponent import PlainButtonComponent import BundleIconComponent import GlassBackgroundComponent import GlassBarButtonComponent import DatePickerNode import UndoUI private let calendar = Calendar(identifier: .gregorian) private final class ChatScheduleTimeSheetContentComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment public class ExternalState { public fileprivate(set) var repeatValueFrame: CGRect public init() { self.repeatValueFrame = .zero } } let context: AccountContext let mode: ChatScheduleTimeScreen.Mode let currentTime: Int32? let currentRepeatPeriod: Int32? let minimalTime: Int32? let externalState: ExternalState let dismiss: () -> Void init( context: AccountContext, mode: ChatScheduleTimeScreen.Mode, currentTime: Int32?, currentRepeatPeriod: Int32?, minimalTime: Int32?, externalState: ExternalState, dismiss: @escaping () -> Void ) { self.context = context self.mode = mode self.currentTime = currentTime self.currentRepeatPeriod = currentRepeatPeriod self.minimalTime = minimalTime self.externalState = externalState self.dismiss = dismiss } static func ==(lhs: ChatScheduleTimeSheetContentComponent, rhs: ChatScheduleTimeSheetContentComponent) -> Bool { return true } final class View: UIView { private let cancel = ComponentView() private let title = ComponentView() private let button = ComponentView() private let onlineButton = ComponentView() private var datePicker: DatePickerNode? private let topSeparator = SimpleLayer() private let timeTitle = ComponentView() private let timeValue = ComponentView() private let bottomSeparator = SimpleLayer() private let repeatTitle = ComponentView() private let repeatValue = ComponentView() private var timePicker = ComponentView() private var repeatPicker = ComponentView() private var component: ChatScheduleTimeSheetContentComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? private var isUpdating = false private var monthHeight: CGFloat? private var date: Date? private var minDate: Date? private var maxDate: Date? private var isPickingTime = false private var isPickingRepeatPeriod = false private var repeatPeriod: Int32? private let dateFormatter: DateFormatter override init(frame: CGRect) { self.dateFormatter = DateFormatter() self.dateFormatter.timeStyle = .none self.dateFormatter.dateStyle = .short self.dateFormatter.timeZone = TimeZone.current super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func updateMinimumDate(currentTime: Int32? = nil, minimalTime: Int32? = nil) { let timeZone = TimeZone(secondsFromGMT: 0)! var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone let currentDate = Date() var components = calendar.dateComponents(Set([.era, .year, .month, .day, .hour, .minute, .second]), from: currentDate) components.second = 0 let minute = (components.minute ?? 0) % 5 let next1MinDate = calendar.date(byAdding: .minute, value: 1, to: calendar.date(from: components)!) let next5MinDate = calendar.date(byAdding: .minute, value: 5 - minute, to: calendar.date(from: components)!) if let date = calendar.date(byAdding: .day, value: 365, to: currentDate) { self.maxDate = date } if let next1MinDate = next1MinDate, let next5MinDate = next5MinDate { let minimalTimeValue = minimalTime.flatMap(Double.init) ?? 0.0 self.minDate = max(next1MinDate, Date(timeIntervalSince1970: minimalTimeValue)) if let currentTime = currentTime, Double(currentTime) > max(currentDate.timeIntervalSince1970, minimalTimeValue) { self.date = Date(timeIntervalSince1970: Double(currentTime)) } else { self.date = next5MinDate } } } func update(component: ChatScheduleTimeSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> 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.updateMinimumDate(currentTime: component.currentTime, minimalTime: component.minimalTime) self.repeatPeriod = component.currentRepeatPeriod } self.component = component self.state = state let sideInset: CGFloat = 39.0 var contentHeight: CGFloat = 0.0 contentHeight += 30.0 let barButtonSize = CGSize(width: 40.0, height: 40.0) let cancelSize = self.cancel.update( transition: transition, component: AnyComponent( GlassBarButtonComponent( size: barButtonSize, backgroundColor: environment.theme.rootController.navigationBar.glassBarButtonBackgroundColor, isDark: environment.theme.overallDarkAppearance, state: .generic, component: AnyComponentWithIdentity(id: "close", component: AnyComponent( BundleIconComponent( name: "Navigation/Close", tintColor: environment.theme.rootController.navigationBar.glassBarButtonForegroundColor ) )), action: { [weak self] _ in guard let self, let component = self.component else { return } component.dismiss() } ) ), environment: {}, containerSize: barButtonSize ) let cancelFrame = CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: cancelSize) if let cancelView = self.cancel.view { if cancelView.superview == nil { self.addSubview(cancelView) } transition.setFrame(view: cancelView, frame: cancelFrame) } let title: String switch component.mode { case .scheduledMessages: title = environment.strings.Conversation_ScheduleMessage_Title case .reminders: title = environment.strings.Conversation_SetReminder_Title } let titleSize = self.title.update( transition: transition, component: AnyComponent( Text(text: title, font: Font.semibold(17.0), color: environment.theme.actionSheet.primaryTextColor) ), environment: {}, containerSize: availableSize ) let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: 27.0), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } transition.setFrame(view: titleView, frame: titleFrame) } contentHeight += 62.0 let datePicker: DatePickerNode if let current = self.datePicker { datePicker = current if themeUpdated { datePicker.updateTheme(DatePickerTheme(theme: environment.theme)) } } else { datePicker = DatePickerNode( theme: DatePickerTheme(theme: environment.theme), strings: environment.strings, dateTimeFormat: environment.dateTimeFormat, hasValueRow: false ) datePicker.date = self.date datePicker.valueUpdated = { [weak self] date in if let self { self.date = date self.state?.updated() } } self.addSubview(datePicker.view) self.datePicker = datePicker } datePicker.heightUpdated = { [weak self] height in guard let self else { return } var transition = ComponentTransition.spring(duration: 0.3) if self.monthHeight == nil { transition = .immediate } if height != self.monthHeight { self.monthHeight = height if !self.isUpdating { self.state?.updated(transition: transition) } } } datePicker.displayDateSelection = true if let minDate = self.minDate { datePicker.minimumDate = minDate } else { datePicker.minimumDate = Date() } if let maxDate = self.maxDate { datePicker.maximumDate = maxDate } let constrainedWidth = min(390.0, availableSize.width) let cellSize = floor((constrainedWidth - 12.0 * 2.0) / 7.0) let pickerHeight = 59.0 + cellSize * 6.0 let datePickerSize = CGSize(width: availableSize.width - 22.0, height: pickerHeight) datePicker.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - datePickerSize.width) / 2.0), y: contentHeight), size: datePickerSize) datePicker.updateLayout(size: datePickerSize, transition: .immediate) if let monthHeight = self.monthHeight { contentHeight += monthHeight + 79.0 } else { contentHeight += pickerHeight } transition.setFrame(layer: self.topSeparator, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: UIScreenPixel))) self.topSeparator.backgroundColor = environment.theme.list.itemBlocksSeparatorColor.cgColor if self.topSeparator.superlayer == nil { self.layer.addSublayer(self.topSeparator) } let timeTitleSize = self.timeTitle.update( transition: transition, component: AnyComponent( Text(text: "Time", font: Font.regular(17.0), color: environment.theme.actionSheet.primaryTextColor) ), environment: {}, containerSize: availableSize ) let timeTitleFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 16.0), size: timeTitleSize) if let timeTitleView = self.timeTitle.view { if timeTitleView.superview == nil { self.addSubview(timeTitleView) } transition.setFrame(view: timeTitleView, frame: timeTitleFrame) } let date = self.date ?? Date() var t: time_t = Int(date.timeIntervalSince1970) var timeinfo = tm() localtime_r(&t, &timeinfo); let timeString = stringForShortTimestamp(hours: Int32(timeinfo.tm_hour), minutes: Int32(timeinfo.tm_min), dateTimeFormat: environment.dateTimeFormat) let timeValueSize = self.timeValue.update( transition: transition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( ButtonContentComponent( theme: environment.theme, text: timeString, isActive: self.isPickingTime, isLocked: false ) ), action: { [weak self] in guard let self else { return } if self.isPickingRepeatPeriod { self.isPickingRepeatPeriod = false } else { self.isPickingTime = !self.isPickingTime } self.state?.updated() }, animateScale: false ) ), environment: { }, containerSize: availableSize ) let timeValueFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - timeValueSize.width, y: contentHeight + 10.0), size: timeValueSize) if let timeValueView = self.timeValue.view { if timeValueView.superview == nil { self.addSubview(timeValueView) } transition.setFrame(view: timeValueView, frame: timeValueFrame) } contentHeight += 56.0 transition.setFrame(layer: self.bottomSeparator, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: UIScreenPixel))) self.bottomSeparator.backgroundColor = environment.theme.list.itemBlocksSeparatorColor.cgColor if self.bottomSeparator.superlayer == nil { self.layer.addSublayer(self.bottomSeparator) } let repeatTitleSize = self.repeatTitle.update( transition: transition, component: AnyComponent( Text(text: "Repeat", font: Font.regular(17.0), color: environment.theme.actionSheet.primaryTextColor) ), environment: {}, containerSize: availableSize ) let repeatTitleFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 16.0), size: repeatTitleSize) if let timeTitleView = self.repeatTitle.view { if timeTitleView.superview == nil { self.addSubview(timeTitleView) } transition.setFrame(view: timeTitleView, frame: repeatTitleFrame) } let repeatString: String if let repeatPeriod = self.repeatPeriod { switch repeatPeriod { case 60: repeatString = "Every Minute" case 300: repeatString = "Every 5 Minutes" case 86400: repeatString = "Daily" case 7 * 86400: repeatString = "Weekly" case 14 * 86400: repeatString = "Biweekly" case 30 * 86400: repeatString = "Monthly" case 91 * 86400: repeatString = "Every 3 Months" case 182 * 86400: repeatString = "Every 6 Months" case 365 * 86400: repeatString = "Yearly" default: repeatString = "\(repeatPeriod)" } } else { repeatString = "Never" } let repeatValueSize = self.repeatValue.update( transition: transition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( ButtonContentComponent( theme: environment.theme, text: repeatString, isActive: self.isPickingRepeatPeriod, isLocked: !component.context.isPremium ) ), action: { [weak self] in guard let self else { return } if self.isPickingTime { self.isPickingTime = false } else { self.isPickingRepeatPeriod = !self.isPickingRepeatPeriod } self.state?.updated() } ) ), environment: { }, containerSize: availableSize ) let repeatValueFrame = CGRect(origin: CGPoint(x: availableSize.width - sideInset - repeatValueSize.width, y: contentHeight + 10.0), size: repeatValueSize) if let repeatValueView = self.repeatValue.view { if repeatValueView.superview == nil { self.addSubview(repeatValueView) } transition.setFrame(view: repeatValueView, frame: repeatValueFrame) } contentHeight += 70.0 let time = stringForMessageTimestamp(timestamp: Int32(date.timeIntervalSince1970), dateTimeFormat: environment.dateTimeFormat) let buttonTitle: String switch component.mode { case .scheduledMessages: if calendar.isDateInToday(date) { buttonTitle = environment.strings.Conversation_ScheduleMessage_SendToday(time).string } else if calendar.isDateInTomorrow(date) { buttonTitle = environment.strings.Conversation_ScheduleMessage_SendTomorrow(time).string } else { buttonTitle = environment.strings.Conversation_ScheduleMessage_SendOn(self.dateFormatter.string(from: date), time).string } case .reminders: if calendar.isDateInToday(date) { buttonTitle = environment.strings.Conversation_SetReminder_RemindToday(time).string } else if calendar.isDateInTomorrow(date) { buttonTitle = environment.strings.Conversation_SetReminder_RemindTomorrow(time).string } else { buttonTitle = environment.strings.Conversation_SetReminder_RemindOn(self.dateFormatter.string(from: date), time).string } } let buttonSideInset: CGFloat = 30.0 let buttonSize = self.button.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( style: .glass, color: environment.theme.list.itemCheckColors.fillColor, foreground: environment.theme.list.itemCheckColors.foregroundColor, pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8), ), content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( Text(text: buttonTitle, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor) )), isEnabled: true, displaysProgress: false, action: { [weak self] in guard let self, let component = self.component, let controller = self.environment?.controller() as? ChatScheduleTimeScreen else { return } controller.completion( ChatScheduleTimeScreen.Result( time: Int32(self.date?.timeIntervalSince1970 ?? 0), repeatPeriod: self.repeatPeriod ) ) component.dismiss() } )), environment: {}, containerSize: CGSize(width: availableSize.width - buttonSideInset * 2.0, height: 52.0) ) let buttonFrame = CGRect(origin: CGPoint(x: buttonSideInset, y: contentHeight), size: buttonSize) if let buttonView = self.button.view { if buttonView.superview == nil { self.addSubview(buttonView) } transition.setFrame(view: buttonView, frame: buttonFrame) } contentHeight += buttonSize.height if case .scheduledMessages(true) = component.mode { contentHeight += 8.0 let buttonSize = self.onlineButton.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( style: .glass, color: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.1), foreground: environment.theme.list.itemCheckColors.fillColor, pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8), ), content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( Text(text: environment.strings.Conversation_ScheduleMessage_SendWhenOnline, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.fillColor) )), isEnabled: true, displaysProgress: false, action: { [weak self] in guard let self, let component = self.component, let controller = self.environment?.controller() as? ChatScheduleTimeScreen else { return } controller.completion( ChatScheduleTimeScreen.Result( time: scheduleWhenOnlineTimestamp, repeatPeriod: nil ) ) component.dismiss() } )), environment: {}, containerSize: CGSize(width: availableSize.width - buttonSideInset * 2.0, height: 52.0) ) let buttonFrame = CGRect(origin: CGPoint(x: buttonSideInset, y: contentHeight), size: buttonSize) if let buttonView = self.onlineButton.view { if buttonView.superview == nil { self.addSubview(buttonView) } transition.setFrame(view: buttonView, frame: buttonFrame) } contentHeight += buttonSize.height } let bottomPanelPadding: CGFloat = 15.0 let bottomInset: CGFloat = environment.safeInsets.bottom > 0.0 ? environment.safeInsets.bottom + 5.0 : bottomPanelPadding contentHeight += bottomInset let contentSize = CGSize(width: availableSize.width, height: contentHeight) if self.isPickingTime { let _ = self.timePicker.update( transition: transition, component: AnyComponent( MenuComponent( theme: environment.theme, sourceFrame: timeValueFrame, component: AnyComponent(TimeMenuComponent( value: self.date ?? Date(), valueUpdated: { [weak self] value in guard let self else { return } self.date = value self.state?.updated() } )), dismiss: { [weak self] in guard let self else { return } self.isPickingTime = false self.state?.updated() } ) ), environment: { }, containerSize: contentSize ) let timePickerFrame = CGRect(origin: .zero, size: contentSize) if let timePickerView = self.timePicker.view as? MenuComponent.View { if timePickerView.superview == nil { self.addSubview(timePickerView) timePickerView.animateIn() } transition.setFrame(view: timePickerView, frame: timePickerFrame) } } else if let timePicker = self.timePicker.view as? MenuComponent.View, timePicker.superview != nil { self.timePicker = ComponentView() timePicker.animateOut(completion: { timePicker.removeFromSuperview() }) } if self.isPickingRepeatPeriod { let _ = self.repeatPicker.update( transition: transition, component: AnyComponent( MenuComponent( theme: environment.theme, sourceFrame: repeatValueFrame, component: AnyComponent(RepeatMenuComponent( theme: environment.theme, value: self.repeatPeriod, valueUpdated: { [weak self] value in guard let self, let component = self.component, let environment = self.environment else { return } self.isPickingRepeatPeriod = false if component.context.isPremium { self.repeatPeriod = value } else { let toastController = UndoOverlayController( presentationData: component.context.sharedContext.currentPresentationData.with { $0 }, content: .premiumPaywall( title: "Premium Required", text: "Subscribe to **Telegram Premium** to schedule repeating messages.", customUndoText: "Add", timeout: nil, linkAction: nil ), elevatedLayout: false, action: { [weak environment] action in if case .undo = action { var replaceImpl: ((ViewController) -> Void)? let controller = component.context.sharedContext.makePremiumDemoController(context: component.context, subject: .colors, forceDark: false, action: { let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .nameColor, forceDark: false, dismissed: nil) replaceImpl?(controller) }, dismissed: nil) replaceImpl = { [weak controller] c in controller?.replace(with: c) } environment?.controller()?.push(controller) } return true } ) environment.controller()?.present(toastController, in: .current) } self.state?.updated() } )), dismiss: { [weak self] in guard let self else { return } self.isPickingRepeatPeriod = false self.state?.updated() } ) ), environment: { }, containerSize: contentSize ) let repeatPickerFrame = CGRect(origin: .zero, size: contentSize) if let repeatPickerView = self.repeatPicker.view as? MenuComponent.View { if repeatPickerView.superview == nil { self.addSubview(repeatPickerView) repeatPickerView.animateIn() } transition.setFrame(view: repeatPickerView, frame: repeatPickerFrame) } } else if let repeatPicker = self.repeatPicker.view as? MenuComponent.View, repeatPicker.superview != nil { self.repeatPicker = ComponentView() repeatPicker.animateOut(completion: { repeatPicker.removeFromSuperview() }) } component.externalState.repeatValueFrame = repeatValueFrame return contentSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class ChatScheduleTimeScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let mode: ChatScheduleTimeScreen.Mode let currentTime: Int32? let currentRepeatPeriod: Int32? let minimalTime: Int32? init( context: AccountContext, mode: ChatScheduleTimeScreen.Mode, currentTime: Int32?, currentRepeatPeriod: Int32?, minimalTime: Int32? ) { self.context = context self.mode = mode self.currentTime = currentTime self.currentRepeatPeriod = currentRepeatPeriod self.minimalTime = minimalTime } static func ==(lhs: ChatScheduleTimeScreenComponent, rhs: ChatScheduleTimeScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.mode != rhs.mode { return false } if lhs.currentTime != rhs.currentTime { return false } if lhs.currentRepeatPeriod != rhs.currentRepeatPeriod { return false } if lhs.minimalTime != rhs.minimalTime { return false } return true } final class View: UIView { private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>() private let sheetAnimateOut = ActionSlot>() private let sheetExternalState = SheetComponent.ExternalState() private let contentExternalState = ChatScheduleTimeSheetContentComponent.ExternalState() private var component: ChatScheduleTimeScreenComponent? private var environment: EnvironmentType? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ChatScheduleTimeScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component let environment = environment[ViewControllerComponentContainer.Environment.self].value self.environment = environment let sheetEnvironment = SheetComponentEnvironment( isDisplaying: environment.isVisible, isCentered: environment.metrics.widthClass == .regular, hasInputHeight: !environment.inputHeight.isZero, regularMetricsSize: CGSize(width: 430.0, height: 900.0), dismiss: { [weak self] _ in guard let self, let environment = self.environment else { return } self.sheetAnimateOut.invoke(Action { _ in if let controller = environment.controller() { controller.dismiss(completion: nil) } }) } ) let _ = self.sheet.update( transition: transition, component: AnyComponent(SheetComponent( content: AnyComponent(ChatScheduleTimeSheetContentComponent( context: component.context, mode: component.mode, currentTime: component.currentTime, currentRepeatPeriod: component.currentRepeatPeriod, minimalTime: component.minimalTime, externalState: self.contentExternalState, dismiss: { [weak self] in guard let self else { return } self.sheetAnimateOut.invoke(Action { _ in if let controller = environment.controller() { controller.dismiss(completion: nil) } }) } )), style: .glass, backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor), followContentSizeChanges: true, externalState: self.sheetExternalState, animateOut: self.sheetAnimateOut )), environment: { environment sheetEnvironment }, containerSize: availableSize ) if let sheetView = self.sheet.view { if sheetView.superview == nil { self.addSubview(sheetView) } transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize)) } if let controller = environment.controller(), !controller.automaticallyControlPresentationContextLayout { let sideInset: CGFloat = 20.0 let bottomInset: CGFloat = self.sheetExternalState.contentHeight - self.contentExternalState.repeatValueFrame.minY + 14.0 let layout = ContainerViewLayout( size: availableSize, metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: bottomInset, right: 0.0), safeInsets: UIEdgeInsets(top: 0.0, left: max(sideInset, environment.safeInsets.left), bottom: 0.0, right: max(sideInset, environment.safeInsets.right)), additionalInsets: .zero, statusBarHeight: environment.statusBarHeight, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false ) controller.presentationContext.containerLayoutUpdated(layout, transition: transition.containedViewLayoutTransition) } return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public class ChatScheduleTimeScreen: ViewControllerComponentContainer { public enum Mode: Equatable { case scheduledMessages(sendWhenOnlineAvailable: Bool) case reminders } public struct Result { public let time: Int32 public let repeatPeriod: Int32? } fileprivate let completion: (Result) -> Void public init( context: AccountContext, mode: Mode, currentTime: Int32?, currentRepeatPeriod: Int32?, minimalTime: Int32?, isDark: Bool, completion: @escaping (Result) -> Void ) { self.completion = completion super.init(context: context, component: ChatScheduleTimeScreenComponent( context: context, mode: mode, currentTime: currentTime, currentRepeatPeriod: currentRepeatPeriod, minimalTime: minimalTime ), navigationBarAppearance: .none, theme: isDark ? .dark : .default) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal self.blocksBackgroundWhenInOverlay = true self.automaticallyControlPresentationContextLayout = false } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.view.disablesInteractiveModalDismiss = true } } private final class ButtonContentComponent: Component { let theme: PresentationTheme let text: String let isActive: Bool let isLocked: Bool init( theme: PresentationTheme, text: String, isActive: Bool, isLocked: Bool ) { self.theme = theme self.text = text self.isActive = isActive self.isLocked = isLocked } static func ==(lhs: ButtonContentComponent, rhs: ButtonContentComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.text != rhs.text { return false } if lhs.isActive != rhs.isActive { return false } if lhs.isLocked != rhs.isLocked { return false } return true } final class View: UIView { private var component: ButtonContentComponent? private weak var componentState: EmptyComponentState? private let backgroundLayer = SimpleLayer() private let title = ComponentView() private let icon = ComponentView() override init(frame: CGRect) { super.init(frame: frame) self.layer.addSublayer(self.backgroundLayer) self.backgroundLayer.masksToBounds = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ButtonContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.componentState = state let backgroundColor: UIColor = component.isActive ? component.theme.actionSheet.controlAccentColor.withMultipliedAlpha(0.1) : component.theme.actionSheet.primaryTextColor.withMultipliedAlpha(0.07) let textColor: UIColor = component.isActive ? component.theme.actionSheet.controlAccentColor : component.theme.actionSheet.primaryTextColor let titleSize = self.title.update( transition: transition, component: AnyComponent( Text(text: component.text, font: Font.regular(17.0), color: textColor) ), environment: {}, containerSize: availableSize ) var totalWidth = titleSize.width var iconSize = CGSize() if component.isLocked { iconSize = self.icon.update( transition: .immediate, component: AnyComponent( BundleIconComponent( name: "Media Grid/Lock", tintColor: textColor ) ), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) totalWidth += iconSize.width + 2.0 } let padding: CGFloat = 12.0 let size = CGSize(width: totalWidth + padding * 2.0, height: 36.0) let titleFrame = CGRect(origin: CGPoint(x: padding, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } transition.setFrame(view: titleView, frame: titleFrame) } let iconFrame = CGRect(origin: CGPoint(x: size.width - padding - iconSize.width, y: floorToScreenPixels((size.height - iconSize.height) / 2.0)), size: iconSize) if let iconView = self.icon.view { if iconView.superview == nil { self.addSubview(iconView) } transition.setFrame(view: iconView, frame: iconFrame) } self.backgroundLayer.backgroundColor = backgroundColor.cgColor transition.setFrame(layer: self.backgroundLayer, frame: CGRect(origin: .zero, size: size)) self.backgroundLayer.cornerRadius = size.height / 2.0 return size } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class MenuComponent: Component { let theme: PresentationTheme let sourceFrame: CGRect let component: AnyComponent let dismiss: () -> Void init( theme: PresentationTheme, sourceFrame: CGRect, component: AnyComponent, dismiss: @escaping () -> Void ) { self.theme = theme self.sourceFrame = sourceFrame self.component = component self.dismiss = dismiss } public static func ==(lhs: MenuComponent, rhs: MenuComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.sourceFrame != rhs.sourceFrame { return false } if lhs.component != rhs.component { return false } return true } public final class View: UIView { private let buttonView: UIButton private let containerView: GlassBackgroundContainerView private let backgroundView: GlassBackgroundView private var componentView: ComponentView? private var component: MenuComponent? public override init(frame: CGRect) { self.buttonView = UIButton() self.containerView = GlassBackgroundContainerView() self.backgroundView = GlassBackgroundView() super.init(frame: frame) self.addSubview(self.buttonView) self.addSubview(self.containerView) self.containerView.contentView.addSubview(self.backgroundView) self.buttonView.addTarget(self, action: #selector(self.tapped), for: .touchUpInside) } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc func tapped() { if let component = self.component { component.dismiss() } } func animateIn() { guard let component = self.component else { return } let transition = ComponentTransition.spring(duration: 0.3) transition.animatePosition(view: self.backgroundView, from: component.sourceFrame.center, to: self.backgroundView.center) transition.animateScale(view: self.backgroundView, from: 0.2, to: 1.0) self.containerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) } public func animateOut(completion: (() -> Void)? = nil) { guard let component = self.component else { return } let transition = ComponentTransition.spring(duration: 0.3) transition.setPosition(view: self.backgroundView, position: component.sourceFrame.center) transition.setScale(view: self.backgroundView, scale: 0.2) self.containerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in completion?() }) } public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.backgroundView.frame.contains(point) && self.buttonView.frame.contains(point) { return self.buttonView } return super.hitTest(point, with: event) } func update(component: MenuComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component var componentView: ComponentView var componentTransition = transition if let current = self.componentView { componentView = current } else { componentTransition = .immediate componentView = ComponentView() self.componentView = componentView } let componentSize = componentView.update( transition: componentTransition, component: component.component, environment: {}, containerSize: availableSize ) let backgroundFrame = CGRect(origin: CGPoint(x: component.sourceFrame.maxX - componentSize.width, y: component.sourceFrame.minY - componentSize.height - 20.0), size: componentSize) if let view = componentView.view { if view.superview == nil { self.backgroundView.contentView.addSubview(view) } componentTransition.setFrame(view: view, frame: CGRect(origin: .zero, size: componentSize)) } self.backgroundView.update(size: backgroundFrame.size, cornerRadius: 30.0, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: component.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: transition) self.backgroundView.frame = backgroundFrame self.containerView.frame = CGRect(origin: .zero, size: availableSize) self.containerView.update(size: availableSize, isDark: component.theme.overallDarkAppearance, transition: transition) self.buttonView.frame = CGRect(origin: .zero, size: availableSize) return availableSize } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class RepeatMenuComponent: Component { let theme: PresentationTheme let value: Int32? let valueUpdated: (Int32?) -> Void init( theme: PresentationTheme, value: Int32?, valueUpdated: @escaping (Int32?) -> Void ) { self.theme = theme self.value = value self.valueUpdated = valueUpdated } public static func ==(lhs: RepeatMenuComponent, rhs: RepeatMenuComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.value != rhs.value { return false } return true } public final class View: UIView { private let backgroundView: GlassBackgroundView private let never = ComponentView() private let separator = SimpleLayer() private var itemViews: [Int32: ComponentView] = [:] private let checkIcon = UIImageView() private var component: RepeatMenuComponent? private let values: [Int32] = [ 86400, 7 * 86400, 14 * 86400, 30 * 86400, 91 * 86400, 182 * 86400, 365 * 86400 ] public override init(frame: CGRect) { self.backgroundView = GlassBackgroundView() self.checkIcon.image = UIImage(bundleImageName: "Media Gallery/Check")?.withRenderingMode(.alwaysTemplate) super.init(frame: frame) self.addSubview(self.backgroundView) self.layer.addSublayer(self.separator) } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: RepeatMenuComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component let sideInset: CGFloat = 18.0 let itemInset: CGFloat = 60.0 let textColor = component.theme.contextMenu.primaryColor self.checkIcon.tintColor = textColor let neverSize = self.never.update( transition: transition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( Text(text: "Never", font: Font.regular(17.0), color: textColor) ), action: { [weak self] in guard let self else { return } self.component?.valueUpdated(nil) } ) ), environment: {}, containerSize: availableSize ) let neverFrame = CGRect(origin: CGPoint(x: itemInset, y: 21.0), size: neverSize) if let neverView = self.never.view { if neverView.superview == nil { self.addSubview(neverView) } transition.setFrame(view: neverView, frame: neverFrame) if component.value == nil { neverView.addSubview(self.checkIcon) } } var maxWidth: CGFloat = 0.0 var originY: CGFloat = 83.0 for value in self.values { let itemView: ComponentView if let current = self.itemViews[value] { itemView = current } else { itemView = ComponentView() self.itemViews[value] = itemView } let repeatString: String switch value { case 60: repeatString = "Every Minute" case 300: repeatString = "Every 5 Minutes" case 86400: repeatString = "Daily" case 7 * 86400: repeatString = "Weekly" case 14 * 86400: repeatString = "Biweekly" case 30 * 86400: repeatString = "Monthly" case 91 * 86400: repeatString = "Every 3 Months" case 182 * 86400: repeatString = "Every 6 Months" case 365 * 86400: repeatString = "Yearly" default: repeatString = "\(value)" } let itemSize = itemView.update( transition: transition, component: AnyComponent( PlainButtonComponent( content: AnyComponent( Text(text: repeatString, font: Font.regular(17.0), color: textColor) ), action: { [weak self] in guard let self else { return } self.component?.valueUpdated(value) } ) ), environment: {}, containerSize: availableSize ) maxWidth = max(maxWidth, itemSize.width) let itemFrame = CGRect(origin: CGPoint(x: itemInset, y: originY), size: itemSize) if let itemView = itemView.view { if itemView.superview == nil { self.addSubview(itemView) } transition.setFrame(view: itemView, frame: itemFrame) if component.value == value { itemView.addSubview(self.checkIcon) } } originY += 42.0 } if let image = self.checkIcon.image { self.checkIcon.frame = CGRect(origin: CGPoint(x: -35.0, y: floorToScreenPixels((12.0 - image.size.height) / 2.0) + 5.0), size: image.size) } let size = CGSize(width: itemInset + maxWidth + 40.0, height: originY) self.separator.backgroundColor = textColor.withMultipliedAlpha(0.6).cgColor self.separator.frame = CGRect(origin: CGPoint(x: sideInset, y: 62.0), size: CGSize(width: size.width - sideInset * 2.0, height: UIScreenPixel)) return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class TimeMenuComponent: Component { let value: Date let valueUpdated: (Date) -> Void init( value: Date, valueUpdated: @escaping (Date) -> Void ) { self.value = value self.valueUpdated = valueUpdated } public static func == (lhs: TimeMenuComponent, rhs: TimeMenuComponent) -> Bool { return lhs.value == rhs.value } public final class View: UIView { private let picker = UIDatePicker() private var component: TimeMenuComponent? public override init(frame: CGRect) { super.init(frame: frame) self.picker.datePickerMode = .time if #available(iOS 13.4, *) { self.picker.preferredDatePickerStyle = .wheels } self.picker.addTarget(self, action: #selector(valueChanged), for: .valueChanged) self.addSubview(self.picker) } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func valueChanged() { guard let component = self.component else { return } component.valueUpdated(self.picker.date) } func update(component: TimeMenuComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previous = self.component self.component = component if previous == nil || abs(component.value.timeIntervalSince(self.picker.date)) > 0.5 { self.picker.setDate(component.value, animated: false) } let pickerSize = self.picker.sizeThatFits(availableSize) let width = min(availableSize.width, max(pickerSize.width, 230.0)) let height = pickerSize.height > 0 ? pickerSize.height : 216.0 let frame = CGRect(origin: .zero, size: CGSize(width: width, height: height)) transition.setFrame(view: self.picker, frame: frame) return frame.size } } public func makeView() -> View { return View(frame: .zero) } public 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) } }