| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- import SwiftUI
- struct DictionaryView: View {
- @StateObject private var viewModel = DictionaryViewModel()
- @Environment(\.onSettingsTapped) private var onSettingsTapped
- var body: some View {
- GeometryReader { proxy in
- let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
- let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
- VStack(alignment: .leading, spacing: 0) {
- header
- .padding(.bottom, 28)
- ScrollView(.vertical, showsIndicators: false) {
- VStack(alignment: .leading, spacing: 0) {
- contentCard
- .padding(.bottom, 24)
- if viewModel.hasResult {
- resultCard
- }
- }
- .padding(.bottom, dynamicBottomPadding)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .padding(.horizontal, dynamicHorizontalPadding)
- .padding(.top, AppTheme.brandLabelTopInset)
- .padding(.bottom, dynamicBottomPadding)
- }
- .clearHostingBackground()
- .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(icon: { SettingsGearIcon() }, action: onSettingsTapped)
- .accessibilityLabel("Settings")
- }
- }
- private var contentCard: some View {
- VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
- YourContentHeader()
- searchField
- if let errorMessage = viewModel.errorMessage {
- errorBanner(errorMessage)
- }
- if viewModel.isLoading {
- loadingState
- } else if !viewModel.hasResult {
- emptyState
- }
- if !viewModel.recentSearches.isEmpty {
- recentSearchesSection
- }
- }
- .inputContentCardPadding()
- .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(AppTheme.surface)
- .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
- .overlay(
- RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
- .stroke(AppTheme.inputBorder, lineWidth: 1)
- )
- CompactPrimaryButton(
- title: "Look Up",
- iconName: "book.fill",
- isEnabled: viewModel.canLookUp,
- action: viewModel.lookUp
- )
- }
- 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(AppTheme.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(AppTheme.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 Search")
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(AppTheme.textSecondary)
- if let latestSearch = viewModel.recentSearches.first {
- ToolbarButton(title: latestSearch, iconName: "clock") {
- viewModel.lookUpRecent(latestSearch)
- }
- }
- }
- }
- 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(AppTheme.contentCardPadding)
- .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()
- TealPillButton(title: "Listen", iconName: "speaker.wave.2.fill") {
- viewModel.playPronunciation(for: entry)
- }
- .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(AppTheme.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
- ChipButton(title: word) {
- viewModel.lookUpRecent(word)
- }
- }
- }
- }
- .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())
- }
- }
|