Bläddra i källkod

Redesign Post Generator layout for clearer alignment and hierarchy.

Use a segmented tab control, two-column compose sections, and consistent spacing so the form feels more polished and easier to scan.

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

+ 2 - 1
Reddit App/Views/Components/PostGeneratorComponents.swift

@@ -208,9 +208,10 @@ struct PostTagToggle: View {
                 Text(title)
                     .font(.system(size: 10, weight: .semibold))
             }
+            .frame(maxWidth: .infinity)
             .foregroundStyle(isOn ? activeColor : AppTheme.textSecondary)
             .padding(.horizontal, 10)
-            .padding(.vertical, 6)
+            .padding(.vertical, 7)
             .background(
                 RoundedRectangle(cornerRadius: 6)
                     .fill(isOn ? activeColor.opacity(0.15) : AppTheme.panelBackground)

+ 182 - 92
Reddit App/Views/PostGeneratorView.swift

@@ -6,23 +6,33 @@ struct PostGeneratorView: View {
     @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: PostGeneratorViewModel
 
+    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
             messageBanners
-            tabBar
 
             ScrollView {
-                PostPageBoundary {
-                    switch viewModel.selectedTab {
-                    case .compose:
-                        composeContent
-                    case .preview:
-                        previewContent
+                VStack(alignment: .leading, spacing: 12) {
+                    tabBar
+
+                    PostPageBoundary {
+                        switch viewModel.selectedTab {
+                        case .compose:
+                            composeContent
+                        case .preview:
+                            previewContent
+                        }
                     }
                 }
-                .padding(.horizontal, 24)
-                .padding(.top, 8)
+                .padding(.horizontal, Layout.horizontalPadding)
+                .padding(.top, 12)
                 .padding(.bottom, 24)
             }
         }
@@ -90,7 +100,7 @@ struct PostGeneratorView: View {
 
             headerActions
         }
-        .padding(.horizontal, 24)
+        .padding(.horizontal, Layout.horizontalPadding)
         .padding(.vertical, 16)
         .overlay(alignment: .bottom) {
             Rectangle()
@@ -103,11 +113,11 @@ struct PostGeneratorView: View {
     private var messageBanners: some View {
         if let error = viewModel.errorMessage {
             PostGeneratorMessageBanner(message: error, isError: true)
-                .padding(.horizontal, 24)
+                .padding(.horizontal, Layout.horizontalPadding)
                 .padding(.top, 8)
         } else if let success = viewModel.successMessage {
             PostGeneratorMessageBanner(message: success, isError: false)
-                .padding(.horizontal, 24)
+                .padding(.horizontal, Layout.horizontalPadding)
                 .padding(.top, 8)
         }
     }
@@ -161,51 +171,100 @@ struct PostGeneratorView: View {
     // MARK: - Tab Bar
 
     private var tabBar: some View {
-        HStack(spacing: 4) {
+        HStack(spacing: 0) {
             ForEach(PostGeneratorTab.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: 220)
+        .background(AppTheme.panelBackground)
+        .clipShape(RoundedRectangle(cornerRadius: 9))
+        .overlay(
+            RoundedRectangle(cornerRadius: 9)
+                .stroke(AppTheme.cardBorder, lineWidth: 1)
+        )
     }
 
     // MARK: - Compose
 
     private var composeContent: some View {
         ViewThatFits(in: .horizontal) {
-            HStack(alignment: .top, spacing: 16) {
-                configurationPanel
-                editorPanel
+            composeColumns
+
+            VStack(alignment: .leading, spacing: Layout.columnSpacing) {
+                composeColumn(title: "Configuration", icon: "slider.horizontal.3") {
+                    configurationPanel
+                }
+                composeColumn(title: "Post Content", icon: "doc.text") {
+                    editorPanel
+                }
             }
+        }
+    }
 
-            VStack(alignment: .leading, spacing: 16) {
+    private var composeColumns: some View {
+        HStack(alignment: .top, spacing: Layout.columnSpacing) {
+            composeColumn(title: "Configuration", icon: "slider.horizontal.3") {
                 configurationPanel
+            }
+            .frame(width: Layout.columnWidth)
+
+            Rectangle()
+                .fill(AppTheme.border)
+                .frame(width: 1)
+                .padding(.vertical, 2)
+
+            composeColumn(title: "Post Content", icon: "doc.text") {
                 editorPanel
             }
+            .frame(maxWidth: .infinity)
+        }
+    }
+
+    private func composeColumn<Content: View>(
+        title: String,
+        icon: String,
+        @ViewBuilder content: () -> Content
+    ) -> some View {
+        VStack(alignment: .leading, spacing: Layout.sectionSpacing) {
+            composeSectionHeader(title: title, icon: icon)
+            content()
+        }
+    }
+
+    private func composeSectionHeader(title: String, icon: String) -> some View {
+        HStack(spacing: 6) {
+            Image(systemName: icon)
+                .font(.system(size: 10, weight: .semibold))
+                .foregroundStyle(AppTheme.accentPurpleLight)
+            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: "Post Type", subtitle: "Choose how you want to post") {
                 PostTypePicker(selectedType: Binding(
                     get: { viewModel.draft.postType },
@@ -224,9 +283,7 @@ struct PostGeneratorView: View {
 
                     if !viewModel.draft.subreddit.isEmpty,
                        !PostDraftValidator.isValidSubreddit(viewModel.draft.subreddit) {
-                        Text("Use 3–21 characters: letters, numbers, underscores only.")
-                            .font(.system(size: 9))
-                            .foregroundStyle(Color(hex: 0xF87171))
+                        validationHint("Use 3–21 characters: letters, numbers, underscores only.")
                     }
                 }
             }
@@ -248,42 +305,46 @@ struct PostGeneratorView: 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: "Labels", subtitle: "Tags and optional flair") {
+                VStack(alignment: .leading, spacing: 12) {
+                    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…",
+                        text: $viewModel.draft.flair
                     )
                 }
             }
-
-            PostFormCard(title: "Flair", subtitle: "Optional subreddit flair") {
-                PostFormField(
-                    label: "Flair",
-                    placeholder: "Discussion, Question, Meme…",
-                    text: $viewModel.draft.flair
-                )
-            }
         }
-        .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
     }
 
     private var editorPanel: some View {
-        VStack(spacing: 12) {
+        VStack(spacing: Layout.sectionSpacing) {
             PostFormCard(title: "Title", subtitle: "Required for all post types") {
                 PostFormField(
                     label: "Post Title",
@@ -296,7 +357,6 @@ struct PostGeneratorView: View {
 
             characterCountFooter
         }
-        .frame(maxWidth: .infinity)
     }
 
     @ViewBuilder
@@ -352,9 +412,7 @@ struct PostGeneratorView: View {
 
                 if !viewModel.draft.linkURL.isEmpty,
                    !PostDraftValidator.isValidHTTPURL(viewModel.draft.linkURL) {
-                    Text("Enter a valid http or https URL.")
-                        .font(.system(size: 9))
-                        .foregroundStyle(Color(hex: 0xF87171))
+                    validationHint("Enter a valid http or https URL.")
                 }
 
                 PostFormTextEditor(
@@ -378,9 +436,7 @@ struct PostGeneratorView: View {
 
                 if !viewModel.draft.videoURL.isEmpty,
                    !PostDraftValidator.isValidHTTPURL(viewModel.draft.videoURL) {
-                    Text("Enter a valid http or https URL.")
-                        .font(.system(size: 9))
-                        .foregroundStyle(Color(hex: 0xF87171))
+                    validationHint("Enter a valid http or https URL.")
                 }
 
                 PostFormTextEditor(
@@ -437,7 +493,7 @@ struct PostGeneratorView: View {
                         .font(.system(size: 10, weight: .medium))
                         .foregroundStyle(AppTheme.textTertiary)
 
-                    HStack(spacing: 6) {
+                    HStack(spacing: 8) {
                         ForEach(PollDuration.allCases) { duration in
                             Button {
                                 viewModel.draft.pollDuration = duration
@@ -448,10 +504,10 @@ struct PostGeneratorView: View {
                                     .foregroundStyle(
                                         viewModel.draft.pollDuration == duration ? .white : AppTheme.textSecondary
                                     )
-                                    .padding(.horizontal, 12)
-                                    .padding(.vertical, 6)
+                                    .frame(maxWidth: .infinity)
+                                    .padding(.vertical, 7)
                                     .background(
-                                        Capsule()
+                                        RoundedRectangle(cornerRadius: 7)
                                             .fill(
                                                 viewModel.draft.pollDuration == duration
                                                     ? AppTheme.accentPurple
@@ -459,7 +515,7 @@ struct PostGeneratorView: View {
                                             )
                                     )
                                     .overlay(
-                                        Capsule()
+                                        RoundedRectangle(cornerRadius: 7)
                                             .stroke(AppTheme.cardBorder, lineWidth: 1)
                                     )
                             }
@@ -532,9 +588,9 @@ struct PostGeneratorView: View {
     }
 
     private var characterCountFooter: some View {
-        HStack {
+        HStack(spacing: 12) {
             Text(titleCharacterCount)
-                .font(.system(size: 10))
+                .font(.system(size: 10, weight: .medium, design: .monospaced))
                 .foregroundStyle(
                     viewModel.draft.title.count > PostDraftValidator.maxTitleLength
                         ? Color(hex: 0xF87171)
@@ -542,8 +598,11 @@ struct PostGeneratorView: View {
                 )
 
             if !viewModel.draft.body.isEmpty {
+                Text("·")
+                    .foregroundStyle(AppTheme.textTertiary)
+
                 Text(bodyCharacterCount)
-                    .font(.system(size: 10))
+                    .font(.system(size: 10, weight: .medium, design: .monospaced))
                     .foregroundStyle(
                         viewModel.draft.body.count > PostDraftValidator.maxBodyLength
                             ? Color(hex: 0xF87171)
@@ -557,6 +616,16 @@ struct PostGeneratorView: 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)
+                )
+        )
     }
 
     private var titleCharacterCount: String {
@@ -570,36 +639,39 @@ struct PostGeneratorView: View {
     // MARK: - Preview
 
     private var previewContent: some View {
-        HStack {
-            Spacer(minLength: 0)
+        VStack(alignment: .leading, spacing: Layout.columnSpacing) {
+            composeSectionHeader(title: "Live Preview", icon: "eye")
 
-            VStack(spacing: 16) {
-                Text("Live Preview")
-                    .font(.system(size: 12, weight: .semibold))
-                    .foregroundStyle(AppTheme.textSecondary)
-                    .frame(maxWidth: .infinity, alignment: .leading)
+            HStack {
+                Spacer(minLength: 0)
 
-                RedditPostPreviewCard(
-                    draft: viewModel.draft,
-                    formattedSubreddit: viewModel.formattedSubreddit
-                )
+                VStack(spacing: Layout.sectionSpacing) {
+                    RedditPostPreviewCard(
+                        draft: viewModel.draft,
+                        formattedSubreddit: viewModel.formattedSubreddit
+                    )
 
-                previewMetadata
-            }
-            .frame(maxWidth: 560)
+                    previewMetadata
+                }
+                .frame(maxWidth: 560)
 
-            Spacer(minLength: 0)
+                Spacer(minLength: 0)
+            }
         }
     }
 
     private var previewMetadata: some View {
-        VStack(alignment: .leading, spacing: 8) {
+        VStack(alignment: .leading, spacing: 0) {
             metadataRow(label: "Type", value: viewModel.draft.postType.title)
+            metadataDivider
             metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
+            metadataDivider
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
+            metadataDivider
             metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
 
             if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
+                metadataDivider
                 metadataRow(
                     label: "Tags",
                     value: [
@@ -624,16 +696,34 @@ struct PostGeneratorView: 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 {
+        HStack(alignment: .top, spacing: 12) {
             Text(label)
                 .font(.system(size: 10, weight: .medium))
                 .foregroundStyle(AppTheme.textTertiary)
-                .frame(width: 70, alignment: .leading)
+                .frame(width: 72, alignment: .leading)
             Text(value)
                 .font(.system(size: 10))
                 .foregroundStyle(AppTheme.textSecondary)
+                .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+
+    private func validationHint(_ message: String) -> some View {
+        HStack(spacing: 4) {
+            Image(systemName: "exclamationmark.circle.fill")
+                .font(.system(size: 9))
+            Text(message)
+                .font(.system(size: 9))
         }
+        .foregroundStyle(Color(hex: 0xF87171))
     }
 }