feat(UI): ios için güncellemeler içerir.

This commit is contained in:
2026-03-03 22:50:14 +03:00
parent 5c6a829a4d
commit 8bd4f24774
4 changed files with 264 additions and 42 deletions

View File

@@ -1,5 +1,5 @@
SLASH = / SLASH = /
API_BASE_URL = http:$(SLASH)$(SLASH)localhost:3000 API_BASE_URL = http:$(SLASH)$(SLASH)192.168.1.124:3000
MOBILE_API_KEY = mobile-dev-key-change-me MOBILE_API_KEY = mobile-app-key-change-me-in-production
APP_GROUP_ID = group.net.wisecolt.ratebubble APP_GROUP_ID = group.net.wisecolt.ratebubble
APP_URL_SCHEME = ratebubble APP_URL_SCHEME = ratebubble

View File

@@ -14,6 +14,14 @@
<string>$(PRODUCT_NAME)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Ratebubble Share</string> <string>Ratebubble Share</string>
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
<key>MOBILE_API_KEY</key>
<string>$(MOBILE_API_KEY)</string>
<key>APP_GROUP_ID</key>
<string>$(APP_GROUP_ID)</string>
<key>APP_URL_SCHEME</key>
<string>$(APP_URL_SCHEME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XPC!</string> <string>XPC!</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -2,58 +2,280 @@ import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
final class ShareViewController: UIViewController { final class ShareViewController: UIViewController {
private let statusLabel = UILabel()
// MARK: View state
private enum ViewState {
case loading
case success(GetInfoResponse)
case error(String)
}
// MARK: Header
private let headerView = UIView()
private let headerLabel = UILabel()
private let closeButton = UIButton(type: .system)
// MARK: Loading / error
private let spinner = UIActivityIndicatorView(style: .large)
private let messageLabel = UILabel()
// MARK: Metadata card (genişletilebilir buraya puan/yorum ekleyebilirsin)
private let scrollView = UIScrollView()
/// Bu stack'e ileride yıldız seçici, yorum alanı vb. ekleyebilirsin.
let contentStack = UIStackView()
private let providerLabel = UILabel()
private let titleLabel = UILabel()
private let metaLabel = UILabel()
private let genresLabel = UILabel()
private let plotLabel = UILabel()
// MARK: Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupUI() view.backgroundColor = .systemBackground
setupHeader()
setupLoadingViews()
setupScrollView()
Task { await handleIncomingShare() } Task { await handleIncomingShare() }
} }
private func setupUI() { // MARK: Setup
view.backgroundColor = .systemBackground
statusLabel.translatesAutoresizingMaskIntoConstraints = false private func setupHeader() {
statusLabel.textAlignment = .center headerView.backgroundColor = .systemBackground
statusLabel.numberOfLines = 0 headerView.translatesAutoresizingMaskIntoConstraints = false
statusLabel.text = "Paylaşılan bağlantı alınıyor..." view.addSubview(headerView)
headerLabel.text = "Ratebubble"
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
headerLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(headerLabel)
closeButton.setTitle("Kapat", for: .normal)
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
closeButton.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(closeButton)
let separator = UIView()
separator.backgroundColor = .separator
separator.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(separator)
view.addSubview(statusLabel)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 44),
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),
]) ])
} }
@MainActor private func setupLoadingViews() {
private func updateStatus(_ text: String) { spinner.translatesAutoresizingMaskIntoConstraints = false
statusLabel.text = text spinner.hidesWhenStopped = true
view.addSubview(spinner)
messageLabel.text = "Analiz ediliyor..."
messageLabel.textColor = .secondaryLabel
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
messageLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(messageLabel)
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -16),
messageLabel.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
messageLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
messageLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
])
spinner.startAnimating()
} }
private func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = true
scrollView.isHidden = true
view.addSubview(scrollView)
contentStack.axis = .vertical
contentStack.spacing = 8
contentStack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentStack)
// Provider (NETFLIX / PRIME VIDEO)
providerLabel.font = .systemFont(ofSize: 11, weight: .bold)
// Title
titleLabel.font = .systemFont(ofSize: 26, weight: .bold)
titleLabel.numberOfLines = 3
// Year · Type · Season
metaLabel.font = .systemFont(ofSize: 14)
metaLabel.textColor = .secondaryLabel
// Genres
genresLabel.font = .systemFont(ofSize: 13)
genresLabel.textColor = .secondaryLabel
genresLabel.numberOfLines = 2
// Plot
plotLabel.font = .systemFont(ofSize: 14)
plotLabel.numberOfLines = 5
plotLabel.textColor = .label
contentStack.addArrangedSubview(providerLabel)
contentStack.addArrangedSubview(titleLabel)
contentStack.setCustomSpacing(4, after: providerLabel)
contentStack.addArrangedSubview(metaLabel)
contentStack.addArrangedSubview(genresLabel)
contentStack.setCustomSpacing(12, after: genresLabel)
contentStack.addArrangedSubview(plotLabel)
// Buraya ileride yorum/puan UI'ı eklenecek
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),
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 20),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -20),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -40),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -32),
])
}
// MARK: State application
@MainActor
private func apply(_ state: ViewState) {
switch state {
case .loading:
spinner.startAnimating()
messageLabel.text = "Analiz ediliyor..."
messageLabel.isHidden = false
scrollView.isHidden = true
case .success(let info):
spinner.stopAnimating()
messageLabel.isHidden = true
switch info.provider {
case "netflix":
providerLabel.text = "NETFLIX"
providerLabel.textColor = .systemRed
case "primevideo":
providerLabel.text = "PRIME VIDEO"
providerLabel.textColor = .systemCyan
default:
providerLabel.text = info.provider.uppercased()
providerLabel.textColor = .secondaryLabel
}
titleLabel.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)") }
metaLabel.text = parts.joined(separator: " · ")
if info.genres.isEmpty {
genresLabel.isHidden = true
} else {
genresLabel.text = info.genres.joined(separator: ", ")
genresLabel.isHidden = false
}
if let plot = info.plot, !plot.isEmpty {
plotLabel.text = plot
plotLabel.isHidden = false
} else {
plotLabel.isHidden = true
}
scrollView.isHidden = false
case .error(let message):
spinner.stopAnimating()
messageLabel.text = message
messageLabel.isHidden = false
scrollView.isHidden = true
}
}
// MARK: Share handling
@MainActor
private func handleIncomingShare() async { private func handleIncomingShare() async {
guard let item = extensionContext?.inputItems.first as? NSExtensionItem, let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let providers = item.attachments else { let providers = items.flatMap { $0.attachments ?? [] }
updateStatus("Paylaşılan içerik okunamadı.")
guard !providers.isEmpty else {
apply(.error("Paylaşılan içerik okunamadı."))
return return
} }
for provider in providers { for provider in providers {
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) { if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
// Ana uygulama arka planda açılırsa URL'i görmesi için sakla
SharedPayloadStore.saveIncomingURL(extracted.absoluteString) SharedPayloadStore.saveIncomingURL(extracted.absoluteString)
updateStatus("Bağlantı alındı, uygulama açılıyor...")
openHostApp() do {
let info = try await APIClient.shared.getInfo(url: extracted.absoluteString)
apply(.success(info))
} catch {
apply(.error("Bilgiler alınamadı:\n\(error.localizedDescription)"))
}
return return
} }
} }
updateStatus("Geçerli bir Netflix/Prime Video linki bulunamadı.") apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
} }
// MARK: Actions
@objc private func closeTapped() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
// MARK: URL helpers
private func extractURL(from provider: NSItemProvider) async -> URL? { private func extractURL(from provider: NSItemProvider) async -> URL? {
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
continuation.resume(returning: item as? URL) 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)
} }
} }
} }
@@ -61,7 +283,7 @@ final class ShareViewController: UIViewController {
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
if let raw = item as? String, let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) { if let raw = item as? String, let url = Self.firstURL(in: raw) {
continuation.resume(returning: url) continuation.resume(returning: url)
return return
} }
@@ -76,32 +298,24 @@ final class ShareViewController: UIViewController {
private func isSupportedStreamingURL(_ url: URL) -> Bool { private func isSupportedStreamingURL(_ url: URL) -> Bool {
let host = url.host?.lowercased() ?? "" let host = url.host?.lowercased() ?? ""
let netflixHosts = ["www.netflix.com", "netflix.com", "www.netflix.com.tr", "netflix.com.tr"] 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"] let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com"]
let isNetflix = netflixHosts.contains(host) guard netflixHosts.contains(host) || primeHosts.contains(host) else { return false }
let isPrime = primeHosts.contains(host)
guard isNetflix || isPrime else { return false }
let path = url.path.lowercased() let path = url.path.lowercased()
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") { if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
return true return true
} }
// Some share links can be shortened/redirect style without a canonical path.
return !path.isEmpty && path != "/" return !path.isEmpty && path != "/"
} }
private func openHostApp() { private static func firstURL(in raw: String) -> URL? {
guard let url = URL(string: "\(SharedConfig.appURLScheme)://ingest") else { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
extensionContext?.completeRequest(returningItems: nil) if let url = URL(string: text), url.scheme?.isEmpty == false { return url }
return guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
} return nil
extensionContext?.open(url) { success in
// If opening succeeded, the system should transition to the host app.
// Completing the extension request immediately can bounce back to the source app.
guard !success else { return }
self.extensionContext?.completeRequest(returningItems: nil)
} }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return detector.firstMatch(in: text, options: [], range: range)?.url
} }
} }