diff --git a/meetingnotes/Views/MeetingDetailView.swift b/meetingnotes/Views/MeetingDetailView.swift deleted file mode 100644 index 4c2257b..0000000 --- a/meetingnotes/Views/MeetingDetailView.swift +++ /dev/null @@ -1,291 +0,0 @@ -import SwiftUI - - - -struct CollapsedTranscriptChunkView: View { - let chunk: CollapsedTranscriptChunk - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - // Source indicator - HStack(spacing: 4) { - Image(systemName: chunk.source.icon) - .font(.caption) - .foregroundColor(chunk.source == .mic ? .blue : .orange) - - Text(chunk.source.displayName) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(chunk.source == .mic ? .blue : .orange) - } - .frame(width: 50, alignment: .leading) - - // Transcript text - Text(chunk.combinedText) - .font(.body) - .foregroundColor(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical, 2) - } -} - -struct MeetingDetailView: View { - @StateObject private var viewModel: MeetingViewModel - @Environment(\.dismiss) private var dismiss - @State private var showDeleteAlert = false - @State private var isEditing = false - @State private var showCopyConfirmation = false - - init(meeting: Meeting) { - self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting)) - } - - 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 - // Replace checkbox-style toggles with a native segmented control for a cleaner look - Picker("", selection: $viewModel.selectedTab) { - ForEach(MeetingViewTab.allCases, id: \.self) { tab in - Text(tab.rawValue).tag(tab) - } - } - .pickerStyle(.segmented) - .frame(maxWidth: 260) // keep it compact so the recording controls have space - - Spacer() - - // Right: Recording and Copy Buttons - HStack(spacing: 12) { - Button(action: { - viewModel.toggleRecording() - }) { - HStack(spacing: 4) { - Image(systemName: viewModel.recordingState == .recording ? "stop.circle.fill" : "record.circle") - .foregroundColor(viewModel.recordingState == .recording ? .red : .accentColor) - Text(viewModel.recordingButtonText) - } - .frame(minWidth: 110, minHeight: 36) - .background(viewModel.recordingState == .recording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) - .cornerRadius(8) - } - .buttonStyle(.plain) - - Button(action: { - viewModel.copyCurrentTabContent() - showCopyConfirmation = true - - // Reset confirmation after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - showCopyConfirmation = false - } - }) { - HStack(spacing: 4) { - Image(systemName: showCopyConfirmation ? "checkmark.circle.fill" : "doc.on.doc") - .foregroundColor(showCopyConfirmation ? .green : .primary) - Text(showCopyConfirmation ? "Copied!" : "Copy") - .foregroundColor(showCopyConfirmation ? .green : .primary) - } - .frame(minWidth: 90, minHeight: 36) - .background(showCopyConfirmation ? Color.green.opacity(0.1) : Color.gray.opacity(0.1)) - .cornerRadius(8) - } - .buttonStyle(.plain) - } - } - - // Content Area - VStack(alignment: .leading, spacing: 8) { - switch viewModel.selectedTab { - case .myNotes: - myNotesView - case .transcript: - transcriptView - case .enhancedNotes: - enhancedNotesView - } - } - .frame(maxHeight: .infinity) - } - .padding() - .navigationTitle("") - .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { - Button("OK") { - viewModel.errorMessage = nil - } - } message: { - Text(viewModel.errorMessage ?? "") - } - .alert("Delete Meeting", isPresented: $showDeleteAlert) { - Button("Delete", role: .destructive) { - viewModel.deleteMeeting() - dismiss() - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Are you sure you want to delete this meeting? This action cannot be undone.") - } - .onDisappear { - // Stop recording if it's in progress when leaving the page - if viewModel.isRecording { - print("🛑 Stopping recording because user is leaving the page") - viewModel.stopRecording() - } - - // Auto-delete empty meetings when leaving, otherwise save - viewModel.deleteIfEmpty() - } - } - - // MARK: - Content Views - - private var myNotesView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("My Notes") - .font(.headline) - .foregroundColor(.secondary) - - TextEditor(text: $viewModel.meeting.userNotes) - .font(.body) - .padding(8) - .scrollContentBackground(.hidden) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) - .frame(maxHeight: .infinity) - } - } - - private var transcriptView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Transcript") - .font(.headline) - .foregroundColor(.secondary) - - ScrollView { - if viewModel.meeting.collapsedTranscriptChunks.isEmpty { - Text("Transcript will appear here...") - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .foregroundColor(.secondary) - } else { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in - CollapsedTranscriptChunkView(chunk: chunk) - } - } - .padding() - } - } - .frame(maxHeight: .infinity) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) - } - } - - private var enhancedNotesView: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Enhanced Notes") - .font(.headline) - .foregroundColor(.secondary) - - Spacer() - - if viewModel.isGeneratingNotes { - ProgressView() - .scaleEffect(0.7) - } else { - // Template selector - Picker("", selection: $viewModel.selectedTemplateId) { - ForEach(viewModel.templates) { template in - Text(template.title).tag(template.id as UUID?) - } - } - .pickerStyle(.menu) - .frame(width: 200) - - Button(action: { - Task { - await viewModel.generateNotes() - isEditing = false - } - }) { - HStack(spacing: 4) { - Image(systemName: "sparkles") - Text("Generate") - } - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.accentColor.opacity(0.1)) - .cornerRadius(6) - } - .buttonStyle(.plain) - .disabled(viewModel.meeting.transcript.isEmpty) - } - Button(action: { - isEditing.toggle() - }) { - Image(systemName: isEditing ? "pencil.circle.fill" : "pencil.circle") - } - .buttonStyle(.plain) - } - - if isEditing { - TextEditor(text: Binding( - get: { viewModel.meeting.generatedNotes }, - set: { viewModel.meeting.generatedNotes = $0 } - )) - .font(.body) - .padding(8) - .scrollContentBackground(.hidden) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) - .frame(maxHeight: .infinity) - } else { - RenderedNotesView(text: viewModel.meeting.generatedNotes) - .font(.body) - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) - .frame(maxHeight: .infinity) - } - } - } -} - -#Preview { - NavigationStack { - MeetingDetailView(meeting: Meeting()) - } -} - diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index f99b307..d2d64a9 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -3,22 +3,57 @@ import SwiftUI struct MeetingListView: View { @StateObject private var viewModel = MeetingListViewModel() @ObservedObject var settingsViewModel: SettingsViewModel + @State private var selectedMeeting: Meeting? @State private var navigationPath = NavigationPath() var body: some View { - NavigationStack(path: $navigationPath) { - List { + NavigationSplitView { + // Sidebar with meetings list + sidebarContent + } detail: { + // Detail view with meeting content + detailContent + } + .navigationSplitViewStyle(.balanced) + .overlay { + if viewModel.isLoading { + ProgressView("Loading meetings...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.clear) + } + } + } + + private var sidebarContent: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search meetings...", text: $viewModel.searchText) + .textFieldStyle(.plain) + } + .padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + + Divider() + + Spacer().frame(height: 12) // Add space before list content + + List(selection: $selectedMeeting) { // Only render meeting sections when there are meetings or loading state ForEach(groupedMeetings, id: \.day) { dayGroup in Section { - ForEach(dayGroup.meetings) { meeting in - NavigationLink(value: meeting) { - MeetingRowView(meeting: meeting) - } + ForEach(dayGroup.meetings, id: \.id) { meeting in + MeetingRowView(meeting: meeting) + .tag(meeting) } .onDelete { indexSet in for index in indexSet { - viewModel.deleteMeeting(dayGroup.meetings[index]) + let meetingToDelete = dayGroup.meetings[index] + viewModel.deleteMeeting(meetingToDelete) + // Clear selection if the deleted meeting was selected + if selectedMeeting?.id == meetingToDelete.id { + selectedMeeting = nil + } } } } header: { @@ -39,40 +74,48 @@ struct MeetingListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .navigationTitle("Meetingnotes") + } + .navigationTitle("Meetings") + } + + private var detailContent: some View { + NavigationStack(path: $navigationPath) { + Group { + if let selectedMeeting = selectedMeeting { + MeetingDetailContentView(meeting: selectedMeeting, onDelete: { + // When a meeting is deleted from the detail view, clear the selection + self.selectedMeeting = nil + }) + .id(selectedMeeting.id) // Force recreation when selection changes + } else { + ContentUnavailableView( + "Select a Meeting", + systemImage: "sidebar.leading", + description: Text("Choose a meeting from the sidebar to view its details") + ) + } + } + .navigationTitle("") .toolbar { ToolbarItemGroup(placement: .primaryAction) { - // Search field (leftmost within the right-aligned group) - HStack(spacing: 4) { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search meetings...", text: $viewModel.searchText) - .textFieldStyle(.plain) - } - .padding(6) - .background( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.secondary.opacity(0.5)) - ) - .frame(width: 220) - // Settings button (middle) + Spacer() + Button { navigationPath.append("settings") } label: { Image(systemName: "gearshape") } - // Plus button (rightmost) + .help("Settings") + Button { let newMeeting = viewModel.createNewMeeting() - navigationPath.append(newMeeting) + selectedMeeting = newMeeting } label: { Image(systemName: "plus") } + .help("New Meeting") } } - .navigationDestination(for: Meeting.self) { meeting in - MeetingDetailView(meeting: meeting) - } .navigationDestination(for: String.self) { path in if path == "settings" { SettingsView(viewModel: settingsViewModel, navigationPath: $navigationPath) @@ -81,11 +124,6 @@ struct MeetingListView: View { } } } - .overlay { - if viewModel.isLoading { - ProgressView("Loading meetings...") - } - } } private var groupedMeetings: [DayGroup] { @@ -124,42 +162,307 @@ struct MeetingRowView: View { let meeting: Meeting var body: some View { - HStack { - VStack(alignment: .leading, spacing: 6) { - // Title or default - Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title) - .font(.headline) - .lineLimit(1) - - // Notes preview or placeholder - HStack { - if !meeting.generatedNotes.isEmpty { - Text(meeting.generatedNotes.replacingOccurrences(of: "\n", with: " ").trimmingCharacters(in: .whitespacesAndNewlines)) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(1) - } else { - Text("No notes yet") - .font(.subheadline) - .foregroundColor(.secondary) - .italic() - } - Spacer() - } - .padding(.trailing, 20) + VStack(alignment: .leading, spacing: 6) { + // Title or default + Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title) + .font(.headline) + .lineLimit(1) + + // Date + HStack { + Text(meeting.date, style: .time) + .font(.caption) + .foregroundColor(.secondary) + Spacer() } - - Spacer() - - // Date on the right - Text(meeting.date, style: .time) - .font(.caption) - .foregroundColor(.secondary) } .padding(.vertical, 4) } } +// MARK: - Meeting Detail Content View +// This is a refactored version of MeetingDetailView that works within the sidebar layout + +struct CollapsedTranscriptChunkView: View { + let chunk: CollapsedTranscriptChunk + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + // Source indicator + HStack(spacing: 4) { + Image(systemName: chunk.source.icon) + .font(.caption) + .foregroundColor(chunk.source == .mic ? .blue : .orange) + + Text(chunk.source.displayName) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(chunk.source == .mic ? .blue : .orange) + } + .frame(width: 50, alignment: .leading) + + // Transcript text + Text(chunk.combinedText) + .font(.body) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } +} + +struct MeetingDetailContentView: View { + @StateObject private var viewModel: MeetingViewModel + @State private var showDeleteAlert = false + @State private var isEditing = false + @State private var showCopyConfirmation = false + let onDelete: () -> Void + + init(meeting: Meeting, onDelete: @escaping () -> Void) { + self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting)) + self.onDelete = onDelete + } + + 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: Recording and Copy Buttons + HStack(spacing: 12) { + Button(action: { + viewModel.toggleRecording() + }) { + HStack(spacing: 4) { + Image(systemName: viewModel.recordingState == .recording ? "stop.circle.fill" : "record.circle") + .foregroundColor(viewModel.recordingState == .recording ? .red : .accentColor) + Text(viewModel.recordingButtonText) + } + .frame(minWidth: 110, minHeight: 36) + .background(viewModel.recordingState == .recording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Button(action: { + viewModel.copyCurrentTabContent() + showCopyConfirmation = true + + // Reset confirmation after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + showCopyConfirmation = false + } + }) { + HStack(spacing: 4) { + Image(systemName: showCopyConfirmation ? "checkmark.circle.fill" : "doc.on.doc") + .foregroundColor(showCopyConfirmation ? .green : .primary) + Text(showCopyConfirmation ? "Copied!" : "Copy") + .foregroundColor(showCopyConfirmation ? .green : .primary) + } + .frame(minWidth: 90, minHeight: 36) + .background(showCopyConfirmation ? Color.green.opacity(0.1) : Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + + // Content Area + VStack(alignment: .leading, spacing: 8) { + switch viewModel.selectedTab { + case .myNotes: + myNotesView + case .transcript: + transcriptView + case .enhancedNotes: + enhancedNotesView + } + } + .frame(maxHeight: .infinity) + } + .padding() + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + .alert("Delete Meeting", isPresented: $showDeleteAlert) { + Button("Delete", role: .destructive) { + viewModel.deleteMeeting() + onDelete() + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Are you sure you want to delete this meeting? This action cannot be undone.") + } + .onDisappear { + // Stop recording if it's in progress when leaving the page + if viewModel.isRecording { + print("🛑 Stopping recording because user is leaving the page") + viewModel.stopRecording() + } + + // Auto-delete empty meetings when leaving, otherwise save + viewModel.deleteIfEmpty() + } + } + + // MARK: - Content Views + + private var myNotesView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("My Notes") + .font(.headline) + .foregroundColor(.secondary) + + TextEditor(text: $viewModel.meeting.userNotes) + .font(.body) + .padding(8) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + .frame(maxHeight: .infinity) + } + } + + private var transcriptView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Transcript") + .font(.headline) + .foregroundColor(.secondary) + + ScrollView { + if viewModel.meeting.collapsedTranscriptChunks.isEmpty { + Text("Transcript will appear here...") + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .foregroundColor(.secondary) + } else { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in + CollapsedTranscriptChunkView(chunk: chunk) + } + } + .padding() + } + } + .frame(maxHeight: .infinity) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + } + } + + private var enhancedNotesView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Enhanced Notes") + .font(.headline) + .foregroundColor(.secondary) + + Spacer() + + if viewModel.isGeneratingNotes { + ProgressView() + .scaleEffect(0.7) + } else { + // Template selector + Picker("", selection: $viewModel.selectedTemplateId) { + ForEach(viewModel.templates) { template in + Text(template.title).tag(template.id as UUID?) + } + } + .pickerStyle(.menu) + .frame(width: 200) + + Button(action: { + Task { + await viewModel.generateNotes() + isEditing = false + } + }) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + Text("Generate") + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .disabled(viewModel.meeting.transcript.isEmpty) + } + Button(action: { + isEditing.toggle() + }) { + Image(systemName: isEditing ? "pencil.circle.fill" : "pencil.circle") + } + .buttonStyle(.plain) + } + + if isEditing { + TextEditor(text: Binding( + get: { viewModel.meeting.generatedNotes }, + set: { viewModel.meeting.generatedNotes = $0 } + )) + .font(.body) + .padding(8) + .scrollContentBackground(.hidden) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + .frame(maxHeight: .infinity) + } else { + RenderedNotesView(text: viewModel.meeting.generatedNotes) + .font(.body) + .background(Color.gray.opacity(0.05)) + .cornerRadius(8) + .frame(maxHeight: .infinity) + } + } + } +} + #Preview { MeetingListView(settingsViewModel: SettingsViewModel()) } \ No newline at end of file