Files
ratebubble/ios/Ratebubble/App/ContentView.swift
wisecolt d50eaf250d 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
2026-03-05 12:31:27 +03:00

419 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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)
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 11)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
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 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)
}
}
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)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
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 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: 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))
}
.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
}
}