diff --git a/Promptly.xcodeproj/project.pbxproj b/Promptly.xcodeproj/project.pbxproj index fa3a15c..1567add 100644 --- a/Promptly.xcodeproj/project.pbxproj +++ b/Promptly.xcodeproj/project.pbxproj @@ -431,7 +431,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -475,7 +475,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate b/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate index 06d2da3..0ff1067 100644 Binary files a/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate and b/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist b/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist index ac39bd4..da72113 100644 --- a/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Promptly.xcodeproj/xcuserdata/sashabagrov.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ Promptly-WatchOS Watch App.xcscheme_^#shared#^_ orderHint - 1 + 3 Promptly.xcscheme_^#shared#^_ orderHint - 0 + 2 diff --git a/Promptly/Helpers/MIDIHelpers.swift b/Promptly/Helpers/MIDIHelpers.swift index 628e16a..ce44fb0 100644 --- a/Promptly/Helpers/MIDIHelpers.swift +++ b/Promptly/Helpers/MIDIHelpers.swift @@ -2,18 +2,14 @@ // MIDIHelper.swift // MIDIKit • https://github.com/orchetect/MIDIKit // © 2021-2025 Steffan Andrews • Licensed under MIT License -// +// Edited by Sasha Bagrov - 20/10/2025 import MIDIKitIO import SwiftUI -/// Receiving MIDI happens on an asynchronous background thread. That means it cannot update -/// SwiftUI view state directly. Therefore, we need a helper class marked with `@Observable` -/// which contains properties that SwiftUI can use to update views. @Observable final class MIDIHelper { private weak var midiManager: ObservableMIDIManager? - // MIDI Action Types (matching your existing remote actions) enum RemoteAction: String, CaseIterable { case nextLine = "Next Line" case previousLine = "Previous Line" @@ -21,10 +17,7 @@ import SwiftUI case none = "None" } - // User-configurable mapping: Program Change number -> Action var programChangeMapping: [Int: RemoteAction] = [:] - - // Callback to execute remote functions (same pattern as bluetoothManager) var onButtonPress: ((String) -> Void)? public init() { } @@ -42,42 +35,58 @@ import SwiftUI setupConnections() } + // MARK: - Connection Names + static let universalInputConnectionName = "Universal MIDI Input" + static let usbOutputConnectionName = "USB MIDI Output" + static let bleOutputConnectionName = "BLE MIDI Output" + private func setupConnections() { guard let midiManager else { return } do { + print("Creating universal MIDI input connection.") try midiManager.addInputConnection( to: .allOutputs, - tag: "Listener", + tag: Self.universalInputConnectionName, filter: .owned(), - receiver: .events { [weak self] events,_,_ in + receiver: .events { [weak self] events, timeStamp, source in + print("MIDI from source: \(source)") self?.handleMIDIEvents(events) } ) - } catch { - print("Error setting up MIDI connection:", error.localizedDescription) - } - - // Keep your broadcaster - do { + + print("Creating USB MIDI output connection.") + try midiManager.addOutputConnection( + to: .inputs(matching: [.name("IDAM MIDI Host")]), + tag: Self.usbOutputConnectionName + ) + + print("Creating BLE MIDI output connection.") try midiManager.addOutputConnection( to: .allInputs, - tag: "Broadcaster", + tag: Self.bleOutputConnectionName, filter: .owned() ) + } catch { - print("Error setting up broadcaster connection:", error.localizedDescription) + print("Error setting up MIDI connections:", error.localizedDescription) } } + // MARK: - Event Handling private func handleMIDIEvents(_ events: [MIDIEvent]) { for event in events { switch event { case .programChange(let programChange): handleProgramChange(program: programChange.program, channel: programChange.channel) + case .noteOn(let noteOn): + print("Note On: \(noteOn.note) velocity: \(noteOn.velocity)") + case .noteOff(let noteOff): + print("Note Off: \(noteOff.note)") + case .cc(let cc): + print("CC: \(cc.controller) value: \(cc.value)") default: - // Log other events for debugging - print("MIDI Event: \(event)") + print("Other MIDI Event: \(event)") } } } @@ -85,7 +94,6 @@ import SwiftUI private func handleProgramChange(program: UInt7, channel: UInt4) { let programInt = Int(program) - // Only handle program changes 0-32 guard programInt <= 32 else { print("Program change \(programInt) out of range (0-32)") return @@ -93,7 +101,6 @@ import SwiftUI print("Received PC \(programInt) on channel \(channel)") - // Check if user has mapped this program change to an action guard let action = programChangeMapping[programInt], action != .none else { print("No action mapped for PC \(programInt)") @@ -102,26 +109,20 @@ import SwiftUI print("Executing MIDI action: \(action.rawValue)") - // Convert to the same format as your Bluetooth remote let buttonValue: String switch action { - case .previousLine: - buttonValue = "0" - case .nextLine: - buttonValue = "1" - case .goCue: - buttonValue = "2" - case .none: - return + case .previousLine: buttonValue = "0" + case .nextLine: buttonValue = "1" + case .goCue: buttonValue = "2" + case .none: return } - // Execute on main thread using the same callback pattern DispatchQueue.main.async { self.onButtonPress?(buttonValue) } } - // Configuration methods + // MARK: - Configuration func mapProgramChange(_ program: Int, to action: RemoteAction) { guard program >= 0 && program <= 32 else { return } programChangeMapping[program] = action @@ -132,8 +133,33 @@ import SwiftUI programChangeMapping[program] = .none } - func sendTestMIDIEvent() { - let conn = midiManager?.managedOutputConnections["Broadcaster"] - try? conn?.send(event: .cc(.expression, value: .midi1(64), channel: 0)) + // MARK: - Output Methods + var usbOutputConnection: MIDIOutputConnection? { + midiManager?.managedOutputConnections[Self.usbOutputConnectionName] + } + + var bleOutputConnection: MIDIOutputConnection? { + midiManager?.managedOutputConnections[Self.bleOutputConnectionName] + } + + func sendNoteOnUSB() { + try? usbOutputConnection?.send(event: .noteOn(60, velocity: .midi1(127), channel: 0)) + } + + func sendNoteOffUSB() { + try? usbOutputConnection?.send(event: .noteOff(60, velocity: .midi1(0), channel: 0)) + } + + func sendCC1USB() { + try? usbOutputConnection?.send(event: .cc(1, value: .midi1(64), channel: 0)) + } + + func sendTestMIDIEventBLE() { + try? bleOutputConnection?.send(event: .cc(.expression, value: .midi1(64), channel: 0)) + } + + func sendToAll(event: MIDIEvent) { + try? usbOutputConnection?.send(event: event) + try? bleOutputConnection?.send(event: event) } } diff --git a/Promptly/Models/Script.swift b/Promptly/Models/Script.swift index ca9f5d3..bf27fd8 100644 --- a/Promptly/Models/Script.swift +++ b/Promptly/Models/Script.swift @@ -75,16 +75,18 @@ class ScriptLine: Identifiable { var id: UUID var lineNumber: Int var content: String // Raw text content + var flags: [ScriptLineFlags] = [] @Relationship(deleteRule: .cascade) var elements: [LineElement] = [] @Relationship(deleteRule: .cascade) var cues: [Cue] = [] var isMarked: Bool = false var markColor: String? // Hex color for line marking var notes: String = "" - init(id: UUID, lineNumber: Int, content: String) { + init(id: UUID, lineNumber: Int, content: String, flags: [ScriptLineFlags]) { self.id = id self.lineNumber = lineNumber self.content = content + self.flags = flags // Parse content after initialization DispatchQueue.main.async { self.parseContentIntoElements() @@ -115,6 +117,11 @@ class ScriptLine: Identifiable { } } +enum ScriptLineFlags: String, CaseIterable, Codable { + case stageDirection = "stageDirection" + case skip = "skip" +} + @Model class LineElement: Identifiable { var id: UUID @@ -262,6 +269,7 @@ extension ScriptLine { "id": id.uuidString, "lineNumber": lineNumber, "content": content, + "flags": flags.map { $0.rawValue }, // Convert enum cases to strings "elements": elements.map { $0.toDictionary() }, "cues": cues.map { $0.toDictionary() }, "isMarked": isMarked, @@ -351,7 +359,13 @@ extension ScriptLine { return nil } - self.init(id: id, lineNumber: lineNumber, content: content) + // Parse flags from dictionary + var flags: [ScriptLineFlags] = [] + if let flagsArray = dict["flags"] as? [String] { + flags = flagsArray.compactMap { ScriptLineFlags(rawValue: $0) } + } + + self.init(id: id, lineNumber: lineNumber, content: content, flags: flags) if let elementsArray = dict["elements"] as? [[String: Any]] { self.elements = elementsArray.compactMap { LineElement(from: $0) } diff --git a/Promptly/Parser/Parser.swift b/Promptly/Parser/Parser.swift index 4bb185e..a4e6ddc 100644 --- a/Promptly/Parser/Parser.swift +++ b/Promptly/Parser/Parser.swift @@ -110,10 +110,19 @@ class PDFScriptParser: ObservableObject { // Convert to ScriptLine objects for (index, lineText) in scriptLines.enumerated() { + // Auto-detect flags based on line type + let lineType = detectLineType(lineText) + var flags: [ScriptLineFlags] = [] + + if lineType == .stageDirection { + flags.append(.stageDirection) + } + let scriptLine = ScriptLine( id: UUID(), lineNumber: index + 1, - content: lineText + content: lineText, + flags: flags ) script.lines.append(scriptLine) } diff --git a/Promptly/Views/Performance Mode/DSMScriptLineView.swift b/Promptly/Views/Performance Mode/DSMScriptLineView.swift index f29adf3..95da5f0 100644 --- a/Promptly/Views/Performance Mode/DSMScriptLineView.swift +++ b/Promptly/Views/Performance Mode/DSMScriptLineView.swift @@ -13,6 +13,16 @@ struct DSMScriptLineView: View { let onLineTap: () -> Void let calledCues: Set + private let isStageDirection: Bool + + init(line: ScriptLine, isCurrent: Bool, onLineTap: @escaping () -> Void, calledCues: Set) { + self.line = line + self.isCurrent = isCurrent + self.onLineTap = onLineTap + self.calledCues = calledCues + self.isStageDirection = line.flags.contains(.stageDirection) + } + var body: some View { VStack(alignment: .leading, spacing: 8) { Button(action: onLineTap) { @@ -62,6 +72,7 @@ struct DSMScriptLineView: View { return Text(buildLineWithCues(words: words, cuesByIndex: cuesByIndex)) .font(.body) + .italic(isStageDirection) .foregroundColor(isCurrent ? .black : .primary) } @@ -84,6 +95,10 @@ struct DSMScriptLineView: View { } var wordAttr = AttributedString(word + " ") + + if isStageDirection { + wordAttr.inlinePresentationIntent = .emphasized + } result += wordAttr } diff --git a/Promptly/Views/Performance Mode/LivePerforemanceView.swift b/Promptly/Views/Performance Mode/LivePerforemanceView.swift index 831c8a7..ac64afa 100644 --- a/Promptly/Views/Performance Mode/LivePerforemanceView.swift +++ b/Promptly/Views/Performance Mode/LivePerforemanceView.swift @@ -1678,8 +1678,28 @@ extension DSMPerformanceView { private func moveToLine(_ lineNumber: Int) { guard lineNumber >= 1 && lineNumber <= sortedLinesCache.count else { return } - currentLineNumber = lineNumber - self.mqttManager.sendData(to: "shows/\(self.uuidOfShow)/line", message: String(lineNumber)) + + var targetLineNumber = lineNumber + let isMovingForward = targetLineNumber > currentLineNumber + + while targetLineNumber >= 1 && targetLineNumber <= sortedLinesCache.count { + let targetLine = sortedLinesCache[targetLineNumber - 1] + + if targetLine.flags.contains(.skip) { + if isMovingForward { + targetLineNumber += 1 + } else { + targetLineNumber -= 1 + } + } else { + break + } + } + + targetLineNumber = max(1, min(targetLineNumber, sortedLinesCache.count)) + + currentLineNumber = targetLineNumber + self.mqttManager.sendData(to: "shows/\(self.uuidOfShow)/line", message: String(targetLineNumber)) } private func logCall(_ message: String, type: CallLogEntry.CallType = .note) { diff --git a/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift b/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift index 6da9142..6893677 100644 --- a/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift +++ b/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift @@ -38,6 +38,8 @@ struct EditScriptView: View { @State private var pendingSectionNotes = "" @State private var showingLineConfirmation = false @State private var selectedLineForSection: ScriptLine? + @State private var showingFlagEditor = false + @State private var lineBeingFlagged: ScriptLine? @StateObject private var refreshTrigger = RefreshTrigger() var sortedLines: [ScriptLine] { @@ -48,6 +50,12 @@ struct EditScriptView: View { script.sections.sorted { $0.startLineNumber < $1.startLineNumber } } + var selectedLinesArray: [ScriptLine] { + selectedLines.compactMap { id in + sortedLines.first { $0.id == id } + } + } + var body: some View { VStack(spacing: 0) { if isSelectingLineForSection { @@ -111,6 +119,19 @@ struct EditScriptView: View { renumberLines() } } + .sheet(isPresented: $showingFlagEditor) { + if selectedLines.count == 1, let line = lineBeingFlagged { + FlagEditorView(line: line) { + try? modelContext.save() + refreshTrigger.refresh() + } + } else if selectedLines.count > 1 { + BulkFlagEditorView(lines: selectedLinesArray) { + try? modelContext.save() + refreshTrigger.refresh() + } + } + } .sheet(isPresented: $showingAddSection) { SectionDetailsView( pendingTitle: $pendingSectionTitle, @@ -232,173 +253,72 @@ struct EditScriptView: View { .foregroundColor(.blue) } + Button("Edit Flags") { + // For single line, set the line. For multiple, set to nil for bulk mode + if selectedLines.count == 1, + let lineId = selectedLines.first, + let line = sortedLines.first(where: { $0.id == lineId }) { + lineBeingFlagged = line + } else { + lineBeingFlagged = nil // Bulk mode + } + showingFlagEditor = true + } + .foregroundColor(.purple) + Button("Delete") { - checkForCuesBeforeDelete() + confirmDelete() } .foregroundColor(.red) } - Divider() - .frame(height: 20) - - Button("Add Line") { - showingAddLine = true - } - - Button("Add Section") { - showingAddSection = true - } + Spacer() } .padding(.horizontal) } .padding(.vertical, 8) - .background(Color(.secondarySystemBackground)) + .background(Color(.systemGroupedBackground)) } private var scriptContentView: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(groupLinesBySection(), id: \.stableId) { group in - if let section = group.section { - SectionHeaderView(section: section) - .id("section-\(section.id)") + ScrollView { + LazyVStack(spacing: 8) { + ForEach(sortedLines, id: \.id) { line in + EditableScriptLineView( + line: line, + isEditing: isEditing, + isSelected: selectedLines.contains(line.id), + isEditingText: editingLineId == line.id, + isSelectingForSection: isSelectingLineForSection, + editingText: $editingText + ) { selectedLine in + if isSelectingLineForSection { + selectedLineForSection = selectedLine + showingLineConfirmation = true + isSelectingLineForSection = false + } else { + toggleLineSelection(selectedLine) } - - ForEach(group.lines, id: \.id) { line in - scriptLineView(for: line) - .id("line-\(line.id)") - } - } - } - .padding() - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ScrollToSection"))) { notification in - if let sectionId = notification.object as? UUID { - withAnimation(.easeInOut(duration: 0.5)) { - proxy.scrollTo("section-\(sectionId)", anchor: .top) + } onStartTextEdit: { lineToEdit in + startEditing(line: lineToEdit) + } onFinishTextEdit: { newText in + finishEditing(newText: newText) + } onInsertAfter: { lineToInsertAfter in + insertAfterLineNumber = lineToInsertAfter.lineNumber + showingAddLine = true + } onEditFlags: { lineToFlag in + lineBeingFlagged = lineToFlag + showingFlagEditor = true } } } + .padding() } } - private func scriptLineView(for line: ScriptLine) -> some View { - EditableScriptLineView( - line: line, - isEditing: isEditing, - isSelected: selectedLines.contains(line.id), - isEditingText: editingLineId == line.id, - isSelectingForSection: isSelectingLineForSection, - editingText: $editingText, - onToggleSelection: { line in - if isSelectingLineForSection { - selectLineForSection(line: line) - } else { - toggleLineSelection(line: line) - } - refreshTrigger.refresh() - }, - onStartTextEdit: { line in - startTextEditing(line: line) - }, - onFinishTextEdit: { newText in - finishTextEditing(newText: newText) - refreshTrigger.refresh() - }, - onInsertAfter: { line in - insertAfterLineNumber = line.lineNumber - showingAddLine = true - } - ) - } -} - -// MARK: - EditScriptView Extension for Alerts and Sheets -extension EditScriptView { - private struct LineGroup { - let section: ScriptSection? - let lines: [ScriptLine] - - var stableId: String { - if let section = section { - return "section-\(section.id)" - } else { - let lineIds = lines.map { $0.id.uuidString }.joined(separator: "-") - return "unsectioned-\(lineIds.hashValue)" - } - } - } + // MARK: - Actions - private func groupLinesBySection() -> [LineGroup] { - let currentSortedLines = script.lines.sorted { $0.lineNumber < $1.lineNumber } - let currentSortedSections = script.sections.sorted { $0.startLineNumber < $1.startLineNumber } - - var groups: [LineGroup] = [] - var lineIndex = 0 - var sectionIndex = 0 - - while lineIndex < currentSortedLines.count { - let currentLine = currentSortedLines[lineIndex] - - // Check if we're at the start of a section - if sectionIndex < currentSortedSections.count && - currentLine.lineNumber >= currentSortedSections[sectionIndex].startLineNumber { - - let section = currentSortedSections[sectionIndex] - var sectionLines: [ScriptLine] = [] - - // Determine section end - either explicit end or start of next section - let sectionEnd: Int - if let explicitEnd = section.endLineNumber { - sectionEnd = explicitEnd - } else if sectionIndex + 1 < currentSortedSections.count { - sectionEnd = currentSortedSections[sectionIndex + 1].startLineNumber - 1 - } else { - sectionEnd = Int.max // No end, goes to end of script - } - - // Collect all lines for this section - while lineIndex < currentSortedLines.count && - currentSortedLines[lineIndex].lineNumber >= section.startLineNumber && - currentSortedLines[lineIndex].lineNumber <= sectionEnd { - sectionLines.append(currentSortedLines[lineIndex]) - lineIndex += 1 - } - - if !sectionLines.isEmpty { - groups.append(LineGroup(section: section, lines: sectionLines)) - } - - sectionIndex += 1 - } else { - // Handle unsectioned lines - var unsectionedGroup: [ScriptLine] = [] - - // Collect consecutive unsectioned lines - while lineIndex < currentSortedLines.count { - let line = currentSortedLines[lineIndex] - - // Stop if we hit the start of a section - if sectionIndex < currentSortedSections.count && - line.lineNumber >= currentSortedSections[sectionIndex].startLineNumber { - break - } - - unsectionedGroup.append(line) - lineIndex += 1 - } - - if !unsectionedGroup.isEmpty { - groups.append(LineGroup(section: nil, lines: unsectionedGroup)) - } - } - } - - return groups - } - - private func toggleLineSelection(line: ScriptLine) { + private func toggleLineSelection(_ line: ScriptLine) { if selectedLines.contains(line.id) { selectedLines.remove(line.id) } else { @@ -406,157 +326,121 @@ extension EditScriptView { } } - private func startTextEditing(line: ScriptLine) { + private func startEditing(line: ScriptLine) { editingLineId = line.id editingText = line.content } - private func combineSelectedLines() { - let linesToCombine = selectedLines.compactMap { lineId in - script.lines.first { $0.id == lineId } - }.sorted { $0.lineNumber < $1.lineNumber } - - guard linesToCombine.count > 1 else { return } + private func finishEditing(newText: String) { + if let lineId = editingLineId, + let line = script.lines.first(where: { $0.id == lineId }) { + line.content = newText + line.parseContentIntoElements() + try? modelContext.save() + } + editingLineId = nil + editingText = "" + } + + private func confirmDelete() { + linesToDelete = selectedLines.compactMap { id in + script.lines.first { $0.id == id } + } - let firstLine = linesToCombine[0] - let combinedText = linesToCombine.map { $0.content }.joined(separator: " ") + let hasLinesWithCues = linesToDelete.contains { !$0.cues.isEmpty } - var allCues: [Cue] = [] - for line in linesToCombine { - allCues.append(contentsOf: line.cues) + if hasLinesWithCues { + showingCueWarning = true + } else { + showingDeleteAlert = true + } + } + + private func deleteSelectedLines() { + let linesToRemove = selectedLines.compactMap { id in + script.lines.first { $0.id == id } } - firstLine.content = combinedText - firstLine.parseContentIntoElements() - firstLine.cues.append(contentsOf: allCues) - - for line in linesToCombine.dropFirst() { + for line in linesToRemove { script.lines.removeAll { $0.id == line.id } modelContext.delete(line) } selectedLines.removeAll() + linesToDelete.removeAll() renumberLines() - try? modelContext.save() - refreshTrigger.refresh() } - private func getNextSectionStartLine() -> Int { - if let lastSelected = selectedLines.compactMap({ lineId in - script.lines.first { $0.id == lineId } - }).max(by: { $0.lineNumber < $1.lineNumber }) { - return lastSelected.lineNumber + private func combineSelectedLines() { + let linesToCombine = selectedLines.compactMap { id in + script.lines.first { $0.id == id } + }.sorted { $0.lineNumber < $1.lineNumber } + + guard let firstLine = linesToCombine.first else { return } + + let combinedContent = linesToCombine.map { $0.content }.joined(separator: " ") + let allCues = linesToCombine.flatMap { $0.cues } + let allFlags = Array(Set(linesToCombine.flatMap { $0.flags })) + + firstLine.content = combinedContent + firstLine.flags = allFlags + firstLine.parseContentIntoElements() + + for cue in allCues { + cue.lineId = firstLine.id } - return sortedLines.last?.lineNumber ?? 1 - } - - private func selectLineForSection(line: ScriptLine) { - selectedLineForSection = line - showingLineConfirmation = true + + let linesToRemove = Array(linesToCombine.dropFirst()) + for line in linesToRemove { + script.lines.removeAll { $0.id == line.id } + modelContext.delete(line) + } + + selectedLines.removeAll() + renumberLines() + try? modelContext.save() } private func createSectionWithSelectedLine() { - guard let line = selectedLineForSection else { return } + guard let selectedLine = selectedLineForSection else { return } - let section = ScriptSection( + let newSection = ScriptSection( id: UUID(), title: pendingSectionTitle, type: pendingSectionType, - startLineNumber: line.lineNumber + startLineNumber: selectedLine.lineNumber ) - section.notes = pendingSectionNotes - script.sections.append(section) + script.sections.append(newSection) - isSelectingLineForSection = false selectedLineForSection = nil + isSelectingLineForSection = false pendingSectionTitle = "" pendingSectionNotes = "" try? modelContext.save() - refreshTrigger.refresh() - } - - private func finishTextEditing(newText: String) { - guard let lineId = editingLineId, - let line = script.lines.first(where: { $0.id == lineId }) else { return } - - if line.content != newText { - if !line.cues.isEmpty { - line.content = newText - line.parseContentIntoElements() - } else { - line.content = newText - line.parseContentIntoElements() - } - } - - editingLineId = nil - editingText = "" - } - - private func checkForCuesBeforeDelete() { - linesToDelete = selectedLines.compactMap { lineId in - script.lines.first { $0.id == lineId } - } - - let linesWithCues = linesToDelete.filter { !$0.cues.isEmpty } - - if !linesWithCues.isEmpty { - showingCueWarning = true - } else { - showingDeleteAlert = true - } - } - - private func deleteSelectedLines() { - let lineIdsToDelete = Array(selectedLines) - - for lineId in lineIdsToDelete { - if let line = script.lines.first(where: { $0.id == lineId }) { - script.lines.removeAll { $0.id == lineId } - modelContext.delete(line) - } - } - - selectedLines.removeAll() - linesToDelete.removeAll() - renumberLines() - - try? modelContext.save() - refreshTrigger.refresh() } private func renumberLines() { - let sorted = script.lines.sorted { $0.lineNumber < $1.lineNumber } - for (index, line) in sorted.enumerated() { + let sortedLines = script.lines.sorted { $0.lineNumber < $1.lineNumber } + for (index, line) in sortedLines.enumerated() { line.lineNumber = index + 1 } + try? modelContext.save() } - private func resetEditingState() { + private func saveChanges() { + try? modelContext.save() isEditing = false selectedLines.removeAll() - editingLineId = nil - editingText = "" - isSelectingLineForSection = false - selectedLineForSection = nil - - refreshTrigger.refresh() } private func cancelEditing() { - resetEditingState() - } - - private func saveChanges() { - do { - try modelContext.save() - resetEditingState() - } catch { - print("Failed to save: \(error)") - } + isEditing = false + selectedLines.removeAll() + editingLineId = nil + editingText = "" } } @@ -572,6 +456,7 @@ struct EditableScriptLineView: View { let onStartTextEdit: (ScriptLine) -> Void let onFinishTextEdit: (String) -> Void let onInsertAfter: (ScriptLine) -> Void + let onEditFlags: (ScriptLine) -> Void var body: some View { HStack(alignment: .top, spacing: 12) { @@ -613,6 +498,15 @@ struct EditableScriptLineView: View { .disabled(!(isEditing || isSelectingForSection)) } + // Flag indicators + if !line.flags.isEmpty { + HStack(spacing: 4) { + ForEach(line.flags, id: \.self) { flag in + flagBadge(for: flag) + } + } + } + if !line.cues.isEmpty { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle.fill") @@ -628,6 +522,11 @@ struct EditableScriptLineView: View { if isEditing && isSelected { VStack(spacing: 4) { + Button(action: { onEditFlags(line) }) { + Image(systemName: "flag") + .foregroundColor(.purple) + } + Button(action: { onInsertAfter(line) }) { Image(systemName: "plus.circle") .foregroundColor(.blue) @@ -648,6 +547,47 @@ struct EditableScriptLineView: View { ) } + private func flagBadge(for flag: ScriptLineFlags) -> some View { + HStack(spacing: 2) { + Image(systemName: iconForFlag(flag)) + .font(.caption2) + Text(labelForFlag(flag)) + .font(.caption2) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(colorForFlag(flag)) + .foregroundColor(.white) + .cornerRadius(4) + } + + private func iconForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "theatermasks" + case .skip: + return "forward.fill" + } + } + + private func labelForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "Stage" + case .skip: + return "Skip" + } + } + + private func colorForFlag(_ flag: ScriptLineFlags) -> Color { + switch flag { + case .stageDirection: + return .purple + case .skip: + return .red + } + } + private var backgroundColorForLine: Color { if line.isMarked, let colorHex = line.markColor { return Color(hex: colorHex) @@ -680,6 +620,268 @@ struct EditableScriptLineView: View { } } +struct FlagEditorView: View { + @Environment(\.dismiss) private var dismiss + let line: ScriptLine + let onSave: () -> Void + + @State private var selectedFlags: Set = [] + + var body: some View { + NavigationView { + Form { + Section(header: Text("Line \(line.lineNumber)")) { + Text(line.content) + .font(.body) + .foregroundColor(.primary) + } + + Section(header: Text("Flags")) { + ForEach(ScriptLineFlags.allCases, id: \.self) { flag in + HStack { + Button(action: { + toggleFlag(flag) + }) { + HStack { + Image(systemName: selectedFlags.contains(flag) ? "checkmark.circle.fill" : "circle") + .foregroundColor(selectedFlags.contains(flag) ? .blue : .gray) + + HStack(spacing: 8) { + Image(systemName: iconForFlag(flag)) + .foregroundColor(colorForFlag(flag)) + Text(labelForFlag(flag)) + .foregroundColor(.primary) + + Spacer() + + Text(descriptionForFlag(flag)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(PlainButtonStyle()) + } + } + } + } + .navigationTitle("Edit Flags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + saveFlags() + } + } + } + } + .onAppear { + selectedFlags = Set(line.flags) + } + } + + private func toggleFlag(_ flag: ScriptLineFlags) { + if selectedFlags.contains(flag) { + selectedFlags.remove(flag) + } else { + selectedFlags.insert(flag) + } + } + + private func saveFlags() { + line.flags = Array(selectedFlags) + onSave() + dismiss() + } + + private func iconForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "theatermasks" + case .skip: + return "forward.fill" + } + } + + private func labelForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "Stage Direction" + case .skip: + return "Skip Line" + } + } + + private func colorForFlag(_ flag: ScriptLineFlags) -> Color { + switch flag { + case .stageDirection: + return .purple + case .skip: + return .red + } + } + + private func descriptionForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "Mark as stage direction" + case .skip: + return "Skip during performance" + } + } +} + +struct BulkFlagEditorView: View { + @Environment(\.dismiss) private var dismiss + let lines: [ScriptLine] + let onSave: () -> Void + + var body: some View { + NavigationView { + Form { + Section(header: Text("Selected Lines (\(lines.count))")) { + ForEach(lines.prefix(5), id: \.id) { line in + Text("Line \(line.lineNumber): \(line.content.prefix(50))") + .font(.caption) + .foregroundColor(.secondary) + } + if lines.count > 5 { + Text("... and \(lines.count - 5) more lines") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Section(header: Text("Bulk Flag Operations"), footer: Text("Toggle removes flags from lines that have them, adds to lines that don't")) { + ForEach(ScriptLineFlags.allCases, id: \.self) { flag in + HStack { + Button(action: { + toggleBulkFlag(flag) + }) { + HStack { + let flagStatus = getBulkFlagStatus(for: flag) + Image(systemName: flagStatus.icon) + .foregroundColor(flagStatus.color) + + HStack(spacing: 8) { + Image(systemName: iconForFlag(flag)) + .foregroundColor(colorForFlag(flag)) + + VStack(alignment: .leading) { + Text(labelForFlag(flag)) + .foregroundColor(.primary) + Text(flagStatus.description) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Text(descriptionForFlag(flag)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(PlainButtonStyle()) + } + } + } + } + .navigationTitle("Bulk Edit Flags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } + + private func getBulkFlagStatus(for flag: ScriptLineFlags) -> (icon: String, color: Color, description: String) { + let linesWithFlag = lines.filter { $0.flags.contains(flag) } + let linesWithoutFlag = lines.filter { !$0.flags.contains(flag) } + + if linesWithFlag.count == lines.count { + return ("checkmark.circle.fill", .green, "All lines have this flag") + } else if linesWithFlag.isEmpty { + return ("circle", .gray, "No lines have this flag") + } else { + return ("minus.circle.fill", .orange, "\(linesWithFlag.count) of \(lines.count) lines have this flag") + } + } + + private func toggleBulkFlag(_ flag: ScriptLineFlags) { + let linesWithFlag = lines.filter { $0.flags.contains(flag) } + + if linesWithFlag.isEmpty { + // No lines have the flag - add to all + for line in lines { + if !line.flags.contains(flag) { + line.flags.append(flag) + } + } + } else { + // Some lines have the flag - remove from all that have it + for line in linesWithFlag { + line.flags.removeAll { $0 == flag } + } + } + + onSave() + } + + private func iconForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "theatermasks" + case .skip: + return "forward.fill" + } + } + + private func labelForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "Stage Direction" + case .skip: + return "Skip Line" + } + } + + private func colorForFlag(_ flag: ScriptLineFlags) -> Color { + switch flag { + case .stageDirection: + return .purple + case .skip: + return .red + } + } + + private func descriptionForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "Mark as stage direction" + case .skip: + return "Skip during performance" + } + } +} + struct AddLineView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -690,6 +892,7 @@ struct AddLineView: View { @State private var newLineText = "" @State private var lineType: LineType = .dialogue + @State private var selectedFlags: Set = [] enum LineType: String, CaseIterable { case dialogue = "Dialogue" @@ -714,6 +917,15 @@ struct AddLineView: View { case .sceneDescription: return "]" } } + + var suggestedFlags: [ScriptLineFlags] { + switch self { + case .stageDirection: + return [.stageDirection] + default: + return [] + } + } } var body: some View { @@ -731,13 +943,61 @@ struct AddLineView: View { } } .pickerStyle(SegmentedPickerStyle()) + .onChange(of: lineType) { _, newType in + // Auto-select flags based on line type + selectedFlags = Set(newType.suggestedFlags) + } + } + + Section(header: Text("Flags")) { + ForEach(ScriptLineFlags.allCases, id: \.self) { flag in + HStack { + Button(action: { + toggleFlag(flag) + }) { + HStack { + Image(systemName: selectedFlags.contains(flag) ? "checkmark.circle.fill" : "circle") + .foregroundColor(selectedFlags.contains(flag) ? .blue : .gray) + + HStack(spacing: 8) { + Image(systemName: iconForFlag(flag)) + .foregroundColor(colorForFlag(flag)) + Text(labelForFlag(flag)) + .foregroundColor(.primary) + } + + Spacer() + } + } + .buttonStyle(PlainButtonStyle()) + } + } } Section(header: Text("Preview")) { - Text(lineType.prefix + newLineText + lineType.suffix) - .font(.body) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + Text(lineType.prefix + newLineText + lineType.suffix) + .font(.body) + .foregroundColor(.secondary) + + if !selectedFlags.isEmpty { + HStack { + ForEach(Array(selectedFlags), id: \.self) { flag in + HStack(spacing: 2) { + Image(systemName: iconForFlag(flag)) + .font(.caption2) + Text(labelForFlag(flag)) + .font(.caption2) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(colorForFlag(flag)) + .foregroundColor(.white) + .cornerRadius(4) + } + } + } + } } if let insertAfter = insertAfterLineNumber { @@ -765,6 +1025,17 @@ struct AddLineView: View { } } } + .onAppear { + selectedFlags = Set(lineType.suggestedFlags) + } + } + + private func toggleFlag(_ flag: ScriptLineFlags) { + if selectedFlags.contains(flag) { + selectedFlags.remove(flag) + } else { + selectedFlags.insert(flag) + } } private func addLine() { @@ -778,7 +1049,8 @@ struct AddLineView: View { let newLine = ScriptLine( id: UUID(), lineNumber: newLineNumber, - content: formattedText + content: formattedText, + flags: Array(selectedFlags) ) script.lines.append(newLine) @@ -787,4 +1059,31 @@ struct AddLineView: View { onComplete() dismiss() } + + private func iconForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "theatermasks" + case .skip: + return "forward.fill" + } + } + + private func labelForFlag(_ flag: ScriptLineFlags) -> String { + switch flag { + case .stageDirection: + return "Stage" + case .skip: + return "Skip" + } + } + + private func colorForFlag(_ flag: ScriptLineFlags) -> Color { + switch flag { + case .stageDirection: + return .purple + case .skip: + return .red + } + } }