feat: shimmering generate button (#49)

This commit is contained in:
Owen Gretzinger
2025-07-29 14:36:24 -04:00
committed by GitHub
parent 2b7940fc30
commit fb827dec2c
2 changed files with 130 additions and 82 deletions
@@ -26,6 +26,13 @@ class MeetingViewModel: ObservableObject {
@Published var isValidatingKey = false // Indicates API key validation in progress @Published var isValidatingKey = false // Indicates API key validation in progress
@Published var isStartingRecording = false // Indicates recording start in progress @Published var isStartingRecording = false // Indicates recording start in progress
// Computed property to determine if Generate button should animate
var shouldAnimateGenerateButton: Bool {
let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isStartingRecording
let noEnhancedNotesYet = meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return generateButtonEnabled && noEnhancedNotesYet
}
// Computed property that always uses the direct RecordingSessionManager check // Computed property that always uses the direct RecordingSessionManager check
var isRecording: Bool { var isRecording: Bool {
return recordingSessionManager.isRecordingMeeting(meeting.id) return recordingSessionManager.isRecordingMeeting(meeting.id)
+123 -82
View File
@@ -241,101 +241,113 @@ struct MeetingDetailContentView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
// Meeting Title with Menu
HStack { VStack(alignment: .leading, spacing: 8) {
TextField("Meeting Title", text: $viewModel.meeting.title) // Meeting Title with Menu
.font(.title2) HStack {
.fontWeight(.semibold) TextField("Meeting Title", text: $viewModel.meeting.title)
.textFieldStyle(.plain) .font(.title2)
.fontWeight(.semibold)
Spacer() .textFieldStyle(.plain)
// Ellipsis menu Spacer()
Menu {
Button("Delete Meeting", role: .destructive) { // Ellipsis menu
showDeleteAlert = true
}
} label: {
Image(systemName: "ellipsis")
.resizable()
.scaledToFit()
.frame(width: 12, height: 12)
.foregroundColor(.secondary)
}
.labelStyle(.iconOnly)
.menuIndicator(.hidden)
.menuStyle(BorderlessButtonMenuStyle())
.frame(width: 20, height: 20)
}
.padding(.bottom, 10)
// Controls Section
HStack {
// Left: Tab Toggles
Picker("", selection: $viewModel.selectedTab) {
ForEach(MeetingViewTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
Spacer()
// Right: Generate and Recording Buttons
HStack(spacing: 12) {
// Generate Button (Dropdown)
Menu { Menu {
ForEach(viewModel.templates) { template in Button("Delete Meeting", role: .destructive) {
Button(template.title) { showDeleteAlert = true
viewModel.selectedTemplateId = template.id
viewModel.selectedTab = .enhancedNotes
isEditing = false
}
} }
} label: { } label: {
HStack(spacing: 4) { Image(systemName: "ellipsis")
if viewModel.isGeneratingNotes { .resizable()
ProgressView() .scaledToFit()
.scaleEffect(0.4) .frame(width: 12, height: 12)
.frame(width: 12, height: 12) .foregroundColor(.secondary)
} else {
Image(systemName: "sparkles")
.font(.caption)
}
Text("Generate")
}
.frame(minWidth: 110, minHeight: 36)
.background(Color.green.opacity(0.1))
.cornerRadius(8)
} }
.buttonStyle(.plain) .labelStyle(.iconOnly)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isStartingRecording) .menuIndicator(.hidden)
.help("Generate enhanced notes using a template") .menuStyle(BorderlessButtonMenuStyle())
.frame(width: 20, height: 20)
}
.padding(.bottom, 10)
// Controls Section
HStack {
// Left: Tab Toggles
Picker("", selection: $viewModel.selectedTab) {
ForEach(MeetingViewTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
// Recording Button Spacer()
Button(action: {
viewModel.toggleRecording() // Right: Generate and Recording Buttons
}) { HStack(spacing: 8) {
HStack(spacing: 4) { // Generate Button (Dropdown)
Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle") Menu {
.foregroundColor(viewModel.isRecording ? .red : .accentColor) ForEach(viewModel.templates) { template in
Text(viewModel.recordingButtonText) Button(template.title) {
viewModel.selectedTemplateId = template.id
viewModel.selectedTab = .enhancedNotes
isEditing = false
}
}
} label: {
HStack(spacing: 4) {
if viewModel.isGeneratingNotes {
ProgressView()
.scaleEffect(0.4)
.frame(width: 12, height: 12)
} else {
Image(systemName: "sparkles")
.font(.caption)
}
Text("Generate")
}
.frame(minWidth: 110, minHeight: 36)
.background(Color.green.opacity(0.1))
.cornerRadius(8)
.overlay(
// Shimmer overlay when ready
Group {
if viewModel.shouldAnimateGenerateButton {
ShimmerOverlay()
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
)
} }
.frame(minWidth: 110, minHeight: 36) .buttonStyle(.plain)
.background(viewModel.isRecording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) .disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isStartingRecording)
.cornerRadius(8) .help("Generate enhanced notes using a template")
// Recording Button
Button(action: {
viewModel.toggleRecording()
}) {
HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle")
.foregroundColor(viewModel.isRecording ? .red : .accentColor)
Text(viewModel.recordingButtonText)
}
.frame(minWidth: 110, minHeight: 36)
.background(viewModel.isRecording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(cannotStartRecording || viewModel.isValidatingKey || viewModel.isStartingRecording)
.help(cannotStartRecording ? "Another meeting is currently being recorded" : "Start or stop recording for this meeting")
} }
.buttonStyle(.plain)
.disabled(cannotStartRecording || viewModel.isValidatingKey || viewModel.isStartingRecording)
.help(cannotStartRecording ? "Another meeting is currently being recorded" : "Start or stop recording for this meeting")
} }
} }
// Content Area with Tab-specific Headers // Content Area with Tab-specific Headers
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Tab Header with Copy and Edit buttons // Tab Header with Copy and Edit buttons
HStack { HStack(spacing: 8) {
Text(viewModel.selectedTab.rawValue) Text(viewModel.selectedTab.rawValue)
.font(.headline) .font(.headline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -489,6 +501,35 @@ struct MeetingDetailContentView: View {
} }
} }
// MARK: - Shimmer Overlay
struct ShimmerOverlay: View {
@State private var animate: Bool = false
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
gradient: Gradient(colors: [Color.clear, Color.green.opacity(0.1), Color.clear]),
startPoint: UnitPoint(x: animate ? 2.5 : -1, y: 0.5),
endPoint: UnitPoint(x: animate ? 3.5 : 0, y: 0.5)
)
)
.frame(width: width, height: height)
.onAppear {
animate = true
}
.animation(
Animation.linear(duration: 1.5).repeatForever(autoreverses: false),
value: animate
)
}
.allowsHitTesting(false)
}
}
#Preview { #Preview {
MeetingListView(settingsViewModel: SettingsViewModel()) MeetingListView(settingsViewModel: SettingsViewModel())
} }