feat(UI): ios için güncellemeler içerir.
This commit is contained in:
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user