PunctuationCheckerView.swift 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import SwiftUI
  2. struct PunctuationCheckerView: View {
  3. @StateObject private var viewModel = PunctuationCheckerViewModel()
  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.isChecking {
  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, 12)
  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.isChecking ? "Checking..." : "Check Punctuation") {
  48. guard requirePremiumAccess() else { return }
  49. viewModel.checkPunctuation()
  50. }
  51. .disabled(!viewModel.canCheck)
  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("Checking punctuation and applying corrections")
  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. ToolPageHeader(
  99. title: "Punctuation Checker",
  100. showsBackButton: viewModel.hasResults,
  101. onBack: { viewModel.resetResults() }
  102. ) {
  103. HStack(spacing: 0) {
  104. Text("Perfect your ")
  105. .foregroundStyle(AppTheme.textSecondary)
  106. Text("punctuation")
  107. .fontWeight(.semibold)
  108. .foregroundStyle(AppTheme.teal)
  109. Text(". Write with ")
  110. .foregroundStyle(AppTheme.textSecondary)
  111. Text("clarity")
  112. .fontWeight(.semibold)
  113. .foregroundStyle(AppTheme.teal)
  114. Text(".")
  115. .foregroundStyle(AppTheme.textSecondary)
  116. }
  117. .font(.system(size: 15))
  118. } trailing: {
  119. IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
  120. .accessibilityLabel("Settings")
  121. .padding(.top, 8)
  122. }
  123. }
  124. private var contentCard: some View {
  125. VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
  126. YourContentHeader()
  127. VStack(alignment: .leading, spacing: 6) {
  128. textEditor
  129. .background(AppTheme.surface)
  130. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  131. .overlay(
  132. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  133. .stroke(AppTheme.inputBorder, lineWidth: 1)
  134. )
  135. Text(viewModel.wordCountLimitLabel)
  136. .font(.system(size: 11))
  137. .foregroundStyle(AppTheme.textMuted)
  138. .frame(maxWidth: .infinity, alignment: .trailing)
  139. }
  140. .scrollSafeEditorSectionFrame(compact: false)
  141. HStack(spacing: 8) {
  142. ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
  143. viewModel.pasteText()
  144. }
  145. ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
  146. viewModel.uploadFile()
  147. }
  148. Spacer()
  149. ClearButton {
  150. viewModel.clearText()
  151. }
  152. }
  153. }
  154. .inputContentCardPadding()
  155. .scrollSafeContentCardFrame(compact: false)
  156. .background(AppTheme.cardBackground)
  157. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  158. .overlay(
  159. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  160. .stroke(AppTheme.border, lineWidth: 1)
  161. )
  162. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  163. }
  164. private var textEditor: some View {
  165. ThinCaretTextEditor(
  166. text: $viewModel.text,
  167. placeholder: "Type or paste your text here and press the check button...",
  168. maxWords: PunctuationCheckerViewModel.maxWords,
  169. onWordLimitReached: viewModel.notifyWordLimitReached
  170. )
  171. .font(.system(size: 14))
  172. .foregroundStyle(AppTheme.textPrimary)
  173. .scrollSafeTextEditorFrame(compact: false)
  174. }
  175. private var resultsLayout: some View {
  176. correctedTextCard
  177. }
  178. private var correctedTextCard: some View {
  179. VStack(alignment: .leading, spacing: 12) {
  180. HStack {
  181. Text("Corrected Text")
  182. .font(.system(size: 13, weight: .semibold))
  183. .foregroundStyle(AppTheme.textPrimary)
  184. Spacer()
  185. ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
  186. viewModel.copyCorrectedText()
  187. }
  188. .disabled(viewModel.correctedText.isEmpty)
  189. .opacity(viewModel.correctedText.isEmpty ? 0.55 : 1)
  190. }
  191. ScrollView(.vertical, showsIndicators: true) {
  192. HighlightedCorrectedText(
  193. originalText: viewModel.text,
  194. correctedText: viewModel.correctedText,
  195. corrections: viewModel.issues.map(\.asTextCorrection),
  196. padding: 16
  197. )
  198. }
  199. .frame(maxHeight: .infinity, alignment: .top)
  200. .background(AppTheme.tealLight.opacity(0.35))
  201. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  202. .overlay(
  203. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  204. .stroke(AppTheme.teal.opacity(0.2), lineWidth: 1)
  205. )
  206. }
  207. .padding(AppTheme.contentCardPadding)
  208. .background(AppTheme.cardBackground)
  209. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  210. .overlay(
  211. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  212. .stroke(AppTheme.border, lineWidth: 1)
  213. )
  214. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  215. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  216. }
  217. }