|
|
@@ -4,25 +4,35 @@ struct CommentWriterView: View {
|
|
|
@Environment(\.requirePremiumAccess) private var requirePremiumAccess
|
|
|
@Bindable var viewModel: CommentWriterViewModel
|
|
|
|
|
|
+ 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
|
|
|
- case .variants:
|
|
|
- variantsContent
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ tabBar
|
|
|
+
|
|
|
+ PostPageBoundary {
|
|
|
+ switch viewModel.selectedTab {
|
|
|
+ case .compose:
|
|
|
+ composeContent
|
|
|
+ case .preview:
|
|
|
+ previewContent
|
|
|
+ case .variants:
|
|
|
+ variantsContent
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- .padding(.horizontal, 24)
|
|
|
- .padding(.top, 8)
|
|
|
+ .padding(.horizontal, Layout.horizontalPadding)
|
|
|
+ .padding(.top, 12)
|
|
|
.padding(.bottom, 24)
|
|
|
}
|
|
|
}
|
|
|
@@ -74,7 +84,7 @@ struct CommentWriterView: View {
|
|
|
|
|
|
headerActions
|
|
|
}
|
|
|
- .padding(.horizontal, 24)
|
|
|
+ .padding(.horizontal, Layout.horizontalPadding)
|
|
|
.padding(.vertical, 16)
|
|
|
.overlay(alignment: .bottom) {
|
|
|
Rectangle()
|
|
|
@@ -87,11 +97,11 @@ struct CommentWriterView: 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)
|
|
|
}
|
|
|
}
|
|
|
@@ -145,10 +155,12 @@ struct CommentWriterView: View {
|
|
|
// MARK: - Tab Bar
|
|
|
|
|
|
private var tabBar: some View {
|
|
|
- HStack(spacing: 4) {
|
|
|
+ HStack(spacing: 0) {
|
|
|
ForEach(CommentWriterTab.allCases) { tab in
|
|
|
Button {
|
|
|
- viewModel.selectedTab = tab
|
|
|
+ withAnimation(.easeInOut(duration: 0.15)) {
|
|
|
+ viewModel.selectedTab = tab
|
|
|
+ }
|
|
|
} label: {
|
|
|
HStack(spacing: 4) {
|
|
|
Text(tab.title)
|
|
|
@@ -165,43 +177,90 @@ struct CommentWriterView: View {
|
|
|
}
|
|
|
}
|
|
|
.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: - 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: "Your Comment", icon: "bubble.left") {
|
|
|
+ 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: "Your Comment", icon: "bubble.left") {
|
|
|
editorPanel
|
|
|
}
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private func composeColumn<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.accentGreen)
|
|
|
+ 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: "Comment Type", subtitle: "Top-level or reply to a comment") {
|
|
|
CommentTypePicker(selectedType: Binding(
|
|
|
get: { viewModel.draft.commentType },
|
|
|
@@ -220,9 +279,7 @@ struct CommentWriterView: 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.")
|
|
|
}
|
|
|
|
|
|
PostFormField(
|
|
|
@@ -255,9 +312,7 @@ struct CommentWriterView: View {
|
|
|
|
|
|
if !viewModel.draft.postURL.isEmpty,
|
|
|
!CommentDraftValidator.isValidRedditURL(viewModel.draft.postURL) {
|
|
|
- Text("Enter a valid reddit.com URL to open the thread.")
|
|
|
- .font(.system(size: 9))
|
|
|
- .foregroundStyle(Color(hex: 0xF87171))
|
|
|
+ validationHint("Enter a valid reddit.com URL to open the thread.")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -313,11 +368,10 @@ struct CommentWriterView: View {
|
|
|
|
|
|
RedditCommentEtiquetteCard()
|
|
|
}
|
|
|
- .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
|
|
|
}
|
|
|
|
|
|
private var editorPanel: some View {
|
|
|
- VStack(spacing: 12) {
|
|
|
+ VStack(spacing: Layout.sectionSpacing) {
|
|
|
PostFormCard(
|
|
|
title: "Your Comment",
|
|
|
subtitle: viewModel.draft.useMarkdown
|
|
|
@@ -342,13 +396,12 @@ struct CommentWriterView: View {
|
|
|
formattedSubreddit: viewModel.formattedSubreddit
|
|
|
)
|
|
|
}
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
}
|
|
|
|
|
|
private var characterCountFooter: some View {
|
|
|
- HStack {
|
|
|
+ HStack(spacing: 12) {
|
|
|
Text("\(viewModel.draft.body.count) / \(CommentDraftValidator.maxCommentLength)")
|
|
|
- .font(.system(size: 10))
|
|
|
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
|
.foregroundStyle(
|
|
|
viewModel.draft.body.count > CommentDraftValidator.maxCommentLength
|
|
|
? Color(hex: 0xF87171)
|
|
|
@@ -361,45 +414,60 @@ struct CommentWriterView: 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: - Preview
|
|
|
|
|
|
private var previewContent: some View {
|
|
|
- HStack {
|
|
|
- Spacer(minLength: 0)
|
|
|
-
|
|
|
- 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")
|
|
|
+
|
|
|
+ HStack {
|
|
|
+ Spacer(minLength: 0)
|
|
|
+
|
|
|
+ VStack(spacing: Layout.sectionSpacing) {
|
|
|
+ RedditCommentPreviewCard(
|
|
|
+ commentBody: viewModel.activeCommentBody,
|
|
|
+ formattedSubreddit: viewModel.formattedSubreddit,
|
|
|
+ commentType: viewModel.draft.commentType,
|
|
|
+ parentComment: viewModel.draft.parentComment
|
|
|
+ )
|
|
|
|
|
|
- RedditCommentPreviewCard(
|
|
|
- commentBody: viewModel.activeCommentBody,
|
|
|
- formattedSubreddit: viewModel.formattedSubreddit,
|
|
|
- commentType: viewModel.draft.commentType,
|
|
|
- parentComment: viewModel.draft.parentComment
|
|
|
- )
|
|
|
+ previewMetadata
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 560)
|
|
|
|
|
|
- previewMetadata
|
|
|
+ Spacer(minLength: 0)
|
|
|
}
|
|
|
- .frame(maxWidth: 560)
|
|
|
-
|
|
|
- Spacer(minLength: 0)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private var previewMetadata: some View {
|
|
|
- VStack(alignment: .leading, spacing: 8) {
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
metadataRow(label: "Type", value: viewModel.draft.commentType.title)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Intent", value: viewModel.draft.intent.title)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Tone", value: viewModel.draft.tone.title)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Length", value: viewModel.draft.length.title)
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
|
|
|
|
|
|
if let best = viewModel.bestVariant {
|
|
|
+ metadataDivider
|
|
|
metadataRow(label: "Best Score", value: "\(best.score)/100")
|
|
|
}
|
|
|
}
|
|
|
@@ -418,36 +486,43 @@ struct CommentWriterView: View {
|
|
|
// MARK: - Variants
|
|
|
|
|
|
private var variantsContent: some View {
|
|
|
- VStack(spacing: 16) {
|
|
|
+ VStack(alignment: .leading, spacing: Layout.columnSpacing) {
|
|
|
+ variantsHeader
|
|
|
+
|
|
|
if viewModel.variants.isEmpty {
|
|
|
CommentWriterEmptyState(
|
|
|
icon: "bubble.left.and.bubble.right",
|
|
|
title: "No variants yet",
|
|
|
subtitle: "Fill in thread context on the Compose tab,\nthen tap Generate to get comment variants."
|
|
|
)
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
} else {
|
|
|
- variantsHeader
|
|
|
-
|
|
|
- VStack(spacing: 8) {
|
|
|
- ForEach(viewModel.variants) { variant in
|
|
|
- CommentVariantRow(
|
|
|
- variant: variant,
|
|
|
- isSelected: viewModel.selectedVariantID == variant.id,
|
|
|
- onSelect: { viewModel.selectVariant(variant) },
|
|
|
- onApply: { viewModel.applyVariant(variant) }
|
|
|
- )
|
|
|
- }
|
|
|
+ variantsBody
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var variantsBody: some View {
|
|
|
+ VStack(spacing: Layout.sectionSpacing) {
|
|
|
+ VStack(spacing: 8) {
|
|
|
+ ForEach(viewModel.variants) { variant in
|
|
|
+ CommentVariantRow(
|
|
|
+ variant: variant,
|
|
|
+ isSelected: viewModel.selectedVariantID == variant.id,
|
|
|
+ onSelect: { viewModel.selectVariant(variant) },
|
|
|
+ onApply: { viewModel.applyVariant(variant) }
|
|
|
+ )
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- if !viewModel.activeCommentBody.isEmpty {
|
|
|
- PostFormCard(title: "Selected Preview", subtitle: "How this variant looks in a thread") {
|
|
|
- RedditCommentPreviewCard(
|
|
|
- commentBody: viewModel.activeCommentBody,
|
|
|
- formattedSubreddit: viewModel.formattedSubreddit,
|
|
|
- commentType: viewModel.draft.commentType,
|
|
|
- parentComment: viewModel.draft.parentComment
|
|
|
- )
|
|
|
- }
|
|
|
+ if !viewModel.activeCommentBody.isEmpty {
|
|
|
+ PostFormCard(title: "Selected Preview", subtitle: "How this variant looks in a thread") {
|
|
|
+ RedditCommentPreviewCard(
|
|
|
+ commentBody: viewModel.activeCommentBody,
|
|
|
+ formattedSubreddit: viewModel.formattedSubreddit,
|
|
|
+ commentType: viewModel.draft.commentType,
|
|
|
+ parentComment: viewModel.draft.parentComment
|
|
|
+ )
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -456,15 +531,8 @@ struct CommentWriterView: View {
|
|
|
}
|
|
|
|
|
|
private var variantsHeader: some View {
|
|
|
- HStack {
|
|
|
- VStack(alignment: .leading, spacing: 2) {
|
|
|
- Text("Comment Variants")
|
|
|
- .font(.system(size: 12, weight: .semibold))
|
|
|
- .foregroundStyle(AppTheme.textPrimary)
|
|
|
- Text("Tap a variant to preview, or Apply to use it")
|
|
|
- .font(.system(size: 10))
|
|
|
- .foregroundStyle(AppTheme.textSecondary)
|
|
|
- }
|
|
|
+ HStack(alignment: .top) {
|
|
|
+ sectionHeader(title: "Comment Variants", icon: "bubble.left.and.bubble.right")
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
@@ -477,22 +545,40 @@ struct CommentWriterView: View {
|
|
|
.font(.system(size: 10, weight: .semibold))
|
|
|
.foregroundStyle(AppTheme.textSecondary)
|
|
|
}
|
|
|
+ .padding(.top, 1)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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: 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))
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
#Preview {
|