From faa73a6ec26f5a7c4a0e0ee2dc952033f8491bf0 Mon Sep 17 00:00:00 2001 From: akaevmikail17 Date: Sun, 7 Jun 2026 12:10:59 +0300 Subject: [PATCH 1/3] 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 | Bin 0 -> 4695 bytes .../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, 1813 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 ac857424698..826b3531350 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 7c4e1b3f04d..f7fbc67456b 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 00000000000..708215a4a37 --- /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 0000000000000000000000000000000000000000..3e6eea1d6ee85aadb0aed656837b461a10d119a6 GIT binary patch literal 4695 zcmZvgTW=gi5QX38SIkQ!g2Zg!XIhFPfddEuA~?Ju9?WJ*jO<mAxXYHxIv&&{(jCz&_uZ)@Z_fs48 z^x?cePHqXoV|vzMu~sR)&i9jFlr?4@L7(2{aq@BtbBBB~!qh!e#fpr}Puk6QfX)WJ z#s;n#l$G@bsMWFwYM!9cZpM9xMiaeR2bB3SxTv>=@zeyAchE1ma|* ziR)@dI>4UrhE0>^1Ojbrf>lPk#f}Zs4I|-Bhp3HsWEOALW-_B%c?)A^S#{W=Pf;qZ zoE2;XafxKo6KxoR3vHXM0x^SZp&;(zOHtwezEgk8#1YEjr#MOrkgqGphb zkYvpuXxW#LONa%EGBtw~Km#GP?cf2-gtON#F+_nHeu7cq1C~>O4+X0kAjX0yav}-v z38{f){;6$7<@huGX(MsKafZ_Jt)R!&W z3rD+mbsMIHX!qajE)D)_vl$}zftmPf3 zC|)ZMfvPmxC200Q?jBh~?XuE7x7?Dl)Z$V4C8f=pyr*e)u(gg3Hl265aWs*l#eAgS z?x*)--zj#a55sR=2ybgTFHq@BF7>$DqimYPQjDuTs$prrOaGQ0)lkuZ(zR`JSuBoN zSv#vm8%at{NP0;cqK!ObGj1vM`8#`J3&Q)87b1hAqVS7w)}Xau60nt zbi>-N#PJ4>?WlniiiT3s*cPEOv=3--_lA(&wyvTE0%o_#EB(o(4qc*SQwR^qTA_jb zC>OoXQwv`Py1b8RiAw9TsSVW_`Ct6z3jIA%i(A7g9jfPZaSDk~MApR9ZMJ|l9HzqH zOinCPeqKria-j?%Pmr|e-cK4Nr%Y+!a&}CGn>u+WtXh+)nXhV0>}R^(!}I) zFoIfom}OkC7MCa_%0aIz+K{9gQ9KEp_scM%W2D?zeFuSpbgJY@S>Xa}u3VtS=WY-} zN@$dFS-E5Tj}dbRU%!1i9j6~B`36uf`1NamT>SG_0*BDGj|7}k`^SUb=f~se@jU>6fbS1)Z*RUh{WJeuPQi#wJJXqxI&`N!RZG{0^XadL{oy=w z!kw>+xvSIh@o;2C`BqWE*EbJfGY7r|4PP__YF|2b-`>8()mb9Ap6fbSr@xr1pZI?U z87zehOF7J|o5!2G)4MWNfo6n`vIw&%ou!sX9H)#|Ci`x`dqO zc<>=a 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 50b7728237e..9af5ddcae87 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 00000000000..6f1c5eb3f7d --- /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 00000000000..79d73e0066d --- /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 00000000000..cc0ee60cfb9 --- /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 db5f613c5ca..250a84f6ab7 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!) From c83d57a7d5dac3f68e67da7992a0e3a7ed89b652 Mon Sep 17 00:00:00 2001 From: akaevmikail17 Date: Sun, 7 Jun 2026 14:46:28 +0300 Subject: [PATCH 2/3] Events tab: code quality and correctness fixes - Extract TGEventStorage enum for shared UserDefaults keys (single source of truth) - Move DateFormatter instances to static let (avoid recreating on every call) - Fix floating button safe area: use view's own insets instead of window cast - Fix weak self capture in DispatchQueue closure (memory leak) - Add PeerId.localStorageId extension as single canonical conversion - Add autoresizingMask to floating button for correct rotation behavior - Fix card navigator: preserve scroll position on reload, fix animation order - Personal calendar copy gets fresh UUID; duplicate detection by title+date - Fix participant deletion: reload section to reset button tags after index shift - Update .gitignore: ignore tg-events/ and sync script Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 +- .../Sources/ChatControllerEventButton.swift | 16 +++--- .../ChatControllerOpenAttachmentMenu.swift | 36 ++++++++----- .../Sources/CreateEventController.swift | 8 +-- .../EventCardNavigatorController.swift | 51 ++++++++++++------- .../TelegramUI/Sources/EventsController.swift | 27 +++++----- 6 files changed, 87 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 67f1e82fb48..4c4e5fca78c 100644 --- a/.gitignore +++ b/.gitignore @@ -77,7 +77,8 @@ spm-files xcode-files .bsp/** .sourcekit-lsp/** -/.claude/ -**/.claude/settings.local.json +**/.claude/ **/.vscode/launch.json /buildbox/* +/tg-events/ +/sync-tg-events.sh diff --git a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift index ec88757e836..91d05f83e00 100644 --- a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift +++ b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift @@ -91,7 +91,7 @@ final class EventFloatingButton: UIView { case .ended, .cancelled: snapToEdge(superview) UIView.animate(withDuration: 0.2) { self.alpha = 0.65 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { self.lastTouchWasDrag = false } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.lastTouchWasDrag = false } default: break } @@ -101,8 +101,9 @@ final class EventFloatingButton: 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 + // Use the view's own safe area insets (propagated from the window through the hierarchy). + let safeTop = sv.safeAreaInsets.top + let safeBottom = sv.safeAreaInsets.bottom 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)) @@ -147,7 +148,7 @@ extension ChatControllerImpl { if view.viewWithTag(Self.floatingEventButtonTag) != nil { return } guard let peerId = chatLocation.peerId else { return } - let chatId = peerId.id._internalGetInt64Value() + let chatId = peerId.localStorageId let button = EventFloatingButton { [weak self] in guard let self else { return } @@ -162,6 +163,8 @@ extension ChatControllerImpl { self.present(nav, animated: true) } button.tag = Self.floatingEventButtonTag + // Keep button on the right edge when the view resizes (rotation). + button.autoresizingMask = [.flexibleLeftMargin, .flexibleTopMargin, .flexibleBottomMargin] // Initial position: right edge, vertically centered. let bw: CGFloat = 52 @@ -177,11 +180,10 @@ extension ChatControllerImpl { 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())) ?? [] + from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? [] let votes = (try? JSONDecoder().decode([String: String].self, - from: UserDefaults.standard.data(forKey: "tg_event_votes_v1") ?? Data())) ?? [:] + from: UserDefaults.standard.data(forKey: TGEventStorage.votesKey) ?? Data())) ?? [:] let unvotedCount = stored .filter { $0.chatId == chatId } .filter { votes[$0.id.uuidString] == nil } diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index 9af5ddcae87..e41c316d2c7 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -38,6 +38,13 @@ import ComposePollScreen import Photos import AttachmentFileController +// Stable Int64 encoding of a peer ID for on-device event storage only. +// Uses the same namespace|id packing as Postbox — not for network use. +// Internal (not private) so ChatControllerEventButton in the same module can use it. +extension PeerId { + var localStorageId: Int64 { id._internalGetInt64Value() } +} + extension ChatControllerImpl { enum AttachMenuSubject { case `default` @@ -134,9 +141,6 @@ extension ChatControllerImpl { availableButtons.append(.event) - if "".isEmpty { - availableButtons.insert(.audio, at: max(0, availableButtons.count - 1)) - } let presentationData = self.presentationData @@ -2314,16 +2318,23 @@ extension ChatControllerImpl { self.push(controller) } + // MARK: - Event helpers + + private static let eventDateFmt: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "EEEE, d MMMM"; return f + }() + private static let eventTimeFmt: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "HH:mm"; return f + }() + func sendEventToGroup(event: TGEvent) { guard let peerId = chatLocation.peerId else { return } - let chatId = peerId.id._internalGetInt64Value() + let chatId = peerId.localStorageId - 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" + let dateFmt = Self.eventDateFmt + let timeFmt = Self.eventTimeFmt var dateStr = dateFmt.string(from: event.startDate) if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() } @@ -2346,12 +2357,11 @@ extension ChatControllerImpl { 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())) ?? [] + from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? [] stored = stored.map { $0.id == event.id ? eventWithChat : $0 } if let data = try? JSONEncoder().encode(stored) { - UserDefaults.standard.set(data, forKey: key) + UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey) } } } diff --git a/submodules/TelegramUI/Sources/CreateEventController.swift b/submodules/TelegramUI/Sources/CreateEventController.swift index 6f1c5eb3f7d..006b87185d9 100644 --- a/submodules/TelegramUI/Sources/CreateEventController.swift +++ b/submodules/TelegramUI/Sources/CreateEventController.swift @@ -190,15 +190,15 @@ final class CreateEventController: UIViewController { 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())) ?? [] + var stored = (try? JSONDecoder().decode([TGEvent].self, + from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? 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) + UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey) } onSave?(event) @@ -432,6 +432,8 @@ extension CreateEventController: UITableViewDelegate { if editingStyle == .delete { participants.remove(at: ip.row) tv.deleteRows(at: [ip], with: .automatic) + // Reload remaining rows to reset button tags after index shift. + tv.reloadSections(IndexSet(integer: 2), with: .none) } } } diff --git a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift index 79d73e0066d..d92cc2d3db5 100644 --- a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift +++ b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift @@ -4,29 +4,27 @@ 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), + guard let data = UserDefaults.standard.data(forKey: TGEventStorage.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) + UserDefaults.standard.set(data, forKey: TGEventStorage.votesKey) } } private func loadStoredEvents() -> [TGEvent] { - guard let data = UserDefaults.standard.data(forKey: "tg_events_v1"), + guard let data = UserDefaults.standard.data(forKey: TGEventStorage.eventsKey), 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") + UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey) } } @@ -135,13 +133,18 @@ private final class EventCardView: UIView { ]) } + private static let dateFmt: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "EEEE, d MMMM"; return f + }() + private static let timeFmt: DateFormatter = { + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "HH:mm"; return f + }() + 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" + let dateFmt = Self.dateFmt + let timeFmt = Self.timeFmt var dateStr = dateFmt.string(from: event.startDate) if let first = dateStr.first { dateStr = first.uppercased() + dateStr.dropFirst() } @@ -275,10 +278,13 @@ public final class EventCardNavigatorController: UIViewController { private func reload() { let all = loadStoredEvents() - // Show events for this group, newest first. - events = all.filter { $0.chatId == chatId } + let newEvents = all.filter { $0.chatId == chatId } .sorted { $0.startDate > $1.startDate } - currentIndex = 0 + // Preserve current position if the event list hasn't changed. + if newEvents.map(\.id) != events.map(\.id) { + currentIndex = 0 + } + events = newEvents updateUI() } @@ -321,10 +327,15 @@ public final class EventCardNavigatorController: UIViewController { 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 } + // Match by title+date to avoid duplicates even across different UUID instances. + let alreadyExists = stored.contains { + $0.chatId == nil && $0.title == event.title && $0.startDate == event.startDate + } guard !alreadyExists else { return } + // Use a fresh UUID so the personal copy is independent from the group event — + // deleting one will not accidentally delete the other. let personal = TGEvent( - id: event.id, title: event.title, + id: UUID(), title: event.title, startDate: event.startDate, endDate: event.endDate, participants: event.participants, location: event.location, chatId: nil @@ -336,18 +347,20 @@ public final class EventCardNavigatorController: UIViewController { @objc private func prevTapped() { guard currentIndex < events.count - 1 else { return } currentIndex += 1 - animateTransition(direction: -1) updateUI() + animateTransition(direction: -1) } @objc private func nextTapped() { guard currentIndex > 0 else { return } currentIndex -= 1 - animateTransition(direction: 1) updateUI() + animateTransition(direction: 1) } private func animateTransition(direction: CGFloat) { + // Set start position off-screen, then animate to identity — + // updateUI() has already updated labels so new content slides in. let offset = direction * view.bounds.width * 0.4 cardView.transform = CGAffineTransform(translationX: offset, y: 0) cardView.alpha = 0.4 diff --git a/submodules/TelegramUI/Sources/EventsController.swift b/submodules/TelegramUI/Sources/EventsController.swift index cc0ee60cfb9..4e0628abf7e 100644 --- a/submodules/TelegramUI/Sources/EventsController.swift +++ b/submodules/TelegramUI/Sources/EventsController.swift @@ -8,6 +8,13 @@ import TelegramPresentationData import AccountContext import TelegramBaseController +// MARK: - Shared storage keys + +enum TGEventStorage { + static let eventsKey = "tg_events_v1" + static let votesKey = "tg_event_votes_v1" +} + // MARK: - Model public struct TGEvent: Codable { @@ -343,14 +350,12 @@ public final class EventsController: TelegramBaseController { 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() - } + // Always reload: events may be added from chat without touching our delegate, + // or an existing event's chatId may be stamped after creation. + allEvents = loadEvents() + if isNodeLoaded { + collectionView.reloadData() + updateEventsList() } } @@ -511,10 +516,8 @@ public final class EventsController: TelegramBaseController { // MARK: Persistence - private static let storageKey = "tg_events_v1" - private func loadEvents() -> [TGEvent] { - guard let data = UserDefaults.standard.data(forKey: Self.storageKey), + guard let data = UserDefaults.standard.data(forKey: TGEventStorage.eventsKey), let events = try? JSONDecoder().decode([TGEvent].self, from: data) else { return seedEvents } @@ -523,7 +526,7 @@ public final class EventsController: TelegramBaseController { private func saveEvents() { if let data = try? JSONEncoder().encode(allEvents) { - UserDefaults.standard.set(data, forKey: Self.storageKey) + UserDefaults.standard.set(data, forKey: TGEventStorage.eventsKey) } } From 29d2c08102a131bc102c921569d5d3cc180992fb Mon Sep 17 00:00:00 2001 From: akaevmikail17 Date: Sun, 7 Jun 2026 17:27:33 +0300 Subject: [PATCH 3/3] Events tab: TGEventAttribute, participant list, source icon, tab icon size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TGEventAttribute: new MessageAttribute in TelegramCore/SyncCore storing event data invisibly (eventId, title, timestamps, location); registered via declareEncodable in AccountManager; replaces visible [TGE:...] JSON text - EventCardNavigatorController: full rewrite with UIScrollView layout; participant sections "Идут / Не идут" with names and vote dates; TGVoteEntry (v2 vote storage) alongside v1 for badge compat; scanMessageHistory() uses TGEventAttribute with legacy text fallback; loadCurrentUser() reads voter name from Postbox - EventsController: TGEvent.chatIsGroup field (nil=personal, true=group, false=DM); EventCell shows subtle person.2/person source icon top-right; tab icon scaled to 68% via Lottie JSON and PDF asset - ChatControllerOpenAttachmentMenu: clean message text (no [TGE:...]), attaches TGEventAttribute; stores chatIsGroup: true in persisted event Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/Account/AccountManager.swift | 1 + .../SyncCore/SyncCore_TGEventAttribute.swift | 41 +++ .../Tabs/IconEvents.imageset/Contents.json | 12 + .../Tabs/IconEvents.imageset/ic_tb_events.pdf | Bin 0 -> 676 bytes .../Resources/Animations/TabEvents.json | 1 + .../Sources/ChatControllerEventButton.swift | 17 +- .../ChatControllerOpenAttachmentMenu.swift | 12 +- .../EventCardNavigatorController.swift | 347 ++++++++++++++++-- .../TelegramUI/Sources/EventsController.swift | 199 +++++++--- 9 files changed, 534 insertions(+), 96 deletions(-) create mode 100644 submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf create mode 100644 submodules/TelegramUI/Resources/Animations/TabEvents.json diff --git a/submodules/TelegramCore/Sources/Account/AccountManager.swift b/submodules/TelegramCore/Sources/Account/AccountManager.swift index e9ea0821e5c..a4c2fd8d772 100644 --- a/submodules/TelegramCore/Sources/Account/AccountManager.swift +++ b/submodules/TelegramCore/Sources/Account/AccountManager.swift @@ -102,6 +102,7 @@ private var declaredEncodables: Void = { declareEncodable(RegularChatState.self, f: { RegularChatState(decoder: $0) }) declareEncodable(InlineBotMessageAttribute.self, f: { InlineBotMessageAttribute(decoder: $0) }) declareEncodable(InlineBusinessBotMessageAttribute.self, f: { InlineBusinessBotMessageAttribute(decoder: $0) }) + declareEncodable(TGEventAttribute.self, f: { TGEventAttribute(decoder: $0) }) declareEncodable(TextEntitiesMessageAttribute.self, f: { TextEntitiesMessageAttribute(decoder: $0) }) declareEncodable(ReplyMessageAttribute.self, f: { ReplyMessageAttribute(decoder: $0) }) declareEncodable(QuotedReplyMessageAttribute.self, f: { QuotedReplyMessageAttribute(decoder: $0) }) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift new file mode 100644 index 00000000000..46179e1c4ea --- /dev/null +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TGEventAttribute.swift @@ -0,0 +1,41 @@ +import Foundation +import Postbox + +public final class TGEventAttribute: MessageAttribute { + public let eventId: String + public let title: String + public let startTimestamp: Double + public let endTimestamp: Double + public let location: String? + + public var associatedPeerIds: [PeerId] { [] } + public var associatedMessageIds: [MessageId] { [] } + + public init(eventId: String, title: String, startTimestamp: Double, endTimestamp: Double, location: String?) { + self.eventId = eventId + self.title = title + self.startTimestamp = startTimestamp + self.endTimestamp = endTimestamp + self.location = location + } + + required public init(decoder: PostboxDecoder) { + self.eventId = decoder.decodeStringForKey("eid", orElse: UUID().uuidString) + self.title = decoder.decodeStringForKey("t", orElse: "") + self.startTimestamp = decoder.decodeDoubleForKey("s", orElse: 0) + self.endTimestamp = decoder.decodeDoubleForKey("e", orElse: 0) + self.location = decoder.decodeOptionalStringForKey("l") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(eventId, forKey: "eid") + encoder.encodeString(title, forKey: "t") + encoder.encodeDouble(startTimestamp, forKey: "s") + encoder.encodeDouble(endTimestamp, forKey: "e") + if let location { + encoder.encodeString(location, forKey: "l") + } else { + encoder.encodeNil(forKey: "l") + } + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json new file mode 100644 index 00000000000..dbf8bc7d297 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_tb_events.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf b/submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf new file mode 100644 index 0000000000000000000000000000000000000000..34d8cf38ce2caf96326983da9354e80555b669be GIT binary patch literal 676 zcmY!laBub>~4TAW{6lnitp(A{=+NVb3!z-)2L%qdANQqXtH zNi0cqNlngA0ea6TH7~s+L&3<%1f;9Dq$o8pm#bnjIpQQKikN&`_cjx@yjlVx`D?V-4^5DSpmm9R-@SS`f!V>tS z%kEvF)1%G4OOnO-+$6pVDO+A*4w`NHEnDwgyGgKN;P;Fs&r^$}{FHw5f6IRN_}=Y@ zmrux?5-}-vf1>;<<&g2gt=m11sJ?x85S_DpH!I@R53Z}p)%+Cj<4`|{lE=epZ0lUf6(3neA)z#mP3jjdI>Z$+$ literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Resources/Animations/TabEvents.json b/submodules/TelegramUI/Resources/Animations/TabEvents.json new file mode 100644 index 00000000000..fb402c3d453 --- /dev/null +++ b/submodules/TelegramUI/Resources/Animations/TabEvents.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"TabEvents","a":"","k":"","d":"","tc":""},"fr":60,"ip":0,"op":35,"w":512,"h":512,"nm":"TabEvents","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Events","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[0.15,0.15,0.15],"y":[0,0,0]},"t":0,"s":[68.0,68.0,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.6,0.6,0.6],"y":[0,0,0]},"t":8,"s":[59.160000000000004,59.160000000000004,100]},{"t":25,"s":[68.0,68.0,100]}]}},"ao":0,"shapes":[{"ty":"gr","nm":"Calendar","it":[{"ty":"rc","nm":"Body","d":1,"p":{"a":0,"k":[0,51]},"s":{"a":0,"k":[409,341]},"r":{"a":0,"k":51}},{"ty":"rc","nm":"LeftPeg","d":1,"p":{"a":0,"k":[-85,-145]},"s":{"a":0,"k":[68,120]},"r":{"a":0,"k":26}},{"ty":"rc","nm":"RightPeg","d":1,"p":{"a":0,"k":[85,-145]},"s":{"a":0,"k":[68,120]},"r":{"a":0,"k":26}},{"ty":"fl","nm":"Fill","o":{"a":0,"k":100},"c":{"a":0,"k":[1,1,1,1]},"r":1},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}]}],"ip":0,"op":35,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift index 91d05f83e00..a9eb1725ee1 100644 --- a/submodules/TelegramUI/Sources/ChatControllerEventButton.swift +++ b/submodules/TelegramUI/Sources/ChatControllerEventButton.swift @@ -19,16 +19,11 @@ final class EventFloatingButton: UIView { 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 + backgroundColor = .clear + alpha = 0.8 iconLabel.text = "📅" - iconLabel.font = .systemFont(ofSize: 22) + iconLabel.font = .systemFont(ofSize: 34) iconLabel.textAlignment = .center iconLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(iconLabel) @@ -81,7 +76,7 @@ final class EventFloatingButton: UIView { switch gesture.state { case .began: lastTouchWasDrag = false - UIView.animate(withDuration: 0.15) { self.alpha = 0.85 } + UIView.animate(withDuration: 0.15) { self.alpha = 0.4 } case .changed: let t = gesture.translation(in: superview) if abs(t.x) > 4 || abs(t.y) > 4 { lastTouchWasDrag = true } @@ -90,7 +85,7 @@ final class EventFloatingButton: UIView { clampToSuperview(superview) case .ended, .cancelled: snapToEdge(superview) - UIView.animate(withDuration: 0.2) { self.alpha = 0.65 } + UIView.animate(withDuration: 0.2) { self.alpha = 0.8 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.lastTouchWasDrag = false } default: break @@ -152,7 +147,7 @@ extension ChatControllerImpl { let button = EventFloatingButton { [weak self] in guard let self else { return } - let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId)) + let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId, context: self.context)) nav.modalPresentationStyle = .pageSheet if #available(iOS 15.0, *) { if let sheet = nav.sheetPresentationController { diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index e41c316d2c7..52524a750bb 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -2343,8 +2343,16 @@ extension ChatControllerImpl { text += "🕒 \(dateStr) · \(timeFmt.string(from: event.startDate))–\(timeFmt.string(from: event.endDate))" if let loc = event.location, !loc.isEmpty { text += "\n📍 \(loc)" } + // TGEventAttribute stores event data invisibly so other fork devices can discover it. + let eventAttr = TGEventAttribute( + eventId: event.id.uuidString, title: event.title, + startTimestamp: event.startDate.timeIntervalSince1970, + endTimestamp: event.endDate.timeIntervalSince1970, + location: event.location.flatMap { $0.isEmpty ? nil : $0 } + ) + let message: EnqueueMessage = .message( - text: text, attributes: [], inlineStickers: [:], mediaReference: nil, + text: text, attributes: [eventAttr], inlineStickers: [:], mediaReference: nil, threadId: chatLocation.threadId, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [] ) @@ -2355,7 +2363,7 @@ extension ChatControllerImpl { id: event.id, title: event.title, startDate: event.startDate, endDate: event.endDate, participants: event.participants, location: event.location, - chatId: chatId + chatId: chatId, chatIsGroup: true ) var stored = (try? JSONDecoder().decode([TGEvent].self, from: UserDefaults.standard.data(forKey: TGEventStorage.eventsKey) ?? Data())) ?? [] diff --git a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift index d92cc2d3db5..c550daf8228 100644 --- a/submodules/TelegramUI/Sources/EventCardNavigatorController.swift +++ b/submodules/TelegramUI/Sources/EventCardNavigatorController.swift @@ -1,8 +1,20 @@ import Foundation import UIKit import AccountContext +import SwiftSignalKit +import Postbox +import TelegramCore -// MARK: - Vote storage helpers +// MARK: - Vote models and storage + +struct TGVoteEntry: Codable { + let userId: Int64 + let displayName: String + let vote: String // "yes" or "no" + let date: Date +} + +private let votesV2Key = "tg_event_votes_v2" private func loadVotes() -> [String: String] { guard let data = UserDefaults.standard.data(forKey: TGEventStorage.votesKey), @@ -16,6 +28,18 @@ private func saveVotes(_ dict: [String: String]) { } } +private func loadVotesV2() -> [String: [TGVoteEntry]] { + guard let data = UserDefaults.standard.data(forKey: votesV2Key), + let dict = try? JSONDecoder().decode([String: [TGVoteEntry]].self, from: data) else { return [:] } + return dict +} + +private func saveVotesV2(_ dict: [String: [TGVoteEntry]]) { + if let data = try? JSONEncoder().encode(dict) { + UserDefaults.standard.set(data, forKey: votesV2Key) + } +} + private func loadStoredEvents() -> [TGEvent] { guard let data = UserDefaults.standard.data(forKey: TGEventStorage.eventsKey), let events = try? JSONDecoder().decode([TGEvent].self, from: data) else { return [] } @@ -143,15 +167,12 @@ private final class EventCardView: UIView { }() func configure(event: TGEvent, vote: String?) { - let dateFmt = Self.dateFmt - let timeFmt = Self.timeFmt - - var dateStr = dateFmt.string(from: event.startDate) + var dateStr = Self.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))" + timeLabel.text = "⏰ \(Self.timeFmt.string(from: event.startDate)) – \(Self.timeFmt.string(from: event.endDate))" if let loc = event.location, !loc.isEmpty { locationLabel.text = "📍 \(loc)" @@ -197,31 +218,45 @@ private final class EventCardView: UIView { public final class EventCardNavigatorController: UIViewController { private let chatId: Int64 + private let context: AccountContext private var events: [TGEvent] = [] private var currentIndex: Int = 0 + private var scanDisposable: Disposable? + private var currentUserId: Int64 = 0 + private var currentUserName: String = "Вы" - private let cardView = EventCardView() + // Layout + private let scrollView = UIScrollView() + private let contentView = UIView() private let counterLabel = UILabel() + private let cardView = EventCardView() private let prevButton = UIButton(type: .system) private let nextButton = UIButton(type: .system) private let emptyLabel = UILabel() + private let participantsStack = UIStackView() - public init(chatId: Int64) { + public init(chatId: Int64, context: AccountContext) { self.chatId = chatId + self.context = context super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError() } + deinit { + scanDisposable?.dispose() + } + public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGroupedBackground title = "События" - navigationItem.rightBarButtonItem = UIBarButtonItem( barButtonSystemItem: .close, target: self, action: #selector(closeTapped)) setupLayout() + loadCurrentUser() reload() + scanMessageHistory() } public override func viewWillAppear(_ animated: Bool) { @@ -229,7 +264,29 @@ public final class EventCardNavigatorController: UIViewController { reload() } + // MARK: - Layout + private func setupLayout() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + contentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + counterLabel.font = .systemFont(ofSize: 14, weight: .medium) counterLabel.textColor = .secondaryLabel counterLabel.textAlignment = .center @@ -253,34 +310,61 @@ public final class EventCardNavigatorController: UIViewController { emptyLabel.textAlignment = .center emptyLabel.translatesAutoresizingMaskIntoConstraints = false - for v in [counterLabel, cardView, prevButton, nextButton, emptyLabel] as [UIView] { - view.addSubview(v) + participantsStack.axis = .vertical + participantsStack.spacing = 0 + participantsStack.translatesAutoresizingMaskIntoConstraints = false + + for v in [counterLabel, cardView, prevButton, nextButton, emptyLabel, participantsStack] as [UIView] { + contentView.addSubview(v) } NSLayoutConstraint.activate([ - counterLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - counterLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + counterLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + counterLabel.centerXAnchor.constraint(equalTo: contentView.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), + cardView.topAnchor.constraint(equalTo: counterLabel.bottomAnchor, constant: 12), + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), - prevButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24), - prevButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + prevButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20), + prevButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), - nextButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24), - nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + nextButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20), + nextButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), - emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + participantsStack.topAnchor.constraint(equalTo: prevButton.bottomAnchor, constant: 24), + participantsStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + participantsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + participantsStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -32), + + emptyLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + emptyLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 120), ]) } + // MARK: - Current user + + private func loadCurrentUser() { + let accountPeerId = context.account.peerId + let _ = (context.account.postbox.transaction { transaction -> (Int64, String) in + let userId = accountPeerId.toInt64() + if let user = transaction.getPeer(accountPeerId) as? TelegramUser { + let name = [user.firstName, user.lastName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") + return (userId, name.isEmpty ? "Пользователь" : name) + } + return (userId, "Пользователь") + } |> deliverOnMainQueue).startStandalone { [weak self] (userId, name) in + self?.currentUserId = userId + self?.currentUserName = name + } + } + + // MARK: - Data + private func reload() { let all = loadStoredEvents() let newEvents = all.filter { $0.chatId == chatId } .sorted { $0.startDate > $1.startDate } - // Preserve current position if the event list hasn't changed. if newEvents.map(\.id) != events.map(\.id) { currentIndex = 0 } @@ -295,6 +379,7 @@ public final class EventCardNavigatorController: UIViewController { prevButton.isHidden = !hasEvents nextButton.isHidden = !hasEvents emptyLabel.isHidden = hasEvents + participantsStack.isHidden = !hasEvents guard hasEvents else { return } @@ -307,16 +392,148 @@ public final class EventCardNavigatorController: UIViewController { cardView.onYes = { [weak self] in self?.vote("yes", for: event) } cardView.onNo = { [weak self] in self?.vote("no", for: event) } + + rebuildParticipants(for: event) + } + + // MARK: - Participant list + + private static let voteDateFmt: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "ru_RU") + f.dateFormat = "d MMM, HH:mm" + return f + }() + + private func rebuildParticipants(for event: TGEvent) { + participantsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let allVotesV2 = loadVotesV2() + let entries = (allVotesV2[event.id.uuidString] ?? []) + .sorted { $0.date < $1.date } + + let accepted = entries.filter { $0.vote == "yes" } + let declined = entries.filter { $0.vote == "no" } + + let topDivider = makeDivider() + participantsStack.addArrangedSubview(topDivider) + + let headerLabel = UILabel() + headerLabel.text = "Участники" + headerLabel.font = .systemFont(ofSize: 17, weight: .semibold) + headerLabel.textColor = .label + headerLabel.translatesAutoresizingMaskIntoConstraints = false + let headerWrap = wrapWithPadding(headerLabel, top: 20, bottom: 8, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(headerWrap) + + addParticipantSection(title: "✅ Идут (\(accepted.count))", + entries: accepted, + highlightColor: .systemGreen) + addParticipantSection(title: "❌ Не идут (\(declined.count))", + entries: declined, + highlightColor: .systemRed) + + if accepted.isEmpty && declined.isEmpty { + let noVotesLabel = UILabel() + noVotesLabel.text = "Пока никто не ответил" + noVotesLabel.font = .systemFont(ofSize: 14) + noVotesLabel.textColor = .tertiaryLabel + let wrap = wrapWithPadding(noVotesLabel, top: 8, bottom: 8, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(wrap) + } + } + + private func addParticipantSection(title: String, entries: [TGVoteEntry], highlightColor: UIColor) { + let sectionHeader = UILabel() + sectionHeader.text = title + sectionHeader.font = .systemFont(ofSize: 14, weight: .semibold) + sectionHeader.textColor = highlightColor + let sectionWrap = wrapWithPadding(sectionHeader, top: 12, bottom: 4, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(sectionWrap) + + if entries.isEmpty { + let emptyRowLabel = UILabel() + emptyRowLabel.text = " —" + emptyRowLabel.font = .systemFont(ofSize: 14) + emptyRowLabel.textColor = .tertiaryLabel + let wrap = wrapWithPadding(emptyRowLabel, top: 2, bottom: 2, leading: 20, trailing: 20) + participantsStack.addArrangedSubview(wrap) + } else { + for entry in entries { + let row = makeParticipantRow(entry: entry) + participantsStack.addArrangedSubview(row) + } + } + } + + private func makeParticipantRow(entry: TGVoteEntry) -> UIView { + let nameLabel = UILabel() + nameLabel.text = entry.displayName + nameLabel.font = .systemFont(ofSize: 15) + nameLabel.textColor = .label + nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let dateLabel = UILabel() + dateLabel.text = Self.voteDateFmt.string(from: entry.date) + dateLabel.font = .systemFont(ofSize: 13) + dateLabel.textColor = .tertiaryLabel + dateLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let row = UIStackView(arrangedSubviews: [nameLabel, dateLabel]) + row.axis = .horizontal + row.spacing = 8 + row.alignment = .center + row.layoutMargins = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20) + row.isLayoutMarginsRelativeArrangement = true + return row + } + + private func makeDivider() -> UIView { + let v = UIView() + v.backgroundColor = .separator + v.heightAnchor.constraint(equalToConstant: 0.5).isActive = true + return v } + private func wrapWithPadding(_ child: UIView, top: CGFloat, bottom: CGFloat, leading: CGFloat, trailing: CGFloat) -> UIView { + let wrap = UIView() + child.translatesAutoresizingMaskIntoConstraints = false + wrap.addSubview(child) + NSLayoutConstraint.activate([ + child.topAnchor.constraint(equalTo: wrap.topAnchor, constant: top), + child.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -bottom), + child.leadingAnchor.constraint(equalTo: wrap.leadingAnchor, constant: leading), + child.trailingAnchor.constraint(equalTo: wrap.trailingAnchor, constant: -trailing), + ]) + return wrap + } + + // MARK: - Voting + private func vote(_ answer: String, for event: TGEvent) { - var votes = loadVotes() let key = event.id.uuidString + + // v1 (for badge counting) + var votes = loadVotes() let prev = votes[key] - votes[key] = (prev == answer) ? nil : answer // toggle off if tapping same + votes[key] = (prev == answer) ? nil : answer saveVotes(votes) - // "Yes" → add event to personal calendar (if not already present). + // v2 (for participant list with names/dates) + var votesV2 = loadVotesV2() + var entries = votesV2[key] ?? [] + entries.removeAll { $0.userId == currentUserId } + if prev != answer { + entries.append(TGVoteEntry( + userId: currentUserId, + displayName: currentUserName, + vote: answer, + date: Date() + )) + } + votesV2[key] = entries + saveVotesV2(votesV2) + if answer == "yes", prev != "yes" { addEventToPersonalCalendar(event) } @@ -326,24 +543,86 @@ public final class EventCardNavigatorController: UIViewController { private func addEventToPersonalCalendar(_ event: TGEvent) { var stored = loadStoredEvents() - // Only add if not already there as a personal (chatId-less) copy. - // Match by title+date to avoid duplicates even across different UUID instances. let alreadyExists = stored.contains { $0.chatId == nil && $0.title == event.title && $0.startDate == event.startDate } guard !alreadyExists else { return } - // Use a fresh UUID so the personal copy is independent from the group event — - // deleting one will not accidentally delete the other. - let personal = TGEvent( + stored.append(TGEvent( id: UUID(), title: event.title, startDate: event.startDate, endDate: event.endDate, participants: event.participants, location: event.location, chatId: nil - ) - stored.append(personal) + )) saveStoredEvents(stored) } + // MARK: - Cross-device event discovery via TGEventAttribute + + private func scanMessageHistory() { + let chatId = self.chatId + let groupPeerId = PeerId(namespace: Namespaces.Peer.CloudGroup, id: PeerId.Id._internalFromInt64Value(chatId)) + let channelPeerId = PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(chatId)) + + scanDisposable = (context.account.postbox.transaction { transaction -> [TGEvent] in + var resolvedPeerId: PeerId? + for candidate in [groupPeerId, channelPeerId] { + if transaction.getPeer(candidate) != nil { resolvedPeerId = candidate; break } + } + guard let peerId = resolvedPeerId else { return [] } + + let view = transaction.getMessagesHistoryViewState( + input: .single(peerId: peerId, threadId: nil), + ignoreMessagesInTimestampRange: nil, + ignoreMessageIds: Set(), + count: 200, clipHoles: true, + anchor: .upperBound, + namespaces: .just(Set([Namespaces.Message.Cloud])) + ) + + var found: [TGEvent] = [] + for entry in view.entries { + // Primary path: TGEventAttribute (invisible, stored on fork clients) + if let attr = entry.message.attributes.first(where: { $0 is TGEventAttribute }) as? TGEventAttribute, + let uuid = UUID(uuidString: attr.eventId) { + found.append(TGEvent( + id: uuid, title: attr.title, + startDate: Date(timeIntervalSince1970: attr.startTimestamp), + endDate: Date(timeIntervalSince1970: attr.endTimestamp), + participants: [], location: attr.location, chatId: chatId + )) + continue + } + // Fallback: legacy [TGE:{...}] text marker (messages sent before attribute migration) + let text = entry.message.text + guard let start = text.range(of: "[TGE:"), + let end = text.range(of: "]", range: start.upperBound.. deliverOnMainQueue).startStandalone { [weak self] discovered in + guard let self, !discovered.isEmpty else { return } + var stored = loadStoredEvents() + let existingIds = Set(stored.map { $0.id }) + let newEvents = discovered.filter { !existingIds.contains($0.id) } + guard !newEvents.isEmpty else { return } + stored.append(contentsOf: newEvents) + saveStoredEvents(stored) + self.reload() + } + } + + // MARK: - Navigation + @objc private func prevTapped() { guard currentIndex < events.count - 1 else { return } currentIndex += 1 @@ -359,8 +638,6 @@ public final class EventCardNavigatorController: UIViewController { } private func animateTransition(direction: CGFloat) { - // Set start position off-screen, then animate to identity — - // updateUI() has already updated labels so new content slides in. let offset = direction * view.bounds.width * 0.4 cardView.transform = CGAffineTransform(translationX: offset, y: 0) cardView.alpha = 0.4 diff --git a/submodules/TelegramUI/Sources/EventsController.swift b/submodules/TelegramUI/Sources/EventsController.swift index 4e0628abf7e..0c6bf46159f 100644 --- a/submodules/TelegramUI/Sources/EventsController.swift +++ b/submodules/TelegramUI/Sources/EventsController.swift @@ -25,6 +25,7 @@ public struct TGEvent: Codable { public let participants: [String] public let location: String? public var chatId: Int64? + public var chatIsGroup: Bool? // nil = personal, true = group, false = DM } // MARK: - Mock data @@ -125,13 +126,14 @@ private final class CalendarDayCell: UICollectionViewCell { // 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 let startLbl = UILabel() + private let timeline = UIView() + private let endLbl = UILabel() + private let titleLbl = UILabel() + private let peopleLbl = UILabel() + private let pinIcon = UIImageView() + private let locLbl = UILabel() + private let sourceIcon = UIImageView() // group vs DM badge private static let tf: DateFormatter = { let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU") @@ -184,6 +186,11 @@ private final class EventCell: UITableViewCell { locLbl.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(locLbl) + sourceIcon.contentMode = .scaleAspectFit + sourceIcon.tintColor = .tertiaryLabel + sourceIcon.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sourceIcon) + let timeW: CGFloat = 46 NSLayoutConstraint.activate([ startLbl.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), @@ -217,6 +224,11 @@ private final class EventCell: UITableViewCell { locLbl.centerYAnchor.constraint(equalTo: pinIcon.centerYAnchor), locLbl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), locLbl.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -14), + + sourceIcon.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + sourceIcon.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + sourceIcon.widthAnchor.constraint(equalToConstant: 14), + sourceIcon.heightAnchor.constraint(equalToConstant: 14), ]) } @@ -236,6 +248,15 @@ private final class EventCell: UITableViewCell { } else { pinIcon.isHidden = true; locLbl.isHidden = true } + + let srcCfg = UIImage.SymbolConfiguration(pointSize: 10, weight: .light) + if let isGroup = event.chatIsGroup { + let name = isGroup ? "person.2" : "person" + sourceIcon.image = UIImage(systemName: name, withConfiguration: srcCfg) + sourceIcon.isHidden = false + } else { + sourceIcon.isHidden = true + } } } @@ -297,6 +318,9 @@ public final class EventsController: TelegramBaseController { return UICollectionView(frame: .zero, collectionViewLayout: fl) }() + private let searchBar = UISearchBar() + private var searchText: String = "" + private let divider = UIView() private let dateHeaderLabel = UILabel() private let tableView = UITableView(frame: .zero, style: .insetGrouped) @@ -321,11 +345,20 @@ public final class EventsController: TelegramBaseController { self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style self.tabBarItem.title = "События" - self.tabBarItem.image = UIImage(systemName: "calendar") - self.tabBarItem.selectedImage = UIImage(systemName: "calendar") + let tabIcon: UIImage? = UIImage(bundleImageName: "Chat List/Tabs/IconEvents").flatMap { icon in + let size = CGSize(width: 18, height: 18) + return UIGraphicsImageRenderer(size: size).image { _ in + icon.draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + } + self.tabBarItem.image = tabIcon + self.tabBarItem.selectedImage = tabIcon + if !self.presentationData.reduceMotion { + self.tabBarItem.animationName = "TabEvents" + } self.navigationItem.title = "События" - let addBtn = UIBarButtonItem(image: UIImage(systemName: "plus"), + let addBtn = UIBarButtonItem(image: UIImage(systemName: "plus.circle.fill"), style: .plain, target: self, action: #selector(addEventTapped)) addBtn.tintColor = .systemOrange @@ -336,6 +369,7 @@ public final class EventsController: TelegramBaseController { guard let self else { return } self.presentationData = pd self.statusBar.statusBarStyle = pd.theme.rootController.statusBarStyle.style + self.tabBarItem.animationName = pd.reduceMotion ? nil : "TabEvents" }) allEvents = loadEvents() @@ -421,6 +455,12 @@ public final class EventsController: TelegramBaseController { emptyView.isHidden = true root.addSubview(emptyView) + // Search bar — always visible + searchBar.placeholder = "Поиск событий" + searchBar.searchBarStyle = .minimal + searchBar.delegate = self + root.addSubview(searchBar) + refreshMonthLabel() updateEventsList() } @@ -441,6 +481,7 @@ public final class EventsController: TelegramBaseController { let calW = w - calPad * 2 let cellW = calW / 7 let cellH: CGFloat = 48 + let isSearching = !searchText.isEmpty // Nav bar bottom — safe fallback let navBottom: CGFloat @@ -450,42 +491,59 @@ public final class EventsController: TelegramBaseController { 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 + // Search bar — always pinned below nav bar + let searchBarH: CGFloat = 52 + searchBar.frame = CGRect(x: 0, y: navBottom, width: w, height: searchBarH) + + let tableTop: CGFloat + if isSearching { + // Hide calendar components during search + monthHeaderView.isHidden = true + weekdayRow.isHidden = true + collectionView.isHidden = true + divider.isHidden = true + dateHeaderLabel.isHidden = true + tableTop = searchBar.frame.maxY + } else { + monthHeaderView.isHidden = false + weekdayRow.isHidden = false + collectionView.isHidden = false + divider.isHidden = false + dateHeaderLabel.isHidden = false + + // Month header + let headerH: CGFloat = 44 + monthHeaderView.frame = CGRect(x: 0, y: searchBar.frame.maxY, 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) + tableTop = dateHeaderLabel.frame.maxY } - 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 } @@ -531,9 +589,18 @@ public final class EventsController: TelegramBaseController { } private func reloadEvents() { - displayedEvents = allEvents - .filter { cal.isDate($0.startDate, inSameDayAs: selectedDate) } - .sorted { $0.startDate < $1.startDate } + if searchText.isEmpty { + displayedEvents = allEvents + .filter { cal.isDate($0.startDate, inSameDayAs: selectedDate) } + .sorted { $0.startDate < $1.startDate } + } else { + let q = searchText.lowercased() + displayedEvents = allEvents.filter { + $0.title.lowercased().contains(q) || + $0.location?.lowercased().contains(q) == true || + $0.participants.joined(separator: " ").lowercased().contains(q) + }.sorted { $0.startDate < $1.startDate } + } } private static let dateHeaderFmt: DateFormatter = { @@ -546,10 +613,14 @@ public final class EventsController: TelegramBaseController { 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 + if searchText.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 + } else { + dateHeaderLabel.text = "Результаты поиска" + } } private func refreshMonthLabel() { @@ -702,3 +773,35 @@ extension EventsController: UITableViewDataSource, UITableViewDelegate { return UISwipeActionsConfiguration(actions: [action]) } } + +// MARK: - UISearchBarDelegate + +extension EventsController: UISearchBarDelegate { + public func searchBar(_ searchBar: UISearchBar, textDidChange text: String) { + searchText = text + updateEventsList() + if let layout = validLayout { applyLayout(layout) } + } + + public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } + + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = "" + searchBar.resignFirstResponder() + searchText = "" + updateEventsList() + if let layout = validLayout { applyLayout(layout) } + } + + public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + } + + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + if searchText.isEmpty { + searchBar.setShowsCancelButton(false, animated: true) + } + } +}