Files
ratebubble/ios/Ratebubble/ShareExtension/ShareViewController.swift
wisecolt d50eaf250d feat(ios): align app UX with share extension interactions
- redesign main app screen to dark card-based layout

- add half-star drag/tap rating with haptic feedback

- add in-app comments list and composer interactions
2026-03-05 12:31:27 +03:00

839 lines
36 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
import UniformTypeIdentifiers
final class ShareViewController: UIViewController, UITextViewDelegate {
private enum ViewState {
case loading
case success(GetInfoResponse)
case error(String)
}
private struct CommentItem {
let user: String
let body: String
let time: String
}
private var selectedRating = 0.0
private var starButtons: [UIButton] = []
private var comments: [CommentItem] = []
private let headerView = UIView()
private let headerLabel = UILabel()
private let closeButton = UIButton(type: .system)
private let overlayView = UIView()
private let spinner = UIActivityIndicatorView(style: .large)
private let overlayLabel = UILabel()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let backdropContainer = UIView()
private let backdropImageView = UIImageView()
private let gradientLayer = CAGradientLayer()
private let providerBadge = UILabel()
private let heroTitleLabel = UILabel()
private let heroMetaLabel = UILabel()
private let genreScroll = UIScrollView()
private let genreStack = UIStackView()
private let plotLabel = UILabel()
private let castLabel = UILabel()
private let commentsListStack = UIStackView()
private let commentTextView = UITextView()
private let commentPlaceholderLabel = UILabel()
private let submitCommentButton = UIButton(type: .system)
private let starsRow = UIStackView()
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
private var dismissPanStartTransform: CGAffineTransform = .identity
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .dark
// Prevent system pull-down dismiss that only closes this extension UI.
// We'll handle downward dismiss ourselves and always call completeRequest.
isModalInPresentation = true
view.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 1)
setupHeader()
setupScrollView()
setupOverlay()
setupFeedback()
Task { await handleIncomingShare() }
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
gradientLayer.frame = backdropContainer.bounds
}
private func setupFeedback() {
hoverHaptic.prepare()
}
private func setupHeader() {
headerView.backgroundColor = UIColor(red: 0.06, green: 0.06, blue: 0.08, alpha: 0.96)
headerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(headerView)
headerLabel.text = "Ratebubble"
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
headerLabel.textColor = .white
headerLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(headerLabel)
closeButton.setTitle("Kapat", for: .normal)
closeButton.setTitleColor(.systemGray2, for: .normal)
closeButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
closeButton.accessibilityLabel = "Ekranı kapat"
closeButton.accessibilityHint = "Paylaşım ekranını kapatır"
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
headerView.addSubview(closeButton)
let dismissPan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan(_:)))
dismissPan.maximumNumberOfTouches = 1
headerView.addGestureRecognizer(dismissPan)
let separator = UIView()
separator.backgroundColor = UIColor.white.withAlphaComponent(0.08)
separator.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(separator)
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 46),
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
closeButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16),
closeButton.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
separator.heightAnchor.constraint(equalToConstant: 0.5)
])
}
private func setupScrollView() {
scrollView.alwaysBounceVertical = true
scrollView.keyboardDismissMode = .interactive
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.isHidden = true
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
setupBackdrop()
setupContentStack()
setupMetadataSection()
setupRatingSection()
setupCommentsSection()
}
private func setupBackdrop() {
backdropContainer.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.clipsToBounds = true
scrollView.addSubview(backdropContainer)
backdropImageView.translatesAutoresizingMaskIntoConstraints = false
backdropImageView.contentMode = .scaleAspectFill
backdropImageView.backgroundColor = UIColor(red: 0.10, green: 0.10, blue: 0.12, alpha: 1)
backdropImageView.alpha = 0
backdropContainer.addSubview(backdropImageView)
gradientLayer.colors = [
UIColor.clear.cgColor,
UIColor.black.withAlphaComponent(0.40).cgColor,
UIColor.black.withAlphaComponent(0.92).cgColor
]
gradientLayer.locations = [0.25, 0.55, 1.0]
backdropContainer.layer.addSublayer(gradientLayer)
providerBadge.font = .systemFont(ofSize: 10, weight: .bold)
providerBadge.textColor = .white
providerBadge.layer.cornerRadius = 10
providerBadge.layer.masksToBounds = true
providerBadge.textAlignment = .center
providerBadge.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.addSubview(providerBadge)
heroTitleLabel.font = .systemFont(ofSize: 28, weight: .heavy)
heroTitleLabel.textColor = .white
heroTitleLabel.numberOfLines = 2
heroTitleLabel.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.addSubview(heroTitleLabel)
heroMetaLabel.font = .systemFont(ofSize: 14, weight: .medium)
heroMetaLabel.textColor = UIColor.white.withAlphaComponent(0.78)
heroMetaLabel.translatesAutoresizingMaskIntoConstraints = false
backdropContainer.addSubview(heroMetaLabel)
NSLayoutConstraint.activate([
backdropContainer.topAnchor.constraint(equalTo: scrollView.topAnchor),
backdropContainer.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
backdropContainer.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
backdropContainer.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
backdropContainer.heightAnchor.constraint(equalToConstant: 248),
backdropImageView.topAnchor.constraint(equalTo: backdropContainer.topAnchor),
backdropImageView.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor),
backdropImageView.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor),
backdropImageView.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor),
providerBadge.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
providerBadge.bottomAnchor.constraint(equalTo: heroTitleLabel.topAnchor, constant: -10),
providerBadge.heightAnchor.constraint(equalToConstant: 20),
providerBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 78),
heroTitleLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
heroTitleLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
heroTitleLabel.bottomAnchor.constraint(equalTo: heroMetaLabel.topAnchor, constant: -5),
heroMetaLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
heroMetaLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
heroMetaLabel.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: -16)
])
}
private func setupContentStack() {
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentStack)
NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: 14),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 14),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -14),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -28),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -30)
])
}
private func setupMetadataSection() {
let card = makeSectionCard()
let stack = makeCardStack()
genreScroll.showsHorizontalScrollIndicator = false
genreScroll.translatesAutoresizingMaskIntoConstraints = false
genreScroll.heightAnchor.constraint(equalToConstant: 32).isActive = true
genreStack.axis = .horizontal
genreStack.spacing = 8
genreStack.translatesAutoresizingMaskIntoConstraints = false
genreScroll.addSubview(genreStack)
NSLayoutConstraint.activate([
genreStack.topAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.topAnchor),
genreStack.leadingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.leadingAnchor),
genreStack.trailingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.trailingAnchor),
genreStack.bottomAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.bottomAnchor),
genreStack.heightAnchor.constraint(equalTo: genreScroll.frameLayoutGuide.heightAnchor)
])
plotLabel.font = .systemFont(ofSize: 14)
plotLabel.textColor = UIColor.white.withAlphaComponent(0.84)
plotLabel.numberOfLines = 6
castLabel.font = .systemFont(ofSize: 13, weight: .regular)
castLabel.textColor = UIColor.white.withAlphaComponent(0.66)
castLabel.numberOfLines = 2
stack.addArrangedSubview(genreScroll)
stack.addArrangedSubview(plotLabel)
stack.addArrangedSubview(castLabel)
card.addSubview(stack)
pinCardStack(stack, in: card)
contentStack.addArrangedSubview(card)
}
private func setupRatingSection() {
let card = makeSectionCard()
let stack = makeCardStack()
let title = UILabel()
title.text = "Puanla"
title.font = .systemFont(ofSize: 17, weight: .semibold)
title.textColor = .white
let subtitle = UILabel()
subtitle.text = "Puanını istediğin zaman değiştirebilirsin."
subtitle.font = .systemFont(ofSize: 12, weight: .regular)
subtitle.textColor = UIColor.white.withAlphaComponent(0.55)
starsRow.axis = .horizontal
starsRow.alignment = .center
starsRow.distribution = .fillEqually
starsRow.spacing = 4
starsRow.translatesAutoresizingMaskIntoConstraints = false
starsRow.isUserInteractionEnabled = true
starsRow.heightAnchor.constraint(equalToConstant: 48).isActive = true
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleStarPan(_:)))
panGesture.maximumNumberOfTouches = 1
starsRow.addGestureRecognizer(panGesture)
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
for i in 1...5 {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "star", withConfiguration: symbolConfig), for: .normal)
button.tintColor = UIColor.white.withAlphaComponent(0.18)
button.tag = i
button.addTarget(self, action: #selector(starTapped(_:forEvent:)), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: 46).isActive = true
button.accessibilityLabel = "\(i) yıldız"
button.accessibilityHint = "Puanı ayarlar"
starButtons.append(button)
starsRow.addArrangedSubview(button)
}
stack.addArrangedSubview(title)
stack.addArrangedSubview(subtitle)
stack.addArrangedSubview(starsRow)
card.addSubview(stack)
pinCardStack(stack, in: card)
contentStack.addArrangedSubview(card)
}
private func setupCommentsSection() {
let card = makeSectionCard()
let stack = makeCardStack()
let title = UILabel()
title.text = "Yorumlar"
title.font = .systemFont(ofSize: 17, weight: .semibold)
title.textColor = .white
commentsListStack.axis = .vertical
commentsListStack.spacing = 10
commentsListStack.translatesAutoresizingMaskIntoConstraints = false
let composerContainer = UIView()
composerContainer.translatesAutoresizingMaskIntoConstraints = false
composerContainer.backgroundColor = UIColor.white.withAlphaComponent(0.06)
composerContainer.layer.cornerRadius = 14
composerContainer.layer.borderWidth = 1
composerContainer.layer.borderColor = UIColor.white.withAlphaComponent(0.08).cgColor
commentTextView.backgroundColor = .clear
commentTextView.textColor = .white
commentTextView.font = .systemFont(ofSize: 14)
commentTextView.tintColor = .systemRed
commentTextView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
commentTextView.translatesAutoresizingMaskIntoConstraints = false
commentTextView.delegate = self
commentTextView.isScrollEnabled = true
commentTextView.heightAnchor.constraint(equalToConstant: 96).isActive = true
commentPlaceholderLabel.text = "Yorumunu yaz..."
commentPlaceholderLabel.textColor = UIColor.white.withAlphaComponent(0.38)
commentPlaceholderLabel.font = .systemFont(ofSize: 14)
commentPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false
submitCommentButton.setTitle("Gönder", for: .normal)
submitCommentButton.setTitleColor(.white, for: .normal)
submitCommentButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
submitCommentButton.layer.cornerRadius = 10
submitCommentButton.contentEdgeInsets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
submitCommentButton.translatesAutoresizingMaskIntoConstraints = false
submitCommentButton.addTarget(self, action: #selector(submitCommentTapped), for: .touchUpInside)
submitCommentButton.isEnabled = false
submitCommentButton.alpha = 0.5
submitCommentButton.accessibilityLabel = "Yorumu gönder"
composerContainer.addSubview(commentTextView)
composerContainer.addSubview(commentPlaceholderLabel)
composerContainer.addSubview(submitCommentButton)
NSLayoutConstraint.activate([
commentTextView.topAnchor.constraint(equalTo: composerContainer.topAnchor),
commentTextView.leadingAnchor.constraint(equalTo: composerContainer.leadingAnchor),
commentTextView.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor),
commentPlaceholderLabel.leadingAnchor.constraint(equalTo: commentTextView.leadingAnchor, constant: 14),
commentPlaceholderLabel.topAnchor.constraint(equalTo: commentTextView.topAnchor, constant: 12),
submitCommentButton.topAnchor.constraint(equalTo: commentTextView.bottomAnchor, constant: 8),
submitCommentButton.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor, constant: -10),
submitCommentButton.bottomAnchor.constraint(equalTo: composerContainer.bottomAnchor, constant: -10)
])
stack.addArrangedSubview(title)
stack.addArrangedSubview(commentsListStack)
stack.addArrangedSubview(composerContainer)
card.addSubview(stack)
pinCardStack(stack, in: card)
contentStack.addArrangedSubview(card)
}
private func pinCardStack(_ stack: UIStackView, in card: UIView) {
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
])
}
private func makeSectionCard() -> UIView {
let card = UIView()
card.backgroundColor = UIColor.white.withAlphaComponent(0.05)
card.layer.cornerRadius = 16
card.layer.borderWidth = 1
card.layer.borderColor = UIColor.white.withAlphaComponent(0.07).cgColor
return card
}
private func makeCardStack() -> UIStackView {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}
@MainActor
private func apply(_ state: ViewState) {
switch state {
case .loading:
overlayView.isHidden = false
scrollView.isHidden = true
spinner.startAnimating()
overlayLabel.text = "İçerik analiz ediliyor..."
case .success(let info):
if info.provider == "netflix" {
providerBadge.text = " NETFLIX "
providerBadge.backgroundColor = UIColor(red: 0.90, green: 0.11, blue: 0.15, alpha: 0.95)
} else if info.provider == "primevideo" {
providerBadge.text = " PRIME VIDEO "
providerBadge.backgroundColor = UIColor(red: 0.05, green: 0.62, blue: 0.90, alpha: 0.95)
} else {
providerBadge.text = " \(info.provider.uppercased()) "
providerBadge.backgroundColor = UIColor.white.withAlphaComponent(0.20)
}
heroTitleLabel.text = info.title
var parts: [String] = []
if let year = info.year { parts.append("\(year)") }
parts.append(info.type == "movie" ? "Film" : "Dizi")
if let season = info.currentSeason { parts.append("Sezon \(season)") }
heroMetaLabel.text = parts.joined(separator: "")
genreStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if info.genres.isEmpty {
genreScroll.isHidden = true
} else {
genreScroll.isHidden = false
info.genres.forEach { genreStack.addArrangedSubview(makeChip($0)) }
}
if let plot = info.plot, !plot.isEmpty {
plotLabel.text = plot
plotLabel.isHidden = false
} else {
plotLabel.text = nil
plotLabel.isHidden = true
}
if info.cast.isEmpty {
castLabel.text = nil
castLabel.isHidden = true
} else {
castLabel.text = "Oyuncular: \(info.cast.prefix(7).joined(separator: ", "))"
castLabel.isHidden = false
}
renderComments()
if let urlString = info.backdrop, let imageURL = URL(string: urlString) {
Task {
guard let (data, _) = try? await URLSession.shared.data(from: imageURL),
let image = UIImage(data: data) else { return }
await MainActor.run {
self.backdropImageView.image = image
UIView.animate(withDuration: 0.35) {
self.backdropImageView.alpha = 1
}
}
}
} else {
backdropImageView.image = nil
backdropImageView.alpha = 0
}
scrollView.isHidden = false
spinner.stopAnimating()
UIView.animate(withDuration: 0.20, animations: {
self.overlayView.alpha = 0
}, completion: { _ in
self.overlayView.isHidden = true
self.overlayView.alpha = 1
})
case .error(let message):
overlayView.isHidden = false
scrollView.isHidden = true
spinner.stopAnimating()
overlayLabel.text = message
}
}
private func makeChip(_ text: String) -> UIView {
var cfg = UIButton.Configuration.filled()
cfg.title = text
cfg.baseForegroundColor = .white
cfg.baseBackgroundColor = UIColor.white.withAlphaComponent(0.10)
cfg.cornerStyle = .capsule
cfg.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)
cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in
var attrs = attrs
attrs.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
return attrs
}
let button = UIButton(configuration: cfg)
button.isUserInteractionEnabled = false
return button
}
@objc private func starTapped(_ sender: UIButton, forEvent event: UIEvent?) {
let touchLocation = event?.allTouches?.first?.location(in: sender) ?? CGPoint(x: sender.bounds.midX, y: sender.bounds.midY)
let isLeftHalf = touchLocation.x < sender.bounds.midX
let rating = Double(sender.tag - 1) + (isLeftHalf ? 0.5 : 1.0)
updateRating(to: rating, animatedFrom: sender, withHaptic: true)
}
@objc private func handleStarPan(_ gesture: UIPanGestureRecognizer) {
let point = gesture.location(in: starsRow)
guard starsRow.bounds.width > 0 else { return }
switch gesture.state {
case .began:
hoverHaptic.prepare()
fallthrough
case .changed:
let clampedX = min(max(point.x, 0), starsRow.bounds.width - 0.001)
let ratio = clampedX / starsRow.bounds.width
let starCount = Double(max(starButtons.count, 1))
let rawValue = ratio * starCount
let halfStepped = max(0.5, min(starCount, (rawValue * 2).rounded(.up) / 2))
let value = halfStepped
updateRating(to: value, animatedFrom: nil, withHaptic: true)
default:
break
}
}
private func updateRating(to newValue: Double, animatedFrom sourceButton: UIButton?, withHaptic: Bool) {
let maxRating = Double(starButtons.count)
let clamped = min(max(newValue, 0.5), maxRating)
guard abs(clamped - selectedRating) > 0.001 else { return }
selectedRating = clamped
refreshStars(animatedFrom: sourceButton)
if withHaptic {
hoverHaptic.impactOccurred(intensity: 1.0)
hoverHaptic.prepare()
}
}
private func refreshStars(animatedFrom sourceButton: UIButton? = nil) {
let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
for button in starButtons {
let buttonValue = Double(button.tag)
let imageName: String
if selectedRating >= buttonValue {
imageName = "star.fill"
} else if selectedRating >= (buttonValue - 0.5) {
imageName = "star.leadinghalf.filled"
} else {
imageName = "star"
}
button.setImage(UIImage(systemName: imageName, withConfiguration: config), for: .normal)
button.tintColor = imageName == "star" ? UIColor.white.withAlphaComponent(0.18) : UIColor(red: 0.96, green: 0.74, blue: 0.20, alpha: 1.0)
let isActive = (selectedRating >= (buttonValue - 0.5))
if isActive && sourceButton === button {
UIView.animate(withDuration: 0.10, animations: {
button.transform = CGAffineTransform(scaleX: 1.18, y: 1.18)
}, completion: { _ in
UIView.animate(withDuration: 0.10) {
button.transform = .identity
}
})
}
}
}
private func setupOverlay() {
overlayView.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 0.96)
overlayView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayView)
spinner.color = .white
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.hidesWhenStopped = true
overlayView.addSubview(spinner)
overlayLabel.font = .systemFont(ofSize: 15, weight: .medium)
overlayLabel.textColor = UIColor.white.withAlphaComponent(0.82)
overlayLabel.textAlignment = .center
overlayLabel.numberOfLines = 0
overlayLabel.translatesAutoresizingMaskIntoConstraints = false
overlayView.addSubview(overlayLabel)
NSLayoutConstraint.activate([
overlayView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
spinner.centerXAnchor.constraint(equalTo: overlayView.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: overlayView.centerYAnchor, constant: -18),
overlayLabel.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
overlayLabel.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor, constant: 24),
overlayLabel.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor, constant: -24)
])
spinner.startAnimating()
overlayLabel.text = "İçerik analiz ediliyor..."
}
private func renderComments() {
commentsListStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
guard !comments.isEmpty else {
let emptyState = UILabel()
emptyState.text = "Henüz yorum yok. İlk yorumu sen yaz."
emptyState.textColor = UIColor.white.withAlphaComponent(0.55)
emptyState.font = .systemFont(ofSize: 13, weight: .medium)
emptyState.numberOfLines = 0
commentsListStack.addArrangedSubview(emptyState)
return
}
for item in comments {
commentsListStack.addArrangedSubview(makeCommentBubble(item))
}
}
private func makeCommentBubble(_ item: CommentItem) -> UIView {
let bubble = UIView()
bubble.backgroundColor = UIColor.white.withAlphaComponent(0.07)
bubble.layer.cornerRadius = 12
bubble.layer.borderWidth = 1
bubble.layer.borderColor = UIColor.white.withAlphaComponent(0.06).cgColor
let userLabel = UILabel()
userLabel.font = .systemFont(ofSize: 12, weight: .semibold)
userLabel.textColor = .white
userLabel.text = "@\(item.user)"
userLabel.translatesAutoresizingMaskIntoConstraints = false
let bodyLabel = UILabel()
bodyLabel.font = .systemFont(ofSize: 13, weight: .regular)
bodyLabel.textColor = UIColor.white.withAlphaComponent(0.83)
bodyLabel.numberOfLines = 0
bodyLabel.text = item.body
bodyLabel.translatesAutoresizingMaskIntoConstraints = false
let timeLabel = UILabel()
timeLabel.font = .systemFont(ofSize: 11, weight: .regular)
timeLabel.textColor = UIColor.white.withAlphaComponent(0.50)
timeLabel.text = item.time
timeLabel.translatesAutoresizingMaskIntoConstraints = false
bubble.addSubview(userLabel)
bubble.addSubview(bodyLabel)
bubble.addSubview(timeLabel)
NSLayoutConstraint.activate([
userLabel.topAnchor.constraint(equalTo: bubble.topAnchor, constant: 10),
userLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
userLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
bodyLabel.topAnchor.constraint(equalTo: userLabel.bottomAnchor, constant: 6),
bodyLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
bodyLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
timeLabel.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 8),
timeLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
timeLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
timeLabel.bottomAnchor.constraint(equalTo: bubble.bottomAnchor, constant: -10)
])
return bubble
}
@objc private func submitCommentTapped() {
let text = commentTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
comments.insert(CommentItem(user: "sen", body: text, time: "Şimdi"), at: 0)
commentTextView.text = ""
textViewDidChange(commentTextView)
renderComments()
hoverHaptic.impactOccurred(intensity: 0.35)
hoverHaptic.prepare()
submitCommentButton.setTitle("Gönderildi", for: .normal)
submitCommentButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.9)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
self.submitCommentButton.setTitle("Gönder", for: .normal)
self.submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
}
}
func textViewDidChange(_ textView: UITextView) {
commentPlaceholderLabel.isHidden = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasText = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
submitCommentButton.alpha = hasText ? 1.0 : 0.5
submitCommentButton.isEnabled = hasText
}
@MainActor
private func handleIncomingShare() async {
apply(.loading)
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let providers = items.flatMap { $0.attachments ?? [] }
guard !providers.isEmpty else {
apply(.error("Paylaşılan içerik okunamadı."))
return
}
for provider in providers {
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
let normalized = normalizeURL(extracted)
SharedPayloadStore.saveIncomingURL(normalized.absoluteString)
do {
let info = try await APIClient.shared.getInfo(url: normalized.absoluteString)
apply(.success(info))
} catch {
apply(.error("Bilgiler alınamadı.\n\(error.localizedDescription)"))
}
return
}
}
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
}
@objc private func closeTapped() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
@objc private func handleDismissPan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
let velocity = gesture.velocity(in: view)
switch gesture.state {
case .began:
dismissPanStartTransform = headerView.transform
case .changed:
let downY = max(0, translation.y)
let progress = min(downY / 140.0, 1.0)
headerView.transform = dismissPanStartTransform.translatedBy(x: 0, y: downY * 0.3)
headerView.alpha = 1.0 - (progress * 0.25)
case .ended, .cancelled, .failed:
let shouldDismiss = translation.y > 90 || velocity.y > 900
if shouldDismiss {
closeTapped()
return
}
UIView.animate(withDuration: 0.18) {
self.headerView.transform = .identity
self.headerView.alpha = 1.0
}
default:
break
}
}
private func extractURL(from provider: NSItemProvider) async -> URL? {
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
return await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
if let url = item as? URL {
continuation.resume(returning: url)
return
}
if let raw = item as? String {
continuation.resume(returning: Self.firstURL(in: raw))
return
}
continuation.resume(returning: nil)
}
}
}
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
return await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
if let raw = item as? String, let url = Self.firstURL(in: raw) {
continuation.resume(returning: url)
return
}
continuation.resume(returning: nil)
}
}
}
return nil
}
private func normalizeURL(_ url: URL) -> URL {
let host = url.host?.lowercased() ?? ""
guard host == "app.primevideo.com" else { return url }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let gti = components?.queryItems?.first(where: { $0.name == "gti" }) {
components?.queryItems = [gti]
} else {
components?.queryItems = nil
}
return components?.url ?? url
}
private func isSupportedStreamingURL(_ url: URL) -> Bool {
let host = url.host?.lowercased() ?? ""
let netflixHosts = ["www.netflix.com", "netflix.com", "www.netflix.com.tr", "netflix.com.tr"]
let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com", "app.primevideo.com"]
guard netflixHosts.contains(host) || primeHosts.contains(host) else { return false }
let path = url.path.lowercased()
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
return true
}
return !path.isEmpty && path != "/"
}
private static func firstURL(in raw: String) -> URL? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: text), url.scheme?.isEmpty == false {
return url
}
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return nil
}
return detector.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text))?.url
}
}