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
+ }
+ }
}