Events tab: card navigator, floating button, group invite flow

- 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 <noreply@anthropic.com>
This commit is contained in:
akaevmikail17 2026-06-07 12:10:59 +03:00
parent ffd82647ee
commit faa73a6ec2
11 changed files with 1969 additions and 1 deletions

View file

@ -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

View file

@ -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:

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "calendar_24.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,156 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 3.834991 3.835022 cm
0.000000 0.000000 0.000000 scn
4.665000 16.330017 m
5.032270 16.330017 5.330000 16.032286 5.330000 15.665017 c
5.330000 15.330014 l
5.436179 15.330017 l
5.465001 15.330017 l
10.865002 15.330017 l
10.893824 15.330017 l
10.929527 15.330017 10.964918 15.330018 11.000000 15.330014 c
11.000000 15.665017 l
11.000000 16.032286 11.297730 16.330017 11.665000 16.330017 c
12.032269 16.330017 12.330000 16.032286 12.330000 15.665017 c
12.330000 15.316912 l
12.530820 15.310531 12.716895 15.301043 12.889549 15.286936 c
13.430929 15.242703 13.898640 15.149774 14.328878 14.930556 c
15.018492 14.579180 15.579165 14.018507 15.930542 13.328892 c
16.149759 12.898653 16.242689 12.430944 16.286922 11.889564 c
16.330013 11.362141 16.330009 10.709471 16.330004 9.893824 c
16.330004 9.865017 l
16.330004 5.465019 l
16.330004 5.436213 l
16.330009 4.620564 16.330013 3.967896 16.286922 3.440473 c
16.242689 2.899092 16.149759 2.431382 15.930542 2.001143 c
15.579165 1.311530 15.018492 0.750856 14.328878 0.399480 c
13.898640 0.180264 13.430929 0.087334 12.889549 0.043100 c
12.362140 0.000010 11.709491 0.000013 10.893873 0.000019 c
10.893807 0.000019 l
10.865002 0.000019 l
5.465001 0.000019 l
5.436195 0.000019 l
5.436131 0.000019 l
4.620513 0.000013 3.967864 0.000010 3.440454 0.043100 c
2.899074 0.087334 2.431364 0.180264 2.001125 0.399480 c
1.311511 0.750856 0.750837 1.311530 0.399462 2.001143 c
0.180244 2.431382 0.087314 2.899092 0.043082 3.440473 c
-0.000010 3.967892 -0.000006 4.620557 0.000000 5.436197 c
0.000000 5.465019 l
0.000000 9.865017 l
0.000000 9.893839 l
0.000000 9.893844 l
-0.000006 10.709482 -0.000010 11.362145 0.043082 11.889564 c
0.087314 12.430944 0.180244 12.898653 0.399462 13.328892 c
0.750837 14.018507 1.311511 14.579180 2.001125 14.930556 c
2.431364 15.149774 2.899074 15.242703 3.440454 15.286936 c
3.613107 15.301043 3.799181 15.310531 4.000000 15.316912 c
4.000000 15.665017 l
4.000000 16.032286 4.297731 16.330017 4.665000 16.330017 c
h
11.000000 14.000007 m
11.000000 13.665017 l
11.000000 13.297748 11.297730 13.000017 11.665000 13.000017 c
12.032269 13.000017 12.330000 13.297748 12.330000 13.665017 c
12.330000 13.986263 l
12.493963 13.980605 12.643336 13.972620 12.781244 13.961353 c
13.240376 13.923841 13.513575 13.853280 13.725071 13.745518 c
14.164429 13.521653 14.521639 13.164444 14.745503 12.725085 c
14.853266 12.513588 14.923826 12.240391 14.961339 11.781260 c
14.999486 11.314363 15.000003 10.716069 15.000003 9.865017 c
15.000003 5.465019 l
15.000003 4.613967 14.999486 4.015673 14.961339 3.548777 c
14.923826 3.089645 14.853266 2.816447 14.745503 2.604951 c
14.521639 2.165593 14.164429 1.808383 13.725071 1.584518 c
13.513575 1.476756 13.240376 1.406196 12.781244 1.368683 c
12.314349 1.330536 11.716054 1.330019 10.865002 1.330019 c
5.465001 1.330019 l
4.613949 1.330019 4.015654 1.330536 3.548759 1.368683 c
3.089627 1.406196 2.816429 1.476756 2.604933 1.584518 c
2.165574 1.808383 1.808365 2.165593 1.584500 2.604951 c
1.476737 2.816447 1.406177 3.089645 1.368665 3.548777 c
1.330518 4.015673 1.330001 4.613967 1.330001 5.465019 c
1.330001 9.865017 l
1.330001 10.716069 1.330518 11.314363 1.368665 11.781260 c
1.406177 12.240391 1.476737 12.513588 1.584500 12.725085 c
1.808365 13.164444 2.165574 13.521653 2.604933 13.745518 c
2.816429 13.853280 3.089627 13.923841 3.548759 13.961353 c
3.686666 13.972620 3.836038 13.980605 4.000000 13.986263 c
4.000000 13.665017 l
4.000000 13.297748 4.297731 13.000017 4.665000 13.000017 c
5.032270 13.000017 5.330000 13.297748 5.330000 13.665017 c
5.330000 14.000007 l
5.465001 14.000017 l
10.865002 14.000017 l
10.910725 14.000017 10.955717 14.000015 11.000000 14.000007 c
h
3.365005 11.230013 m
2.997736 11.230013 2.700005 10.932281 2.700005 10.565012 c
2.700005 10.197743 2.997736 9.900013 3.365005 9.900013 c
12.965006 9.900013 l
13.332275 9.900013 13.630005 10.197743 13.630005 10.565012 c
13.630005 10.932281 13.332275 11.230013 12.965006 11.230013 c
3.365005 11.230013 l
h
f*
n
Q
endstream
endobj
3 0 obj
4054
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000004144 00000 n
0000004167 00000 n
0000004340 00000 n
0000004414 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
4473
%%EOF

