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