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
This commit is contained in:
@@ -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<Content: View>(@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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user