|
|
@@ -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])")
|
|
|
+ }
|
|
|
+}
|