EmailWriterView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import SwiftUI
  2. struct EmailWriterView: View {
  3. @StateObject private var viewModel = EmailWriterViewModel()
  4. let restoreEntry: HistoryEntry?
  5. let onRestoreHandled: () -> Void
  6. init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
  7. self.restoreEntry = restoreEntry
  8. self.onRestoreHandled = onRestoreHandled
  9. }
  10. var body: some View {
  11. GeometryReader { proxy in
  12. let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
  13. let dynamicTopPadding = min(max(proxy.size.height * 0.03, 16), 28)
  14. let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
  15. VStack(alignment: .leading, spacing: 0) {
  16. header
  17. .padding(.bottom, 16)
  18. contentCard
  19. .frame(maxHeight: .infinity, alignment: .topLeading)
  20. .padding(.bottom, 24)
  21. if let errorMessage = viewModel.errorMessage {
  22. Text(errorMessage)
  23. .font(.system(size: 14))
  24. .foregroundStyle(.red)
  25. .padding(.bottom, 12)
  26. }
  27. GradientButton(title: viewModel.isGenerating ? "Generating..." : "Generate Email") {
  28. viewModel.generateEmail()
  29. }
  30. .disabled(!viewModel.canGenerate)
  31. .opacity(viewModel.canGenerate ? 1 : 0.55)
  32. }
  33. .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
  34. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  35. .padding(.horizontal, dynamicHorizontalPadding)
  36. .padding(.top, dynamicTopPadding)
  37. .padding(.bottom, dynamicBottomPadding)
  38. }
  39. .onChange(of: viewModel.prompt) { newValue in
  40. if newValue.count > EmailWriterViewModel.maxCharacters {
  41. viewModel.prompt = String(newValue.prefix(EmailWriterViewModel.maxCharacters))
  42. }
  43. }
  44. .onAppear(perform: restoreIfNeeded)
  45. }
  46. private func restoreIfNeeded() {
  47. guard let restoreEntry else { return }
  48. viewModel.restore(from: restoreEntry)
  49. onRestoreHandled()
  50. }
  51. private var header: some View {
  52. HStack(alignment: .top) {
  53. VStack(alignment: .leading, spacing: 6) {
  54. Text("AI Email Writer")
  55. .font(.system(size: 26, weight: .bold))
  56. .foregroundStyle(AppTheme.textPrimary)
  57. HStack(spacing: 0) {
  58. Text("Write ")
  59. .foregroundStyle(AppTheme.textSecondary)
  60. Text("better emails")
  61. .fontWeight(.semibold)
  62. .foregroundStyle(AppTheme.teal)
  63. Text(". Save ")
  64. .foregroundStyle(AppTheme.textSecondary)
  65. Text("time")
  66. .fontWeight(.semibold)
  67. .foregroundStyle(AppTheme.teal)
  68. Text(".")
  69. .foregroundStyle(AppTheme.textSecondary)
  70. }
  71. .font(.system(size: 14))
  72. }
  73. Spacer()
  74. IconButton {
  75. SettingsGearIcon()
  76. } action: {}
  77. .accessibilityLabel("Settings")
  78. }
  79. }
  80. private var contentCard: some View {
  81. VStack(alignment: .leading, spacing: 12) {
  82. optionsRow
  83. subjectField
  84. inputPanel
  85. .frame(maxHeight: .infinity)
  86. outputPanel
  87. .frame(maxHeight: .infinity)
  88. }
  89. .frame(maxHeight: .infinity, alignment: .top)
  90. .padding(18)
  91. .background(AppTheme.cardBackground)
  92. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  93. .overlay(
  94. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  95. .stroke(AppTheme.border, lineWidth: 1)
  96. )
  97. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  98. }
  99. private var optionsRow: some View {
  100. HStack(spacing: 8) {
  101. EmailOptionMenu(
  102. title: "Type",
  103. selection: $viewModel.emailType,
  104. options: EmailType.allCases,
  105. label: \.title
  106. )
  107. EmailOptionMenu(
  108. title: "Tone",
  109. selection: $viewModel.tone,
  110. options: EmailTone.allCases,
  111. label: \.title
  112. )
  113. Spacer()
  114. }
  115. }
  116. private var subjectField: some View {
  117. VStack(alignment: .leading, spacing: 6) {
  118. Text("Subject (optional)")
  119. .font(.system(size: 12, weight: .medium))
  120. .foregroundStyle(AppTheme.textMuted)
  121. TextField("e.g. Follow-up on our meeting", text: $viewModel.subject)
  122. .textFieldStyle(.plain)
  123. .font(.system(size: 14))
  124. .foregroundStyle(AppTheme.textPrimary)
  125. .padding(.horizontal, 14)
  126. .padding(.vertical, 8)
  127. .background(Color.white)
  128. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  129. .overlay(
  130. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  131. .stroke(AppTheme.inputBorder, lineWidth: 1)
  132. )
  133. }
  134. }
  135. private var inputPanel: some View {
  136. VStack(alignment: .leading, spacing: 10) {
  137. HStack(spacing: 8) {
  138. Text("What should the email say?")
  139. .font(.system(size: 13, weight: .semibold))
  140. .foregroundStyle(AppTheme.textPrimary)
  141. Spacer()
  142. ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
  143. viewModel.pastePrompt()
  144. }
  145. ClearButton {
  146. viewModel.clearAll()
  147. }
  148. }
  149. ZStack(alignment: .bottomTrailing) {
  150. ThinCaretTextEditor(text: $viewModel.prompt, onSubmit: viewModel.generateEmail)
  151. .font(.system(size: 14))
  152. .foregroundStyle(AppTheme.textPrimary)
  153. .frame(maxWidth: .infinity, maxHeight: .infinity)
  154. .frame(minHeight: 72)
  155. .background(Color.white)
  156. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  157. .overlay(
  158. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  159. .stroke(AppTheme.inputBorder, lineWidth: 1)
  160. )
  161. .overlay(alignment: .topLeading) {
  162. if viewModel.prompt.isEmpty {
  163. Text("Describe the email you need — key points, recipient context, or a rough draft...")
  164. .font(.system(size: 14))
  165. .foregroundStyle(AppTheme.textMuted)
  166. .padding(.leading, AppTheme.textEditorHorizontalInset)
  167. .padding(.top, AppTheme.textEditorVerticalInset)
  168. .allowsHitTesting(false)
  169. }
  170. }
  171. Text(viewModel.characterCountLabel)
  172. .font(.system(size: 11))
  173. .foregroundStyle(AppTheme.textMuted)
  174. .padding(.trailing, 16)
  175. .padding(.bottom, 12)
  176. }
  177. .frame(maxHeight: .infinity)
  178. }
  179. .frame(maxHeight: .infinity)
  180. .padding(12)
  181. .background(AppTheme.background)
  182. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  183. .overlay(
  184. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  185. .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
  186. )
  187. }
  188. private var outputPanel: some View {
  189. VStack(alignment: .leading, spacing: 10) {
  190. HStack(spacing: 8) {
  191. Text("Generated Email")
  192. .font(.system(size: 13, weight: .semibold))
  193. .foregroundStyle(AppTheme.textPrimary)
  194. Spacer()
  195. ToolbarButton(title: "Copy Email", iconName: "doc.on.doc") {
  196. viewModel.copyGeneratedEmail()
  197. }
  198. .disabled(!viewModel.canCopy)
  199. .opacity(viewModel.canCopy ? 1 : 0.55)
  200. }
  201. ZStack(alignment: .topLeading) {
  202. ThinCaretTextEditor(
  203. text: $viewModel.generatedEmail,
  204. isEditable: false
  205. )
  206. .font(.system(size: 14))
  207. .foregroundStyle(AppTheme.textPrimary)
  208. .frame(maxWidth: .infinity, maxHeight: .infinity)
  209. .frame(minHeight: 72)
  210. .background(Color.white)
  211. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  212. .overlay(
  213. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  214. .stroke(AppTheme.inputBorder, lineWidth: 1)
  215. )
  216. if viewModel.generatedEmail.isEmpty {
  217. Text(viewModel.isGenerating ? "Writing your email..." : "Your generated email will appear here...")
  218. .font(.system(size: 14))
  219. .foregroundStyle(AppTheme.textMuted)
  220. .padding(.leading, AppTheme.textEditorHorizontalInset)
  221. .padding(.top, AppTheme.textEditorVerticalInset)
  222. .allowsHitTesting(false)
  223. }
  224. }
  225. .frame(maxHeight: .infinity)
  226. }
  227. .frame(maxHeight: .infinity)
  228. .padding(12)
  229. .background(AppTheme.tealLight.opacity(0.35))
  230. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  231. .overlay(
  232. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  233. .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
  234. )
  235. }
  236. }
  237. private struct EmailOptionMenu<Option: Identifiable & Equatable>: View {
  238. let title: String
  239. @Binding var selection: Option
  240. let options: [Option]
  241. let label: KeyPath<Option, String>
  242. @State private var isPresented = false
  243. @State private var isHovered = false
  244. private var iconName: String {
  245. title == "Type" ? "envelope" : "textformat"
  246. }
  247. var body: some View {
  248. Button {
  249. isPresented.toggle()
  250. } label: {
  251. HStack(spacing: 6) {
  252. Image(systemName: iconName)
  253. .font(.system(size: 12))
  254. Text("\(title): \(selection[keyPath: label])")
  255. .font(.system(size: 12, weight: .medium))
  256. Image(systemName: "chevron.down")
  257. .font(.system(size: 10, weight: .semibold))
  258. }
  259. .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
  260. .padding(.horizontal, 24)
  261. .padding(.vertical, 7)
  262. .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
  263. .clipShape(RoundedRectangle(cornerRadius: 8))
  264. .overlay(
  265. RoundedRectangle(cornerRadius: 8)
  266. .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
  267. )
  268. .shadow(
  269. color: isHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
  270. radius: isHovered ? 4 : 2,
  271. y: isHovered ? 2 : 1
  272. )
  273. .scaleEffect(isHovered ? 1.02 : 1.0)
  274. .animation(.easeOut(duration: 0.15), value: isHovered)
  275. }
  276. .buttonStyle(.plain)
  277. .onHover { isHovered = $0 }
  278. .popover(isPresented: $isPresented, arrowEdge: .bottom) {
  279. VStack(alignment: .leading, spacing: 4) {
  280. ForEach(options) { option in
  281. Button {
  282. selection = option
  283. isPresented = false
  284. } label: {
  285. HStack {
  286. Text(option[keyPath: label])
  287. if selection == option {
  288. Spacer()
  289. Image(systemName: "checkmark")
  290. }
  291. }
  292. .frame(minWidth: 180, alignment: .leading)
  293. .contentShape(Rectangle())
  294. }
  295. .buttonStyle(.plain)
  296. }
  297. }
  298. .padding(8)
  299. }
  300. .accessibilityLabel("\(title): \(selection[keyPath: label])")
  301. }
  302. }