Просмотр исходного кода

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 месяц назад
Родитель
Сommit
36bdf36b57

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

@@ -164,7 +164,15 @@ struct IconButton: View {
         Button(action: action) {
         Button(action: action) {
             iconContent
             iconContent
                 .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
                 .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())
                 .contentShape(Rectangle())
                 .scaleEffect(isHovered ? 1.05 : 1.0)
                 .scaleEffect(isHovered ? 1.05 : 1.0)
                 .animation(.easeOut(duration: 0.15), value: isHovered)
                 .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 {
 struct ClearButton: View {
     let action: () -> Void
     let action: () -> Void
 
 

+ 8 - 45
gramora/Views/DictionaryView.swift

@@ -137,22 +137,12 @@ struct DictionaryView: View {
                         .stroke(AppTheme.inputBorder, lineWidth: 1)
                         .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) {
             HStack(spacing: 8) {
@@ -280,23 +270,9 @@ struct DictionaryView: View {
 
 
             Spacer()
             Spacer()
 
 
-            Button {
+            TealPillButton(title: "Listen", iconName: "speaker.wave.2.fill") {
                 viewModel.playPronunciation(for: entry)
                 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")
             .accessibilityLabel("Play pronunciation")
         }
         }
     }
     }
@@ -365,22 +341,9 @@ struct DictionaryView: View {
                 spacing: 8
                 spacing: 8
             ) {
             ) {
                 ForEach(words, id: \.self) { word in
                 ForEach(words, id: \.self) { word in
-                    Button {
+                    ChipButton(title: word) {
                         viewModel.lookUpRecent(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) {
         .popover(isPresented: $isPresented, arrowEdge: .bottom) {
             VStack(alignment: .leading, spacing: 4) {
             VStack(alignment: .leading, spacing: 4) {
                 ForEach(options) { option in
                 ForEach(options) { option in
-                    Button {
+                    PopoverOptionRow(
+                        title: option[keyPath: label],
+                        isSelected: selection == option
+                    ) {
                         selection = option
                         selection = option
                         isPresented = false
                         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)
             .padding(8)

+ 9 - 41
gramora/Views/JournalFinderView.swift

@@ -354,7 +354,7 @@ struct JournalFinderView: View {
 
 
                 HStack(spacing: 8) {
                 HStack(spacing: 8) {
                     ForEach(JournalIndex.allCases) { index in
                     ForEach(JournalIndex.allCases) { index in
-                        FilterChip(
+                        ChipButton(
                             title: index.rawValue,
                             title: index.rawValue,
                             isSelected: viewModel.selectedIndexes.contains(index)
                             isSelected: viewModel.selectedIndexes.contains(index)
                         ) {
                         ) {
@@ -413,22 +413,9 @@ struct JournalFinderView: View {
                 Spacer()
                 Spacer()
 
 
                 if journal.websiteURL != nil {
                 if journal.websiteURL != nil {
-                    Button {
+                    TealPillButton(title: "Visit", iconName: "safari") {
                         viewModel.openJournalWebsite(journal)
                         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)
             RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                 .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.inputBorder, lineWidth: 1)
                 .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 }
         .onHover { isHovered = $0 }
         .accessibilityLabel("Research field: \(selection.displayName)")
         .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 {
 private struct MatchScoreRing: View {
     let score: Int
     let score: Int