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:
2026-03-05 12:31:27 +03:00
parent d268bc5696
commit d50eaf250d
2 changed files with 404 additions and 58 deletions

View File

@@ -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
}
}

View File

@@ -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) {