Bläddra i källkod

Redesign Title Optimizer layout for clearer alignment and hierarchy.

Match the Post Generator patterns with a segmented tab control, two-column optimize view, and consistent spacing so the tool feels more polished and easier to scan.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 månad sedan
förälder
incheckning
5432d455d6

+ 5 - 5
Reddit App/Views/Components/TitleOptimizerComponents.swift

@@ -56,7 +56,7 @@ struct TitleVariantCountPicker: View {
     @Binding var selectedCount: TitleVariantCount
 
     var body: some View {
-        HStack(spacing: 6) {
+        HStack(spacing: 8) {
             ForEach(TitleVariantCount.allCases) { count in
                 Button {
                     selectedCount = count
@@ -64,10 +64,10 @@ struct TitleVariantCountPicker: View {
                     Text(count.label)
                         .font(.system(size: 10, weight: .semibold))
                         .foregroundStyle(selectedCount == count ? .white : AppTheme.textSecondary)
-                        .padding(.horizontal, 12)
-                        .padding(.vertical, 6)
+                        .frame(maxWidth: .infinity)
+                        .padding(.vertical, 7)
                         .background(
-                            Capsule()
+                            RoundedRectangle(cornerRadius: 7)
                                 .fill(
                                     selectedCount == count
                                         ? AppTheme.accentBlue
@@ -75,7 +75,7 @@ struct TitleVariantCountPicker: View {
                                 )
                         )
                         .overlay(
-                            Capsule()
+                            RoundedRectangle(cornerRadius: 7)
                                 .stroke(AppTheme.cardBorder, lineWidth: 1)
                         )
                 }

+ 243 - 137
Reddit App/Views/TitleOptimizerView.swift

@@ -4,24 +4,35 @@ struct TitleOptimizerView: View {
     @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: TitleOptimizerViewModel
 
+    private enum Layout {
+        static let horizontalPadding: CGFloat = 24
+        static let columnWidth: CGFloat = 300
+        static let columnSpacing: CGFloat = 20
+        static let sectionSpacing: CGFloat = 12
+    }
+
     var body: some View {
         VStack(spacing: 0) {
             header
-            tabBar
+            messageBanners
 
             ScrollView {
-                PostPageBoundary {
-                    switch viewModel.selectedTab {
-                    case .optimize:
-                        optimizeContent
-                    case .compare:
-                        compareContent
-                    case .preview:
-                        previewContent
+                VStack(alignment: .leading, spacing: 12) {
+                    tabBar
+
+                    PostPageBoundary {
+                        switch viewModel.selectedTab {
+                        case .optimize:
+                            optimizeContent
+                        case .compare:
+                            compareContent
+                        case .preview:
+                            previewContent
+                        }
                     }
                 }
-                .padding(.horizontal, 24)
-                .padding(.top, 8)
+                .padding(.horizontal, Layout.horizontalPadding)
+                .padding(.top, 12)
                 .padding(.bottom, 24)
             }
         }
@@ -47,7 +58,7 @@ struct TitleOptimizerView: View {
 
             headerActions
         }
-        .padding(.horizontal, 24)
+        .padding(.horizontal, Layout.horizontalPadding)
         .padding(.vertical, 16)
         .overlay(alignment: .bottom) {
             Rectangle()
@@ -56,6 +67,19 @@ struct TitleOptimizerView: View {
         }
     }
 
+    @ViewBuilder
+    private var messageBanners: some View {
+        if let error = viewModel.errorMessage {
+            PostGeneratorMessageBanner(message: error, isError: true)
+                .padding(.horizontal, Layout.horizontalPadding)
+                .padding(.top, 8)
+        } else if let success = viewModel.successMessage {
+            PostGeneratorMessageBanner(message: success, isError: false)
+                .padding(.horizontal, Layout.horizontalPadding)
+                .padding(.top, 8)
+        }
+    }
+
     private var headerActions: some View {
         HStack(spacing: 8) {
             Button {
@@ -105,44 +129,100 @@ struct TitleOptimizerView: View {
     // MARK: - Tab Bar
 
     private var tabBar: some View {
-        HStack(spacing: 4) {
+        HStack(spacing: 0) {
             ForEach(TitleOptimizerTab.allCases) { tab in
                 Button {
-                    viewModel.selectedTab = tab
+                    withAnimation(.easeInOut(duration: 0.15)) {
+                        viewModel.selectedTab = tab
+                    }
                 } label: {
                     Text(tab.title)
                         .font(.system(size: 11, weight: .semibold))
                         .foregroundStyle(viewModel.selectedTab == tab ? AppTheme.textPrimary : AppTheme.textSecondary)
-                        .padding(.horizontal, 14)
-                        .padding(.vertical, 8)
+                        .frame(maxWidth: .infinity)
+                        .padding(.vertical, 7)
                         .background(
                             viewModel.selectedTab == tab
                                 ? AppTheme.cardBackground
                                 : Color.clear
                         )
-                        .clipShape(RoundedRectangle(cornerRadius: 8))
+                        .clipShape(RoundedRectangle(cornerRadius: 7))
                 }
                 .buttonStyle(.plain)
             }
-
-            Spacer()
         }
-        .padding(.horizontal, 24)
-        .padding(.top, 12)
-        .padding(.bottom, 4)
+        .padding(3)
+        .frame(width: 300)
+        .background(AppTheme.panelBackground)
+        .clipShape(RoundedRectangle(cornerRadius: 9))
+        .overlay(
+            RoundedRectangle(cornerRadius: 9)
+                .stroke(AppTheme.cardBorder, lineWidth: 1)
+        )
     }
 
     // MARK: - Optimize
 
     private var optimizeContent: some View {
-        HStack(alignment: .top, spacing: 16) {
-            configurationPanel
-            editorPanel
+        ViewThatFits(in: .horizontal) {
+            optimizeColumns
+
+            VStack(alignment: .leading, spacing: Layout.columnSpacing) {
+                optimizeColumn(title: "Configuration", icon: "slider.horizontal.3") {
+                    configurationPanel
+                }
+                optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
+                    editorPanel
+                }
+            }
+        }
+    }
+
+    private var optimizeColumns: some View {
+        HStack(alignment: .top, spacing: Layout.columnSpacing) {
+            optimizeColumn(title: "Configuration", icon: "slider.horizontal.3") {
+                configurationPanel
+            }
+            .frame(width: Layout.columnWidth)
+
+            Rectangle()
+                .fill(AppTheme.border)
+                .frame(width: 1)
+                .padding(.vertical, 2)
+
+            optimizeColumn(title: "Title & Suggestions", icon: "textformat") {
+                editorPanel
+            }
+            .frame(maxWidth: .infinity)
+        }
+    }
+
+    private func optimizeColumn<Content: View>(
+        title: String,
+        icon: String,
+        @ViewBuilder content: () -> Content
+    ) -> some View {
+        VStack(alignment: .leading, spacing: Layout.sectionSpacing) {
+            sectionHeader(title: title, icon: icon)
+            content()
         }
     }
 
+    private func sectionHeader(title: String, icon: String) -> some View {
+        HStack(spacing: 6) {
+            Image(systemName: icon)
+                .font(.system(size: 10, weight: .semibold))
+                .foregroundStyle(AppTheme.accentBlue)
+            Text(title)
+                .font(.system(size: 11, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+        .textCase(.uppercase)
+        .tracking(0.4)
+    }
+
     private var configurationPanel: some View {
-        VStack(spacing: 12) {
+        VStack(spacing: Layout.sectionSpacing) {
             PostFormCard(title: "Target Subreddit", subtitle: "Tailor titles for the community") {
                 PostFormField(
                     label: "Subreddit",
@@ -173,13 +253,39 @@ struct TitleOptimizerView: View {
                 TitleGoalPicker(selectedGoal: $viewModel.draft.titleGoal)
             }
 
-            PostFormCard(title: "Post Context", subtitle: "Optional — affects preview style") {
+            PostFormCard(title: "Preview Context", subtitle: "Optional — affects preview style") {
                 VStack(alignment: .leading, spacing: 12) {
                     PostTypePicker(selectedType: Binding(
                         get: { viewModel.draft.postType },
                         set: { viewModel.selectPostType($0) }
                     ))
 
+                    HStack(spacing: 8) {
+                        PostTagToggle(
+                            title: "NSFW",
+                            systemImage: "exclamationmark.triangle.fill",
+                            activeColor: Color(hex: 0xEF4444),
+                            isOn: $viewModel.draft.isNSFW
+                        )
+                        .frame(maxWidth: .infinity)
+
+                        PostTagToggle(
+                            title: "Spoiler",
+                            systemImage: "eye.slash.fill",
+                            activeColor: AppTheme.textSecondary,
+                            isOn: $viewModel.draft.isSpoiler
+                        )
+                        .frame(maxWidth: .infinity)
+
+                        PostTagToggle(
+                            title: "OC",
+                            systemImage: "star.fill",
+                            activeColor: AppTheme.accentGreen,
+                            isOn: $viewModel.draft.isOC
+                        )
+                        .frame(maxWidth: .infinity)
+                    }
+
                     PostFormField(
                         label: "Flair",
                         placeholder: "Discussion, Question, Meme…",
@@ -188,44 +294,14 @@ struct TitleOptimizerView: View {
                 }
             }
 
-            PostFormCard(title: "Post Tags", subtitle: "Reddit content labels") {
-                HStack(spacing: 6) {
-                    PostTagToggle(
-                        title: "NSFW",
-                        systemImage: "exclamationmark.triangle.fill",
-                        activeColor: Color(hex: 0xEF4444),
-                        isOn: $viewModel.draft.isNSFW
-                    )
-                    PostTagToggle(
-                        title: "Spoiler",
-                        systemImage: "eye.slash.fill",
-                        activeColor: AppTheme.textSecondary,
-                        isOn: $viewModel.draft.isSpoiler
-                    )
-                    PostTagToggle(
-                        title: "OC",
-                        systemImage: "star.fill",
-                        activeColor: AppTheme.accentGreen,
-                        isOn: $viewModel.draft.isOC
-                    )
-                }
-            }
-
             PostFormCard(title: "Suggestions", subtitle: "How many variants to generate") {
                 TitleVariantCountPicker(selectedCount: $viewModel.draft.variantCount)
             }
-
-            if let error = viewModel.errorMessage {
-                PostGeneratorMessageBanner(message: error, isError: true)
-            } else if let success = viewModel.successMessage {
-                PostGeneratorMessageBanner(message: success, isError: false)
-            }
         }
-        .frame(width: 300)
     }
 
     private var editorPanel: some View {
-        VStack(spacing: 12) {
+        VStack(spacing: Layout.sectionSpacing) {
             PostFormCard(title: "Original Title", subtitle: "The title you want to improve") {
                 PostFormField(
                     label: "Post Title",
@@ -242,7 +318,6 @@ struct TitleOptimizerView: View {
 
             suggestionsSection
         }
-        .frame(maxWidth: .infinity)
     }
 
     @ViewBuilder
@@ -275,9 +350,9 @@ struct TitleOptimizerView: View {
     }
 
     private var characterCountFooter: some View {
-        HStack {
+        HStack(spacing: 12) {
             Text("\(viewModel.draft.originalTitle.count) / 300")
-                .font(.system(size: 10))
+                .font(.system(size: 10, weight: .medium, design: .monospaced))
                 .foregroundStyle(
                     viewModel.draft.originalTitle.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
                 )
@@ -288,74 +363,84 @@ struct TitleOptimizerView: View {
                 .font(.system(size: 10))
                 .foregroundStyle(AppTheme.textTertiary)
         }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 9)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                .fill(AppTheme.cardBackground)
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                        .stroke(AppTheme.cardBorder, lineWidth: 1)
+                )
+        )
     }
 
     // MARK: - Compare
 
     private var compareContent: some View {
-        VStack(spacing: 16) {
+        VStack(alignment: .leading, spacing: Layout.columnSpacing) {
+            compareHeader
+
             if viewModel.variants.isEmpty {
                 TitleOptimizerEmptyState(
                     icon: "arrow.left.arrow.right",
                     title: "Nothing to compare yet",
                     subtitle: "Generate title suggestions on the Optimize tab first."
                 )
+                .frame(maxWidth: .infinity)
             } else {
-                compareHeader
-
-                TitleCompareCard(
-                    title: viewModel.draft.originalTitle,
-                    score: viewModel.analysis?.overallScore,
-                    label: "Original",
-                    isOriginal: true,
-                    isSelected: viewModel.selectedVariantID == nil
-                )
+                compareBody
+            }
+        }
+    }
 
-                HStack {
-                    Rectangle()
-                        .fill(AppTheme.border)
-                        .frame(height: 1)
-                    Text("vs")
-                        .font(.system(size: 10, weight: .semibold))
-                        .foregroundStyle(AppTheme.textTertiary)
-                        .padding(.horizontal, 8)
-                    Rectangle()
-                        .fill(AppTheme.border)
-                        .frame(height: 1)
-                }
+    private var compareBody: some View {
+        VStack(spacing: Layout.sectionSpacing) {
+            TitleCompareCard(
+                title: viewModel.draft.originalTitle,
+                score: viewModel.analysis?.overallScore,
+                label: "Original",
+                isOriginal: true,
+                isSelected: viewModel.selectedVariantID == nil
+            )
 
-                VStack(spacing: 8) {
-                    ForEach(viewModel.variants) { variant in
-                        TitleCompareCard(
-                            title: variant.title,
-                            score: variant.score,
-                            label: "Variant #\(variantRank(variant))",
-                            isOriginal: false,
-                            isSelected: viewModel.selectedVariantID == variant.id
-                        )
-                        .onTapGesture {
-                            viewModel.selectVariant(variant)
-                        }
+            HStack {
+                Rectangle()
+                    .fill(AppTheme.border)
+                    .frame(height: 1)
+                Text("vs")
+                    .font(.system(size: 10, weight: .semibold))
+                    .foregroundStyle(AppTheme.textTertiary)
+                    .padding(.horizontal, 8)
+                Rectangle()
+                    .fill(AppTheme.border)
+                    .frame(height: 1)
+            }
+
+            VStack(spacing: 8) {
+                ForEach(viewModel.variants) { variant in
+                    TitleCompareCard(
+                        title: variant.title,
+                        score: variant.score,
+                        label: "Variant #\(variantRank(variant))",
+                        isOriginal: false,
+                        isSelected: viewModel.selectedVariantID == variant.id
+                    )
+                    .onTapGesture {
+                        viewModel.selectVariant(variant)
                     }
                 }
-
-                compareMetadata
             }
+
+            compareMetadata
         }
         .frame(maxWidth: 640)
         .frame(maxWidth: .infinity)
     }
 
     private var compareHeader: some View {
-        HStack {
-            VStack(alignment: .leading, spacing: 2) {
-                Text("Side-by-Side Comparison")
-                    .font(.system(size: 12, weight: .semibold))
-                    .foregroundStyle(AppTheme.textPrimary)
-                Text("Tap a variant to select it as active")
-                    .font(.system(size: 10))
-                    .foregroundStyle(AppTheme.textSecondary)
-            }
+        HStack(alignment: .top) {
+            sectionHeader(title: "Side-by-Side Comparison", icon: "arrow.left.arrow.right")
 
             Spacer()
 
@@ -368,15 +453,19 @@ struct TitleOptimizerView: View {
                         .font(.system(size: 10, weight: .semibold))
                         .foregroundStyle(AppTheme.textSecondary)
                 }
+                .padding(.top, 1)
             }
         }
     }
 
     private var compareMetadata: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 0) {
             metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
+            metadataDivider
             metadataRow(label: "Goal", value: viewModel.draft.titleGoal.title)
+            metadataDivider
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
+            metadataDivider
             metadataRow(label: "Active", value: viewModel.activeTitle)
         }
         .padding(14)
@@ -398,54 +487,64 @@ struct TitleOptimizerView: View {
     // MARK: - Preview
 
     private var previewContent: some View {
-        VStack(spacing: 16) {
-            Text("Live Preview")
-                .font(.system(size: 12, weight: .semibold))
-                .foregroundStyle(AppTheme.textSecondary)
-                .frame(maxWidth: .infinity, alignment: .leading)
+        VStack(alignment: .leading, spacing: Layout.columnSpacing) {
+            sectionHeader(title: "Live Preview", icon: "eye")
 
-            RedditPostPreviewCard(
-                draft: viewModel.previewDraft,
-                formattedSubreddit: viewModel.formattedSubreddit
-            )
+            HStack {
+                Spacer(minLength: 0)
 
-            PostFormCard(title: "Feed Preview", subtitle: "How your title looks in a crowded feed") {
-                VStack(spacing: 6) {
-                    TitleFeedPreviewRow(
-                        title: "Other trending post in this subreddit right now",
-                        subreddit: viewModel.formattedSubreddit
-                    )
-                    TitleFeedPreviewRow(
-                        title: viewModel.activeTitle,
-                        subreddit: viewModel.formattedSubreddit,
-                        isHighlighted: true
-                    )
-                    TitleFeedPreviewRow(
-                        title: "Another popular discussion happening nearby",
-                        subreddit: viewModel.formattedSubreddit
+                VStack(spacing: Layout.sectionSpacing) {
+                    RedditPostPreviewCard(
+                        draft: viewModel.previewDraft,
+                        formattedSubreddit: viewModel.formattedSubreddit
                     )
+
+                    PostFormCard(title: "Feed Preview", subtitle: "How your title looks in a crowded feed") {
+                        VStack(spacing: 6) {
+                            TitleFeedPreviewRow(
+                                title: "Other trending post in this subreddit right now",
+                                subreddit: viewModel.formattedSubreddit
+                            )
+                            TitleFeedPreviewRow(
+                                title: viewModel.activeTitle,
+                                subreddit: viewModel.formattedSubreddit,
+                                isHighlighted: true
+                            )
+                            TitleFeedPreviewRow(
+                                title: "Another popular discussion happening nearby",
+                                subreddit: viewModel.formattedSubreddit
+                            )
+                        }
+                    }
+
+                    previewMetadata
                 }
-            }
+                .frame(maxWidth: 560)
 
-            previewMetadata
+                Spacer(minLength: 0)
+            }
         }
-        .frame(maxWidth: 560)
-        .frame(maxWidth: .infinity)
     }
 
     private var previewMetadata: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 0) {
             metadataRow(label: "Active Title", value: viewModel.activeTitle)
+            metadataDivider
             metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
+            metadataDivider
             metadataRow(label: "Goal", value: viewModel.draft.titleGoal.title)
+            metadataDivider
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
+            metadataDivider
             metadataRow(label: "Type", value: viewModel.draft.postType.title)
 
             if let score = viewModel.analysis?.overallScore {
+                metadataDivider
                 metadataRow(label: "Score", value: "\(score)/100")
             }
 
             if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
+                metadataDivider
                 metadataRow(
                     label: "Tags",
                     value: [
@@ -470,12 +569,19 @@ struct TitleOptimizerView: View {
         )
     }
 
+    private var metadataDivider: some View {
+        Rectangle()
+            .fill(AppTheme.border)
+            .frame(height: 1)
+            .padding(.vertical, 6)
+    }
+
     private func metadataRow(label: String, value: String) -> some View {
-        HStack(alignment: .top) {
+        HStack(alignment: .top, spacing: 12) {
             Text(label)
                 .font(.system(size: 10, weight: .medium))
                 .foregroundStyle(AppTheme.textTertiary)
-                .frame(width: 80, alignment: .leading)
+                .frame(width: 72, alignment: .leading)
             Text(value)
                 .font(.system(size: 10))
                 .foregroundStyle(AppTheme.textSecondary)