DictionaryView.swift 15 KB

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