View file

@ -7259,7 +7259,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.setupEventFloatingButtonIfNeeded()
if self.willAppear {
self.chatDisplayNode.historyNode.refreshPollActionsForVisibleMessages()
} else {

View file

@ -0,0 +1,191 @@
import Foundation
import UIKit
import TelegramCore
import AccountContext
// MARK: - Floating button view
final class EventFloatingButton: UIView {
private let iconLabel = UILabel()
private let badge = UIView()
private var onTap: (() -> Void)?
private var lastTouchWasDrag = false
init(onTap: @escaping () -> Void) {
self.onTap = onTap
super.init(frame: CGRect(x: 0, y: 0, width: 52, height: 52))
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
layer.cornerRadius = 26
backgroundColor = UIColor.systemOrange.withAlphaComponent(0.88)
alpha = 0.65
layer.shadowColor = UIColor.black.cgColor
layer.shadowOffset = CGSize(width: 0, height: 3)
layer.shadowOpacity = 0.25
layer.shadowRadius = 6
iconLabel.text = "📅"
iconLabel.font = .systemFont(ofSize: 22)
iconLabel.textAlignment = .center
iconLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(iconLabel)
NSLayoutConstraint.activate([
iconLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
iconLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tap)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.minimumNumberOfTouches = 1
addGestureRecognizer(pan)
}
func updateEventCount(_ count: Int) {
badge.subviews.forEach { $0.removeFromSuperview() }
badge.removeFromSuperview()
guard count > 0 else { return }
let label = UILabel()
label.text = "\(count)"
label.font = .systemFont(ofSize: 10, weight: .bold)
label.textColor = .white
label.sizeToFit()
let size: CGFloat = max(16, label.frame.width + 8)
badge.frame = CGRect(x: frame.width - size / 2 - 2, y: -4, width: size, height: 16)
badge.backgroundColor = .systemRed
badge.layer.cornerRadius = 8
label.center = CGPoint(x: size / 2, y: 8)
badge.addSubview(label)
addSubview(badge)
}
@objc private func handleTap() {
guard !lastTouchWasDrag else { return }
UIView.animate(withDuration: 0.1, animations: {
self.transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
}) { _ in
UIView.animate(withDuration: 0.15) { self.transform = .identity }
}
onTap?()
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let superview = superview else { return }
switch gesture.state {
case .began:
lastTouchWasDrag = false
UIView.animate(withDuration: 0.15) { self.alpha = 0.85 }
case .changed:
let t = gesture.translation(in: superview)
if abs(t.x) > 4 || abs(t.y) > 4 { lastTouchWasDrag = true }
center = CGPoint(x: center.x + t.x, y: center.y + t.y)
gesture.setTranslation(.zero, in: superview)
clampToSuperview(superview)
case .ended, .cancelled:
snapToEdge(superview)
UIView.animate(withDuration: 0.2) { self.alpha = 0.65 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { self.lastTouchWasDrag = false }
default:
break
}
}
private func clampToSuperview(_ sv: UIView) {
let pad: CGFloat = 8
let halfW = bounds.width / 2
let halfH = bounds.height / 2
let safeTop = (sv as? UIWindow)?.safeAreaInsets.top ?? 44
let safeBottom = (sv as? UIWindow)?.safeAreaInsets.bottom ?? 34
center = CGPoint(
x: max(halfW + pad, min(sv.bounds.width - halfW - pad, center.x)),
y: max(halfH + safeTop + 8, min(sv.bounds.height - halfH - safeBottom - 80, center.y))
)
}
private func snapToEdge(_ sv: UIView) {
let pad: CGFloat = 16
let targetX: CGFloat = center.x < sv.bounds.midX
? bounds.width / 2 + pad
: sv.bounds.width - bounds.width / 2 - pad
UIView.animate(withDuration: 0.35, delay: 0,
usingSpringWithDamping: 0.75, initialSpringVelocity: 0.5, options: []) {
self.center.x = targetX
}
}
}
// MARK: - ChatControllerImpl extension
extension ChatControllerImpl {
private static let floatingEventButtonTag = 77421
func setupEventFloatingButtonIfNeeded() {
let peer = presentationInterfaceState.renderedPeer?.peer
let isGroup: Bool
if peer is TelegramGroup {
isGroup = true
} else if let ch = peer as? TelegramChannel, case .group = ch.info {
isGroup = true
} else {
isGroup = false
}
if !isGroup {
view.viewWithTag(Self.floatingEventButtonTag)?.removeFromSuperview()
return
}
// Already installed.
if view.viewWithTag(Self.floatingEventButtonTag) != nil { return }
guard let peerId = chatLocation.peerId else { return }
let chatId = peerId.id._internalGetInt64Value()
let button = EventFloatingButton { [weak self] in
guard let self else { return }
let nav = UINavigationController(rootViewController: EventCardNavigatorController(chatId: chatId))
nav.modalPresentationStyle = .pageSheet
if #available(iOS 15.0, *) {
if let sheet = nav.sheetPresentationController {
sheet.detents = [.large()]
sheet.prefersGrabberVisible = true
}
}
self.present(nav, animated: true)
}
button.tag = Self.floatingEventButtonTag
// Initial position: right edge, vertically centered.
let bw: CGFloat = 52
button.frame = CGRect(
x: view.bounds.width - bw - 16,
y: view.bounds.height / 2,
width: bw, height: bw
)
view.addSubview(button)
refreshEventFloatingButtonBadge(chatId: chatId)
}
func refreshEventFloatingButtonBadge(chatId: Int64) {
guard let button = view.viewWithTag(Self.floatingEventButtonTag) as? EventFloatingButton else { return }
let key = "tg_events_v1"
let stored = (try? JSONDecoder().decode([TGEvent].self,
from: UserDefaults.standard.data(forKey: key) ?? Data())) ?? []
let votes = (try? JSONDecoder().decode([String: String].self,
from: UserDefaults.standard.data(forKey: "tg_event_votes_v1") ?? Data())) ?? [:]
let unvotedCount = stored
.filter { $0.chatId == chatId }
.filter { votes[$0.id.uuidString] == nil }
.count
button.updateEventCount(unvotedCount)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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])
}
}

View file

@ -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!)