mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Events tab: TGEventAttribute, participant list, source icon, tab icon size
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
c83d57a7d5
commit
29d2c08102
9 changed files with 561 additions and 94 deletions
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
12
submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ic_tb_events.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
29
submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf
vendored
Normal file
29
submodules/TelegramUI/Images.xcassets/Chat List/Tabs/IconEvents.imageset/ic_tb_events.pdf
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 30 30] /Contents 4 0 R /Resources << >> >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Filter /FlateDecode /Length 224 >>
|
||||
stream
|
||||
xœm<EFBFBD>Kà ÷9'@ø>9FÏ€TuA½ÿ¦‰1‘<31>ª(<28>‰äg<C3A4>¿[ð.ÜÏý±ûñ¶sË>„àÀçé°+ìÉçTQøŠ>îqBâ³M¤cFõ#9ÒR›5ök.·4S1Q÷h‚IÊ¢N"ø<>ökîãÞÛáÓÈÊ4wGä#êdÂ3Áµ·IÄ%íáÄÜÎ1“d7Ò§È(‡Æî*7¬:¦êm"I˪ç“<C3A7>1ºñê´¸cq‡q‡qÇâŽÅÆÆ‹;-òdìÉèÓâOËÈl€Ì
|
||||
èÏ^ÛRü”5
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000217 00000 n
|
||||
trailer
|
||||
<< /Size 5 /Root 1 0 R >>
|
||||
startxref
|
||||
513
|
||||
%%EOF
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())) ?? []
|
||||
|
|
|
|||
|
|
@ -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..<text.endIndex) else { continue }
|
||||
struct LegacyMarker: Decodable { let i: String; let t: String; let s: Double; let e: Double; let l: String? }
|
||||
let jsonStr = String(text[start.upperBound..<end.lowerBound])
|
||||
guard let data = jsonStr.data(using: .utf8),
|
||||
let m = try? JSONDecoder().decode(LegacyMarker.self, from: data),
|
||||
let uuid = UUID(uuidString: m.i) else { continue }
|
||||
found.append(TGEvent(
|
||||
id: uuid, title: m.t,
|
||||
startDate: Date(timeIntervalSince1970: m.s),
|
||||
endDate: Date(timeIntervalSince1970: m.e),
|
||||
participants: [], location: m.l, chatId: chatId
|
||||
))
|
||||
}
|
||||
return found
|
||||
} |> 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// Search bar — always pinned below nav bar
|
||||
let searchBarH: CGFloat = 52
|
||||
searchBar.frame = CGRect(x: 0, y: navBottom, width: w, height: searchBarH)
|
||||
|
||||
// Weekday labels
|
||||
weekdayRow.frame = CGRect(x: calPad, y: monthHeaderView.frame.maxY,
|
||||
width: calW, height: 28)
|
||||
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
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue