Kaynağa Gözat

Add AI Email Writer page with type, tone, and email generation UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ay önce
ebeveyn
işleme
10946d3975

+ 47 - 0
gramora/Models/EmailWriterModels.swift

@@ -0,0 +1,47 @@
+import Foundation
+
+enum EmailType: String, CaseIterable, Identifiable {
+    case followUp
+    case thankYou
+    case introduction
+    case apology
+    case request
+    case meetingInvite
+    case salesOutreach
+    case custom
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .followUp: "Follow-up"
+        case .thankYou: "Thank You"
+        case .introduction: "Introduction"
+        case .apology: "Apology"
+        case .request: "Request"
+        case .meetingInvite: "Meeting Invite"
+        case .salesOutreach: "Sales Outreach"
+        case .custom: "Custom"
+        }
+    }
+}
+
+enum EmailTone: String, CaseIterable, Identifiable {
+    case professional
+    case friendly
+    case formal
+    case casual
+    case persuasive
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .professional: "Professional"
+        case .friendly: "Friendly"
+        case .formal: "Formal"
+        case .casual: "Casual"
+        case .persuasive: "Persuasive"
+        }
+    }
+}

+ 70 - 0
gramora/ViewModels/EmailWriterViewModel.swift

@@ -0,0 +1,70 @@
+import AppKit
+import Combine
+import Foundation
+
+@MainActor
+final class EmailWriterViewModel: ObservableObject {
+    static let maxCharacters = 5_000
+
+    @Published var subject: String = ""
+    @Published var prompt: String = ""
+    @Published var generatedEmail: String = ""
+    @Published var emailType: EmailType = .followUp
+    @Published var tone: EmailTone = .professional
+    @Published var isGenerating = false
+
+    var characterCount: Int { prompt.count }
+
+    var characterCountLabel: String {
+        "\(characterCount)/\(Self.maxCharacters)"
+    }
+
+    var canGenerate: Bool {
+        !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isGenerating
+    }
+
+    var canCopy: Bool {
+        !generatedEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+    }
+
+    func pastePrompt() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        prompt = String(clipboard.prefix(Self.maxCharacters))
+    }
+
+    func clearAll() {
+        subject = ""
+        prompt = ""
+        generatedEmail = ""
+    }
+
+    func copyGeneratedEmail() {
+        guard canCopy else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(generatedEmail, forType: .string)
+    }
+
+    func generateEmail() {
+        let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedPrompt.isEmpty else { return }
+
+        isGenerating = true
+        generatedEmail = ""
+
+        // Email generation service integration point
+        let subjectLine = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+        let greeting = tone == .formal ? "Dear Recipient," : "Hi there,"
+
+        var email = "\(greeting)\n\n"
+        email += "[\(emailType.title) · \(tone.title)]\n\n"
+        email += trimmedPrompt
+        email += "\n\nBest regards,\n[Your Name]"
+
+        if !subjectLine.isEmpty {
+            email = "Subject: \(subjectLine)\n\n" + email
+        }
+
+        generatedEmail = email
+        isGenerating = false
+    }
+}

+ 276 - 0
gramora/Views/EmailWriterView.swift

@@ -0,0 +1,276 @@
+import SwiftUI
+
+struct EmailWriterView: View {
+    @StateObject private var viewModel = EmailWriterViewModel()
+
+    var body: some View {
+        GeometryReader { proxy in
+            let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
+            let dynamicTopPadding = min(max(proxy.size.height * 0.05, 24), 44)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
+
+            VStack(alignment: .leading, spacing: 0) {
+                header
+                    .padding(.bottom, 28)
+
+                contentCard
+                    .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+
+                GradientButton(title: "Generate Email") {
+                    viewModel.generateEmail()
+                }
+                .disabled(!viewModel.canGenerate)
+                .opacity(viewModel.canGenerate ? 1 : 0.55)
+                .padding(.top, 24)
+            }
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+            .padding(.horizontal, dynamicHorizontalPadding)
+            .padding(.top, dynamicTopPadding)
+            .padding(.bottom, dynamicBottomPadding)
+        }
+        .onChange(of: viewModel.prompt) { newValue in
+            if newValue.count > EmailWriterViewModel.maxCharacters {
+                viewModel.prompt = String(newValue.prefix(EmailWriterViewModel.maxCharacters))
+            }
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("AI Email Writer")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                HStack(spacing: 0) {
+                    Text("Write ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("better emails")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(". Save ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("time")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(".")
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+                .font(.system(size: 15))
+            }
+
+            Spacer()
+
+            IconButton {
+                SettingsGearIcon()
+            } action: {}
+            .accessibilityLabel("Settings")
+        }
+    }
+
+    private var contentCard: some View {
+        VStack(alignment: .leading, spacing: 16) {
+            optionsRow
+
+            subjectField
+
+            inputPanel
+                .frame(maxHeight: .infinity)
+
+            outputPanel
+                .frame(maxHeight: .infinity)
+        }
+        .frame(maxHeight: .infinity, alignment: .top)
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
+
+    private var optionsRow: some View {
+        HStack(spacing: 8) {
+            EmailOptionMenu(
+                title: "Type",
+                selection: $viewModel.emailType,
+                options: EmailType.allCases,
+                label: \.title
+            )
+
+            EmailOptionMenu(
+                title: "Tone",
+                selection: $viewModel.tone,
+                options: EmailTone.allCases,
+                label: \.title
+            )
+
+            Spacer()
+        }
+    }
+
+    private var subjectField: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text("Subject (optional)")
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(AppTheme.textMuted)
+
+            TextField("e.g. Follow-up on our meeting", text: $viewModel.subject)
+                .textFieldStyle(.plain)
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textPrimary)
+                .padding(.horizontal, 14)
+                .padding(.vertical, 10)
+                .background(Color.white)
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                        .stroke(AppTheme.inputBorder, lineWidth: 1)
+                )
+        }
+    }
+
+    private var inputPanel: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack(spacing: 8) {
+                Text("What should the email say?")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Spacer()
+
+                ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
+                    viewModel.pastePrompt()
+                }
+
+                ClearButton {
+                    viewModel.clearAll()
+                }
+            }
+
+            ZStack(alignment: .bottomTrailing) {
+                ThinCaretTextEditor(text: $viewModel.prompt, onSubmit: viewModel.generateEmail)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .frame(minHeight: 96)
+                    .background(Color.white)
+                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                            .stroke(AppTheme.inputBorder, lineWidth: 1)
+                    )
+                    .overlay(alignment: .topLeading) {
+                        if viewModel.prompt.isEmpty {
+                            Text("Describe the email you need — key points, recipient context, or a rough draft...")
+                                .font(.system(size: 14))
+                                .foregroundStyle(AppTheme.textMuted)
+                                .padding(.leading, AppTheme.textEditorHorizontalInset)
+                                .padding(.top, AppTheme.textEditorVerticalInset)
+                                .allowsHitTesting(false)
+                        }
+                    }
+
+                Text(viewModel.characterCountLabel)
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.textMuted)
+                    .padding(.trailing, 16)
+                    .padding(.bottom, 12)
+            }
+            .frame(maxHeight: .infinity)
+        }
+        .frame(maxHeight: .infinity)
+        .padding(16)
+        .background(AppTheme.background)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+    }
+
+    private var outputPanel: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack(spacing: 8) {
+                Text("Generated Email")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Spacer()
+
+                ToolbarButton(title: "Copy Email", iconName: "doc.on.doc") {
+                    viewModel.copyGeneratedEmail()
+                }
+                .disabled(!viewModel.canCopy)
+                .opacity(viewModel.canCopy ? 1 : 0.55)
+            }
+
+            ZStack(alignment: .topLeading) {
+                ThinCaretTextEditor(
+                    text: $viewModel.generatedEmail,
+                    isEditable: false
+                )
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textPrimary)
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+                .frame(minHeight: 96)
+                .background(Color.white)
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                        .stroke(AppTheme.inputBorder, lineWidth: 1)
+                )
+
+                if viewModel.generatedEmail.isEmpty {
+                    Text(viewModel.isGenerating ? "Writing your email..." : "Your generated email will appear here...")
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textMuted)
+                        .padding(.leading, AppTheme.textEditorHorizontalInset)
+                        .padding(.top, AppTheme.textEditorVerticalInset)
+                        .allowsHitTesting(false)
+                }
+            }
+            .frame(maxHeight: .infinity)
+        }
+        .frame(maxHeight: .infinity)
+        .padding(16)
+        .background(AppTheme.tealLight.opacity(0.35))
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
+        )
+    }
+}
+
+private struct EmailOptionMenu<Option: Identifiable & Equatable>: View {
+    let title: String
+    @Binding var selection: Option
+    let options: [Option]
+    let label: KeyPath<Option, String>
+
+    var body: some View {
+        ToolbarMenuButton(
+            title: "\(title): \(selection[keyPath: label])",
+            iconName: title == "Type" ? "envelope" : "textformat"
+        ) {
+            ForEach(options) { option in
+                Button {
+                    selection = option
+                } label: {
+                    HStack {
+                        Text(option[keyPath: label])
+                        if selection == option {
+                            Spacer()
+                            Image(systemName: "checkmark")
+                        }
+                    }
+                }
+            }
+        }
+        .accessibilityLabel("\(title): \(selection[keyPath: label])")
+    }
+}

+ 2 - 0
gramora/Views/MainView.swift

@@ -62,6 +62,8 @@ struct MainView: View {
             PunctuationCheckerView()
         case .wordsCount:
             WordsCountView()
+        case .aiEmailWriter:
+            EmailWriterView()
         default:
             PlaceholderView(destination: viewModel.selectedDestination)
         }