mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
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:
parent
ffd82647ee
commit
faa73a6ec2
11 changed files with 1969 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
12
submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "calendar_24.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
156
submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/calendar_24.pdf
vendored
Normal file
156
submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Event.imageset/calendar_24.pdf
vendored
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
191
submodules/TelegramUI/Sources/ChatControllerEventButton.swift
Normal file
191
submodules/TelegramUI/Sources/ChatControllerEventButton.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
437
submodules/TelegramUI/Sources/CreateEventController.swift
Normal file
437
submodules/TelegramUI/Sources/CreateEventController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
363
submodules/TelegramUI/Sources/EventCardNavigatorController.swift
Normal file
363
submodules/TelegramUI/Sources/EventCardNavigatorController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
701
submodules/TelegramUI/Sources/EventsController.swift
Normal file
701
submodules/TelegramUI/Sources/EventsController.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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!)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue