DictionaryView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import SwiftUI
  2. struct DictionaryView: View {
  3. @StateObject private var viewModel = DictionaryViewModel()
  4. @Environment(\.onSettingsTapped) private var onSettingsTapped
  5. var body: some View {
  6. GeometryReader { proxy in
  7. let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
  8. let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
  9. VStack(alignment: .leading, spacing: 0) {
  10. header
  11. .padding(.bottom, 28)
  12. ScrollView(.vertical, showsIndicators: false) {
  13. VStack(alignment: .leading, spacing: 0) {
  14. contentCard
  15. .padding(.bottom, 24)
  16. if viewModel.hasResult {
  17. resultCard
  18. }
  19. }
  20. .padding(.bottom, dynamicBottomPadding)
  21. }
  22. .frame(maxWidth: .infinity, maxHeight: .infinity)
  23. }
  24. .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
  25. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  26. .padding(.horizontal, dynamicHorizontalPadding)
  27. .padding(.top, AppTheme.brandLabelTopInset)
  28. .padding(.bottom, dynamicBottomPadding)
  29. }
  30. .clearHostingBackground()
  31. .onChange(of: viewModel.query) { newValue in
  32. if newValue.count > DictionaryViewModel.maxQueryLength {
  33. viewModel.query = String(newValue.prefix(DictionaryViewModel.maxQueryLength))
  34. }
  35. if viewModel.hasResult {
  36. viewModel.resetResult()
  37. }
  38. }
  39. }
  40. private var header: some View {
  41. HStack(alignment: .top) {
  42. VStack(alignment: .leading, spacing: 8) {
  43. Text("Dictionary")
  44. .font(.system(size: 30, weight: .bold))
  45. .foregroundStyle(AppTheme.textPrimary)
  46. HStack(spacing: 0) {
  47. Text("Find ")
  48. .foregroundStyle(AppTheme.textSecondary)
  49. Text("meaning")
  50. .fontWeight(.semibold)
  51. .foregroundStyle(AppTheme.teal)
  52. Text(". Learn ")
  53. .foregroundStyle(AppTheme.textSecondary)
  54. Text("words")
  55. .fontWeight(.semibold)
  56. .foregroundStyle(AppTheme.teal)
  57. Text(". Expand your ")
  58. .foregroundStyle(AppTheme.textSecondary)
  59. Text("vocabulary")
  60. .fontWeight(.semibold)
  61. .foregroundStyle(AppTheme.teal)
  62. Text(".")
  63. .foregroundStyle(AppTheme.textSecondary)
  64. }
  65. .font(.system(size: 15))
  66. }
  67. Spacer()
  68. IconButton(icon: { SettingsGearIcon() }, action: onSettingsTapped)
  69. .accessibilityLabel("Settings")
  70. }
  71. }
  72. private var contentCard: some View {
  73. VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
  74. YourContentHeader()
  75. searchField
  76. if let errorMessage = viewModel.errorMessage {
  77. errorBanner(errorMessage)
  78. }
  79. if viewModel.isLoading {
  80. loadingState
  81. } else if !viewModel.hasResult {
  82. emptyState
  83. }
  84. if !viewModel.recentSearches.isEmpty {
  85. recentSearchesSection
  86. }
  87. }
  88. .inputContentCardPadding()
  89. .background(AppTheme.cardBackground)
  90. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  91. .overlay(
  92. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  93. .stroke(AppTheme.border, lineWidth: 1)
  94. )
  95. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  96. }
  97. private var searchField: some View {
  98. VStack(alignment: .leading, spacing: 12) {
  99. Text("Look up a word")
  100. .font(.system(size: 13, weight: .semibold))
  101. .foregroundStyle(AppTheme.textSecondary)
  102. HStack(spacing: 10) {
  103. HStack(spacing: 10) {
  104. Image(systemName: "magnifyingglass")
  105. .font(.system(size: 14, weight: .medium))
  106. .foregroundStyle(AppTheme.textMuted)
  107. TextField("Type a word and press Return...", text: $viewModel.query, onCommit: viewModel.lookUp)
  108. .textFieldStyle(.plain)
  109. .font(.system(size: 16))
  110. .foregroundStyle(AppTheme.textPrimary)
  111. }
  112. .padding(.horizontal, 16)
  113. .padding(.vertical, 14)
  114. .background(AppTheme.surface)
  115. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  116. .overlay(
  117. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  118. .stroke(AppTheme.inputBorder, lineWidth: 1)
  119. )
  120. CompactPrimaryButton(
  121. title: "Look Up",
  122. iconName: "book.fill",
  123. isEnabled: viewModel.canLookUp,
  124. action: viewModel.lookUp
  125. )
  126. }
  127. HStack(spacing: 8) {
  128. ToolbarButton(title: "Paste Word", iconName: "doc.on.clipboard") {
  129. viewModel.pasteWord()
  130. }
  131. Spacer()
  132. ClearButton {
  133. viewModel.clearSearch()
  134. }
  135. }
  136. }
  137. }
  138. private var emptyState: some View {
  139. VStack(spacing: 12) {
  140. Image(systemName: "text.book.closed")
  141. .font(.system(size: 34))
  142. .foregroundStyle(AppTheme.teal.opacity(0.45))
  143. Text("Search for any English word to see definitions, examples, and synonyms.")
  144. .font(.system(size: 14))
  145. .foregroundStyle(AppTheme.textSecondary)
  146. .multilineTextAlignment(.center)
  147. .frame(maxWidth: 420)
  148. }
  149. .frame(maxWidth: .infinity)
  150. .padding(.vertical, 28)
  151. .padding(.horizontal, 20)
  152. .background(AppTheme.panelBackground)
  153. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  154. .overlay(
  155. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  156. .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
  157. )
  158. }
  159. private var loadingState: some View {
  160. HStack(spacing: 12) {
  161. ProgressView()
  162. .controlSize(.small)
  163. Text("Looking up definition...")
  164. .font(.system(size: 14))
  165. .foregroundStyle(AppTheme.textSecondary)
  166. }
  167. .frame(maxWidth: .infinity, alignment: .leading)
  168. .padding(18)
  169. .background(AppTheme.panelBackground)
  170. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  171. }
  172. private func errorBanner(_ message: String) -> some View {
  173. HStack(spacing: 10) {
  174. Image(systemName: "exclamationmark.triangle.fill")
  175. .font(.system(size: 14))
  176. .foregroundStyle(AppTheme.clearForeground)
  177. Text(message)
  178. .font(.system(size: 14))
  179. .foregroundStyle(AppTheme.clearForeground)
  180. }
  181. .frame(maxWidth: .infinity, alignment: .leading)
  182. .padding(.horizontal, 16)
  183. .padding(.vertical, 12)
  184. .background(AppTheme.clearBackground)
  185. .clipShape(RoundedRectangle(cornerRadius: 10))
  186. .overlay(
  187. RoundedRectangle(cornerRadius: 10)
  188. .stroke(AppTheme.clearBorder, lineWidth: 1)
  189. )
  190. }
  191. private var recentSearchesSection: some View {
  192. VStack(alignment: .leading, spacing: 10) {
  193. Text("Recent Search")
  194. .font(.system(size: 13, weight: .semibold))
  195. .foregroundStyle(AppTheme.textSecondary)
  196. if let latestSearch = viewModel.recentSearches.first {
  197. ToolbarButton(title: latestSearch, iconName: "clock") {
  198. viewModel.lookUpRecent(latestSearch)
  199. }
  200. }
  201. }
  202. }
  203. private var resultCard: some View {
  204. Group {
  205. if let entry = viewModel.entry {
  206. VStack(alignment: .leading, spacing: 20) {
  207. wordHeader(entry)
  208. ForEach(entry.meanings) { meaning in
  209. meaningSection(meaning)
  210. }
  211. }
  212. .padding(AppTheme.contentCardPadding)
  213. .background(AppTheme.cardBackground)
  214. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  215. .overlay(
  216. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  217. .stroke(AppTheme.border, lineWidth: 1)
  218. )
  219. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  220. }
  221. }
  222. }
  223. private func wordHeader(_ entry: DictionaryEntry) -> some View {
  224. HStack(alignment: .top, spacing: 16) {
  225. VStack(alignment: .leading, spacing: 6) {
  226. Text(entry.word)
  227. .font(.system(size: 34, weight: .bold))
  228. .foregroundStyle(AppTheme.textPrimary)
  229. if let phonetic = entry.phonetic {
  230. Text(phonetic)
  231. .font(.system(size: 16))
  232. .foregroundStyle(AppTheme.textSecondary)
  233. }
  234. }
  235. Spacer()
  236. TealPillButton(title: "Listen", iconName: "speaker.wave.2.fill") {
  237. viewModel.playPronunciation(for: entry)
  238. }
  239. .accessibilityLabel("Play pronunciation")
  240. }
  241. }
  242. private func meaningSection(_ meaning: DictionaryMeaning) -> some View {
  243. VStack(alignment: .leading, spacing: 14) {
  244. PartOfSpeechBadge(title: meaning.partOfSpeech)
  245. VStack(alignment: .leading, spacing: 12) {
  246. ForEach(Array(meaning.definitions.enumerated()), id: \.element.id) { index, definition in
  247. definitionRow(number: index + 1, definition: definition)
  248. }
  249. }
  250. if !meaning.synonyms.isEmpty {
  251. relatedWordsSection(title: "Synonyms", words: meaning.synonyms)
  252. }
  253. if !meaning.antonyms.isEmpty {
  254. relatedWordsSection(title: "Antonyms", words: meaning.antonyms)
  255. }
  256. }
  257. .padding(18)
  258. .frame(maxWidth: .infinity, alignment: .leading)
  259. .background(AppTheme.panelBackground)
  260. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  261. .overlay(
  262. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  263. .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
  264. )
  265. }
  266. private func definitionRow(number: Int, definition: DictionaryDefinition) -> some View {
  267. VStack(alignment: .leading, spacing: 6) {
  268. HStack(alignment: .top, spacing: 10) {
  269. Text("\(number).")
  270. .font(.system(size: 14, weight: .semibold))
  271. .foregroundStyle(AppTheme.teal)
  272. .frame(width: 20, alignment: .leading)
  273. Text(definition.text)
  274. .font(.system(size: 14))
  275. .foregroundStyle(AppTheme.textPrimary)
  276. .fixedSize(horizontal: false, vertical: true)
  277. }
  278. if let example = definition.example {
  279. Text("\"\(example)\"")
  280. .font(.system(size: 13))
  281. .italic()
  282. .foregroundStyle(AppTheme.textMuted)
  283. .padding(.leading, 30)
  284. }
  285. }
  286. }
  287. private func relatedWordsSection(title: String, words: [String]) -> some View {
  288. VStack(alignment: .leading, spacing: 8) {
  289. Text(title)
  290. .font(.system(size: 12, weight: .semibold))
  291. .foregroundStyle(AppTheme.textSecondary)
  292. LazyVGrid(
  293. columns: [GridItem(.adaptive(minimum: 72), spacing: 8)],
  294. alignment: .leading,
  295. spacing: 8
  296. ) {
  297. ForEach(words, id: \.self) { word in
  298. ChipButton(title: word) {
  299. viewModel.lookUpRecent(word)
  300. }
  301. }
  302. }
  303. }
  304. .padding(.top, 4)
  305. }
  306. }
  307. private struct PartOfSpeechBadge: View {
  308. let title: String
  309. var body: some View {
  310. Text(title.capitalized)
  311. .font(.system(size: 12, weight: .semibold))
  312. .foregroundStyle(AppTheme.teal)
  313. .padding(.horizontal, 12)
  314. .padding(.vertical, 6)
  315. .background(AppTheme.tealLight)
  316. .clipShape(Capsule())
  317. }
  318. }