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 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
var isRecording: Bool {
return recordingSessionManager.isRecordingMeeting(meeting.id)
+123 -82
View File
@@ -241,101 +241,113 @@ struct MeetingDetailContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Meeting Title with Menu
HStack {
TextField("Meeting Title", text: $viewModel.meeting.title)
.font(.title2)
.fontWeight(.semibold)
.textFieldStyle(.plain)
Spacer()
// Ellipsis menu
Menu {
Button("Delete Meeting", role: .destructive) {
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)
VStack(alignment: .leading, spacing: 8) {
// Meeting Title with Menu
HStack {
TextField("Meeting Title", text: $viewModel.meeting.title)
.font(.title2)
.fontWeight(.semibold)
.textFieldStyle(.plain)
Spacer()
// Ellipsis menu
Menu {
ForEach(viewModel.templates) { template in
Button(template.title) {
viewModel.selectedTemplateId = template.id
viewModel.selectedTab = .enhancedNotes
isEditing = false
}
Button("Delete Meeting", role: .destructive) {
showDeleteAlert = true
}
} 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)
Image(systemName: "ellipsis")
.resizable()
.scaledToFit()
.frame(width: 12, height: 12)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isStartingRecording)
.help("Generate enhanced notes using a template")
.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)
// 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)
Spacer()
// Right: Generate and Recording Buttons
HStack(spacing: 8) {
// Generate Button (Dropdown)
Menu {
ForEach(viewModel.templates) { template in
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)
.background(viewModel.isRecording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1))
.cornerRadius(8)
.buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isStartingRecording)
.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
VStack(alignment: .leading, spacing: 8) {
// Tab Header with Copy and Edit buttons
HStack {
HStack(spacing: 8) {
Text(viewModel.selectedTab.rawValue)
.font(.headline)
.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 {
MeetingListView(settingsViewModel: SettingsViewModel())
}