feat: shimmering generate button (#49)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user