diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f8efe48
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+
+Promptly.xcodeproj/xcuserdata/
\ No newline at end of file
diff --git a/Promptly.xcodeproj/project.pbxproj b/Promptly.xcodeproj/project.pbxproj
index 1567add..1fd713a 100644
--- a/Promptly.xcodeproj/project.pbxproj
+++ b/Promptly.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ 6F7FFE192EAD3F0100CE9D01 /* WhatsNewKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6F7FFE182EAD3F0100CE9D01 /* WhatsNewKit */; };
6F8BC3002E927EA7008EE618 /* Promptly-WatchOS Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 6F8BC2F62E927EA5008EE618 /* Promptly-WatchOS Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
6F8BC30B2E927FED008EE618 /* MQTTNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 6F8BC30A2E927FED008EE618 /* MQTTNIO */; };
6F9232672EA2A9E500D929A7 /* MIDIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6F9232662EA2A9E500D929A7 /* MIDIKit */; };
@@ -73,12 +74,20 @@
);
target = 6F8BC2F52E927EA5008EE618 /* Promptly-WatchOS Watch App */;
};
+ 6FE901B92EA6F9CF002F79B4 /* Exceptions for "Promptly" folder in "Promptly" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 6F0425B82DF0BFE5002B2081 /* Promptly */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
6F0425BB2DF0BFE5002B2081 /* Promptly */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
+ 6FE901B92EA6F9CF002F79B4 /* Exceptions for "Promptly" folder in "Promptly" target */,
6F8BC3082E927F26008EE618 /* Exceptions for "Promptly" folder in "Promptly-WatchOS Watch App" target */,
);
path = Promptly;
@@ -97,6 +106,7 @@
buildActionMask = 2147483647;
files = (
6F9232692EA2A9E500D929A7 /* MIDIKitControlSurfaces in Frameworks */,
+ 6F7FFE192EAD3F0100CE9D01 /* WhatsNewKit in Frameworks */,
6F92326D2EA2A9E500D929A7 /* MIDIKitIO in Frameworks */,
6F9232672EA2A9E500D929A7 /* MIDIKit in Frameworks */,
6F92326F2EA2A9E500D929A7 /* MIDIKitSMF in Frameworks */,
@@ -171,6 +181,7 @@
6F92326A2EA2A9E500D929A7 /* MIDIKitCore */,
6F92326C2EA2A9E500D929A7 /* MIDIKitIO */,
6F92326E2EA2A9E500D929A7 /* MIDIKitSMF */,
+ 6F7FFE182EAD3F0100CE9D01 /* WhatsNewKit */,
);
productName = Promptly;
productReference = 6F0425B92DF0BFE5002B2081 /* Promptly.app */;
@@ -229,6 +240,7 @@
packageReferences = (
6FAE1C932E89E0A500D067BE /* XCRemoteSwiftPackageReference "mqtt-nio" */,
6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */,
+ 6F7FFE172EAD3F0100CE9D01 /* XCRemoteSwiftPackageReference "WhatsNewKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6F0425BA2DF0BFE5002B2081 /* Products */;
@@ -412,6 +424,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Promptly/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = DSMPrompt;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "We use bluetooth connectivity to connect to Promptly Clicker devices for script line control.";
@@ -431,7 +444,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.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -456,6 +469,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Promptly/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = DSMPrompt;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "We use bluetooth connectivity to connect to Promptly Clicker devices for script line control.";
@@ -475,7 +489,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.3;
+ MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.urbanmechanicsltd.Promptly;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -588,6 +602,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
+ 6F7FFE172EAD3F0100CE9D01 /* XCRemoteSwiftPackageReference "WhatsNewKit" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/SvenTiigi/WhatsNewKit";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.2.1;
+ };
+ };
6F9232652EA2A9E500D929A7 /* XCRemoteSwiftPackageReference "MIDIKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/orchetect/MIDIKit";
@@ -607,6 +629,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
+ 6F7FFE182EAD3F0100CE9D01 /* WhatsNewKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 6F7FFE172EAD3F0100CE9D01 /* XCRemoteSwiftPackageReference "WhatsNewKit" */;
+ productName = WhatsNewKit;
+ };
6F8BC30A2E927FED008EE618 /* MQTTNIO */ = {
isa = XCSwiftPackageProductDependency;
package = 6FAE1C932E89E0A500D067BE /* XCRemoteSwiftPackageReference "mqtt-nio" */;
diff --git a/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 6df2dba..a032e57 100644
--- a/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Promptly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "bfa12cb89869b24734bffcae0a12d3f2ad26093cc5af5a79d12afe7e1c4ce68c",
+ "originHash" : "a302ced32300583da905545009a08575f6b11e9996f3f89223cf2d89f08b3e92",
"pins" : [
{
"identity" : "midikit",
@@ -90,6 +90,15 @@
"revision" : "957fdaeda020396d150ee1afc7c5172791cb0ad5",
"version" : "2.3.4"
}
+ },
+ {
+ "identity" : "whatsnewkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/SvenTiigi/WhatsNewKit",
+ "state" : {
+ "revision" : "6157c77e8be9b3d2310bc680681b61a8d9e290ac",
+ "version" : "2.2.1"
+ }
}
],
"version" : 3
diff --git a/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate b/Promptly.xcodeproj/project.xcworkspace/xcuserdata/sashabagrov.xcuserdatad/UserInterfaceState.xcuserstate
index 0ff1067..48a8dec 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 da72113..ac39bd4 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
- 3
+ 1
Promptly.xcscheme_^#shared#^_
orderHint
- 2
+ 0
diff --git a/Promptly/.DS_Store b/Promptly/.DS_Store
index 4229bdb..6b4d84b 100644
Binary files a/Promptly/.DS_Store and b/Promptly/.DS_Store differ
diff --git a/Promptly/Assets.xcassets/.DS_Store b/Promptly/Assets.xcassets/.DS_Store
index 61ab1c0..b22526b 100644
Binary files a/Promptly/Assets.xcassets/.DS_Store and b/Promptly/Assets.xcassets/.DS_Store differ
diff --git a/Promptly/Imports & Exports/Exports.swift b/Promptly/Imports & Exports/Exports.swift
new file mode 100644
index 0000000..5b8bbed
--- /dev/null
+++ b/Promptly/Imports & Exports/Exports.swift
@@ -0,0 +1,437 @@
+//
+// Exports.swift
+// Promptly
+//
+// Created by Sasha Bagrov on 20/10/2025.
+//
+
+import Foundation
+import SwiftData
+
+class ShowExportManager {
+ static func exportShow(_ show: Show) -> Data? {
+ let exportData = ShowExportData(from: show)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = .prettyPrinted
+
+ return try? encoder.encode(exportData)
+ }
+
+ static func exportShowToFile(_ show: Show, to url: URL) throws {
+ guard let data = exportShow(show) else {
+ throw ExportError1.serialisationFailed
+ }
+ try data.write(to: url)
+ }
+}
+
+struct ShowExportData: Codable {
+ var version: String = "1.0"
+ var exportDate: Date = Date()
+
+ let show: ShowData
+ let script: ScriptData?
+ let performances: [PerformanceData]
+
+ init(from show: Show) {
+ self.show = ShowData(from: show)
+ self.script = show.script.map { ScriptData(from: $0) }
+ self.performances = show.peformances.map { PerformanceData(from: $0) }
+ }
+}
+
+struct ShowData: Codable {
+ let id: String
+ let title: String
+ let locationString: String
+ let performanceDates: [Date]
+
+ init(from show: Show) {
+ self.id = show.id.uuidString
+ self.title = show.title
+ self.locationString = show.locationString
+ self.performanceDates = show.dates
+ }
+}
+
+struct ScriptData: Codable {
+ let id: String
+ let name: String
+ let dateAdded: Date
+ let lines: [ScriptLineData]
+ let sections: [ScriptSectionData]
+
+ init(from script: Script) {
+ self.id = script.id.uuidString
+ self.name = script.name
+ self.dateAdded = script.dateAdded
+ self.lines = script.lines.map { ScriptLineData(from: $0) }
+ self.sections = script.sections.map { ScriptSectionData(from: $0) }
+ }
+}
+
+struct ScriptLineData: Codable {
+ let id: String
+ let lineNumber: Int
+ let content: String
+ let flags: [String]
+ let elements: [LineElementData]
+ let cues: [CueData]
+ let isMarked: Bool
+ let markColor: String?
+ let notes: String
+
+ init(from line: ScriptLine) {
+ self.id = line.id.uuidString
+ self.lineNumber = line.lineNumber
+ self.content = line.content
+ self.flags = line.flags.map { $0.rawValue }
+ self.elements = line.elements.map { LineElementData(from: $0) }
+ self.cues = line.cues.map { CueData(from: $0) }
+ self.isMarked = line.isMarked
+ self.markColor = line.markColor
+ self.notes = line.notes
+ }
+}
+
+struct ScriptSectionData: Codable {
+ let id: String
+ let title: String
+ let type: String
+ let startLineNumber: Int
+ let endLineNumber: Int?
+ let notes: String
+
+ init(from section: ScriptSection) {
+ self.id = section.id.uuidString
+ self.title = section.title
+ self.type = section.type.rawValue
+ self.startLineNumber = section.startLineNumber
+ self.endLineNumber = section.endLineNumber
+ self.notes = section.notes
+ }
+}
+
+struct LineElementData: Codable {
+ let id: String
+ let position: Int
+ let content: String
+ let type: String
+ let isMarked: Bool
+ let markColor: String?
+
+ init(from element: LineElement) {
+ self.id = element.id.uuidString
+ self.position = element.position
+ self.content = element.content
+ self.type = element.type.rawValue
+ self.isMarked = element.isMarked
+ self.markColor = element.markColor
+ }
+}
+
+struct CueData: Codable {
+ let id: String
+ let lineId: String
+ let position: CuePositionData
+ let type: String
+ let label: String
+ let notes: String
+ let hasAlert: Bool
+ let alertSound: String?
+
+ init(from cue: Cue) {
+ self.id = cue.id.uuidString
+ self.lineId = cue.lineId.uuidString
+ self.position = CuePositionData(from: cue.position)
+ self.type = cue.type.rawValue
+ self.label = cue.label
+ self.notes = cue.notes
+ self.hasAlert = cue.hasAlert
+ self.alertSound = cue.alertSound
+ }
+}
+
+struct CuePositionData: Codable {
+ let elementIndex: Int
+ let offset: String
+
+ init(from position: CuePosition) {
+ self.elementIndex = position.elementIndex
+ self.offset = position.offset.rawValue
+ }
+}
+
+struct PerformanceData: Codable {
+ let id: String
+ let date: Date
+ let calls: [PerformanceCallData]
+ let timing: PerformanceTimingData?
+
+ init(from performance: Performance) {
+ self.id = performance.id.uuidString
+ self.date = performance.date
+ self.calls = performance.calls.map { PerformanceCallData(from: $0) }
+ self.timing = performance.timing.map { PerformanceTimingData(from: $0) }
+ }
+}
+
+struct PerformanceCallData: Codable {
+ let id: String
+ let title: String
+ let call: CallTypeData
+
+ init(from call: PerformanceCall) {
+ self.id = call.id.uuidString
+ self.title = call.title
+ self.call = CallTypeData(from: call.call)
+ }
+}
+
+struct CallTypeData: Codable {
+ let type: String
+ let value: String?
+ let date: Date?
+
+ init(from callType: CallType) {
+ switch callType {
+ case .preShow(let preShowCall):
+ self.type = "preShow"
+ self.value = preShowCall.rawValue
+ self.date = nil
+ case .interval(let intervalCall):
+ self.type = "interval"
+ self.value = intervalCall.rawValue
+ self.date = nil
+ case .houseManagement(let houseCall):
+ self.type = "houseManagement"
+ self.value = houseCall.rawValue
+ self.date = nil
+ case .custom(let customDate):
+ self.type = "custom"
+ self.value = nil
+ self.date = customDate
+ }
+ }
+}
+
+struct PerformanceTimingData: Codable {
+ let id: String
+ let curtainTime: Date
+ let houseOpenTime: Date?
+ let houseOpenPlanned: Date?
+ let clearanceTime: Date?
+ let acts: [ActData]
+ let intervals: [IntervalData]
+ let callSettings: CallSettingsData
+ let currentState: PerformanceStateData
+ let startTime: Date?
+ let endTime: Date?
+ let actTimings: [ActTimingData]
+ let showStops: [ShowStopData]
+
+ init(from timing: PerformanceTiming) {
+ self.id = timing.id.uuidString
+ self.curtainTime = timing.curtainTime
+ self.houseOpenTime = timing.houseOpenTime
+ self.houseOpenPlanned = timing.houseOpenPlanned
+ self.clearanceTime = timing.clearanceTime
+ self.acts = timing.acts.map { ActData(from: $0) }
+ self.intervals = timing.intervals.map { IntervalData(from: $0) }
+ self.callSettings = CallSettingsData(from: timing.callSettings)
+ self.currentState = PerformanceStateData(from: timing.currentState)
+ self.startTime = timing.startTime
+ self.endTime = timing.endTime
+ self.actTimings = timing.actTimings.map { ActTimingData(from: $0) }
+ self.showStops = timing.showStops.map { ShowStopData(from: $0) }
+ }
+}
+
+struct ActData: Codable {
+ let id: String
+ let number: Int
+ let name: String
+ let startTime: Date?
+ let endTime: Date?
+ let includeInRunningTime: Bool
+
+ init(from act: Act) {
+ self.id = act.id.uuidString
+ self.number = act.number
+ self.name = act.name
+ self.startTime = act.startTime
+ self.endTime = act.endTime
+ self.includeInRunningTime = act.includeInRunningTime
+ }
+}
+
+struct IntervalData: Codable {
+ let id: String
+ let number: Int
+ let name: String
+ let plannedDuration: TimeInterval
+ let actualDuration: TimeInterval?
+ let startTime: Date?
+ let endTime: Date?
+
+ init(from interval: Interval) {
+ self.id = interval.id.uuidString
+ self.number = interval.number
+ self.name = interval.name
+ self.plannedDuration = interval.plannedDuration
+ self.actualDuration = interval.actualDuration
+ self.startTime = interval.startTime
+ self.endTime = interval.endTime
+ }
+}
+
+struct CallSettingsData: Codable {
+ let halfTime: TimeInterval
+ let quarterTime: TimeInterval
+ let fiveTime: TimeInterval
+ let beginnersTime: TimeInterval
+ let houseOpenOffset: TimeInterval
+ let clearanceOffset: TimeInterval
+ let enableSoundAlerts: Bool
+ let customMessage: String
+
+ init(from settings: CallSettings) {
+ self.halfTime = settings.halfTime
+ self.quarterTime = settings.quarterTime
+ self.fiveTime = settings.fiveTime
+ self.beginnersTime = settings.beginnersTime
+ self.houseOpenOffset = settings.houseOpenOffset
+ self.clearanceOffset = settings.clearanceOffset
+ self.enableSoundAlerts = settings.enableSoundAlerts
+ self.customMessage = settings.customMessage
+ }
+}
+
+struct PerformanceStateData: Codable {
+ let type: String
+ let value: Int?
+
+ init(from state: PerformanceState) {
+ switch state {
+ case .preShow:
+ self.type = "preShow"
+ self.value = nil
+ case .houseOpen:
+ self.type = "houseOpen"
+ self.value = nil
+ case .clearance:
+ self.type = "clearance"
+ self.value = nil
+ case .inProgress(let actNumber):
+ self.type = "inProgress"
+ self.value = actNumber
+ case .interval(let intervalNumber):
+ self.type = "interval"
+ self.value = intervalNumber
+ case .completed:
+ self.type = "completed"
+ self.value = nil
+ case .stopped:
+ self.type = "stopped"
+ self.value = nil
+ }
+ }
+}
+
+struct ActTimingData: Codable {
+ let id: String
+ let actNumber: Int
+ let startTime: Date
+ let endTime: Date?
+
+ init(from timing: ActTiming) {
+ self.id = timing.id.uuidString
+ self.actNumber = timing.actNumber
+ self.startTime = timing.startTime
+ self.endTime = timing.endTime
+ }
+}
+
+struct ShowStopData: Codable {
+ let id: String
+ let timestamp: Date
+ let reason: String
+ let duration: TimeInterval?
+ let actNumber: Int
+
+ init(from stop: ShowStop) {
+ self.id = stop.id.uuidString
+ self.timestamp = stop.timestamp
+ self.reason = stop.reason
+ self.duration = stop.duration
+ self.actNumber = stop.actNumber
+ }
+}
+
+enum ExportError1: Error {
+ case serialisationFailed
+ case invalidFileFormat
+ case unsupportedVersion
+}
+
+extension PreShowCall {
+ var rawValue: String {
+ switch self {
+ case .half: return "half"
+ case .quarter: return "quarter"
+ case .five: return "five"
+ case .beginners: return "beginners"
+ case .places: return "places"
+ }
+ }
+
+ init?(rawValue: String) {
+ switch rawValue {
+ case "half": self = .half
+ case "quarter": self = .quarter
+ case "five": self = .five
+ case "beginners": self = .beginners
+ case "places": self = .places
+ default: return nil
+ }
+ }
+}
+
+extension IntervalCall {
+ var rawValue: String {
+ switch self {
+ case .half: return "half"
+ case .quarter: return "quarter"
+ case .five: return "five"
+ case .beginners: return "beginners"
+ }
+ }
+
+ init?(rawValue: String) {
+ switch rawValue {
+ case "half": self = .half
+ case "quarter": self = .quarter
+ case "five": self = .five
+ case "beginners": self = .beginners
+ default: return nil
+ }
+ }
+}
+
+extension HouseCall {
+ var rawValue: String {
+ switch self {
+ case .houseOpen: return "houseOpen"
+ case .clearance: return "clearance"
+ }
+ }
+
+ init?(rawValue: String) {
+ switch rawValue {
+ case "houseOpen": self = .houseOpen
+ case "clearance": self = .clearance
+ default: return nil
+ }
+ }
+}
diff --git a/Promptly/Imports & Exports/Imports.swift b/Promptly/Imports & Exports/Imports.swift
new file mode 100644
index 0000000..0fe6042
--- /dev/null
+++ b/Promptly/Imports & Exports/Imports.swift
@@ -0,0 +1,295 @@
+//
+// Imports.swift
+// Promptly
+//
+// Created by Sasha Bagrov on 20/10/2025.
+//
+
+import Foundation
+import SwiftData
+
+class ShowImportManager {
+ static func importShow(from data: Data, into context: ModelContext) throws -> Show {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+
+ let exportData = try decoder.decode(ShowExportData.self, from: data)
+
+ let show = Show()
+ show.id = UUID()
+ show.title = exportData.show.title
+ show.locationString = exportData.show.locationString
+ show.dates = exportData.show.performanceDates
+
+ context.insert(show)
+
+ if let scriptData = exportData.script {
+ let script = try createScript(from: scriptData, context: context)
+ show.script = script
+ }
+
+ for perfData in exportData.performances {
+ let performance = try createPerformance(from: perfData, show: show, context: context)
+ show.peformances.append(performance)
+ }
+
+ return show
+ }
+
+ static func importShowFromFile(from url: URL, into context: ModelContext) throws -> Show {
+ let data = try Data(contentsOf: url)
+ return try importShow(from: data, into: context)
+ }
+
+ private static func createScript(from data: ScriptData, context: ModelContext) throws -> Script {
+ let script = Script(
+ id: UUID(),
+ name: data.name,
+ dateAdded: data.dateAdded
+ )
+
+ context.insert(script)
+
+ for sectionData in data.sections {
+ let section = ScriptSection(
+ id: UUID(),
+ title: sectionData.title,
+ type: SectionType(rawValue: sectionData.type) ?? .custom,
+ startLineNumber: sectionData.startLineNumber
+ )
+ section.endLineNumber = sectionData.endLineNumber
+ section.notes = sectionData.notes
+
+ context.insert(section)
+ script.sections.append(section)
+ }
+
+ for lineData in data.lines {
+ let line = ScriptLine(
+ id: UUID(),
+ lineNumber: lineData.lineNumber,
+ content: lineData.content,
+ flags: lineData.flags.compactMap { ScriptLineFlags(rawValue: $0) }
+ )
+
+ line.isMarked = lineData.isMarked
+ line.markColor = lineData.markColor
+ line.notes = lineData.notes
+
+ context.insert(line)
+
+ for elementData in lineData.elements {
+ let element = LineElement(
+ id: UUID(),
+ position: elementData.position,
+ content: elementData.content,
+ type: ElementType(rawValue: elementData.type) ?? .word
+ )
+ element.isMarked = elementData.isMarked
+ element.markColor = elementData.markColor
+
+ context.insert(element)
+ line.elements.append(element)
+ }
+
+ for cueData in lineData.cues {
+ let cue = Cue(
+ id: UUID(),
+ lineId: line.id,
+ position: CuePosition(
+ elementIndex: cueData.position.elementIndex,
+ offset: CueOffset(rawValue: cueData.position.offset) ?? .after
+ ),
+ type: CueType(rawValue: cueData.type) ?? .lightingGo,
+ label: cueData.label
+ )
+ cue.notes = cueData.notes
+ cue.hasAlert = cueData.hasAlert
+ cue.alertSound = cueData.alertSound
+
+ context.insert(cue)
+ line.cues.append(cue)
+ }
+
+ script.lines.append(line)
+ }
+
+ return script
+ }
+
+ private static func createPerformance(from data: PerformanceData, show: Show, context: ModelContext) throws -> Performance {
+ let performance = Performance(
+ id: UUID(),
+ date: data.date,
+ calls: [],
+ timing: nil,
+ show: show
+ )
+
+ context.insert(performance)
+
+ for callData in data.calls {
+ let call = PerformanceCall(
+ id: UUID(),
+ title: callData.title,
+ call: createCallType(from: callData.call)
+ )
+
+ context.insert(call)
+ performance.calls.append(call)
+ }
+
+ if let timingData = data.timing {
+ let timing = try createPerformanceTiming(from: timingData, context: context)
+ performance.timing = timing
+ }
+
+ return performance
+ }
+
+ private static func createCallType(from data: CallTypeData) -> CallType {
+ switch data.type {
+ case "preShow":
+ if let value = data.value, let preShowCall = PreShowCall(rawValue: value) {
+ return .preShow(preShowCall)
+ }
+ return .preShow(.half)
+ case "interval":
+ if let value = data.value, let intervalCall = IntervalCall(rawValue: value) {
+ return .interval(intervalCall)
+ }
+ return .interval(.half)
+ case "houseManagement":
+ if let value = data.value, let houseCall = HouseCall(rawValue: value) {
+ return .houseManagement(houseCall)
+ }
+ return .houseManagement(.houseOpen)
+ case "custom":
+ if let date = data.date {
+ return .custom(date)
+ }
+ return .custom(Date())
+ default:
+ return .preShow(.half)
+ }
+ }
+
+ private static func createPerformanceTiming(from data: PerformanceTimingData, context: ModelContext) throws -> PerformanceTiming {
+ let callSettings = CallSettings(
+ halfTime: data.callSettings.halfTime,
+ quarterTime: data.callSettings.quarterTime,
+ fiveTime: data.callSettings.fiveTime,
+ beginnersTime: data.callSettings.beginnersTime,
+ houseOpenOffset: data.callSettings.houseOpenOffset,
+ clearanceOffset: data.callSettings.clearanceOffset,
+ enableSoundAlerts: data.callSettings.enableSoundAlerts,
+ customMessage: data.callSettings.customMessage
+ )
+
+ context.insert(callSettings)
+
+ let currentState = createPerformanceState(from: data.currentState)
+
+ let timing = PerformanceTiming(
+ id: UUID(),
+ curtainTime: data.curtainTime,
+ houseOpenTime: data.houseOpenTime,
+ houseOpenPlanned: data.houseOpenPlanned,
+ clearanceTime: data.clearanceTime,
+ acts: [],
+ intervals: [],
+ callSettings: callSettings,
+ currentState: currentState,
+ startTime: data.startTime,
+ endTime: data.endTime,
+ actTimings: [],
+ showStops: []
+ )
+
+ context.insert(timing)
+
+ for actData in data.acts {
+ let act = Act(number: actData.number, name: actData.name)
+ act.id = UUID()
+ act.startTime = actData.startTime
+ act.endTime = actData.endTime
+ act.includeInRunningTime = actData.includeInRunningTime
+
+ context.insert(act)
+ timing.acts.append(act)
+ }
+
+ for intervalData in data.intervals {
+ let interval = Interval(number: intervalData.number, plannedDuration: intervalData.plannedDuration)
+ interval.id = UUID()
+ interval.name = intervalData.name
+ interval.actualDuration = intervalData.actualDuration
+ interval.startTime = intervalData.startTime
+ interval.endTime = intervalData.endTime
+
+ context.insert(interval)
+ timing.intervals.append(interval)
+ }
+
+ for timingData in data.actTimings {
+ let actTiming = ActTiming(
+ actNumber: timingData.actNumber,
+ startTime: timingData.startTime
+ )
+ actTiming.id = UUID()
+ actTiming.endTime = timingData.endTime
+
+ context.insert(actTiming)
+ timing.actTimings.append(actTiming)
+ }
+
+ for stopData in data.showStops {
+ let showStop = ShowStop(
+ timestamp: stopData.timestamp,
+ reason: stopData.reason,
+ actNumber: stopData.actNumber
+ )
+ showStop.id = UUID()
+ showStop.duration = stopData.duration
+
+ context.insert(showStop)
+ timing.showStops.append(showStop)
+ }
+
+ return timing
+ }
+
+ private static func createPerformanceState(from data: PerformanceStateData) -> PerformanceState {
+ switch data.type {
+ case "preShow":
+ return .preShow
+ case "houseOpen":
+ return .houseOpen
+ case "clearance":
+ return .clearance
+ case "inProgress":
+ if let actNumber = data.value {
+ return .inProgress(actNumber: actNumber)
+ }
+ return .preShow
+ case "interval":
+ if let intervalNumber = data.value {
+ return .interval(intervalNumber: intervalNumber)
+ }
+ return .preShow
+ case "completed":
+ return .completed
+ case "stopped":
+ return .stopped
+ default:
+ return .preShow
+ }
+ }
+}
+
+enum ImportError: Error {
+ case invalidFileFormat
+ case corruptedData
+ case unsupportedVersion
+ case missingRequiredFields
+}
diff --git a/Promptly/Info.plist b/Promptly/Info.plist
new file mode 100644
index 0000000..2505aff
--- /dev/null
+++ b/Promptly/Info.plist
@@ -0,0 +1,46 @@
+
+
+
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ com.promptly.dsmprompt
+ UTTypeDescription
+ DSM Prompt Show File
+ UTTypeConformsTo
+
+ public.data
+ public.content
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ dsmprompt
+
+ public.mime-type
+
+ application/x-dsmprompt
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ DSM Prompt Show File
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Owner
+ LSItemContentTypes
+
+ com.promptly.dsmprompt
+
+
+
+
+
diff --git a/Promptly/Models/Script.swift b/Promptly/Models/Script.swift
index bf27fd8..63fc134 100644
--- a/Promptly/Models/Script.swift
+++ b/Promptly/Models/Script.swift
@@ -120,6 +120,33 @@ class ScriptLine: Identifiable {
enum ScriptLineFlags: String, CaseIterable, Codable {
case stageDirection = "stageDirection"
case skip = "skip"
+
+ var icon: String {
+ switch self {
+ case .stageDirection:
+ return "theatermasks"
+ case .skip:
+ return "forward.fill"
+ }
+ }
+
+ var label: String {
+ switch self {
+ case .stageDirection:
+ return "Stage"
+ case .skip:
+ return "Skip"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .stageDirection:
+ return .purple
+ case .skip:
+ return .red
+ }
+ }
}
@Model
diff --git a/Promptly/PromptlyApp.swift b/Promptly/PromptlyApp.swift
index d38c69d..4795a30 100644
--- a/Promptly/PromptlyApp.swift
+++ b/Promptly/PromptlyApp.swift
@@ -8,7 +8,7 @@
import SwiftUI
import SwiftData
import MIDIKitIO
-
+import WhatsNewKit
@main
struct PromptlyApp: App {
@@ -36,7 +36,73 @@ struct PromptlyApp: App {
HomeScreenView()
.environment(midiManager)
.environment(midiHelper)
+ .environment(
+ \.whatsNew,
+ .init(
+ versionStore: UserDefaultsWhatsNewVersionStore(),
+ whatsNewCollection: self
+ )
+ )
}
.modelContainer(for: [Show.self, PerformanceReport.self])
}
}
+
+// MARK: - App+WhatsNewCollectionProvider
+extension PromptlyApp: WhatsNewCollectionProvider {
+ /// A WhatsNewCollection
+ var whatsNewCollection: WhatsNewCollection {
+ WhatsNew(
+ version: "1.0.4",
+ title: "DSMPrompt",
+ features: [
+ .init(
+ image: .init(
+ systemName: "square.and.arrow.down.on.square",
+ foregroundColor: .orange
+ ),
+ title: "Import & Export Show Files",
+ subtitle: "Import and export show files to have manual backups / share between members."
+ ),
+ .init(
+ image: .init(
+ systemName: "wand.and.stars",
+ foregroundColor: .cyan
+ ),
+ title: "What's New View",
+ subtitle: "Find out what's changed between versions."
+ ),
+ .init(
+ image: .init(
+ systemName: "hammer",
+ foregroundColor: .gray
+ ),
+ title: "Bug Fixes",
+ subtitle: "Bug fixes and stability improvements."
+ ),
+ .init(
+ image: .init(
+ systemName: "hammer",
+ foregroundColor: .red
+ ),
+ title: "PDF Rendering",
+ subtitle: "Fixed bug where if you were in dark mode the text would not show in PDF exports."
+ )
+ ],
+ primaryAction: .init(
+ hapticFeedback: {
+ #if os(iOS)
+ .notification(.success)
+ #else
+ nil
+ #endif
+ }()
+ ),
+ secondaryAction: .init(
+ title: "View on GitHub",
+ action: .openURL(.init(string: "https://github.com/DSMPrompt/app/releases"))
+ )
+ )
+ }
+
+}
diff --git a/Promptly/Views/.DS_Store b/Promptly/Views/.DS_Store
index 151c6b3..6005b5b 100644
Binary files a/Promptly/Views/.DS_Store and b/Promptly/Views/.DS_Store differ
diff --git a/Promptly/Views/Export/PerformanceReportView.swift b/Promptly/Views/Export/PerformanceReportView.swift
index 2336898..456e4b1 100644
--- a/Promptly/Views/Export/PerformanceReportView.swift
+++ b/Promptly/Views/Export/PerformanceReportView.swift
@@ -19,7 +19,7 @@ struct PerformanceReportView: View {
@State private var generatePDFSheetIsPresent = false
var body: some View {
- NavigationView {
+ Group {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
reportHeader
@@ -46,13 +46,7 @@ struct PerformanceReportView: View {
}
.navigationTitle("Performance Report")
.navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button("Done") {
- dismiss()
- }
- }
-
+ .toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button("Export PDF", systemImage: "square.and.arrow.up") {
@@ -499,6 +493,17 @@ class PerformanceReportPDFGenerator {
yPosition = drawText("Calls Executed: \(report.callsExecuted)", at: yPosition, in: context.cgContext)
yPosition = drawText("Cues Executed: \(report.cuesExecuted)", at: yPosition, in: context.cgContext)
yPosition = drawText("Show Stops: \(report.showStops)", at: yPosition, in: context.cgContext)
+
+ if report.showStops > 0 {
+ for entry in report.callLogEntries {
+ if entry.message.contains("EMERGENCY STOP: ") {
+ if let range = entry.message.range(of: "EMERGENCY STOP: ") {
+ let textAfterStop = String(entry.message[range.upperBound...])
+ yPosition = drawText(" E STOP: \(textAfterStop)", at: yPosition, in: context.cgContext)
+ }
+ }
+ }
+ }
yPosition += 20
if let startTime = report.startTime, let endTime = report.endTime {
@@ -555,7 +560,7 @@ class PerformanceReportPDFGenerator {
private func drawTitle(_ text: String, at y: CGFloat, in context: CGContext) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
- .foregroundColor: UIColor.label
+ .foregroundColor: UIColor.black
]
let attributedString = NSAttributedString(string: text, attributes: attributes)
@@ -568,7 +573,7 @@ class PerformanceReportPDFGenerator {
private func drawSubtitle(_ text: String, at y: CGFloat, in context: CGContext) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 18, weight: .medium),
- .foregroundColor: UIColor.secondaryLabel
+ .foregroundColor: UIColor.blue
]
let attributedString = NSAttributedString(string: text, attributes: attributes)
@@ -581,7 +586,7 @@ class PerformanceReportPDFGenerator {
private func drawSectionHeader(_ text: String, at y: CGFloat, in context: CGContext) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 16),
- .foregroundColor: UIColor.label
+ .foregroundColor: UIColor.black
]
let attributedString = NSAttributedString(string: text, attributes: attributes)
@@ -594,7 +599,7 @@ class PerformanceReportPDFGenerator {
private func drawText(_ text: String, at y: CGFloat, in context: CGContext, fontSize: CGFloat = 12) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: fontSize),
- .foregroundColor: UIColor.label
+ .foregroundColor: UIColor.black
]
let attributedString = NSAttributedString(string: text, attributes: attributes)
diff --git a/Promptly/Views/Home Screen/HomeScreenView.swift b/Promptly/Views/Home Screen/HomeScreenView.swift
index 278bf86..bdc2caa 100644
--- a/Promptly/Views/Home Screen/HomeScreenView.swift
+++ b/Promptly/Views/Home Screen/HomeScreenView.swift
@@ -8,7 +8,8 @@
import SwiftUI
import SwiftData
import MIDIKitIO
-
+import UniformTypeIdentifiers
+import WhatsNewKit
struct HomeScreenView: View {
@Query var shows: [Show] = []
@@ -18,6 +19,8 @@ struct HomeScreenView: View {
@State var navStackMessage: String = ""
@State var addShow: Bool = false
@State var showNetworkSettings: Bool = false
+ @State var showingImportShowSheet = false
+ @State var importError: ImportError?
@State var availableShows: [String: String] = [:]
@StateObject private var mqttManager = MQTTManager()
@@ -54,6 +57,38 @@ struct HomeScreenView: View {
.sheet(isPresented: self.$showNetworkSettings) {
NetworkSettingsView()
}
+ .fileImporter(
+ isPresented: $showingImportShowSheet,
+ allowedContentTypes: [.dsmPrompt],
+ allowsMultipleSelection: false
+ ) { result in
+ switch result {
+ case .success(let urls):
+ guard let url = urls.first else { return }
+
+ do {
+ let importedShow = try ShowImportManager.importShowFromFile(from: url, into: modelContext)
+ print("✅ Show imported successfully: \(importedShow.title)")
+ } catch {
+ print("❌ Import failed: \(error)")
+ importError = error as? ImportError ?? .invalidFileFormat
+ }
+ case .failure(let error):
+ print("❌ File selection failed: \(error)")
+ importError = .invalidFileFormat
+ }
+ }
+ .alert("Import Error", isPresented: Binding(
+ get: { importError != nil },
+ set: { _ in importError = nil }
+ )) {
+ Button("OK") { importError = nil }
+ } message: {
+ if let error = importError {
+ Text(error.localizedDescription)
+ }
+ }
+ .whatsNewSheet()
}
}
@@ -112,6 +147,12 @@ struct HomeScreenView: View {
Label("MIDI", systemImage: "av.remote")
}
+ Button {
+ showingImportShowSheet = true
+ } label: {
+ Label("Import Show", systemImage: "square.and.arrow.down")
+ }
+
Button {
self.addShow = true
} label: {
@@ -180,6 +221,21 @@ struct NetworkSettingsView: View {
}
}
+extension ImportError: LocalizedError {
+ var errorDescription: String? {
+ switch self {
+ case .invalidFileFormat:
+ return "Invalid file format. Please select a valid .dsmprompt file."
+ case .corruptedData:
+ return "The file appears to be corrupted and cannot be imported."
+ case .unsupportedVersion:
+ return "This file was created with a newer version of the app."
+ case .missingRequiredFields:
+ return "The file is missing required data and cannot be imported."
+ }
+ }
+}
+
#Preview {
HomeScreenView()
}
diff --git a/Promptly/Views/Scripts/.DS_Store b/Promptly/Views/Scripts/.DS_Store
index dc1d6b1..18db17f 100644
Binary files a/Promptly/Views/Scripts/.DS_Store and b/Promptly/Views/Scripts/.DS_Store differ
diff --git a/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift b/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift
index 6893677..e93eaff 100644
--- a/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift
+++ b/Promptly/Views/Scripts/Edit Contents/EditScriptView.swift
@@ -14,6 +14,12 @@ class RefreshTrigger: ObservableObject {
}
}
+struct LineGroup: Identifiable {
+ let id = UUID()
+ let section: ScriptSection?
+ let lines: [ScriptLine]
+}
+
struct EditScriptView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@@ -283,32 +289,40 @@ struct EditScriptView: View {
private var scriptContentView: some View {
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(groupLinesBySection(), id: \.id) { group in
+ if let section = group.section {
+ SectionHeaderView(section: section)
+ .padding(.horizontal)
+ .padding(.top, 8)
+ }
+
+ ForEach(group.lines, 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)
+ }
+ } 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
}
- } 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
}
}
}
@@ -318,6 +332,63 @@ struct EditScriptView: View {
// MARK: - Actions
+ private func groupLinesBySection() -> [LineGroup] {
+ // For performance, only process visible sections
+ let maxVisibleSections = 20
+ let limitedSections = Array(sortedSections.prefix(maxVisibleSections))
+
+ var groups: [LineGroup] = []
+ var lastProcessedLine = 0
+
+ for section in limitedSections {
+ let startLine = section.startLineNumber
+
+ // Find the next section's start line (or end of script)
+ let nextSectionStart = sortedSections.first {
+ $0.startLineNumber > startLine
+ }?.startLineNumber ?? (sortedLines.last?.lineNumber ?? 0) + 1
+
+ // Add ungrouped lines before this section
+ if startLine > lastProcessedLine + 1 {
+ let ungroupedLines = sortedLines.filter {
+ $0.lineNumber > lastProcessedLine && $0.lineNumber < startLine
+ }
+ if !ungroupedLines.isEmpty {
+ groups.append(LineGroup(section: nil, lines: ungroupedLines))
+ }
+ }
+
+ // Add this section's lines (only up to next section)
+ let sectionLines = sortedLines.filter {
+ $0.lineNumber >= startLine && $0.lineNumber < nextSectionStart
+ }
+
+ if !sectionLines.isEmpty {
+ groups.append(LineGroup(section: section, lines: sectionLines))
+ lastProcessedLine = sectionLines.last?.lineNumber ?? lastProcessedLine
+ }
+ }
+
+ // Add remaining ungrouped lines
+ let remainingLines = sortedLines.filter { $0.lineNumber > lastProcessedLine }
+ if !remainingLines.isEmpty {
+ groups.append(LineGroup(section: nil, lines: remainingLines))
+ }
+
+ return groups
+ }
+
+ private func updateSectionsAfterLineChange(at changePoint: Int, delta: Int) {
+ script.sections.forEach { section in
+ if section.startLineNumber > changePoint {
+ section.startLineNumber += delta
+ }
+ if let endLine = section.endLineNumber, endLine > changePoint {
+ section.endLineNumber! += delta
+ }
+ }
+ }
+
private func toggleLineSelection(_ line: ScriptLine) {
if selectedLines.contains(line.id) {
selectedLines.remove(line.id)
@@ -357,49 +428,48 @@ struct EditScriptView: View {
}
private func deleteSelectedLines() {
- let linesToRemove = selectedLines.compactMap { id in
- script.lines.first { $0.id == id }
- }
+ let sortedLinesToDelete = linesToDelete.sorted { $0.lineNumber < $1.lineNumber }
+ let firstDeletedLineNumber = sortedLinesToDelete.first?.lineNumber ?? 0
+ let deletedCount = sortedLinesToDelete.count
- for line in linesToRemove {
+ for line in linesToDelete {
script.lines.removeAll { $0.id == line.id }
- modelContext.delete(line)
}
- selectedLines.removeAll()
- linesToDelete.removeAll()
+ updateSectionsAfterLineChange(at: firstDeletedLineNumber, delta: -deletedCount)
+
renumberLines()
try? modelContext.save()
+
+ selectedLines.removeAll()
+ linesToDelete.removeAll()
}
private func combineSelectedLines() {
- let linesToCombine = selectedLines.compactMap { id in
- script.lines.first { $0.id == id }
- }.sorted { $0.lineNumber < $1.lineNumber }
+ let sortedSelected = selectedLinesArray.sorted { $0.lineNumber < $1.lineNumber }
+ guard let firstLine = sortedSelected.first else { return }
- guard let firstLine = linesToCombine.first else { return }
+ let combinedContent = sortedSelected.map { $0.content }.joined(separator: " ")
+ let combinedCues = sortedSelected.flatMap { $0.cues }
+ let combinedFlags = Array(Set(sortedSelected.flatMap { $0.flags }))
- let combinedContent = linesToCombine.map { $0.content }.joined(separator: " ")
- let allCues = linesToCombine.flatMap { $0.cues }
- let allFlags = Array(Set(linesToCombine.flatMap { $0.flags }))
+ let linesToRemove = sortedSelected.dropFirst()
+ let removedCount = linesToRemove.count
- firstLine.content = combinedContent
- firstLine.flags = allFlags
- firstLine.parseContentIntoElements()
-
- for cue in allCues {
- cue.lineId = firstLine.id
- }
-
- let linesToRemove = Array(linesToCombine.dropFirst())
for line in linesToRemove {
script.lines.removeAll { $0.id == line.id }
- modelContext.delete(line)
}
- selectedLines.removeAll()
+ firstLine.content = combinedContent
+ firstLine.cues = combinedCues
+ firstLine.flags = combinedFlags
+
+ updateSectionsAfterLineChange(at: firstLine.lineNumber, delta: -removedCount)
+
renumberLines()
try? modelContext.save()
+
+ selectedLines.removeAll()
}
private func createSectionWithSelectedLine() {
@@ -1046,6 +1116,15 @@ struct AddLineView: View {
line.lineNumber += 1
}
+ script.sections.forEach { section in
+ if section.startLineNumber >= newLineNumber {
+ section.startLineNumber += 1
+ }
+ if let endLine = section.endLineNumber, endLine >= newLineNumber {
+ section.endLineNumber! += 1
+ }
+ }
+
let newLine = ScriptLine(
id: UUID(),
lineNumber: newLineNumber,
@@ -1061,29 +1140,14 @@ struct AddLineView: View {
}
private func iconForFlag(_ flag: ScriptLineFlags) -> String {
- switch flag {
- case .stageDirection:
- return "theatermasks"
- case .skip:
- return "forward.fill"
- }
+ return flag.icon
}
private func labelForFlag(_ flag: ScriptLineFlags) -> String {
- switch flag {
- case .stageDirection:
- return "Stage"
- case .skip:
- return "Skip"
- }
+ return flag.label
}
private func colorForFlag(_ flag: ScriptLineFlags) -> Color {
- switch flag {
- case .stageDirection:
- return .purple
- case .skip:
- return .red
- }
+ return flag.color
}
}
diff --git a/Promptly/Views/Scripts/ScriptEditorView.swift b/Promptly/Views/Scripts/ScriptEditorView.swift
index dd84e28..95a0f94 100644
--- a/Promptly/Views/Scripts/ScriptEditorView.swift
+++ b/Promptly/Views/Scripts/ScriptEditorView.swift
@@ -1,5 +1,5 @@
//
-// ScriptEditorView.swift
+// ScriptEditorView_v2.swift
// Promptly
//
// Created by Sasha Bagrov on 04/06/2025.
@@ -21,10 +21,10 @@ struct ScriptEditorView: View {
@State private var editingText: String = ""
@State private var showingDeleteCueAlert = false
@State private var cueToDelete: Cue?
-
- private var sortedLines: [ScriptLine] {
- script.lines.sorted(by: { $0.lineNumber < $1.lineNumber })
- }
+ @State private var sortedLines: [ScriptLine] = []
+ @State private var sortedSections: [ScriptSection] = []
+ @State private var lineGroups: [LineGroup] = []
+ @State private var isProcessingGroups = false
private func handleCueEdit(cue: Cue) {
guard let line = script.lines.first(where: { $0.id == cue.lineId }) else { return }
@@ -33,9 +33,121 @@ struct ScriptEditorView: View {
isShowingCueEditor = true
}
+ private func setupSortedData() {
+ Task.detached(priority: .userInitiated) {
+ let lines = script.lines.sorted { $0.lineNumber < $1.lineNumber }
+ let sections = script.sections.sorted { $0.startLineNumber < $1.startLineNumber }
+ let groups = await groupLinesBySection(lines: lines, sections: sections)
+
+ await MainActor.run {
+ self.sortedLines = lines
+ self.sortedSections = sections
+ self.lineGroups = groups
+ self.isProcessingGroups = false
+ }
+ }
+ }
+
+ private func groupLinesBySection(lines: [ScriptLine], sections: [ScriptSection]) async -> [LineGroup] {
+ guard !lines.isEmpty else { return [] }
+ guard !sections.isEmpty else {
+ return [LineGroup(section: nil, lines: lines)]
+ }
+
+ let sectionRanges = await withTaskGroup(of: (Int, Int, Int).self) { group in
+ for (index, section) in sections.enumerated() {
+ group.addTask {
+ let startLine = section.startLineNumber
+ let endLine = (index + 1 < sections.count) ?
+ sections[index + 1].startLineNumber - 1 :
+ lines.last?.lineNumber ?? startLine
+ return (index, startLine, endLine)
+ }
+ }
+
+ var ranges: [(Int, Int, Int)] = []
+ for await range in group {
+ ranges.append(range)
+ }
+ return ranges.sorted { $0.0 < $1.0 }
+ }
+
+ var groups: [LineGroup] = []
+ var processedLines = Set()
+
+ for (index, startLine, endLine) in sectionRanges {
+ let section = sections[index]
+
+ let startIdx = binarySearchStart(lines: lines, lineNumber: startLine)
+ let endIdx = binarySearchEnd(lines: lines, lineNumber: endLine)
+
+ guard startIdx < lines.count && endIdx >= 0 else { continue }
+
+ let sectionLines = lines[startIdx...min(endIdx, lines.count - 1)]
+ .filter { !processedLines.contains($0.lineNumber) }
+
+ if !sectionLines.isEmpty {
+ groups.append(LineGroup(section: section, lines: Array(sectionLines)))
+ processedLines.formUnion(sectionLines.map { $0.lineNumber })
+ }
+ }
+
+ let ungroupedLines = lines.filter { !processedLines.contains($0.lineNumber) }
+ if !ungroupedLines.isEmpty {
+ groups.append(LineGroup(section: nil, lines: ungroupedLines))
+ }
+
+ return groups.sorted {
+ ($0.lines.first?.lineNumber ?? 0) < ($1.lines.first?.lineNumber ?? 0)
+ }
+ }
+
+ private func binarySearchStart(lines: [ScriptLine], lineNumber: Int) -> Int {
+ var left = 0, right = lines.count - 1
+ while left <= right {
+ let mid = (left + right) / 2
+ if lines[mid].lineNumber >= lineNumber {
+ right = mid - 1
+ } else {
+ left = mid + 1
+ }
+ }
+ return left
+ }
+
+ private func binarySearchEnd(lines: [ScriptLine], lineNumber: Int) -> Int {
+ var left = 0, right = lines.count - 1
+ while left <= right {
+ let mid = (left + right) / 2
+ if lines[mid].lineNumber <= lineNumber {
+ left = mid + 1
+ } else {
+ right = mid - 1
+ }
+ }
+ return right
+ }
+
var body: some View {
VStack(spacing: 0) {
- scriptContentView
+ if isProcessingGroups {
+ ProgressView("Organizing script...")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ scriptContentView
+ }
+ }
+ .onAppear {
+ isProcessingGroups = true
+ setupSortedData()
+ }
+ .onChange(of: script.sections.count) { _, _ in
+ isProcessingGroups = true
+ setupSortedData()
+ }
+ .onChange(of: script.lines.count) { _, _ in
+ isProcessingGroups = true
+ setupSortedData()
}
.sheet(isPresented: $isShowingCueEditor) {
if let line = selectedLine, let element = selectedElement {
@@ -71,25 +183,33 @@ struct ScriptEditorView: View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
- ForEach(sortedLines, id: \.id) { line in
- ScriptLineView(
- line: line,
- isSelected: selectedLine?.id == line.id,
- isEditing: editingLineId == line.id,
- editingText: $editingText,
- onElementTap: { element in
- handleElementTap(element: element, line: line)
- },
- onLineTap: {
- handleLineTap(line: line)
- },
- onEditComplete: { newText in
- updateLineContent(line: line, newText: newText)
- },
- onCueDelete: handleCueDelete,
- onCueEdit: handleCueEdit
- )
- .id("line-\(line.id)")
+ ForEach(lineGroups, id: \.id) { group in
+ if let section = group.section {
+ SectionHeaderView(section: section)
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ }
+
+ ForEach(group.lines, id: \.id) { line in
+ ScriptLineView(
+ line: line,
+ isSelected: selectedLine?.id == line.id,
+ isEditing: editingLineId == line.id,
+ editingText: $editingText,
+ onElementTap: { element in
+ handleElementTap(element: element, line: line)
+ },
+ onLineTap: {
+ handleLineTap(line: line)
+ },
+ onEditComplete: { newText in
+ updateLineContent(line: line, newText: newText)
+ },
+ onCueDelete: handleCueDelete,
+ onCueEdit: handleCueEdit
+ )
+ .id("line-\(line.id)")
+ }
}
}
.padding()
@@ -118,6 +238,8 @@ struct ScriptEditorView: View {
line.parseContentIntoElements()
editingLineId = nil
try? modelContext.save()
+ isProcessingGroups = true
+ setupSortedData()
}
private func handleCueDelete(cue: Cue) {
diff --git a/Promptly/Views/Shows/.DS_Store b/Promptly/Views/Shows/.DS_Store
index 1b229ee..30155bd 100644
Binary files a/Promptly/Views/Shows/.DS_Store and b/Promptly/Views/Shows/.DS_Store differ
diff --git a/Promptly/Views/Shows/Detail/ShowDetailView.swift b/Promptly/Views/Shows/Detail/ShowDetailView.swift
index 8ccd00f..041b98d 100644
--- a/Promptly/Views/Shows/Detail/ShowDetailView.swift
+++ b/Promptly/Views/Shows/Detail/ShowDetailView.swift
@@ -7,6 +7,13 @@
import SwiftUI
import SwiftData
+import UniformTypeIdentifiers
+
+extension UTType {
+ static var dsmPrompt: UTType {
+ UTType(exportedAs: "com.promptly.dsmprompt")
+ }
+}
struct ShowDetailView: View {
@Environment(\.modelContext) private var modelContext
@@ -24,6 +31,9 @@ struct ShowDetailView: View {
@State private var showPerformanceAlert: Bool = false
@State private var performanceToStart: Performance? = nil
+ @State private var showingExportShowSheet = false
+ @State private var exportError: ExportError1?
+
@Query private var performanceReports: [PerformanceReport]
@@ -74,6 +84,12 @@ struct ShowDetailView: View {
Divider()
+ Button("Export Show", systemImage: "square.and.arrow.up") {
+ showingExportShowSheet = true
+ }
+
+ Divider()
+
Button("Delete Show", systemImage: "trash", role: .destructive) {
isShowingDeleteAlert = true
}
@@ -132,6 +148,20 @@ struct ShowDetailView: View {
}
}
}
+ .fileExporter(
+ isPresented: $showingExportShowSheet,
+ document: DSMPromptDocument(show: show),
+ contentType: .dsmPrompt,
+ defaultFilename: "\(show.title.replacingOccurrences(of: " ", with: "_")).dsmprompt"
+ ) { result in
+ switch result {
+ case .success(let url):
+ print("✅ Show exported successfully to: \(url)")
+ case .failure(let error):
+ print("❌ Export failed: \(error)")
+ exportError = .serialisationFailed
+ }
+ }
.alert("Delete Show", isPresented: $isShowingDeleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) {
@@ -140,6 +170,16 @@ struct ShowDetailView: View {
} message: {
Text("Are you sure you want to delete '\(show.title)'? This action cannot be undone.")
}
+ .alert("Export Error", isPresented: Binding(
+ get: { exportError != nil },
+ set: { _ in exportError = nil }
+ )) {
+ Button("OK") { exportError = nil }
+ } message: {
+ if let error = exportError {
+ Text(error.localizedDescription)
+ }
+ }
.alert("Which performance would you like to start?", isPresented: $showPerformanceAlert) {
Button("Cancel", role: .cancel) { }
ForEach(show.peformances) { performance in
@@ -229,11 +269,14 @@ struct ShowDetailView: View {
// } label: {
// HStack {
// Image(systemName: "trash")
- // Text("Delete All & Add Today")
+ // .foregroundColor(.red)
+ // Text("Delete All and Create Today")
+ // .font(.subheadline)
+ // .foregroundColor(.red)
// Spacer()
// }
// .padding()
- // .background(Color(.tertiarySystemGroupedBackground))
+ // .background(Color(.secondarySystemGroupedBackground))
// .cornerRadius(8)
// }
}
@@ -242,7 +285,7 @@ struct ShowDetailView: View {
private var scriptSection: some View {
VStack(alignment: .leading, spacing: 12) {
- Text("Script & Cues")
+ Text("Script")
.font(.headline)
if let script = show.script {
@@ -260,36 +303,25 @@ struct ShowDetailView: View {
private var performanceReportsSection: some View {
VStack(alignment: .leading, spacing: 12) {
+ Text("Performance Reports")
+ .font(.headline)
+
HStack {
- Text("Performance Reports")
- .font(.headline)
+ Text("\(reportsForThisShow.count) reports available")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
Spacer()
Button("View All") {
showingReports = true
}
- .font(.caption)
+ .font(.subheadline)
.foregroundColor(.blue)
}
-
- VStack(spacing: 8) {
- ForEach(reportsForThisShow.prefix(3)) { report in
- PerformanceReportSummaryCard(report: report)
- }
-
- if reportsForThisShow.count > 3 {
- Button("View \(reportsForThisShow.count - 3) more reports") {
- showingReports = true
- }
- .font(.caption)
- .foregroundColor(.blue)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 8)
- .background(Color(.secondarySystemGroupedBackground))
- .cornerRadius(8)
- }
- }
+ .padding()
+ .background(Color(.secondarySystemGroupedBackground))
+ .cornerRadius(8)
}
}
@@ -343,40 +375,70 @@ struct ShowDetailView: View {
}
}
+ private func deleteShow() {
+ modelContext.delete(show)
+ dismiss()
+ }
+
+ private func deletePerformanceDate(for performanceDate: PerformanceDate) {
+ if let index = show.performanceDates.firstIndex(of: performanceDate) {
+ show.removePerformanceDate(at: index)
+ }
+
+ if let matchingPerformance = show.peformances.first(where: {
+ abs($0.date.timeIntervalSince(performanceDate.date)) < 86400
+ }) {
+ show.peformances.removeAll { $0.id == matchingPerformance.id }
+ modelContext.delete(matchingPerformance)
+ }
+ }
+
private func addPerformance(date: Date) {
- let newPerformanceDate = PerformanceDate(date: date)
- show.performanceDates.append(newPerformanceDate)
+ show.addPerformanceDate(date)
- let newPerformance = Performance(
+ let performance = Performance(
id: UUID(),
date: date,
calls: [],
timing: nil,
show: show
)
- show.peformances.append(newPerformance)
- try? modelContext.save()
+ modelContext.insert(performance)
+ show.peformances.append(performance)
}
- private func deleteShow() {
- modelContext.delete(show)
- try? modelContext.save()
- dismiss()
- }
-
- private func deletePerformanceDate(for date: PerformanceDate) {
- if let dateIndex = show.performanceDates.firstIndex(where: { $0.id == date.id }) {
- show.performanceDates.remove(at: dateIndex)
+ private func formatDuration(_ duration: TimeInterval) -> String {
+ let hours = Int(duration) / 3600
+ let minutes = (Int(duration) % 3600) / 60
+ let seconds = Int(duration) % 60
+
+ if hours > 0 {
+ return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ return String(format: "%02d:%02d", minutes, seconds)
}
+ }
+}
- if let perfIndex = show.peformances.firstIndex(where: {
- Calendar.current.isDate($0.date, inSameDayAs: date.date)
- }) {
- show.peformances.remove(at: perfIndex)
+struct DSMPromptDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.dsmPrompt] }
+
+ let show: Show
+
+ init(show: Show) {
+ self.show = show
+ }
+
+ init(configuration: ReadConfiguration) throws {
+ throw CocoaError(.featureUnsupported)
+ }
+
+ func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
+ guard let data = ShowExportManager.exportShow(show) else {
+ throw ExportError1.serialisationFailed
}
-
- try? modelContext.save()
+ return FileWrapper(regularFileWithContents: data)
}
}