|
|
@@ -0,0 +1,421 @@
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+struct DictionaryView: View {
|
|
|
+ @StateObject private var viewModel = DictionaryViewModel()
|
|
|
+
|
|
|
+ private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ GeometryReader { proxy in
|
|
|
+ let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
|
|
|
+ let dynamicTopPadding = min(max(proxy.size.height * 0.05, 24), 44)
|
|
|
+ let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
|
|
|
+
|
|
|
+ ScrollView(.vertical, showsIndicators: false) {
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
+ header
|
|
|
+ .padding(.bottom, 28)
|
|
|
+
|
|
|
+ contentCard
|
|
|
+ .padding(.bottom, 24)
|
|
|
+
|
|
|
+ if viewModel.hasResult {
|
|
|
+ resultCard
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: 1200, alignment: .topLeading)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
+ .padding(.horizontal, dynamicHorizontalPadding)
|
|
|
+ .padding(.top, dynamicTopPadding)
|
|
|
+ .padding(.bottom, dynamicBottomPadding)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .onChange(of: viewModel.query) { newValue in
|
|
|
+ if newValue.count > DictionaryViewModel.maxQueryLength {
|
|
|
+ viewModel.query = String(newValue.prefix(DictionaryViewModel.maxQueryLength))
|
|
|
+ }
|
|
|
+ if viewModel.hasResult {
|
|
|
+ viewModel.resetResult()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var header: some View {
|
|
|
+ HStack(alignment: .top) {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text("Dictionary")
|
|
|
+ .font(.system(size: 30, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ HStack(spacing: 0) {
|
|
|
+ Text("Find ")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ Text("meaning")
|
|
|
+ .fontWeight(.semibold)
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ Text(". Learn ")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ Text("words")
|
|
|
+ .fontWeight(.semibold)
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ Text(". Expand your ")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ Text("vocabulary")
|
|
|
+ .fontWeight(.semibold)
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ Text(".")
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ .font(.system(size: 15))
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ IconButton {
|
|
|
+ SettingsGearIcon()
|
|
|
+ } action: {}
|
|
|
+ .accessibilityLabel("Settings")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var contentCard: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 18) {
|
|
|
+ Image("YourContentLogo")
|
|
|
+ .resizable()
|
|
|
+ .aspectRatio(contentMode: .fit)
|
|
|
+ .frame(height: 52, alignment: .topLeading)
|
|
|
+ .frame(height: 46, alignment: .topLeading)
|
|
|
+ .clipped()
|
|
|
+ .accessibilityLabel("Your Content")
|
|
|
+
|
|
|
+ searchField
|
|
|
+
|
|
|
+ if let errorMessage = viewModel.errorMessage {
|
|
|
+ errorBanner(errorMessage)
|
|
|
+ }
|
|
|
+
|
|
|
+ if viewModel.isLoading {
|
|
|
+ loadingState
|
|
|
+ } else if !viewModel.hasResult {
|
|
|
+ emptyState
|
|
|
+ }
|
|
|
+
|
|
|
+ if !viewModel.recentSearches.isEmpty {
|
|
|
+ recentSearchesSection
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(22)
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
+ .stroke(AppTheme.border, lineWidth: 1)
|
|
|
+ )
|
|
|
+ .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
|
|
|
+ }
|
|
|
+
|
|
|
+ private var searchField: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ Text("Look up a word")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Image(systemName: "magnifyingglass")
|
|
|
+ .font(.system(size: 14, weight: .medium))
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
+
|
|
|
+ TextField("Type a word and press Return...", text: $viewModel.query, onCommit: viewModel.lookUp)
|
|
|
+ .textFieldStyle(.plain)
|
|
|
+ .font(.system(size: 16))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.vertical, 14)
|
|
|
+ .background(Color.white)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(AppTheme.inputBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+
|
|
|
+ Button(action: viewModel.lookUp) {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Image(systemName: "book.fill")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+
|
|
|
+ Text("Look Up")
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
+ }
|
|
|
+ .foregroundStyle(.white)
|
|
|
+ .padding(.horizontal, 20)
|
|
|
+ .padding(.vertical, 14)
|
|
|
+ .background(viewModel.canLookUp ? AppTheme.primaryGradient : LinearGradient(colors: [AppTheme.border, AppTheme.border], startPoint: .leading, endPoint: .trailing))
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .disabled(!viewModel.canLookUp)
|
|
|
+ }
|
|
|
+
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ ToolbarButton(title: "Paste Word", iconName: "doc.on.clipboard") {
|
|
|
+ viewModel.pasteWord()
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ ClearButton {
|
|
|
+ viewModel.clearSearch()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var emptyState: some View {
|
|
|
+ VStack(spacing: 12) {
|
|
|
+ Image(systemName: "text.book.closed")
|
|
|
+ .font(.system(size: 34))
|
|
|
+ .foregroundStyle(AppTheme.teal.opacity(0.45))
|
|
|
+
|
|
|
+ Text("Search for any English word to see definitions, examples, and synonyms.")
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ .frame(maxWidth: 420)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 28)
|
|
|
+ .padding(.horizontal, 20)
|
|
|
+ .background(panelBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
|
|
|
+ .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private var loadingState: some View {
|
|
|
+ HStack(spacing: 12) {
|
|
|
+ ProgressView()
|
|
|
+ .controlSize(.small)
|
|
|
+
|
|
|
+ Text("Looking up definition...")
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ .padding(18)
|
|
|
+ .background(panelBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
|
|
|
+ }
|
|
|
+
|
|
|
+ private func errorBanner(_ message: String) -> some View {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Image(systemName: "exclamationmark.triangle.fill")
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.clearForeground)
|
|
|
+
|
|
|
+ Text(message)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.clearForeground)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ .padding(.horizontal, 16)
|
|
|
+ .padding(.vertical, 12)
|
|
|
+ .background(AppTheme.clearBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 10)
|
|
|
+ .stroke(AppTheme.clearBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private var recentSearchesSection: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ Text("Recent Searches")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ LazyVGrid(
|
|
|
+ columns: [GridItem(.adaptive(minimum: 72), spacing: 8)],
|
|
|
+ alignment: .leading,
|
|
|
+ spacing: 8
|
|
|
+ ) {
|
|
|
+ ForEach(viewModel.recentSearches, id: \.self) { word in
|
|
|
+ Button {
|
|
|
+ viewModel.lookUpRecent(word)
|
|
|
+ } label: {
|
|
|
+ Text(word)
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 7)
|
|
|
+ .background(AppTheme.tealLight)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var resultCard: some View {
|
|
|
+ Group {
|
|
|
+ if let entry = viewModel.entry {
|
|
|
+ VStack(alignment: .leading, spacing: 20) {
|
|
|
+ wordHeader(entry)
|
|
|
+
|
|
|
+ ForEach(entry.meanings) { meaning in
|
|
|
+ meaningSection(meaning)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(22)
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
+ .stroke(AppTheme.border, lineWidth: 1)
|
|
|
+ )
|
|
|
+ .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func wordHeader(_ entry: DictionaryEntry) -> some View {
|
|
|
+ HStack(alignment: .top, spacing: 16) {
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
+ Text(entry.word)
|
|
|
+ .font(.system(size: 34, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ if let phonetic = entry.phonetic {
|
|
|
+ Text(phonetic)
|
|
|
+ .font(.system(size: 16))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ Button {
|
|
|
+ viewModel.playPronunciation(for: entry)
|
|
|
+ } label: {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Image(systemName: "speaker.wave.2.fill")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+
|
|
|
+ Text("Listen")
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
+ }
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ .padding(.horizontal, 14)
|
|
|
+ .padding(.vertical, 9)
|
|
|
+ .background(AppTheme.tealLight)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .accessibilityLabel("Play pronunciation")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func meaningSection(_ meaning: DictionaryMeaning) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 14) {
|
|
|
+ PartOfSpeechBadge(title: meaning.partOfSpeech)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
+ ForEach(Array(meaning.definitions.enumerated()), id: \.element.id) { index, definition in
|
|
|
+ definitionRow(number: index + 1, definition: definition)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !meaning.synonyms.isEmpty {
|
|
|
+ relatedWordsSection(title: "Synonyms", words: meaning.synonyms)
|
|
|
+ }
|
|
|
+
|
|
|
+ if !meaning.antonyms.isEmpty {
|
|
|
+ relatedWordsSection(title: "Antonyms", words: meaning.antonyms)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(18)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ .background(panelBackground)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
+ .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func definitionRow(number: Int, definition: DictionaryDefinition) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 6) {
|
|
|
+ HStack(alignment: .top, spacing: 10) {
|
|
|
+ Text("\(number).")
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ .frame(width: 20, alignment: .leading)
|
|
|
+
|
|
|
+ Text(definition.text)
|
|
|
+ .font(.system(size: 14))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let example = definition.example {
|
|
|
+ Text("\"\(example)\"")
|
|
|
+ .font(.system(size: 13))
|
|
|
+ .italic()
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
+ .padding(.leading, 30)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func relatedWordsSection(title: String, words: [String]) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text(title)
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ LazyVGrid(
|
|
|
+ columns: [GridItem(.adaptive(minimum: 72), spacing: 8)],
|
|
|
+ alignment: .leading,
|
|
|
+ spacing: 8
|
|
|
+ ) {
|
|
|
+ ForEach(words, id: \.self) { word in
|
|
|
+ Button {
|
|
|
+ viewModel.lookUpRecent(word)
|
|
|
+ } label: {
|
|
|
+ Text(word)
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 6)
|
|
|
+ .background(Color.white)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ .overlay(
|
|
|
+ Capsule()
|
|
|
+ .stroke(AppTheme.border.opacity(0.7), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.top, 4)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PartOfSpeechBadge: View {
|
|
|
+ let title: String
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Text(title.capitalized)
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 6)
|
|
|
+ .background(AppTheme.tealLight)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ }
|
|
|
+}
|