| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- import SwiftUI
- struct EmailWriterView: View {
- @StateObject private var viewModel = EmailWriterViewModel()
- let restoreEntry: HistoryEntry?
- let onRestoreHandled: () -> Void
- init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
- self.restoreEntry = restoreEntry
- self.onRestoreHandled = onRestoreHandled
- }
- 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.03, 16), 28)
- let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
- VStack(alignment: .leading, spacing: 0) {
- header
- .padding(.bottom, 16)
- contentCard
- .frame(maxHeight: .infinity, alignment: .topLeading)
- .padding(.bottom, 24)
- if let errorMessage = viewModel.errorMessage {
- Text(errorMessage)
- .font(.system(size: 14))
- .foregroundStyle(.red)
- .padding(.bottom, 12)
- }
- GradientButton(title: viewModel.isGenerating ? "Generating..." : "Generate Email") {
- viewModel.generateEmail()
- }
- .disabled(!viewModel.canGenerate)
- .opacity(viewModel.canGenerate ? 1 : 0.55)
- }
- .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
- .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))
- }
- }
- .onAppear(perform: restoreIfNeeded)
- }
- private func restoreIfNeeded() {
- guard let restoreEntry else { return }
- viewModel.restore(from: restoreEntry)
- onRestoreHandled()
- }
- private var header: some View {
- HStack(alignment: .top) {
- VStack(alignment: .leading, spacing: 6) {
- Text("AI Email Writer")
- .font(.system(size: 26, 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: 14))
- }
- Spacer()
- IconButton {
- SettingsGearIcon()
- } action: {}
- .accessibilityLabel("Settings")
- }
- }
- private var contentCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- optionsRow
- subjectField
- inputPanel
- .frame(maxHeight: .infinity)
- outputPanel
- .frame(maxHeight: .infinity)
- }
- .frame(maxHeight: .infinity, alignment: .top)
- .padding(18)
- .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: 6) {
- 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, 8)
- .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: 10) {
- 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: 72)
- .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(12)
- .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: 10) {
- 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: 72)
- .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(12)
- .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>
- @State private var isPresented = false
- @State private var isHovered = false
- private var iconName: String {
- title == "Type" ? "envelope" : "textformat"
- }
- var body: some View {
- Button {
- isPresented.toggle()
- } label: {
- HStack(spacing: 6) {
- Image(systemName: iconName)
- .font(.system(size: 12))
- Text("\(title): \(selection[keyPath: label])")
- .font(.system(size: 12, weight: .medium))
- Image(systemName: "chevron.down")
- .font(.system(size: 10, weight: .semibold))
- }
- .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
- .padding(.horizontal, 24)
- .padding(.vertical, 7)
- .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
- .clipShape(RoundedRectangle(cornerRadius: 8))
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
- )
- .shadow(
- color: isHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
- radius: isHovered ? 4 : 2,
- y: isHovered ? 2 : 1
- )
- .scaleEffect(isHovered ? 1.02 : 1.0)
- .animation(.easeOut(duration: 0.15), value: isHovered)
- }
- .buttonStyle(.plain)
- .onHover { isHovered = $0 }
- .popover(isPresented: $isPresented, arrowEdge: .bottom) {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(options) { option in
- Button {
- selection = option
- isPresented = false
- } label: {
- HStack {
- Text(option[keyPath: label])
- if selection == option {
- Spacer()
- Image(systemName: "checkmark")
- }
- }
- .frame(minWidth: 180, alignment: .leading)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
- }
- .padding(8)
- }
- .accessibilityLabel("\(title): \(selection[keyPath: label])")
- }
- }
|