|
@@ -0,0 +1,335 @@
|
|
|
|
|
+import SwiftUI
|
|
|
|
|
+
|
|
|
|
|
+struct SettingsView: View {
|
|
|
|
|
+ @StateObject private var viewModel = SettingsViewModel()
|
|
|
|
|
+
|
|
|
|
|
+ let onClose: () -> Void
|
|
|
|
|
+
|
|
|
|
|
+ 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.04, 20), 40)
|
|
|
|
|
+
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
|
|
+ header
|
|
|
|
|
+ .padding(.bottom, 24)
|
|
|
|
|
+
|
|
|
|
|
+ settingsCard
|
|
|
|
|
+ .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)
|
|
|
|
|
+ }
|
|
|
|
|
+ .confirmationDialog(
|
|
|
|
|
+ "Clear all history?",
|
|
|
|
|
+ isPresented: $viewModel.isShowingClearHistoryConfirmation,
|
|
|
|
|
+ titleVisibility: .visible
|
|
|
|
|
+ ) {
|
|
|
|
|
+ Button("Clear All", role: .destructive) {
|
|
|
|
|
+ viewModel.clearHistory()
|
|
|
|
|
+ }
|
|
|
|
|
+ Button("Cancel", role: .cancel) {}
|
|
|
|
|
+ } message: {
|
|
|
|
|
+ Text("This will permanently remove all saved history items.")
|
|
|
|
|
+ }
|
|
|
|
|
+ .confirmationDialog(
|
|
|
|
|
+ "Clear dictionary searches?",
|
|
|
|
|
+ isPresented: $viewModel.isShowingClearSearchesConfirmation,
|
|
|
|
|
+ titleVisibility: .visible
|
|
|
|
|
+ ) {
|
|
|
|
|
+ Button("Clear", role: .destructive) {
|
|
|
|
|
+ viewModel.clearDictionarySearches()
|
|
|
|
|
+ }
|
|
|
|
|
+ Button("Cancel", role: .cancel) {}
|
|
|
|
|
+ } message: {
|
|
|
|
|
+ Text("This will remove your recent dictionary lookups.")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var header: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
+ IconButton(iconName: "arrow.left", action: onClose)
|
|
|
|
|
+ .accessibilityLabel("Go back")
|
|
|
|
|
+
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
+ Text("Settings")
|
|
|
|
|
+ .font(.system(size: 30, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
+
|
|
|
|
|
+ Text("Manage your account, data, and app preferences.")
|
|
|
|
|
+ .font(.system(size: 15))
|
|
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var settingsCard: some View {
|
|
|
|
|
+ ScrollView(.vertical, showsIndicators: false) {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 24) {
|
|
|
|
|
+ dataSection
|
|
|
|
|
+ aboutSection
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(AppTheme.contentCardPadding)
|
|
|
|
|
+ .padding(.top, AppTheme.contentCardTopPadding)
|
|
|
|
|
+ .padding(.leading, AppTheme.contentCardLeadingPadding)
|
|
|
|
|
+ .padding(.bottom, AppTheme.contentCardBottomPadding)
|
|
|
|
|
+ }
|
|
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
|
|
|
|
|
+ .stroke(AppTheme.inputBorder, lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var dataSection: some View {
|
|
|
|
|
+ SettingsSectionCard(title: "Data & Privacy", iconName: "externaldrive.fill") {
|
|
|
|
|
+ VStack(spacing: 0) {
|
|
|
|
|
+ SettingsActionRow(
|
|
|
|
|
+ iconName: "clock.arrow.circlepath",
|
|
|
|
|
+ title: "History",
|
|
|
|
|
+ subtitle: historySubtitle,
|
|
|
|
|
+ actionTitle: "Clear",
|
|
|
|
|
+ isDestructive: true,
|
|
|
|
|
+ isActionEnabled: viewModel.historyEntryCount > 0
|
|
|
|
|
+ ) {
|
|
|
|
|
+ viewModel.isShowingClearHistoryConfirmation = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ SettingsRowDivider()
|
|
|
|
|
+
|
|
|
|
|
+ SettingsActionRow(
|
|
|
|
|
+ iconName: "book.closed.fill",
|
|
|
|
|
+ title: "Dictionary Searches",
|
|
|
|
|
+ subtitle: dictionarySubtitle,
|
|
|
|
|
+ actionTitle: "Clear",
|
|
|
|
|
+ isDestructive: true,
|
|
|
|
|
+ isActionEnabled: viewModel.dictionarySearchCount > 0
|
|
|
|
|
+ ) {
|
|
|
|
|
+ viewModel.isShowingClearSearchesConfirmation = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var aboutSection: some View {
|
|
|
|
|
+ SettingsSectionCard(title: "About", iconName: "info.circle.fill") {
|
|
|
|
|
+ VStack(spacing: 0) {
|
|
|
|
|
+ SettingsInfoRow(
|
|
|
|
|
+ iconName: "app.badge.fill",
|
|
|
|
|
+ title: "Version",
|
|
|
|
|
+ value: viewModel.appVersion
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ SettingsRowDivider()
|
|
|
|
|
+
|
|
|
|
|
+ SettingsLinkRow(iconName: "hand.raised.fill", title: "Privacy Policy")
|
|
|
|
|
+ SettingsRowDivider()
|
|
|
|
|
+ SettingsLinkRow(iconName: "doc.text.fill", title: "Terms of Service")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var historySubtitle: String {
|
|
|
|
|
+ switch viewModel.historyEntryCount {
|
|
|
|
|
+ case 0: "No saved items"
|
|
|
|
|
+ case 1: "1 saved item"
|
|
|
|
|
+ default: "\(viewModel.historyEntryCount) saved items"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var dictionarySubtitle: String {
|
|
|
|
|
+ switch viewModel.dictionarySearchCount {
|
|
|
|
|
+ case 0: "No recent searches"
|
|
|
|
|
+ case 1: "1 recent search"
|
|
|
|
|
+ default: "\(viewModel.dictionarySearchCount) recent searches"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Section Components
|
|
|
|
|
+
|
|
|
|
|
+private struct SettingsSectionCard<Content: View>: View {
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let iconName: String
|
|
|
|
|
+ @ViewBuilder let content: () -> Content
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 14) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: iconName)
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
|
|
+
|
|
|
|
|
+ Text(title.uppercased())
|
|
|
|
|
+ .font(.system(size: 11, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ content()
|
|
|
|
|
+ .padding(16)
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .background(Color(red: 0.98, green: 0.99, blue: 0.99))
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .stroke(AppTheme.border.opacity(0.85), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SettingsActionRow: View {
|
|
|
|
|
+ let iconName: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let subtitle: String
|
|
|
|
|
+ let actionTitle: String
|
|
|
|
|
+ var isDestructive = false
|
|
|
|
|
+ var isActionEnabled = true
|
|
|
|
|
+ let action: () -> Void
|
|
|
|
|
+
|
|
|
|
|
+ @State private var isActionHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ HStack(spacing: 12) {
|
|
|
|
|
+ SettingsRowIcon(systemName: iconName)
|
|
|
|
|
+
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
+
|
|
|
|
|
+ Text(subtitle)
|
|
|
|
|
+ .font(.system(size: 12))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Spacer(minLength: 8)
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: action) {
|
|
|
|
|
+ Text(actionTitle)
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(actionForeground)
|
|
|
|
|
+ .padding(.horizontal, 12)
|
|
|
|
|
+ .padding(.vertical, 6)
|
|
|
|
|
+ .background(actionBackground)
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
|
|
|
+ .stroke(actionBorder, lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .scaleEffect(isActionHovered && isActionEnabled ? 1.02 : 1.0)
|
|
|
|
|
+ .animation(.easeOut(duration: 0.15), value: isActionHovered)
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(!isActionEnabled)
|
|
|
|
|
+ .opacity(isActionEnabled ? 1 : 0.45)
|
|
|
|
|
+ .onHover { isActionHovered = $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.vertical, 4)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var actionForeground: Color {
|
|
|
|
|
+ guard isActionEnabled else { return AppTheme.textMuted }
|
|
|
|
|
+ if isDestructive {
|
|
|
|
|
+ return isActionHovered ? AppTheme.clearForegroundHover : AppTheme.clearForeground
|
|
|
|
|
+ }
|
|
|
|
|
+ return isActionHovered ? AppTheme.tealDark : AppTheme.teal
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var actionBackground: Color {
|
|
|
|
|
+ guard isActionEnabled else { return Color.white }
|
|
|
|
|
+ if isDestructive {
|
|
|
|
|
+ return isActionHovered ? AppTheme.clearBackgroundHover : AppTheme.clearBackground
|
|
|
|
|
+ }
|
|
|
|
|
+ return isActionHovered ? AppTheme.tealLight.opacity(0.85) : AppTheme.tealLight
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var actionBorder: Color {
|
|
|
|
|
+ guard isActionEnabled else { return AppTheme.border }
|
|
|
|
|
+ if isDestructive {
|
|
|
|
|
+ return isActionHovered ? AppTheme.clearForeground.opacity(0.35) : AppTheme.clearBorder
|
|
|
|
|
+ }
|
|
|
|
|
+ return isActionHovered ? AppTheme.toolbarBorderHover : Color.clear
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SettingsInfoRow: View {
|
|
|
|
|
+ let iconName: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let value: String
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ HStack(spacing: 12) {
|
|
|
|
|
+ SettingsRowIcon(systemName: iconName)
|
|
|
|
|
+
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
+
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+
|
|
|
|
|
+ Text(value)
|
|
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textMuted)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.vertical, 4)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SettingsLinkRow: View {
|
|
|
|
|
+ let iconName: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Button {} label: {
|
|
|
|
|
+ HStack(spacing: 12) {
|
|
|
|
|
+ SettingsRowIcon(systemName: iconName)
|
|
|
|
|
+
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
|
|
+
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+
|
|
|
|
|
+ Image(systemName: "arrow.up.right")
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(isHovered ? AppTheme.teal : AppTheme.textMuted)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.vertical, 4)
|
|
|
|
|
+ .contentShape(Rectangle())
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SettingsRowIcon: View {
|
|
|
|
|
+ let systemName: String
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
|
|
|
+ .fill(AppTheme.tealLight)
|
|
|
|
|
+ .frame(width: 32, height: 32)
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ Image(systemName: systemName)
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(AppTheme.teal)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SettingsRowDivider: View {
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Rectangle()
|
|
|
|
|
+ .fill(AppTheme.border.opacity(0.6))
|
|
|
|
|
+ .frame(height: 1)
|
|
|
|
|
+ .padding(.leading, 44)
|
|
|
|
|
+ .padding(.vertical, 10)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|