Browse Source

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 tháng trước cách đây
mục cha
commit
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