SummarizerView.swift 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import SwiftUI
  2. struct SummarizerView: View {
  3. @StateObject private var viewModel = SummarizerViewModel()
  4. @Environment(\.onSettingsTapped) private var onSettingsTapped
  5. @Environment(\.requirePremiumAccess) private var requirePremiumAccess
  6. let restoreEntry: HistoryEntry?
  7. let onRestoreHandled: () -> Void
  8. init(restoreEntry: HistoryEntry? = nil, onRestoreHandled: @escaping () -> Void = {}) {
  9. self.restoreEntry = restoreEntry
  10. self.onRestoreHandled = onRestoreHandled
  11. }
  12. var body: some View {
  13. GeometryReader { proxy in
  14. let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
  15. let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
  16. Group {
  17. if viewModel.isSummarizing {
  18. loadingLayout(
  19. horizontalPadding: dynamicHorizontalPadding,
  20. topPadding: AppTheme.brandLabelTopInset,
  21. bottomPadding: dynamicBottomPadding
  22. )
  23. } else if viewModel.hasResults {
  24. VStack(alignment: .leading, spacing: 0) {
  25. header
  26. .padding(.bottom, 20)
  27. resultsLayout
  28. .frame(maxWidth: .infinity, maxHeight: .infinity)
  29. }
  30. .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
  31. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  32. .padding(.horizontal, dynamicHorizontalPadding)
  33. .padding(.top, AppTheme.brandLabelTopInset)
  34. .padding(.bottom, dynamicBottomPadding)
  35. } else {
  36. VStack(alignment: .leading, spacing: 0) {
  37. header
  38. .padding(.bottom, 24)
  39. contentCard
  40. .frame(maxWidth: .infinity, maxHeight: .infinity)
  41. if let errorMessage = viewModel.errorMessage {
  42. Text(errorMessage)
  43. .font(.system(size: 14))
  44. .foregroundStyle(.red)
  45. .padding(.top, 12)
  46. }
  47. GradientButton(title: viewModel.isSummarizing ? "Summarizing..." : "Summarize") {
  48. guard requirePremiumAccess() else { return }
  49. viewModel.summarize()
  50. }
  51. .disabled(!viewModel.canSummarize)
  52. .padding(.top, 16)
  53. }
  54. .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
  55. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  56. .padding(.horizontal, dynamicHorizontalPadding)
  57. .padding(.top, AppTheme.brandLabelTopInset)
  58. .padding(.bottom, dynamicBottomPadding)
  59. }
  60. }
  61. }
  62. .clearHostingBackground()
  63. .onChange(of: viewModel.text) { _ in
  64. viewModel.handleTextChange()
  65. }
  66. .onAppear(perform: restoreIfNeeded)
  67. .onChange(of: restoreEntry?.id) { _ in
  68. restoreIfNeeded()
  69. }
  70. }
  71. private func restoreIfNeeded() {
  72. guard let restoreEntry else { return }
  73. viewModel.restore(from: restoreEntry)
  74. onRestoreHandled()
  75. }
  76. private func loadingLayout(
  77. horizontalPadding: CGFloat,
  78. topPadding: CGFloat,
  79. bottomPadding: CGFloat
  80. ) -> some View {
  81. VStack(spacing: 14) {
  82. ProgressView()
  83. .controlSize(.regular)
  84. Text("Loading...")
  85. .font(.system(size: 16, weight: .semibold))
  86. .foregroundStyle(AppTheme.textPrimary)
  87. Text("Summarizing your content")
  88. .font(.system(size: 14))
  89. .foregroundStyle(AppTheme.textSecondary)
  90. }
  91. .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .center)
  92. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
  93. .padding(.horizontal, horizontalPadding)
  94. .padding(.top, topPadding)
  95. .padding(.bottom, bottomPadding)
  96. }
  97. private var header: some View {
  98. VStack(alignment: .leading, spacing: 16) {
  99. if viewModel.hasResults {
  100. IconButton(iconName: "arrow.left") {
  101. viewModel.resetResults()
  102. }
  103. .accessibilityLabel("Go back")
  104. }
  105. HStack(alignment: .top) {
  106. VStack(alignment: .leading, spacing: 8) {
  107. Text("Summarizer")
  108. .font(.system(size: 30, weight: .bold))
  109. .foregroundStyle(AppTheme.textPrimary)
  110. HStack(spacing: 0) {
  111. Text("Read ")
  112. .foregroundStyle(AppTheme.textSecondary)
  113. Text("less")
  114. .fontWeight(.semibold)
  115. .foregroundStyle(AppTheme.teal)
  116. Text(". Understand ")
  117. .foregroundStyle(AppTheme.textSecondary)
  118. Text("more")
  119. .fontWeight(.semibold)
  120. .foregroundStyle(AppTheme.teal)
  121. Text(".")
  122. .foregroundStyle(AppTheme.textSecondary)
  123. }
  124. .font(.system(size: 15))
  125. }
  126. Spacer()
  127. IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
  128. .accessibilityLabel("Settings")
  129. }
  130. }
  131. }
  132. private var contentCard: some View {
  133. VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
  134. YourContentHeader()
  135. VStack(alignment: .leading, spacing: 6) {
  136. textEditor
  137. .background(AppTheme.surface)
  138. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  139. .overlay(
  140. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  141. .stroke(AppTheme.inputBorder, lineWidth: 1)
  142. )
  143. Text(viewModel.wordCountLimitLabel)
  144. .font(.system(size: 11))
  145. .foregroundStyle(AppTheme.textMuted)
  146. .frame(maxWidth: .infinity, alignment: .trailing)
  147. }
  148. .scrollSafeEditorSectionFrame(compact: false)
  149. HStack(spacing: 8) {
  150. ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
  151. viewModel.pasteText()
  152. }
  153. ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
  154. viewModel.uploadFile()
  155. }
  156. Spacer()
  157. ClearButton {
  158. viewModel.clearText()
  159. }
  160. }
  161. }
  162. .inputContentCardPadding()
  163. .scrollSafeContentCardFrame(compact: false)
  164. .background(AppTheme.cardBackground)
  165. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  166. .overlay(
  167. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  168. .stroke(AppTheme.border, lineWidth: 1)
  169. )
  170. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  171. }
  172. private var textEditor: some View {
  173. ThinCaretTextEditor(
  174. text: $viewModel.text,
  175. placeholder: "Type or paste your text here and press the summarize button...",
  176. maxWords: SummarizerViewModel.maxWords,
  177. onWordLimitReached: viewModel.notifyWordLimitReached
  178. )
  179. .font(.system(size: 14))
  180. .foregroundStyle(AppTheme.textPrimary)
  181. .scrollSafeTextEditorFrame(compact: false)
  182. }
  183. private var resultsLayout: some View {
  184. summaryCard
  185. }
  186. private var summaryCard: some View {
  187. VStack(alignment: .leading, spacing: 12) {
  188. HStack {
  189. Text("Summary")
  190. .font(.system(size: 13, weight: .semibold))
  191. .foregroundStyle(AppTheme.textPrimary)
  192. Spacer()
  193. ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
  194. viewModel.copySummary()
  195. }
  196. .disabled(viewModel.summary.isEmpty)
  197. .opacity(viewModel.summary.isEmpty ? 0.55 : 1)
  198. }
  199. Text(viewModel.summary)
  200. .font(.system(size: 14))
  201. .foregroundStyle(AppTheme.textPrimary)
  202. .frame(maxWidth: .infinity, alignment: .leading)
  203. .padding(16)
  204. .background(AppTheme.tealLight.opacity(0.35))
  205. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  206. .overlay(
  207. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  208. .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
  209. )
  210. }
  211. .padding(AppTheme.contentCardPadding)
  212. .background(AppTheme.cardBackground)
  213. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  214. .overlay(
  215. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  216. .stroke(AppTheme.border, lineWidth: 1)
  217. )
  218. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  219. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  220. }
  221. }