diff --git a/ios/Ratebubble.xcodeproj/project.xcworkspace/xcuserdata/wisecolt-macmini.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ratebubble.xcodeproj/project.xcworkspace/xcuserdata/wisecolt-macmini.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..f1d9b41 Binary files /dev/null and b/ios/Ratebubble.xcodeproj/project.xcworkspace/xcuserdata/wisecolt-macmini.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ratebubble/Resources/Config.xcconfig b/ios/Ratebubble/Resources/Config.xcconfig index 027fa81..e6a49a2 100644 --- a/ios/Ratebubble/Resources/Config.xcconfig +++ b/ios/Ratebubble/Resources/Config.xcconfig @@ -1,5 +1,5 @@ SLASH = / -API_BASE_URL = http:$(SLASH)$(SLASH)localhost:3000 -MOBILE_API_KEY = mobile-dev-key-change-me +API_BASE_URL = http:$(SLASH)$(SLASH)192.168.1.124:3000 +MOBILE_API_KEY = mobile-app-key-change-me-in-production APP_GROUP_ID = group.net.wisecolt.ratebubble APP_URL_SCHEME = ratebubble diff --git a/ios/Ratebubble/Resources/RatebubbleShare-Info.plist b/ios/Ratebubble/Resources/RatebubbleShare-Info.plist index 57404f2..5f65ef7 100644 --- a/ios/Ratebubble/Resources/RatebubbleShare-Info.plist +++ b/ios/Ratebubble/Resources/RatebubbleShare-Info.plist @@ -14,6 +14,14 @@ $(PRODUCT_NAME) CFBundleDisplayName Ratebubble Share + API_BASE_URL + $(API_BASE_URL) + MOBILE_API_KEY + $(MOBILE_API_KEY) + APP_GROUP_ID + $(APP_GROUP_ID) + APP_URL_SCHEME + $(APP_URL_SCHEME) CFBundlePackageType XPC! CFBundleShortVersionString diff --git a/ios/Ratebubble/ShareExtension/ShareViewController.swift b/ios/Ratebubble/ShareExtension/ShareViewController.swift index 3f096c5..cf8047e 100644 --- a/ios/Ratebubble/ShareExtension/ShareViewController.swift +++ b/ios/Ratebubble/ShareExtension/ShareViewController.swift @@ -2,58 +2,280 @@ import UIKit import UniformTypeIdentifiers 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() { super.viewDidLoad() - setupUI() + view.backgroundColor = .systemBackground + setupHeader() + setupLoadingViews() + setupScrollView() Task { await handleIncomingShare() } } - private func setupUI() { - view.backgroundColor = .systemBackground - statusLabel.translatesAutoresizingMaskIntoConstraints = false - statusLabel.textAlignment = .center - statusLabel.numberOfLines = 0 - statusLabel.text = "Paylaşılan bağlantı alınıyor..." + // MARK: – Setup + + private func setupHeader() { + headerView.backgroundColor = .systemBackground + headerView.translatesAutoresizingMaskIntoConstraints = false + 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([ - statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), - statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + 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 updateStatus(_ text: String) { - statusLabel.text = text + private func setupLoadingViews() { + spinner.translatesAutoresizingMaskIntoConstraints = false + 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 { - guard let item = extensionContext?.inputItems.first as? NSExtensionItem, - let providers = item.attachments else { - updateStatus("Paylaşılan içerik okunamadı.") + 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) { + // Ana uygulama arka planda açılırsa URL'i görmesi için sakla 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 } } - 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? { if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { return await withCheckedContinuation { continuation 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) { return await withCheckedContinuation { continuation 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) return } @@ -76,32 +298,24 @@ final class ShareViewController: UIViewController { 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"] + let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com"] - let isNetflix = netflixHosts.contains(host) - let isPrime = primeHosts.contains(host) - guard isNetflix || isPrime else { return false } + 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 } - - // Some share links can be shortened/redirect style without a canonical path. return !path.isEmpty && path != "/" } - private func openHostApp() { - guard let url = URL(string: "\(SharedConfig.appURLScheme)://ingest") else { - extensionContext?.completeRequest(returningItems: nil) - return - } - - 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) + 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 } + let range = NSRange(text.startIndex..