فهرست منبع

Add consistent hover effects to all remaining buttons.

Introduce reusable hoverable button components and migrate inline buttons in Dictionary, Journal Finder, and Email Writer to match the existing toolbar and gradient button interactions.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ماه پیش
والد
کامیت
36bdf36b57

+ 168 - 1
gramora/Views/Components/GradientButton.swift

@@ -164,7 +164,15 @@ struct IconButton: View {
         Button(action: action) {
             iconContent
                 .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
-                .padding(4)
+                .padding(6)
+                .background(
+                    RoundedRectangle(cornerRadius: 6)
+                        .fill(isHovered ? AppTheme.toolbarBackgroundHover : Color.clear)
+                )
+                .overlay(
+                    RoundedRectangle(cornerRadius: 6)
+                        .stroke(isHovered ? AppTheme.toolbarBorderHover : Color.clear, lineWidth: 1)
+                )
                 .contentShape(Rectangle())
                 .scaleEffect(isHovered ? 1.05 : 1.0)
                 .animation(.easeOut(duration: 0.15), value: isHovered)
@@ -174,6 +182,165 @@ struct IconButton: View {
     }
 }
 
+struct CompactPrimaryButton: View {
+    let title: String
+    let iconName: String
+    var isEnabled: Bool = true
+    let action: () -> Void
+
+    @State private var isHovered = false
+
+    var body: some View {
+        Button(action: action) {
+            HStack(spacing: 8) {
+                Image(systemName: iconName)
+                    .font(.system(size: 13, weight: .semibold))
+
+                Text(title)
+                    .font(.system(size: 14, weight: .semibold))
+            }
+            .foregroundStyle(.white)
+            .padding(.horizontal, 20)
+            .padding(.vertical, 14)
+            .background(backgroundGradient)
+            .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+            .shadow(
+                color: isEnabled && isHovered ? AppTheme.primaryShadowHover : AppTheme.primaryShadow.opacity(isEnabled ? 1 : 0),
+                radius: isHovered ? 12 : 8,
+                y: isHovered ? 5 : 3
+            )
+            .scaleEffect(isEnabled && isHovered ? 1.02 : 1.0)
+            .animation(.easeOut(duration: 0.15), value: isHovered)
+        }
+        .buttonStyle(.plain)
+        .disabled(!isEnabled)
+        .onHover { isHovered = $0 }
+    }
+
+    private var backgroundGradient: LinearGradient {
+        guard isEnabled else {
+            return LinearGradient(
+                colors: [AppTheme.border, AppTheme.border],
+                startPoint: .leading,
+                endPoint: .trailing
+            )
+        }
+        return isHovered ? AppTheme.primaryGradientHover : AppTheme.primaryGradient
+    }
+}
+
+struct TealPillButton: View {
+    let title: String
+    let iconName: String
+    let action: () -> Void
+
+    @State private var isHovered = false
+
+    var body: some View {
+        Button(action: action) {
+            HStack(spacing: 6) {
+                Image(systemName: iconName)
+                    .font(.system(size: 12, weight: .semibold))
+
+                Text(title)
+                    .font(.system(size: 13, weight: .semibold))
+            }
+            .foregroundStyle(isHovered ? AppTheme.tealDark : AppTheme.teal)
+            .padding(.horizontal, 14)
+            .padding(.vertical, 9)
+            .background(isHovered ? AppTheme.tealLight.opacity(0.85) : AppTheme.tealLight)
+            .clipShape(RoundedRectangle(cornerRadius: 10))
+            .overlay(
+                RoundedRectangle(cornerRadius: 10)
+                    .stroke(isHovered ? AppTheme.toolbarBorderHover : Color.clear, lineWidth: 1)
+            )
+            .shadow(
+                color: isHovered ? AppTheme.toolbarShadowHover : .clear,
+                radius: isHovered ? 4 : 0,
+                y: isHovered ? 2 : 0
+            )
+            .scaleEffect(isHovered ? 1.02 : 1.0)
+            .animation(.easeOut(duration: 0.15), value: isHovered)
+        }
+        .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
+    }
+}
+
+struct ChipButton: View {
+    let title: String
+    var isSelected: Bool = false
+    let action: () -> Void
+
+    @State private var isHovered = false
+
+    var body: some View {
+        Button(action: action) {
+            Text(title)
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(isSelected ? AppTheme.teal : (isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary))
+                .padding(.horizontal, isSelected ? 12 : 10)
+                .padding(.vertical, isSelected ? 7 : 6)
+                .background(chipBackground)
+                .clipShape(Capsule())
+                .overlay(
+                    Capsule()
+                        .stroke(chipBorder, lineWidth: 1)
+                )
+                .scaleEffect(isHovered ? 1.02 : 1.0)
+                .animation(.easeOut(duration: 0.15), value: isHovered)
+        }
+        .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
+    }
+
+    private var chipBackground: Color {
+        if isSelected {
+            return AppTheme.tealLight
+        }
+        return isHovered ? AppTheme.toolbarBackgroundHover : Color.white
+    }
+
+    private var chipBorder: Color {
+        if isSelected {
+            return AppTheme.teal.opacity(0.4)
+        }
+        return isHovered ? AppTheme.toolbarBorderHover : AppTheme.border.opacity(0.7)
+    }
+}
+
+struct PopoverOptionRow: View {
+    let title: String
+    let isSelected: Bool
+    let action: () -> Void
+
+    @State private var isHovered = false
+
+    var body: some View {
+        Button(action: action) {
+            HStack {
+                Text(title)
+                if isSelected {
+                    Spacer()
+                    Image(systemName: "checkmark")
+                }
+            }
+            .font(.system(size: 13, weight: isSelected ? .semibold : .regular))
+            .foregroundStyle(isHovered || isSelected ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
+            .frame(minWidth: 180, alignment: .leading)
+            .padding(.horizontal, 10)
+            .padding(.vertical, 7)
+            .background(
+                RoundedRectangle(cornerRadius: 6)
+                    .fill(isHovered ? AppTheme.toolbarBackgroundHover : Color.clear)
+            )
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
+    }
+}
+
 struct ClearButton: View {
     let action: () -> Void
 

+ 8 - 45
gramora/Views/DictionaryView.swift

@@ -137,22 +137,12 @@ struct DictionaryView: View {
                         .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)
+                CompactPrimaryButton(
+                    title: "Look Up",
+                    iconName: "book.fill",
+                    isEnabled: viewModel.canLookUp,
+                    action: viewModel.lookUp
+                )
             }
 
             HStack(spacing: 8) {
@@ -280,23 +270,9 @@ struct DictionaryView: View {
 
             Spacer()
 
-            Button {
+            TealPillButton(title: "Listen", iconName: "speaker.wave.2.fill") {
                 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")
         }
     }
@@ -365,22 +341,9 @@ struct DictionaryView: View {
                 spacing: 8
             ) {
                 ForEach(words, id: \.self) { word in
-                    Button {
+                    ChipButton(title: word) {
                         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)
                 }
             }
         }

+ 4 - 12
gramora/Views/EmailWriterView.swift

@@ -322,21 +322,13 @@ private struct EmailOptionMenu<Option: Identifiable & Equatable>: View {
         .popover(isPresented: $isPresented, arrowEdge: .bottom) {
             VStack(alignment: .leading, spacing: 4) {
                 ForEach(options) { option in
-                    Button {
+                    PopoverOptionRow(
+                        title: option[keyPath: label],
+                        isSelected: selection == option
+                    ) {
                         selection = option
                         isPresented = false
-                    } label: {
-                        HStack {
-                            Text(option[keyPath: label])
-                            if selection == option {
-                                Spacer()
-                                Image(systemName: "checkmark")
-                            }
-                        }
-                        .frame(minWidth: 180, alignment: .leading)
-                        .contentShape(Rectangle())
                     }
-                    .buttonStyle(.plain)
                 }
             }
             .padding(8)

+ 9 - 41
gramora/Views/JournalFinderView.swift

@@ -354,7 +354,7 @@ struct JournalFinderView: View {
 
                 HStack(spacing: 8) {
                     ForEach(JournalIndex.allCases) { index in
-                        FilterChip(
+                        ChipButton(
                             title: index.rawValue,
                             isSelected: viewModel.selectedIndexes.contains(index)
                         ) {
@@ -413,22 +413,9 @@ struct JournalFinderView: View {
                 Spacer()
 
                 if journal.websiteURL != nil {
-                    Button {
+                    TealPillButton(title: "Visit", iconName: "safari") {
                         viewModel.openJournalWebsite(journal)
-                    } label: {
-                        HStack(spacing: 6) {
-                            Image(systemName: "safari")
-                                .font(.system(size: 12, weight: .semibold))
-                            Text("Visit")
-                                .font(.system(size: 13, weight: .semibold))
-                        }
-                        .foregroundStyle(AppTheme.teal)
-                        .padding(.horizontal, 14)
-                        .padding(.vertical, 9)
-                        .background(AppTheme.tealLight)
-                        .clipShape(RoundedRectangle(cornerRadius: 10))
                     }
-                    .buttonStyle(.plain)
                 }
             }
 
@@ -576,37 +563,18 @@ private struct ResearchFieldPicker: View {
             RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                 .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.inputBorder, lineWidth: 1)
         )
+        .shadow(
+            color: isHovered ? AppTheme.toolbarShadowHover : .clear,
+            radius: isHovered ? 4 : 0,
+            y: isHovered ? 2 : 0
+        )
+        .scaleEffect(isHovered ? 1.01 : 1.0)
+        .animation(.easeOut(duration: 0.15), value: isHovered)
         .onHover { isHovered = $0 }
         .accessibilityLabel("Research field: \(selection.displayName)")
     }
 }
 
-private struct FilterChip: View {
-    let title: String
-    let isSelected: Bool
-    let action: () -> Void
-
-    @State private var isHovered = false
-
-    var body: some View {
-        Button(action: action) {
-            Text(title)
-                .font(.system(size: 12, weight: .medium))
-                .foregroundStyle(isSelected ? AppTheme.teal : AppTheme.textSecondary)
-                .padding(.horizontal, 12)
-                .padding(.vertical, 7)
-                .background(isSelected ? AppTheme.tealLight : (isHovered ? AppTheme.toolbarBackgroundHover : Color.white))
-                .clipShape(Capsule())
-                .overlay(
-                    Capsule()
-                        .stroke(isSelected ? AppTheme.teal.opacity(0.4) : AppTheme.border.opacity(0.7), lineWidth: 1)
-                )
-        }
-        .buttonStyle(.plain)
-        .onHover { isHovered = $0 }
-    }
-}
-
 private struct MatchScoreRing: View {
     let score: Int