diff --git a/ios/Ratebubble/App/ContentView.swift b/ios/Ratebubble/App/ContentView.swift index 5360bae..e1f8ce0 100644 --- a/ios/Ratebubble/App/ContentView.swift +++ b/ios/Ratebubble/App/ContentView.swift @@ -1,82 +1,418 @@ import SwiftUI +import UIKit struct ContentView: View { @StateObject private var viewModel = MainViewModel() @Environment(\.scenePhase) private var scenePhase + @State private var selectedRating: Double = 0 + @State private var commentDraft = "" + @State private var comments: [CommentItem] = [] + @State private var interactionKey = "" + + private let hoverHaptic = UIImpactFeedbackGenerator(style: .light) + private let submitHaptic = UIImpactFeedbackGenerator(style: .medium) + var body: some View { NavigationStack { - Form { - Section("Paylaşılan Link") { - TextField("https://www.netflix.com/tr/title/...", text: $viewModel.sharedURL) + ZStack { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.05, blue: 0.07), + Color(red: 0.02, green: 0.02, blue: 0.03) + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + urlComposerCard + + if viewModel.isLoading { + loadingCard + } + + if let error = viewModel.errorMessage { + errorCard(error) + } + + if let result = viewModel.result { + resultCard(result) + ratingCard + commentsCard + } else if !viewModel.isLoading { + emptyCard + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Ratebubble") + .font(.headline) + .foregroundStyle(.white) + } + } + } + .preferredColorScheme(.dark) + .onAppear { + hoverHaptic.prepare() + submitHaptic.prepare() + viewModel.consumeSharedURLIfAny() + } + .onOpenURL { _ in viewModel.consumeSharedURLIfAny() } + .onChange(of: scenePhase) { phase in + if phase == .active { viewModel.consumeSharedURLIfAny() } + } + } + + private var urlComposerCard: some View { + card { + VStack(alignment: .leading, spacing: 12) { + Text("Paylaşılan Link") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + + HStack(spacing: 10) { + Image(systemName: "link") + .foregroundStyle(.white.opacity(0.65)) + TextField("https://www.netflix.com/title/...", text: $viewModel.sharedURL) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .keyboardType(.URL) - - Button("Backend'den Getir") { - Task { await viewModel.fetch() } - } - .disabled(viewModel.isLoading) + .foregroundStyle(.white) } + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) - if viewModel.isLoading { - Section { - HStack { - ProgressView() - Text("Veri alınıyor...") + Button { + Task { await viewModel.fetch() } + } label: { + HStack { + Spacer() + Text(viewModel.isLoading ? "Analiz Ediliyor..." : "İçeriği Analiz Et") + .font(.subheadline.weight(.semibold)) + Spacer() + } + .padding(.vertical, 11) + .background( + LinearGradient( + colors: [Color.red.opacity(0.95), Color.orange.opacity(0.9)], + startPoint: .leading, + endPoint: .trailing + ), + in: RoundedRectangle(cornerRadius: 12) + ) + } + .disabled(viewModel.isLoading) + .foregroundStyle(.white) + .opacity(viewModel.isLoading ? 0.7 : 1) + } + } + } + + private var loadingCard: some View { + card { + HStack(spacing: 12) { + ProgressView() + Text("İçerik çözülüyor ve metadata hazırlanıyor...") + .font(.footnote) + .foregroundStyle(.white.opacity(0.8)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func errorCard(_ message: String) -> some View { + card { + VStack(alignment: .leading, spacing: 8) { + Text("Bir sorun oluştu") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.red.opacity(0.95)) + Text(message) + .font(.footnote) + .foregroundStyle(.white.opacity(0.82)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func resultCard(_ result: GetInfoResponse) -> some View { + let key = contentKey(result) + + return card { + VStack(alignment: .leading, spacing: 10) { + providerBadge(result.provider) + Text(result.title) + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + .lineLimit(3) + + Text(metaLine(result)) + .font(.footnote) + .foregroundStyle(.white.opacity(0.65)) + + if !result.genres.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(result.genres, id: \.self) { genre in + Text(genre) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.09), in: Capsule()) + } } } } - if let error = viewModel.errorMessage { - Section("Hata") { - Text(error) - .foregroundStyle(.red) + if let plot = result.plot, !plot.isEmpty { + Text(plot) + .font(.footnote) + .foregroundStyle(.white.opacity(0.84)) + .lineLimit(6) + } + } + .onAppear { prepareInteractionState(for: result, key: key) } + .onChange(of: key) { _ in prepareInteractionState(for: result, key: key) } + } + } + + private var ratingCard: some View { + card { + VStack(alignment: .leading, spacing: 10) { + Text("Puanla") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + Text("Yarım yıldız da seçebilirsin. Sürükleyerek hızlı puan ver.") + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + + StarRatingBar( + rating: $selectedRating, + onChanged: { changed in + guard changed else { return } + hoverHaptic.impactOccurred(intensity: 0.8) + hoverHaptic.prepare() + } + ) + .frame(height: 46) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var commentsCard: some View { + card { + VStack(alignment: .leading, spacing: 12) { + Text("Yorumlar") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + + if comments.isEmpty { + Text("Henüz yorum yok. İlk yorumu sen yaz.") + .font(.footnote) + .foregroundStyle(.white.opacity(0.58)) + } else { + ForEach(comments) { item in + CommentBubble(item: item) } } - if let result = viewModel.result { - Section("Sonuç") { - KeyValueRow(key: "Provider", value: result.provider) - KeyValueRow(key: "Title", value: result.title) - KeyValueRow(key: "Year", value: result.year.map(String.init) ?? "-") - KeyValueRow(key: "Type", value: result.type) - KeyValueRow(key: "Age Rating", value: result.ageRating ?? "-") - KeyValueRow(key: "Current Season", value: result.currentSeason.map(String.init) ?? "-") - KeyValueRow(key: "Genres", value: result.genres.joined(separator: ", ")) - KeyValueRow(key: "Cast", value: result.cast.joined(separator: ", ")) - KeyValueRow(key: "Plot", value: result.plot ?? "-") + VStack(alignment: .trailing, spacing: 8) { + ZStack(alignment: .topLeading) { + TextEditor(text: $commentDraft) + .scrollContentBackground(.hidden) + .foregroundStyle(.white) + .frame(minHeight: 88, maxHeight: 120) + .padding(8) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) + + if commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Yorumunu yaz...") + .font(.footnote) + .foregroundStyle(.white.opacity(0.38)) + .padding(.horizontal, 18) + .padding(.vertical, 18) + .allowsHitTesting(false) + } } + + Button { + submitComment() + } label: { + Text("Gönder") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.red.opacity(0.9), in: Capsule()) + } + .disabled(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .opacity(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.45 : 1) + .foregroundStyle(.white) } } - .navigationTitle("Ratebubble") + .frame(maxWidth: .infinity, alignment: .leading) } - .onAppear { - viewModel.consumeSharedURLIfAny() - } - .onOpenURL { _ in - viewModel.consumeSharedURLIfAny() - } - .onChange(of: scenePhase) { phase in - if phase == .active { - viewModel.consumeSharedURLIfAny() + } + + private var emptyCard: some View { + card { + VStack(alignment: .leading, spacing: 8) { + Text("Hazır") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + Text("Paylaşımdan gelen bir içerik varsa otomatik doldurulur. İstersen yukarıdan URL girip analiz başlat.") + .font(.footnote) + .foregroundStyle(.white.opacity(0.66)) } + .frame(maxWidth: .infinity, alignment: .leading) } } + + private func providerBadge(_ provider: String) -> some View { + Text(provider.uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + provider == "netflix" ? Color.red.opacity(0.92) : + (provider == "primevideo" ? Color.cyan.opacity(0.82) : Color.white.opacity(0.2)), + in: Capsule() + ) + } + + private func metaLine(_ result: GetInfoResponse) -> String { + var parts: [String] = [] + if let year = result.year { parts.append(String(year)) } + parts.append(result.type == "movie" ? "Film" : "Dizi") + if let currentSeason = result.currentSeason { parts.append("Sezon \(currentSeason)") } + return parts.joined(separator: " • ") + } + + private func contentKey(_ result: GetInfoResponse) -> String { + "\(result.provider)|\(result.title)|\(result.year.map(String.init) ?? "-")" + } + + private func prepareInteractionState(for result: GetInfoResponse, key: String) { + guard interactionKey != key else { return } + interactionKey = key + selectedRating = 0 + commentDraft = "" + comments = [ + CommentItem(user: "deniz", body: "Sinematografi çok temiz, finali de iyi bağlamışlar.", time: "2 saat önce"), + CommentItem(user: "melis", body: "\(result.title) için tempo yer yer düşse de genel deneyim çok keyifli.", time: "Dün") + ] + } + + private func submitComment() { + let trimmed = commentDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + comments.insert(CommentItem(user: "sen", body: trimmed, time: "Şimdi"), at: 0) + commentDraft = "" + submitHaptic.impactOccurred(intensity: 0.5) + submitHaptic.prepare() + } + + private func card(@ViewBuilder _ content: () -> Content) -> some View { + content() + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + ) + } } -private struct KeyValueRow: View { - let key: String - let value: String +private struct CommentItem: Identifiable { + let id = UUID() + let user: String + let body: String + let time: String +} + +private struct CommentBubble: View { + let item: CommentItem var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(key) - .font(.caption) - .foregroundStyle(.secondary) - Text(value.isEmpty ? "-" : value) - .font(.body) + VStack(alignment: .leading, spacing: 6) { + Text("@\(item.user)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + Text(item.body) + .font(.footnote) + .foregroundStyle(.white.opacity(0.82)) + Text(item.time) + .font(.caption2) + .foregroundStyle(.white.opacity(0.5)) } - .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) + } +} + +private struct StarRatingBar: View { + @Binding var rating: Double + let onChanged: (Bool) -> Void + + var body: some View { + GeometryReader { geo in + HStack(spacing: 4) { + ForEach(1...5, id: \.self) { idx in + Image(systemName: iconName(for: idx)) + .font(.system(size: 30, weight: .medium)) + .foregroundStyle(iconColor(for: idx)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let changed = updateRating(from: value.location.x, width: geo.size.width) + onChanged(changed) + } + ) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Puan") + .accessibilityValue("\(String(format: "%.1f", rating)) yıldız") + } + + private func iconName(for index: Int) -> String { + let value = Double(index) + if rating >= value { return "star.fill" } + if rating >= value - 0.5 { return "star.leadinghalf.filled" } + return "star" + } + + private func iconColor(for index: Int) -> Color { + let value = Double(index) + return rating >= value - 0.5 + ? Color(red: 0.96, green: 0.74, blue: 0.20) + : Color.white.opacity(0.20) + } + + @discardableResult + private func updateRating(from x: CGFloat, width: CGFloat) -> Bool { + guard width > 0 else { return false } + let clampedX = min(max(x, 0), width - 0.001) + let raw = (clampedX / width) * 5.0 + let stepped = max(0.5, min(5.0, (raw * 2).rounded(.up) / 2)) + guard abs(stepped - rating) > 0.001 else { return false } + rating = stepped + return true } } diff --git a/ios/Ratebubble/ShareExtension/ShareViewController.swift b/ios/Ratebubble/ShareExtension/ShareViewController.swift index 60477d3..10a2f2b 100644 --- a/ios/Ratebubble/ShareExtension/ShareViewController.swift +++ b/ios/Ratebubble/ShareExtension/ShareViewController.swift @@ -90,6 +90,8 @@ final class ShareViewController: UIViewController, UITextViewDelegate { 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) @@ -296,6 +298,7 @@ final class ShareViewController: UIViewController, UITextViewDelegate { 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) } @@ -354,6 +357,7 @@ final class ShareViewController: UIViewController, UITextViewDelegate { 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) @@ -458,7 +462,6 @@ final class ShareViewController: UIViewController, UITextViewDelegate { castLabel.isHidden = false } - seedCommentsIfNeeded(for: info) renderComments() if let urlString = info.backdrop, let imageURL = URL(string: urlString) { @@ -615,17 +618,17 @@ final class ShareViewController: UIViewController, UITextViewDelegate { overlayLabel.text = "İçerik analiz ediliyor..." } - private func seedCommentsIfNeeded(for info: GetInfoResponse) { - guard comments.isEmpty else { return } - comments = [ - CommentItem(user: "deniz", body: "Görüntü yönetimi çok iyi, finali de güçlüydü.", time: "2 saat önce"), - CommentItem(user: "melis", body: "Tempo bazı bölümlerde düşüyor ama genel olarak keyifli.", time: "5 saat önce"), - CommentItem(user: "arda", body: "\(info.title) için müzik seçimleri efsane olmuş.", time: "Dün") - ] - } - 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)) } @@ -689,6 +692,13 @@ final class ShareViewController: UIViewController, UITextViewDelegate { 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) {