From faa73a6ec26f5a7c4a0e0ee2dc952033f8491bf0 Mon Sep 17 00:00:00 2001 From: akaevmikail17 Date: Sun, 7 Jun 2026 12:10:59 +0300 Subject: [PATCH] Events tab: card navigator, floating button, group invite flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Events tab to root tab bar (EventsController) - CreateEventController: create/edit events with participants - Attachment menu: new "Событие" button in any chat - DM: pre-fills recipient as participant - Group: opens blank form; on save sends card message + stores with chatId - sendEventToGroup: single formatted message (removed poll) - EventCardNavigatorController: per-group card UI with Yes/No voting - Yes → adds event to personal calendar - Navigates newest-first through group events - ChatControllerEventButton: floating draggable 📅 button in group chats - Semi-transparent, snaps to screen edge - Badge shows count of unvoted events - TGEvent: added chatId field for group association Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/AttachmentController.swift | 9 + .../Sources/AttachmentPanel.swift | 5 + .../Attach Menu/Event.imageset/Contents.json | 12 + .../Event.imageset/calendar_24.pdf | 156 ++++ .../TelegramUI/Sources/ChatController.swift | 4 +- .../Sources/ChatControllerEventButton.swift | 191 +++++ .../ChatControllerOpenAttachmentMenu.swift | 87 +++ .../Sources/CreateEventController.swift | 437 +++++++++++ .../EventCardNavigatorController.swift | 363 +++++++++ .../TelegramUI/Sources/EventsController.swift | 701 ++++++++++++++++++ .../Sources/TelegramRootController.swift | 5 + 11 files changed, 1969 insertions(+), 1 deletion(-) create mode 100644 submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/calendar_24.pdf create mode 100644 submodules/TelegramUI/Sources/ChatControllerEventButton.swift create mode 100644 submodules/TelegramUI/Sources/CreateEventController.swift create mode 100644 submodules/TelegramUI/Sources/EventCardNavigatorController.swift create mode 100644 submodules/TelegramUI/Sources/EventsController.swift diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index ac85742469..826b353135 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -28,6 +28,7 @@ public enum AttachmentButtonType: Equatable { case quickReply case contact case poll + case event case app(AttachMenuBot) case gift case sticker @@ -51,6 +52,8 @@ public enum AttachmentButtonType: Equatable { return "contact" case .poll: return "poll" + case .event: + return "event" case let .app(bot): return "app_\(bot.shortName)" case .gift: @@ -110,6 +113,12 @@ public enum AttachmentButtonType: Equatable { } else { return false } + case .event: + if case .event = rhs { + return true + } else { + return false + } case let .app(lhsBot): if case let .app(rhsBot) = rhs, lhsBot.peer.id == rhsBot.peer.id { return true diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 7c4e1b3f04..f7fbc67456 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -232,6 +232,9 @@ private final class AttachButtonComponent: CombinedComponent { case .poll: name = strings.Attachment_Poll imageName = "Chat/Attach Menu/Poll" + case .event: + name = "Событие" + imageName = "Chat/Attach Menu/Event" case .gift: name = strings.Attachment_Gift imageName = "Chat/Attach Menu/Gift" @@ -2163,6 +2166,8 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate, ASGestureRecog accessibilityTitle = self.presentationData.strings.Attachment_Contact case .poll: accessibilityTitle = self.presentationData.strings.Attachment_Poll + case .event: + accessibilityTitle = "Событие" case .gift: accessibilityTitle = self.presentationData.strings.Attachment_Gift case .sticker: diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/Contents.json new file mode 100644 index 0000000000..708215a4a3 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "calendar_24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/calendar_24.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/calendar_24.pdf new file mode 100644 index 0000000000..3e6eea1d6e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/calendar_24.pdf @@ -0,0 +1,156 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.834991 3.835022 cm +0.000000 0.000000 0.000000 scn +4.665000 16.330017 m +5.032270 16.330017 5.330000 16.032286 5.330000 15.665017 c +5.330000 15.330014 l +5.436179 15.330017 l +5.465001 15.330017 l +10.865002 15.330017 l +10.893824 15.330017 l +10.929527 15.330017 10.964918 15.330018 11.000000 15.330014 c +11.000000 15.665017 l +11.000000 16.032286 11.297730 16.330017 11.665000 16.330017 c +12.032269 16.330017 12.330000 16.032286 12.330000 15.665017 c +12.330000 15.316912 l +12.530820 15.310531 12.716895 15.301043 12.889549 15.286936 c +13.430929 15.242703 13.898640 15.149774 14.328878 14.930556 c +15.018492 14.579180 15.579165 14.018507 15.930542 13.328892 c +16.149759 12.898653 16.242689 12.430944 16.286922 11.889564 c +16.330013 11.362141 16.330009 10.709471 16.330004 9.893824 c +16.330004 9.865017 l +16.330004 5.465019 l +16.330004 5.436213 l +16.330009 4.620564 16.330013 3.967896 16.286922 3.440473 c +16.242689 2.899092 16.149759 2.431382 15.930542 2.001143 c +15.579165 1.311530 15.018492 0.750856 14.328878 0.399480 c +13.898640 0.180264 13.430929 0.087334 12.889549 0.043100 c +12.362140 0.000010 11.709491 0.000013 10.893873 0.000019 c +10.893807 0.000019 l +10.865002 0.000019 l +5.465001 0.000019 l +5.436195 0.000019 l +5.436131 0.000019 l +4.620513 0.000013 3.967864 0.000010 3.440454 0.043100 c +2.899074 0.087334 2.431364 0.180264 2.001125 0.399480 c +1.311511 0.750856 0.750837 1.311530 0.399462 2.001143 c +0.180244 2.431382 0.087314 2.899092 0.043082 3.440473 c +-0.000010 3.967892 -0.000006 4.620557 0.000000 5.436197 c +0.000000 5.465019 l +0.000000 9.865017 l +0.000000 9.893839 l +0.000000 9.893844 l +-0.000006 10.709482 -0.000010 11.362145 0.043082 11.889564 c +0.087314 12.430944 0.180244 12.898653 0.399462 13.328892 c +0.750837 14.018507 1.311511 14.579180 2.001125 14.930556 c +2.431364 15.149774 2.899074 15.242703 3.440454 15.286936 c +3.613107 15.301043 3.799181 15.310531 4.000000 15.316912 c +4.000000 15.665017 l +4.000000 16.032286 4.297731 16.330017 4.665000 16.330017 c +h +11.000000 14.000007 m +11.000000 13.665017 l +11.000000 13.297748 11.297730 13.000017 11.665000 13.000017 c +12.032269 13.000017 12.330000 13.297748 12.330000 13.665017 c +12.330000 13.986263 l +12.493963 13.980605 12.643336 13.972620 12.781244 13.961353 c +13.240376 13.923841 13.513575 13.853280 13.725071 13.745518 c +14.164429 13.521653 14.521639 13.164444 14.745503 12.725085 c +14.853266 12.513588 14.923826 12.240391 14.961339 11.781260 c +14.999486 11.314363 15.000003 10.716069 15.000003 9.865017 c +15.000003 5.465019 l +15.000003 4.613967 14.999486 4.015673 14.961339 3.548777 c +14.923826 3.089645 14.853266 2.816447 14.745503 2.604951 c +14.521639 2.165593 14.164429 1.808383 13.725071 1.584518 c +13.513575 1.476756 13.240376 1.406196 12.781244 1.368683 c +12.314349 1.330536 11.716054 1.330019 10.865002 1.330019 c +5.465001 1.330019 l +4.613949 1.330019 4.015654 1.330536 3.548759 1.368683 c +3.089627 1.406196 2.816429 1.476756 2.604933 1.584518 c +2.165574 1.808383 1.808365 2.165593 1.584500 2.604951 c +1.476737 2.816447 1.406177 3.089645 1.368665 3.548777 c +1.330518 4.015673 1.330001 4.613967 1.330001 5.465019 c +1.330001 9.865017 l +1.330001 10.716069 1.330518 11.314363 1.368665 11.781260 c +1.406177 12.240391 1.476737 12.513588 1.584500 12.725085 c +1.808365 13.164444 2.165574 13.521653 2.604933 13.745518 c +2.816429 13.853280 3.089627 13.923841 3.548759 13.961353 c +3.686666 13.972620 3.836038 13.980605 4.000000 13.986263 c +4.000000 13.665017 l +4.000000 13.297748 4.297731 13.000017 4.665000 13.000017 c +5.032270 13.000017 5.330000 13.297748 5.330000 13.665017 c +5.330000 14.000007 l +5.465001 14.000017 l +10.865002 14.000017 l +10.910725 14.000017 10.955717 14.000015 11.000000 14.000007 c +h +3.365005 11.230013 m +2.997736 11.230013 2.700005 10.932281 2.700005 10.565012 c +2.700005 10.197743 2.997736 9.900013 3.365005 9.900013 c +12.965006 9.900013 l +13.332275 9.900013 13.630005 10.197743 13.630005 10.565012 c +13.630005 10.932281 13.332275 11.230013 12.965006 11.230013 c +3.365005 11.230013 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 4054 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000004144 00000 n +0000004167 00000 n +0000004340 00000 n +0000004414 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +4473 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index f48dc84ed4..9d72ddbb0a 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -7259,7 +7259,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + + self.setupEventFloatingButtonIfNeeded() + if self.willAppear { self.chatDisplayNode.historyNode.refreshPollActionsForVisibleMessages() } else { diff --git a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift new file mode 100644 index 0000000000..ec88757e83 --- /dev/null +++ b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift @@ -0,0 +1,191 @@ +import Foundation +import UIKit +import TelegramCore +import AccountContext + +// MARK: - Floating button view + +final class EventFloatingButton: UIView { + private let iconLabel = UILabel() + private let badge = UIView() + private var onTap: (() -> Void)? + private var lastTouchWasDrag = false + + init(onTap: @escaping () -> Void) { + self.onTap = onTap + super.init(frame: CGRect(x: 0, y: 0, width: 52, height: 52)) + setupUI() + } + required init?(coder: NSCoder) { fatalError() } + + private func setupUI() { + layer.cornerRadius = 26 + backgroundColor = UIColor.systemOrange.withAlphaComponent(0.88) + alpha = 0.65 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOffset = CGSize(width: 0, height: 3) + layer.shadowOpacity = 0.25 + layer.shadowRadius = 6 + + iconLabel.text = "📅" + iconLabel.font = .systemFont(ofSize: 22) + iconLabel.textAlignment = .center + iconLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(iconLabel) + NSLayoutConstraint.activate([ + iconLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + iconLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.minimumNumberOfTouches = 1 + addGestureRecognizer(pan) + } + + func updateEventCount(_ count: Int) { + badge.subviews.forEach { $0.removeFromSuperview() } + badge.removeFromSuperview() + guard count > 0 else { return } + + let label = UILabel() + label.text = "\(count)" + label.font = .systemFont(ofSize: 10, weight: .bold) + label.textColor = .white + label.sizeToFit() + + let size: CGFloat = max(16, label.frame.width + 8) + badge.frame = CGRect(x: frame.width - size / 2 - 2, y: -4, width: size, height: 16) + badge.backgroundColor = .systemRed + badge.layer.cornerRadius = 8 + label.center = CGPoint(x: size / 2, y: 8) + badge.addSubview(label) + addSubview(badge) + } + + @objc private func handleTap() { + guard !lastTouchWasDrag else { return } + UIView.animate(withDuration: 0.1, animations: { + self.transform = CGAffineTransform(scaleX: 0.92, y: 0.92) + }) { _ in + UIView.animate(withDuration: 0.15) { self.transform = .identity } + } + onTap?() + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let superview = superview else { return } + + switch gesture.state { + case .began: + lastTouchWasDrag = false + UIView.animate(withDuration: 0.15) { self.alpha = 0.85 } + case .changed: + let t = gesture.translation(in: superview) + if abs(t.x) > 4 || abs(t.y) > 4 { lastTouchWasDrag = true } + center = CGPoint(x: center.x + t.x, y: center.y + t.y) + gesture.setTranslation(.zero, in: superview) + clampToSuperview(superview) + case .ended, .cancelled: + snapToEdge(superview) + UIView.animate(withDuration: 0.2) { self.alpha = 0.65 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { self.lastTouchWasDrag = false } + default: + break + } + } + + private func clampToSuperview(_ sv: UIView) { + let pad: CGFloat = 8 + let halfW = bounds.width / 2 + let halfH = bounds.height / 2 + let safeTop = (sv as? UIWindow)?.safeAreaInsets.top ?? 44 + let safeBottom = (sv as? UIWindow)?.safeAreaInsets.bottom ?? 34 + center = CGPoint( + x: max(halfW + pad, min(sv.bounds.width - halfW - pad, center.x)), + y: max(halfH + safeTop + 8, min(sv.bounds.height - halfH - safeBottom - 80, center.y)) + ) + } + + private func snapToEdge(_ sv: UIView) { + let pad: CGFloat = 16 + let targetX: CGFloat = center.x < sv.bounds.midX + ? bounds.width / 2 + pad + : sv.bounds.width - bounds.width / 2 - pad + UIView.animate(withDuration: 0.35, delay: 0, + usingSpringWithDamping: 0.75, initialSpringVelocity: 0.5, options: []) { + self.center.x = targetX + } + } +} + +// MARK: - ChatControllerImpl extension + +extension ChatControllerImpl { + + private static let floatingEventButtonTag = 77421 + + func setupEventFloatingButtonIfNeeded() { + let peer = presentationInterfaceState.renderedPeer?.peer + let isGroup: Bool + if peer is TelegramGroup { + isGroup = true + } else if let ch = peer as? TelegramChannel, case .group = ch.info { + isGroup = true + } else { + isGroup = false + } + + if !isGroup { + view.viewWithTag(Self.floatingEventButtonTag)?.removeFromSuperview() + return + } + + // Already installed. + if view.viewWithTag(Self.floatingEventButtonTag) != nil { return } + + guard let peerId = chatLocation.peerId else { return } + let chatId = peerId.id._internalGetInt64Value() + + let button = EventFloatingButton { [weak self] in + guard let self else { return } + let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId)) + nav.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + if let sheet = nav.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + } + self.present(nav, animated: true) + } + button.tag = Self.floatingEventButtonTag + + // Initial position: right edge, vertically centered. + let bw: CGFloat = 52 + button.frame = CGRect( + x: view.bounds.width - bw - 16, + y: view.bounds.height / 2, + width: bw, height: bw + ) + view.addSubview(button) + + refreshEventFloatingButtonBadge(chatId: chatId) + } + + func refreshEventFloatingButtonBadge(chatId: Int64) { + guard let button = view.viewWithTag(Self.floatingEventButtonTag) as? EventFloatingButton else { return } + let key = "tg_events_v1" + let stored = (try? JSONDecoder().decode([TGEvent].self, + from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? [] + let votes = (try? JSONDecoder().decode([String: String].self, + from: UserDefaults.standard.data(forKey: "tg_event_votes_v1") ?? Data())) ?? [:] + let unvotedCount = stored + .filter { $0.chatId == chatId } + .filter { votes[$0.id.uuidString] == nil } + .count + button.updateEventCount(unvotedCount) + } +} diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index 50b7728237..9af5ddcae8 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -132,6 +132,8 @@ extension ChatControllerImpl { availableButtons.insert(.todo, at: max(0, availableButtons.count - 1)) } + availableButtons.append(.event) + if "".isEmpty { availableButtons.insert(.audio, at: max(0, availableButtons.count - 1)) } @@ -747,6 +749,50 @@ extension ChatControllerImpl { strongSelf.push(demoController) return false } + case .event: + strongSelf.controllerNavigationDisposable.set(nil) + let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer + + let isGroupChat: Bool + if peer is TelegramGroup { + isGroupChat = true + } else if let channel = peer as? TelegramChannel, case .group = channel.info { + isGroupChat = true + } else { + isGroupChat = false + } + + // For groups don't pre-fill — participants decide via the card. + // For DMs pre-fill the other person's name. + let initialParticipants: [String] + if !isGroupChat, let peer = peer { + let pd = strongSelf.presentationData + let name = EnginePeer(peer).displayTitle(strings: pd.strings, displayOrder: pd.nameDisplayOrder) + initialParticipants = name.isEmpty ? [] : [name] + } else { + initialParticipants = [] + } + + let eventController = CreateEventController(context: context, initialParticipants: initialParticipants) + if isGroupChat { + eventController.onSave = { [weak strongSelf] event in + strongSelf?.sendEventToGroup(event: event) + } + } + + attachmentController?.dismiss(animated: true, completion: { [weak strongSelf] in + guard let strongSelf = strongSelf else { return } + let nav = UINavigationController(rootViewController: eventController) + nav.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + if let sheet = nav.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + } + strongSelf.present(nav, animated: true) + }) + return true case .gift: if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer, let starsContext = context.starsContext { let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions @@ -2267,4 +2313,45 @@ extension ChatControllerImpl { controller.navigationPresentation = .modal self.push(controller) } + + func sendEventToGroup(event: TGEvent) { + guard let peerId = chatLocation.peerId else { return } + let chatId = peerId.id._internalGetInt64Value() + + let dateFmt = DateFormatter() + dateFmt.locale = Locale(identifier: "ru_RU") + dateFmt.dateFormat = "EEEE, d MMMM" + let timeFmt = DateFormatter() + timeFmt.locale = Locale(identifier: "ru_RU") + timeFmt.dateFormat = "HH:mm" + + var dateStr = dateFmt.string(from: event.startDate) + if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() } + + var text = "📅 \(event.title)\n" + text += "🕒 \(dateStr) · \(timeFmt.string(from: event.startDate))–\(timeFmt.string(from: event.endDate))" + if let loc = event.location, !loc.isEmpty { text += "\n📍 \(loc)" } + + let message: EnqueueMessage = .message( + text: text, attributes: [], inlineStickers: [:], mediaReference: nil, + threadId: chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, + localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [] + ) + sendMessages([message]) + + // Persist event with the group chat identifier so the floating button can find it. + let eventWithChat = TGEvent( + id: event.id, title: event.title, + startDate: event.startDate, endDate: event.endDate, + participants: event.participants, location: event.location, + chatId: chatId + ) + let key = "tg_events_v1" + var stored = (try? JSONDecoder().decode([TGEvent].self, + from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? [] + stored = stored.map { $0.id == event.id ? eventWithChat : $0 } + if let data = try? JSONEncoder().encode(stored) { + UserDefaults.standard.set(data, forKey: key) + } + } } diff --git a/submodules/TelegramUI/Sources/CreateEventController.swift b/submodules/TelegramUI/Sources/CreateEventController.swift new file mode 100644 index 0000000000..6f1c5eb3f7 --- /dev/null +++ b/submodules/TelegramUI/Sources/CreateEventController.swift @@ -0,0 +1,437 @@ +import Foundation +import UIKit +import Display +import AccountContext +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData + +// MARK: - Delegate + +protocol CreateEventControllerDelegate: AnyObject { + func createEventController(_ controller: CreateEventController, didCreate event: TGEvent) + func createEventController(_ controller: CreateEventController, didUpdate event: TGEvent) +} + +// MARK: - Text input cell + +private final class TextInputCell: UITableViewCell { + let textField = UITextField() + var onTextChange: ((String) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + textField.clearButtonMode = .whileEditing + textField.returnKeyType = .next + textField.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textField) + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + textField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + textField.topAnchor.constraint(equalTo: contentView.topAnchor), + textField.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) + } + + required init?(coder: NSCoder) { fatalError() } + + @objc private func textChanged() { onTextChange?(textField.text ?? "") } +} + +// MARK: - Date picker cell + +private final class DatePickerCell: UITableViewCell { + let titleLabel = UILabel() + let picker = UIDatePicker() + var onValueChange: ((Date) -> Void)? + + init(title: String, mode: UIDatePicker.Mode, date: Date) { + super.init(style: .default, reuseIdentifier: nil) + selectionStyle = .none + + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + picker.datePickerMode = mode + if #available(iOS 13.4, *) { + picker.preferredDatePickerStyle = .compact + } + picker.tintColor = .systemOrange + picker.date = date + if mode == .time { picker.minuteInterval = 15 } + picker.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(picker) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + picker.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + picker.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + picker.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8), + ]) + + picker.addTarget(self, action: #selector(changed), for: .valueChanged) + } + + required init?(coder: NSCoder) { fatalError() } + + @objc private func changed() { onValueChange?(picker.date) } +} + +// MARK: - Controller + +final class CreateEventController: UIViewController { + weak var delegate: CreateEventControllerDelegate? + var onSave: ((TGEvent) -> Void)? + private let context: AccountContext + private let editingEvent: TGEvent? + private var pickerDisposable: Disposable? + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + + // Form state + private var eventTitle = "" + private var eventDate = Calendar.current.startOfDay(for: Date()) + private var startTime: Date = { + var c = Calendar.current.dateComponents([.year,.month,.day], from: Date()) + c.hour = (Calendar.current.component(.hour, from: Date()) + 1) % 24 + c.minute = 0; c.second = 0 + return Calendar.current.date(from: c) ?? Date() + }() + private var endTime: Date = { + var c = Calendar.current.dateComponents([.year,.month,.day], from: Date()) + c.hour = (Calendar.current.component(.hour, from: Date()) + 2) % 24 + c.minute = 0; c.second = 0 + return Calendar.current.date(from: c) ?? Date() + }() + private var participants: [String] = [] + private var eventLocation = "" + + private lazy var dateCell = DatePickerCell(title: "Дата", mode: .date, date: eventDate) + private lazy var startCell = DatePickerCell(title: "Начало", mode: .time, date: startTime) + private lazy var endCell = DatePickerCell(title: "Конец", mode: .time, date: endTime) + + init(context: AccountContext, editingEvent: TGEvent? = nil, initialParticipants: [String] = []) { + self.context = context + self.editingEvent = editingEvent + if let e = editingEvent { + self.eventTitle = e.title + self.eventDate = Calendar.current.startOfDay(for: e.startDate) + self.startTime = e.startDate + self.endTime = e.endDate + self.participants = e.participants + self.eventLocation = e.location ?? "" + } else { + self.participants = initialParticipants + } + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + deinit { pickerDisposable?.dispose() } + + // MARK: Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + title = editingEvent == nil ? "Новая встреча" : "Редактировать" + view.backgroundColor = .systemGroupedBackground + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Отмена", style: .plain, + target: self, action: #selector(cancelTapped)) + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Готово", style: .done, + target: self, action: #selector(saveTapped)) + navigationItem.leftBarButtonItem?.tintColor = .systemOrange + navigationItem.rightBarButtonItem?.tintColor = .systemOrange + + dateCell.onValueChange = { [weak self] d in self?.eventDate = d } + startCell.onValueChange = { [weak self] d in self?.startTime = d } + endCell.onValueChange = { [weak self] d in self?.endTime = d } + + tableView.dataSource = self + tableView.delegate = self + tableView.register(TextInputCell.self, forCellReuseIdentifier: "text") + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "basic") + tableView.frame = view.bounds + tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(tableView) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 0)) as? TextInputCell { + cell.textField.becomeFirstResponder() + } + } + + // MARK: Actions + + @objc private func cancelTapped() { dismiss(animated: true) } + + @objc private func saveTapped() { + let title = eventTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + let a = UIAlertController(title: "Введите название", message: nil, preferredStyle: .alert) + a.addAction(UIAlertAction(title: "OK", style: .default)) + present(a, animated: true); return + } + let start = combined(date: eventDate, time: startTime) + var end = combined(date: eventDate, time: endTime) + if end <= start { end = Calendar.current.date(byAdding: .hour, value: 1, to: start) ?? end } + + let event = TGEvent(id: editingEvent?.id ?? UUID(), title: title, startDate: start, endDate: end, + participants: participants, + location: eventLocation.isEmpty ? nil : eventLocation) + + // Always persist directly so events created from any context (chat, events tab) are saved + let key = "tg_events_v1" + var stored = (try? JSONDecoder().decode([TGEvent].self, from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? [] + if editingEvent != nil { + stored = stored.map { $0.id == event.id ? event : $0 } + } else { + stored.append(event) + } + if let data = try? JSONEncoder().encode(stored) { + UserDefaults.standard.set(data, forKey: key) + } + + onSave?(event) + if editingEvent != nil { + delegate?.createEventController(self, didUpdate: event) + } else { + delegate?.createEventController(self, didCreate: event) + } + dismiss(animated: true) + } + + @objc private func addParticipantTapped() { + let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Выбрать из контактов", style: .default) { [weak self] _ in + self?.openContactPicker() + }) + sheet.addAction(UIAlertAction(title: "Добавить вручную", style: .default) { [weak self] _ in + self?.addManually() + }) + sheet.addAction(UIAlertAction(title: "Отмена", style: .cancel)) + present(sheet, animated: true) + } + + private func openContactPicker() { + let params = ContactSelectionControllerParams( + context: context, + title: { _ in "Участники" }, + multipleSelection: .always + ) + let picker = context.sharedContext.makeContactSelectionController(params) + + pickerDisposable?.dispose() + pickerDisposable = (picker.result + |> take(1) + |> deliverOnMainQueue).startStrict(next: { [weak self] result in + guard let self else { return } + if let (peers, _, _, _, _, _) = result { + let pd = self.context.sharedContext.currentPresentationData.with { $0 } + for listPeer in peers { + let name: String + switch listPeer { + case let .peer(enginePeer, _, _): + name = enginePeer.displayTitle(strings: pd.strings, displayOrder: pd.nameDisplayOrder) + case let .deviceContact(_, contact): + name = [contact.firstName, contact.lastName] + .filter { !$0.isEmpty }.joined(separator: " ") + } + if !name.isEmpty && !self.participants.contains(name) { + self.participants.append(name) + } + } + self.tableView.reloadSections(IndexSet(integer: 2), with: .automatic) + } + self.navigationController?.popViewController(animated: true) + }) + navigationController?.pushViewController(picker, animated: true) + } + + private func addManually() { + let alert = UIAlertController(title: "Добавить участника", message: nil, preferredStyle: .alert) + alert.addTextField { tf in + tf.placeholder = "Имя или @username" + tf.autocapitalizationType = .words + tf.autocorrectionType = .no + } + alert.addAction(UIAlertAction(title: "Добавить", style: .default) { [weak self, weak alert] _ in + guard let self, + let text = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces), + !text.isEmpty else { return } + if text.hasPrefix("@") { + self.resolveAndAdd(username: String(text.dropFirst())) + } else { + self.appendParticipant(text) + } + }) + alert.addAction(UIAlertAction(title: "Отмена", style: .cancel)) + present(alert, animated: true) + } + + private func resolveAndAdd(username: String) { + let loading = UIAlertController(title: nil, message: "Поиск @\(username)…", preferredStyle: .alert) + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + indicator.translatesAutoresizingMaskIntoConstraints = false + loading.view.addSubview(indicator) + NSLayoutConstraint.activate([ + indicator.centerXAnchor.constraint(equalTo: loading.view.centerXAnchor), + indicator.bottomAnchor.constraint(equalTo: loading.view.bottomAnchor, constant: -16) + ]) + present(loading, animated: true) + + pickerDisposable?.dispose() + pickerDisposable = (context.engine.peers.resolvePeerByName(name: username, referrer: nil) + |> filter { if case .progress = $0 { return false }; return true } + |> take(1) + |> timeout(10, queue: Queue.mainQueue(), alternate: .single(.result(nil))) + |> deliverOnMainQueue).startStandalone(next: { [weak self, weak loading] result in + guard let self else { return } + loading?.dismiss(animated: true) { + if case let .result(peer) = result, let peer = peer { + let pd = self.context.sharedContext.currentPresentationData.with { $0 } + let name = peer.displayTitle(strings: pd.strings, displayOrder: pd.nameDisplayOrder) + self.appendParticipant(name) + } else { + let err = UIAlertController(title: "Не найдено", + message: "@\(username) не существует или недоступен", + preferredStyle: .alert) + err.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(err, animated: true) + } + } + }) + } + + private func appendParticipant(_ name: String) { + guard !participants.contains(name) else { return } + participants.append(name) + tableView.insertRows(at: [IndexPath(row: participants.count - 1, section: 2)], with: .automatic) + } + + @objc private func removeParticipant(_ sender: UIButton) { + let idx = sender.tag + guard idx < participants.count else { return } + participants.remove(at: idx) + tableView.deleteRows(at: [IndexPath(row: idx, section: 2)], with: .automatic) + tableView.reloadSections(IndexSet(integer: 2), with: .none) + } + + private func combined(date: Date, time: Date) -> Date { + let cal = Calendar.current + var dc = cal.dateComponents([.year,.month,.day], from: date) + let tc = cal.dateComponents([.hour,.minute], from: time) + dc.hour = tc.hour; dc.minute = tc.minute; dc.second = 0 + return cal.date(from: dc) ?? date + } +} + +// MARK: - UITableViewDataSource + +extension CreateEventController: UITableViewDataSource { + func numberOfSections(in tv: UITableView) -> Int { 4 } + + func tableView(_ tv: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: return 1 + case 1: return 3 + case 2: return participants.count + 1 + case 3: return 1 + default: return 0 + } + } + + func tableView(_ tv: UITableView, titleForHeaderInSection section: Int) -> String? { + ["Название", "Дата и время", "Участники", "Место"][section] + } + + func tableView(_ tv: UITableView, cellForRowAt ip: IndexPath) -> UITableViewCell { + switch ip.section { + + case 0: + let cell = tv.dequeueReusableCell(withIdentifier: "text", for: ip) as! TextInputCell + cell.textField.placeholder = "Название встречи" + cell.textField.font = .systemFont(ofSize: 16, weight: .medium) + cell.textField.text = eventTitle + cell.onTextChange = { [weak self] t in self?.eventTitle = t } + return cell + + case 1: + return [dateCell, startCell, endCell][ip.row] + + case 2: + if ip.row < participants.count { + let cell = tv.dequeueReusableCell(withIdentifier: "basic", for: ip) + cell.selectionStyle = .none + cell.textLabel?.text = participants[ip.row] + cell.textLabel?.textColor = .label + cell.textLabel?.font = .systemFont(ofSize: 16) + cell.imageView?.image = nil + let btn = UIButton(type: .system) + btn.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + btn.tintColor = UIColor.systemRed.withAlphaComponent(0.7) + btn.frame = CGRect(x: 0, y: 0, width: 28, height: 28) + btn.tag = ip.row + btn.addTarget(self, action: #selector(removeParticipant(_:)), for: .touchUpInside) + cell.accessoryView = btn + return cell + } else { + let cell = tv.dequeueReusableCell(withIdentifier: "basic", for: ip) + cell.selectionStyle = .default + cell.textLabel?.text = "Добавить участника" + cell.textLabel?.textColor = .systemOrange + cell.textLabel?.font = .systemFont(ofSize: 16) + cell.imageView?.image = UIImage(systemName: "person.badge.plus") + cell.imageView?.tintColor = .systemOrange + cell.accessoryView = nil + cell.accessoryType = .none + return cell + } + + case 3: + let cell = tv.dequeueReusableCell(withIdentifier: "text", for: ip) as! TextInputCell + cell.textField.placeholder = "Место (опционально)" + cell.textField.font = .systemFont(ofSize: 16) + cell.textField.text = eventLocation + cell.onTextChange = { [weak self] t in self?.eventLocation = t } + return cell + + default: + return UITableViewCell() + } + } +} + +// MARK: - UITableViewDelegate + +extension CreateEventController: UITableViewDelegate { + func tableView(_ tv: UITableView, heightForRowAt ip: IndexPath) -> CGFloat { 52 } + + func tableView(_ tv: UITableView, didSelectRowAt ip: IndexPath) { + tv.deselectRow(at: ip, animated: true) + if ip.section == 2, ip.row == participants.count { + addParticipantTapped() + } + } + + func tableView(_ tv: UITableView, canEditRowAt ip: IndexPath) -> Bool { + ip.section == 2 && ip.row < participants.count + } + + func tableView(_ tv: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt ip: IndexPath) { + if editingStyle == .delete { + participants.remove(at: ip.row) + tv.deleteRows(at: [ip], with: .automatic) + } + } +} diff --git a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift new file mode 100644 index 0000000000..79d73e0066 --- /dev/null +++ b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift @@ -0,0 +1,363 @@ +import Foundation +import UIKit +import AccountContext + +// MARK: - Vote storage helpers + +private let votesKey = "tg_event_votes_v1" + +private func loadVotes() -> [String: String] { + guard let data = UserDefaults.standard.data(forKey: votesKey), + let dict = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] } + return dict +} + +private func saveVotes(_ dict: [String: String]) { + if let data = try? JSONEncoder().encode(dict) { + UserDefaults.standard.set(data, forKey: votesKey) + } +} + +private func loadStoredEvents() -> [TGEvent] { + guard let data = UserDefaults.standard.data(forKey: "tg_events_v1"), + let events = try? JSONDecoder().decode([TGEvent].self, from: data) else { return [] } + return events +} + +private func saveStoredEvents(_ events: [TGEvent]) { + if let data = try? JSONEncoder().encode(events) { + UserDefaults.standard.set(data, forKey: "tg_events_v1") + } +} + +// MARK: - Card view + +private final class EventCardView: UIView { + private let titleLabel = UILabel() + private let dateLabel = UILabel() + private let timeLabel = UILabel() + private let locationLabel = UILabel() + private let divider = UIView() + private let yesButton = UIButton(type: .system) + private let noButton = UIButton(type: .system) + private let statusLabel = UILabel() + + var onYes: (() -> Void)? + var onNo: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + required init?(coder: NSCoder) { fatalError() } + + private func setupUI() { + backgroundColor = .secondarySystemGroupedBackground + layer.cornerRadius = 20 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOffset = CGSize(width: 0, height: 4) + layer.shadowOpacity = 0.12 + layer.shadowRadius = 12 + + titleLabel.font = .systemFont(ofSize: 22, weight: .bold) + titleLabel.numberOfLines = 2 + titleLabel.textColor = .label + + dateLabel.font = .systemFont(ofSize: 15, weight: .medium) + dateLabel.textColor = .secondaryLabel + + timeLabel.font = .systemFont(ofSize: 17, weight: .semibold) + timeLabel.textColor = .label + + locationLabel.font = .systemFont(ofSize: 15) + locationLabel.textColor = .secondaryLabel + locationLabel.numberOfLines = 1 + + divider.backgroundColor = .separator + + statusLabel.font = .systemFont(ofSize: 14) + statusLabel.textColor = .secondaryLabel + statusLabel.textAlignment = .center + + for btn in [yesButton, noButton] { + btn.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + btn.layer.cornerRadius = 14 + } + yesButton.setTitle("✅ Иду", for: .normal) + yesButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.15) + yesButton.setTitleColor(.systemGreen, for: .normal) + yesButton.addTarget(self, action: #selector(yesTapped), for: .touchUpInside) + + noButton.setTitle("❌ Не иду", for: .normal) + noButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.1) + noButton.setTitleColor(.systemRed, for: .normal) + noButton.addTarget(self, action: #selector(noTapped), for: .touchUpInside) + + for v in [titleLabel, dateLabel, timeLabel, locationLabel, divider, yesButton, noButton, statusLabel] as [UIView] { + v.translatesAutoresizingMaskIntoConstraints = false + addSubview(v) + } + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 28), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), + + dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20), + dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + + timeLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 4), + timeLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + + locationLabel.topAnchor.constraint(equalTo: timeLabel.bottomAnchor, constant: 8), + locationLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + locationLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), + + divider.topAnchor.constraint(equalTo: locationLabel.bottomAnchor, constant: 24), + divider.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + divider.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), + divider.heightAnchor.constraint(equalToConstant: 0.5), + + yesButton.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 24), + yesButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + yesButton.trailingAnchor.constraint(equalTo: centerXAnchor, constant: -6), + yesButton.heightAnchor.constraint(equalToConstant: 52), + + noButton.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 24), + noButton.leadingAnchor.constraint(equalTo: centerXAnchor, constant: 6), + noButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), + noButton.heightAnchor.constraint(equalToConstant: 52), + + statusLabel.topAnchor.constraint(equalTo: yesButton.bottomAnchor, constant: 16), + statusLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + statusLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), + statusLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -24), + ]) + } + + func configure(event: TGEvent, vote: String?) { + let dateFmt = DateFormatter() + dateFmt.locale = Locale(identifier: "ru_RU") + dateFmt.dateFormat = "EEEE, d MMMM" + let timeFmt = DateFormatter() + timeFmt.locale = Locale(identifier: "ru_RU") + timeFmt.dateFormat = "HH:mm" + + var dateStr = dateFmt.string(from: event.startDate) + if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() } + + titleLabel.text = event.title + dateLabel.text = "📅 \(dateStr)" + timeLabel.text = "⏰ \(timeFmt.string(from: event.startDate)) – \(timeFmt.string(from: event.endDate))" + + if let loc = event.location, !loc.isEmpty { + locationLabel.text = "📍 \(loc)" + locationLabel.isHidden = false + } else { + locationLabel.isHidden = true + } + + applyVoteState(vote) + } + + private func applyVoteState(_ vote: String?) { + switch vote { + case "yes": + yesButton.backgroundColor = .systemGreen + yesButton.setTitleColor(.white, for: .normal) + noButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.1) + noButton.setTitleColor(.systemRed, for: .normal) + statusLabel.text = "Вы идёте на встречу ✓" + statusLabel.textColor = .systemGreen + case "no": + noButton.backgroundColor = .systemRed + noButton.setTitleColor(.white, for: .normal) + yesButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.15) + yesButton.setTitleColor(.systemGreen, for: .normal) + statusLabel.text = "Вы отказались от встречи" + statusLabel.textColor = .systemRed + default: + yesButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.15) + yesButton.setTitleColor(.systemGreen, for: .normal) + noButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.1) + noButton.setTitleColor(.systemRed, for: .normal) + statusLabel.text = "Вы ещё не ответили" + statusLabel.textColor = .secondaryLabel + } + } + + @objc private func yesTapped() { onYes?() } + @objc private func noTapped() { onNo?() } +} + +// MARK: - Navigator controller + +public final class EventCardNavigatorController: UIViewController { + private let chatId: Int64 + private var events: [TGEvent] = [] + private var currentIndex: Int = 0 + + private let cardView = EventCardView() + private let counterLabel = UILabel() + private let prevButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + private let emptyLabel = UILabel() + + public init(chatId: Int64) { + self.chatId = chatId + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError() } + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGroupedBackground + title = "События" + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, target: self, action: #selector(closeTapped)) + + setupLayout() + reload() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reload() + } + + private func setupLayout() { + counterLabel.font = .systemFont(ofSize: 14, weight: .medium) + counterLabel.textColor = .secondaryLabel + counterLabel.textAlignment = .center + counterLabel.translatesAutoresizingMaskIntoConstraints = false + + cardView.translatesAutoresizingMaskIntoConstraints = false + + prevButton.setTitle("◀ Предыдущее", for: .normal) + prevButton.titleLabel?.font = .systemFont(ofSize: 15) + prevButton.addTarget(self, action: #selector(prevTapped), for: .touchUpInside) + prevButton.translatesAutoresizingMaskIntoConstraints = false + + nextButton.setTitle("Следующее ▶", for: .normal) + nextButton.titleLabel?.font = .systemFont(ofSize: 15) + nextButton.addTarget(self, action: #selector(nextTapped), for: .touchUpInside) + nextButton.translatesAutoresizingMaskIntoConstraints = false + + emptyLabel.text = "Нет событий в этом чате" + emptyLabel.font = .systemFont(ofSize: 17) + emptyLabel.textColor = .secondaryLabel + emptyLabel.textAlignment = .center + emptyLabel.translatesAutoresizingMaskIntoConstraints = false + + for v in [counterLabel, cardView, prevButton, nextButton, emptyLabel] as [UIView] { + view.addSubview(v) + } + + NSLayoutConstraint.activate([ + counterLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + counterLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + cardView.topAnchor.constraint(equalTo: counterLabel.bottomAnchor, constant: 16), + cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + cardView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + + prevButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24), + prevButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + + nextButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24), + nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + + emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + private func reload() { + let all = loadStoredEvents() + // Show events for this group, newest first. + events = all.filter { $0.chatId == chatId } + .sorted { $0.startDate > $1.startDate } + currentIndex = 0 + updateUI() + } + + private func updateUI() { + let hasEvents = !events.isEmpty + cardView.isHidden = !hasEvents + counterLabel.isHidden = !hasEvents + prevButton.isHidden = !hasEvents + nextButton.isHidden = !hasEvents + emptyLabel.isHidden = hasEvents + + guard hasEvents else { return } + + let event = events[currentIndex] + let votes = loadVotes() + cardView.configure(event: event, vote: votes[event.id.uuidString]) + counterLabel.text = "\(currentIndex + 1) / \(events.count)" + prevButton.isEnabled = currentIndex < events.count - 1 + nextButton.isEnabled = currentIndex > 0 + + cardView.onYes = { [weak self] in self?.vote("yes", for: event) } + cardView.onNo = { [weak self] in self?.vote("no", for: event) } + } + + private func vote(_ answer: String, for event: TGEvent) { + var votes = loadVotes() + let key = event.id.uuidString + let prev = votes[key] + votes[key] = (prev == answer) ? nil : answer // toggle off if tapping same + saveVotes(votes) + + // "Yes" → add event to personal calendar (if not already present). + if answer == "yes", prev != "yes" { + addEventToPersonalCalendar(event) + } + + updateUI() + } + + private func addEventToPersonalCalendar(_ event: TGEvent) { + var stored = loadStoredEvents() + // Only add if not already there as a personal (chatId-less) copy. + let alreadyExists = stored.contains { $0.id == event.id && $0.chatId == nil } + guard !alreadyExists else { return } + let personal = TGEvent( + id: event.id, title: event.title, + startDate: event.startDate, endDate: event.endDate, + participants: event.participants, location: event.location, + chatId: nil + ) + stored.append(personal) + saveStoredEvents(stored) + } + + @objc private func prevTapped() { + guard currentIndex < events.count - 1 else { return } + currentIndex += 1 + animateTransition(direction: -1) + updateUI() + } + + @objc private func nextTapped() { + guard currentIndex > 0 else { return } + currentIndex -= 1 + animateTransition(direction: 1) + updateUI() + } + + private func animateTransition(direction: CGFloat) { + let offset = direction * view.bounds.width * 0.4 + cardView.transform = CGAffineTransform(translationX: offset, y: 0) + cardView.alpha = 0.4 + UIView.animate(withDuration: 0.28, delay: 0, options: .curveEaseOut) { + self.cardView.transform = .identity + self.cardView.alpha = 1 + } + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} diff --git a/submodules/TelegramUI/Sources/EventsController.swift b/submodules/TelegramUI/Sources/EventsController.swift new file mode 100644 index 0000000000..cc0ee60cfb --- /dev/null +++ b/submodules/TelegramUI/Sources/EventsController.swift @@ -0,0 +1,701 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import AccountContext +import TelegramBaseController + +// MARK: - Model + +public struct TGEvent: Codable { + public let id: UUID + public let title: String + public let startDate: Date + public let endDate: Date + public let participants: [String] + public let location: String? + public var chatId: Int64? +} + +// MARK: - Mock data + +private let cal = Calendar.current + +private func makeMockEvents() -> [TGEvent] { + let now = Date() + func ev(_ dayOff: Int, _ h1: Int, _ h2: Int, _ title: String, _ people: [String], _ loc: String?) -> TGEvent { + func d(_ offset: Int, _ hour: Int) -> Date { + var c = cal.dateComponents([.year, .month, .day], from: now) + c.day = (c.day ?? 1) + offset; c.hour = hour; c.minute = 0; c.second = 0 + return cal.date(from: c) ?? now + } + return TGEvent(id: UUID(), title: title, startDate: d(dayOff, h1), endDate: d(dayOff, h2), + participants: people, location: loc) + } + return [ + ev(0, 10, 11, "Встреча с командой", ["Алексей К.", "Мария В.", "Иван Д."], "Zoom"), + ev(0, 14, 15, "Обед с Максимом", ["Максим Р."], "Кафе Пушкин"), + ev(2, 16, 17, "Созвон по продукту", ["Сергей П.", "Анна Л."], nil), + ev(4, 11, 12, "Встреча с инвестором", ["Андрей С."], "Офис на Тверской"), + ev(6, 19, 23, "День рождения Ольги", ["Ольга М.", "Дмитрий Н.", "Катя Р."], "Ресторан Белуга"), + ev(8, 9, 10, "Ежедневный стендап", ["Вся команда"], "Telegram"), + ] +} + +// module-level seed; EventsController copies this into a mutable instance array +private let seedEvents = makeMockEvents() + +// MARK: - Calendar day cell + +private final class CalendarDayCell: UICollectionViewCell { + private let circleView = UIView() + private let dayLabel = UILabel() + private let dotView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + let size: CGFloat = 34 + + circleView.layer.cornerRadius = size / 2 + circleView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(circleView) + + dayLabel.textAlignment = .center + dayLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(dayLabel) + + dotView.backgroundColor = .systemOrange + dotView.layer.cornerRadius = 3 + dotView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(dotView) + + NSLayoutConstraint.activate([ + circleView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + circleView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: -2), + circleView.widthAnchor.constraint(equalToConstant: size), + circleView.heightAnchor.constraint(equalToConstant: size), + + dayLabel.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), + dayLabel.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), + + dotView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + dotView.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: 3), + dotView.widthAnchor.constraint(equalToConstant: 6), + dotView.heightAnchor.constraint(equalToConstant: 6), + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + func configure(day: Int?, isSelected: Bool, isToday: Bool, hasEvents: Bool) { + guard let day = day else { + circleView.isHidden = true; dayLabel.text = nil; dotView.isHidden = true; return + } + circleView.isHidden = false + dayLabel.text = "\(day)" + dotView.isHidden = !hasEvents || isSelected + + switch (isSelected, isToday) { + case (true, _): + circleView.backgroundColor = .systemOrange + dayLabel.textColor = .white + dayLabel.font = .systemFont(ofSize: 15, weight: .semibold) + case (false, true): + circleView.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.12) + dayLabel.textColor = .systemOrange + dayLabel.font = .systemFont(ofSize: 15, weight: .semibold) + default: + circleView.backgroundColor = .clear + dayLabel.textColor = .label + dayLabel.font = .systemFont(ofSize: 15, weight: .regular) + } + } +} + +// MARK: - Event list cell + +private final class EventCell: UITableViewCell { + private let startLbl = UILabel() // "10:00" orange + private let timeline = UIView() // vertical line + private let endLbl = UILabel() // "11:00" gray + private let titleLbl = UILabel() // event name + private let peopleLbl = UILabel() // participants + private let pinIcon = UIImageView() + private let locLbl = UILabel() // location + + private static let tf: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "HH:mm"; return f + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .default + backgroundColor = .clear + + startLbl.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold) + startLbl.textColor = .systemOrange + startLbl.textAlignment = .right + startLbl.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(startLbl) + + timeline.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.35) + timeline.layer.cornerRadius = 1 + timeline.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(timeline) + + endLbl.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) + endLbl.textColor = .tertiaryLabel + endLbl.textAlignment = .right + endLbl.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(endLbl) + + titleLbl.font = .systemFont(ofSize: 16, weight: .semibold) + titleLbl.textColor = .label + titleLbl.numberOfLines = 2 + titleLbl.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLbl) + + peopleLbl.font = .systemFont(ofSize: 13) + peopleLbl.textColor = .secondaryLabel + peopleLbl.numberOfLines = 1 + peopleLbl.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(peopleLbl) + + let pinCfg = UIImage.SymbolConfiguration(pointSize: 11, weight: .regular) + pinIcon.image = UIImage(systemName: "mappin", withConfiguration: pinCfg) + pinIcon.tintColor = .systemOrange + pinIcon.contentMode = .scaleAspectFit + pinIcon.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pinIcon) + + locLbl.font = .systemFont(ofSize: 13) + locLbl.textColor = .secondaryLabel + locLbl.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(locLbl) + + let timeW: CGFloat = 46 + NSLayoutConstraint.activate([ + startLbl.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + startLbl.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + startLbl.widthAnchor.constraint(equalToConstant: timeW), + + timeline.centerXAnchor.constraint(equalTo: startLbl.centerXAnchor), + timeline.widthAnchor.constraint(equalToConstant: 2), + timeline.topAnchor.constraint(equalTo: startLbl.bottomAnchor, constant: 5), + timeline.bottomAnchor.constraint(equalTo: endLbl.topAnchor, constant: -5), + + endLbl.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + endLbl.widthAnchor.constraint(equalToConstant: timeW), + endLbl.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -14), + + titleLbl.leadingAnchor.constraint(equalTo: startLbl.trailingAnchor, constant: 14), + titleLbl.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 14), + titleLbl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + peopleLbl.leadingAnchor.constraint(equalTo: titleLbl.leadingAnchor), + peopleLbl.topAnchor.constraint(equalTo: titleLbl.bottomAnchor, constant: 4), + peopleLbl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + pinIcon.leadingAnchor.constraint(equalTo: titleLbl.leadingAnchor), + pinIcon.topAnchor.constraint(equalTo: peopleLbl.bottomAnchor, constant: 4), + pinIcon.widthAnchor.constraint(equalToConstant: 13), + pinIcon.heightAnchor.constraint(equalToConstant: 16), + pinIcon.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -14), + + locLbl.leadingAnchor.constraint(equalTo: pinIcon.trailingAnchor, constant: 4), + locLbl.centerYAnchor.constraint(equalTo: pinIcon.centerYAnchor), + locLbl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + locLbl.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -14), + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + func configure(with event: TGEvent) { + startLbl.text = Self.tf.string(from: event.startDate) + endLbl.text = Self.tf.string(from: event.endDate) + titleLbl.text = event.title + + let people = event.participants.joined(separator: ", ") + peopleLbl.text = people.isEmpty ? nil : people + peopleLbl.isHidden = people.isEmpty + + if let loc = event.location { + pinIcon.isHidden = false; locLbl.isHidden = false; locLbl.text = loc + } else { + pinIcon.isHidden = true; locLbl.isHidden = true + } + } +} + +// MARK: - Empty state + +private final class EmptyEventsView: UIView { + init() { + super.init(frame: .zero) + let icon = UIImageView(image: UIImage(systemName: "calendar.badge.plus", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 44, weight: .light))) + icon.tintColor = UIColor.systemOrange.withAlphaComponent(0.4) + icon.translatesAutoresizingMaskIntoConstraints = false + addSubview(icon) + + let lbl = UILabel() + lbl.text = "Нет встреч" + lbl.font = .systemFont(ofSize: 16, weight: .medium) + lbl.textColor = .secondaryLabel + lbl.translatesAutoresizingMaskIntoConstraints = false + addSubview(lbl) + + NSLayoutConstraint.activate([ + icon.centerXAnchor.constraint(equalTo: centerXAnchor), + icon.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -20), + lbl.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 12), + lbl.centerXAnchor.constraint(equalTo: centerXAnchor), + ]) + } + required init?(coder: NSCoder) { fatalError() } +} + +// MARK: - Controller + +public final class EventsController: TelegramBaseController { + private let context: AccountContext + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + // State + private var selectedDate: Date = Date() + private var currentMonthStart: Date = { + let c = cal.dateComponents([.year, .month], from: Date()) + return cal.date(from: c) ?? Date() + }() + private var allEvents: [TGEvent] = [] + private var displayedEvents: [TGEvent] = [] + + // Calendar UI + private let monthHeaderView = UIView() + private let monthLabel = UILabel() + private let prevButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + private let weekdayRow = UIStackView() + private let collectionView: UICollectionView = { + let fl = UICollectionViewFlowLayout() + fl.minimumInteritemSpacing = 0 + fl.minimumLineSpacing = 0 + fl.itemSize = CGSize(width: 48, height: 48) + return UICollectionView(frame: .zero, collectionViewLayout: fl) + }() + + private let divider = UIView() + private let dateHeaderLabel = UILabel() + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let emptyView = EmptyEventsView() + + private var validLayout: ContainerViewLayout? + + private static let monthFmt: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "LLLL yyyy"; return f + }() + + // MARK: Init + + public init(context: AccountContext) { + self.context = context + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + + super.init(context: context, navigationBarPresentationData: + NavigationBarPresentationData(presentationData: self.presentationData, style: .glass)) + + self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style + + self.tabBarItem.title = "События" + self.tabBarItem.image = UIImage(systemName: "calendar") + self.tabBarItem.selectedImage = UIImage(systemName: "calendar") + self.navigationItem.title = "События" + + let addBtn = UIBarButtonItem(image: UIImage(systemName: "plus"), + style: .plain, target: self, + action: #selector(addEventTapped)) + addBtn.tintColor = .systemOrange + self.navigationItem.rightBarButtonItem = addBtn + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).startStrict(next: { [weak self] pd in + guard let self else { return } + self.presentationData = pd + self.statusBar.statusBarStyle = pd.theme.rootController.statusBarStyle.style + }) + + allEvents = loadEvents() + reloadEvents() + } + + required public init(coder aDecoder: NSCoder) { fatalError() } + + deinit { presentationDataDisposable?.dispose() } + + // MARK: View lifecycle + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // Reload in case events were created from a chat (without going through our delegate) + let fresh = loadEvents() + if fresh.map(\.id) != allEvents.map(\.id) { + allEvents = fresh + if isNodeLoaded { + collectionView.reloadData() + updateEventsList() + } + } + } + + override public func displayNodeDidLoad() { + super.displayNodeDidLoad() + let root = self.displayNode.view + root.backgroundColor = .systemBackground + + // Month nav header + root.addSubview(monthHeaderView) + + let chevron = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) + prevButton.setImage(UIImage(systemName: "chevron.left", withConfiguration: chevron), for: .normal) + nextButton.setImage(UIImage(systemName: "chevron.right", withConfiguration: chevron), for: .normal) + prevButton.tintColor = .systemOrange; nextButton.tintColor = .systemOrange + prevButton.addTarget(self, action: #selector(prevMonth), for: .touchUpInside) + nextButton.addTarget(self, action: #selector(nextMonth), for: .touchUpInside) + monthHeaderView.addSubview(prevButton) + monthHeaderView.addSubview(nextButton) + + monthLabel.textAlignment = .center + monthLabel.font = .systemFont(ofSize: 17, weight: .semibold) + monthHeaderView.addSubview(monthLabel) + + // Weekday row + weekdayRow.axis = .horizontal + weekdayRow.distribution = .fillEqually + let days = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"] + for (i, d) in days.enumerated() { + let l = UILabel(); l.text = d; l.textAlignment = .center + l.font = .systemFont(ofSize: 12, weight: .medium) + l.textColor = i >= 5 ? UIColor.systemRed.withAlphaComponent(0.7) : .secondaryLabel + weekdayRow.addArrangedSubview(l) + } + root.addSubview(weekdayRow) + + // Calendar grid + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + collectionView.register(CalendarDayCell.self, forCellWithReuseIdentifier: "day") + collectionView.dataSource = self + collectionView.delegate = self + root.addSubview(collectionView) + + // Divider + divider.backgroundColor = .separator + root.addSubview(divider) + + // Date header label (above events list) + dateHeaderLabel.font = .systemFont(ofSize: 13, weight: .medium) + dateHeaderLabel.textColor = .secondaryLabel + root.addSubview(dateHeaderLabel) + + // Events table + tableView.register(EventCell.self, forCellReuseIdentifier: "event") + tableView.dataSource = self; tableView.delegate = self + tableView.backgroundColor = .systemGroupedBackground + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 88 + root.addSubview(tableView) + + // Empty state + emptyView.isHidden = true + root.addSubview(emptyView) + + refreshMonthLabel() + updateEventsList() + } + + // MARK: Layout + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + self.validLayout = layout + guard self.isNodeLoaded else { return } + applyLayout(layout) + } + + private func applyLayout(_ layout: ContainerViewLayout) { + let w = layout.size.width + let bottomInset = layout.intrinsicInsets.bottom + let calPad: CGFloat = 8 + let calW = w - calPad * 2 + let cellW = calW / 7 + let cellH: CGFloat = 48 + + // Nav bar bottom — safe fallback + let navBottom: CGFloat + if let nb = self.navigationBar, nb.frame.maxY > 0 { + navBottom = nb.frame.maxY + } else { + navBottom = (layout.statusBarHeight ?? 20) + 44 + } + + // Month header + let headerH: CGFloat = 44 + monthHeaderView.frame = CGRect(x: 0, y: navBottom, width: w, height: headerH) + prevButton.frame = CGRect(x: 4, y: 0, width: 48, height: headerH) + nextButton.frame = CGRect(x: w - 52, y: 0, width: 48, height: headerH) + monthLabel.frame = CGRect(x: 56, y: 0, width: w - 112, height: headerH) + + // Weekday labels + weekdayRow.frame = CGRect(x: calPad, y: monthHeaderView.frame.maxY, + width: calW, height: 28) + + // Calendar grid + let rows = calendarRowCount() + let gridH = cellH * CGFloat(rows) + let fl = collectionView.collectionViewLayout as! UICollectionViewFlowLayout + let newItemSize = CGSize(width: cellW, height: cellH) + if fl.itemSize != newItemSize { + fl.itemSize = newItemSize + } + collectionView.frame = CGRect(x: calPad, y: weekdayRow.frame.maxY, + width: calW, height: gridH) + + // Divider + divider.frame = CGRect(x: 0, y: collectionView.frame.maxY + 4, width: w, height: 0.5) + + // Date header label + let dateHeaderH: CGFloat = 36 + dateHeaderLabel.frame = CGRect(x: 20, y: divider.frame.maxY, + width: w - 40, height: dateHeaderH) + + // Table + let tableTop = dateHeaderLabel.frame.maxY + let tableH = layout.size.height - tableTop - bottomInset + tableView.frame = CGRect(x: 0, y: tableTop, width: w, height: tableH) + + // Empty state + emptyView.frame = tableView.frame + } + + // MARK: Calendar helpers + + private func calendarRowCount() -> Int { + let offset = firstWeekdayOffset(for: currentMonthStart) + let days = daysInMonth(for: currentMonthStart) + return (offset + days + 6) / 7 + } + + private func firstWeekdayOffset(for date: Date) -> Int { + let wd = cal.component(.weekday, from: date) // 1=Sun … 7=Sat + return (wd + 5) % 7 // 0=Mon … 6=Sun + } + + private func daysInMonth(for date: Date) -> Int { + return cal.range(of: .day, in: .month, for: date)?.count ?? 30 + } + + private func date(day: Int, in monthStart: Date) -> Date? { + var c = cal.dateComponents([.year, .month], from: monthStart) + c.day = day + return cal.date(from: c) + } + + // MARK: Data + + // MARK: Persistence + + private static let storageKey = "tg_events_v1" + + private func loadEvents() -> [TGEvent] { + guard let data = UserDefaults.standard.data(forKey: Self.storageKey), + let events = try? JSONDecoder().decode([TGEvent].self, from: data) else { + return seedEvents + } + return events + } + + private func saveEvents() { + if let data = try? JSONEncoder().encode(allEvents) { + UserDefaults.standard.set(data, forKey: Self.storageKey) + } + } + + private func reloadEvents() { + displayedEvents = allEvents + .filter { cal.isDate($0.startDate, inSameDayAs: selectedDate) } + .sorted { $0.startDate < $1.startDate } + } + + private static let dateHeaderFmt: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "EEEE, d MMMM"; return f + }() + + private func updateEventsList() { + reloadEvents() + tableView.reloadData() + emptyView.isHidden = !displayedEvents.isEmpty + + var text = Self.dateHeaderFmt.string(from: selectedDate) + if let first = text.first { text = first.uppercased() + text.dropFirst() } + if cal.isDateInToday(selectedDate) { text = "Сегодня, " + text.components(separatedBy: ", ").dropFirst().joined(separator: ", ") } + dateHeaderLabel.text = text + } + + private func refreshMonthLabel() { + var text = Self.monthFmt.string(from: currentMonthStart) + // Capitalize first letter + if let first = text.first { text = first.uppercased() + text.dropFirst() } + monthLabel.text = text + } + + // MARK: Actions + + @objc private func prevMonth() { + guard let d = cal.date(byAdding: .month, value: -1, to: currentMonthStart) else { return } + currentMonthStart = d + refreshMonthLabel() + collectionView.reloadData() + } + + @objc private func nextMonth() { + guard let d = cal.date(byAdding: .month, value: 1, to: currentMonthStart) else { return } + currentMonthStart = d + refreshMonthLabel() + collectionView.reloadData() + } + + @objc private func addEventTapped() { + presentCreateEvent(editingEvent: nil) + } + + private func presentCreateEvent(editingEvent: TGEvent?) { + let vc = CreateEventController(context: self.context, editingEvent: editingEvent) + vc.delegate = self + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .pageSheet + if #available(iOS 15.0, *) { + if let sheet = nav.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + } + self.present(nav, animated: true) + } +} + +// MARK: - CreateEventControllerDelegate + +extension EventsController: CreateEventControllerDelegate { + func createEventController(_ controller: CreateEventController, didUpdate event: TGEvent) { + if let idx = allEvents.firstIndex(where: { $0.id == event.id }) { + allEvents[idx] = event + } + // Save already performed by CreateEventController + let comps = cal.dateComponents([.year, .month], from: event.startDate) + if let monthStart = cal.date(from: comps) { currentMonthStart = monthStart } + selectedDate = event.startDate + collectionView.reloadData() + updateEventsList() + } + + func createEventController(_ controller: CreateEventController, didCreate event: TGEvent) { + allEvents.append(event) + // Save already performed by CreateEventController + let comps = cal.dateComponents([.year, .month], from: event.startDate) + if let monthStart = cal.date(from: comps) { + currentMonthStart = monthStart + } + selectedDate = event.startDate + collectionView.reloadData() + updateEventsList() + } +} + +// MARK: - UICollectionView (Calendar) + +extension EventsController: UICollectionViewDataSource, UICollectionViewDelegate { + public func collectionView(_ cv: UICollectionView, numberOfItemsInSection s: Int) -> Int { + return calendarRowCount() * 7 + } + + public func collectionView(_ cv: UICollectionView, cellForItemAt ip: IndexPath) -> UICollectionViewCell { + let cell = cv.dequeueReusableCell(withReuseIdentifier: "day", for: ip) as! CalendarDayCell + let offset = firstWeekdayOffset(for: currentMonthStart) + let days = daysInMonth(for: currentMonthStart) + let rawDay = ip.item - offset + 1 + + guard rawDay >= 1, rawDay <= days, let cellDate = date(day: rawDay, in: currentMonthStart) else { + cell.configure(day: nil, isSelected: false, isToday: false, hasEvents: false) + return cell + } + + let isSelected = cal.isDate(cellDate, inSameDayAs: selectedDate) + let isToday = cal.isDateInToday(cellDate) + let hasEvents = allEvents.contains { cal.isDate($0.startDate, inSameDayAs: cellDate) } + cell.configure(day: rawDay, isSelected: isSelected, isToday: isToday, hasEvents: hasEvents) + return cell + } + + public func collectionView(_ cv: UICollectionView, didSelectItemAt ip: IndexPath) { + let offset = firstWeekdayOffset(for: currentMonthStart) + let days = daysInMonth(for: currentMonthStart) + let rawDay = ip.item - offset + 1 + guard rawDay >= 1, rawDay <= days, let d = date(day: rawDay, in: currentMonthStart) else { return } + selectedDate = d + cv.reloadData() + updateEventsList() + } +} + +// MARK: - UITableView (Events) + +extension EventsController: UITableViewDataSource, UITableViewDelegate { + public func numberOfSections(in tv: UITableView) -> Int { + displayedEvents.count + } + + public func tableView(_ tv: UITableView, numberOfRowsInSection s: Int) -> Int { 1 } + + public func tableView(_ tv: UITableView, heightForHeaderInSection s: Int) -> CGFloat { 8 } + public func tableView(_ tv: UITableView, heightForFooterInSection s: Int) -> CGFloat { 0 } + public func tableView(_ tv: UITableView, viewForHeaderInSection s: Int) -> UIView? { UIView() } + public func tableView(_ tv: UITableView, viewForFooterInSection s: Int) -> UIView? { nil } + + public func tableView(_ tv: UITableView, cellForRowAt ip: IndexPath) -> UITableViewCell { + let cell = tv.dequeueReusableCell(withIdentifier: "event", for: ip) as! EventCell + cell.configure(with: displayedEvents[ip.section]) + return cell + } + + public func tableView(_ tv: UITableView, didSelectRowAt ip: IndexPath) { + tv.deselectRow(at: ip, animated: true) + presentCreateEvent(editingEvent: displayedEvents[ip.section]) + } + + + public func tableView(_ tv: UITableView, canEditRowAt ip: IndexPath) -> Bool { true } + + public func tableView(_ tv: UITableView, trailingSwipeActionsConfigurationForRowAt ip: IndexPath) -> UISwipeActionsConfiguration? { + let action = UIContextualAction(style: .destructive, title: "Удалить") { [weak self] _, _, done in + guard let self else { done(false); return } + let event = self.displayedEvents[ip.section] + self.allEvents.removeAll { $0.id == event.id } + self.saveEvents() + self.displayedEvents.remove(at: ip.section) + tv.deleteSections(IndexSet(integer: ip.section), with: .automatic) + self.collectionView.reloadData() + self.emptyView.isHidden = !self.displayedEvents.isEmpty + done(true) + } + action.image = UIImage(systemName: "trash") + return UISwipeActionsConfiguration(actions: [action]) + } +} diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index db5f613c5c..250a84f6ab 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -78,6 +78,7 @@ public final class TelegramRootController: NavigationController, TelegramRootCon public var contactsController: ContactsController? public var callListController: CallListController? + public var eventsController: EventsController? public var chatListController: ChatListController? public var accountSettingsController: PeerInfoScreen? @@ -219,6 +220,8 @@ public final class TelegramRootController: NavigationController, TelegramRootCon if showCallsTab { controllers.append(callListController) } + let eventsController = EventsController(context: self.context) + controllers.append(eventsController) controllers.append(chatListController) var restoreSettignsController: (ViewController & SettingsController)? @@ -244,6 +247,7 @@ public final class TelegramRootController: NavigationController, TelegramRootCon self.contactsController = contactsController self.callListController = callListController + self.eventsController = eventsController self.chatListController = chatListController self.accountSettingsController = accountSettingsController self.rootTabController = tabBarController @@ -259,6 +263,7 @@ public final class TelegramRootController: NavigationController, TelegramRootCon if showCallsTab { controllers.append(self.callListController!) } + controllers.append(self.eventsController!) controllers.append(self.chatListController!) controllers.append(self.accountSettingsController!)