From 4ed3f39c05a2205747536a9ad019671027012e9f Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Fri, 22 May 2026 22:53:09 -0700 Subject: [PATCH 1/7] Add endo PDF export feature --- LoopFollow.xcodeproj/project.pbxproj | 20 +- LoopFollow/Stats/AggregatedStatsView.swift | 21 +- LoopFollow/Stats/EndoReportGenerator.swift | 763 +++++++++++++++++++++ LoopFollow/Stats/EndoReportView.swift | 222 ++++++ 4 files changed, 1011 insertions(+), 15 deletions(-) create mode 100644 LoopFollow/Stats/EndoReportGenerator.swift create mode 100644 LoopFollow/Stats/EndoReportView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index c76e57915..bcf591ff0 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -64,6 +64,9 @@ 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; + A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; + A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; @@ -250,7 +253,6 @@ DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; }; DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; }; DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; }; - A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; @@ -291,7 +293,6 @@ FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; }; FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; }; FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; }; - A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; }; FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; }; @@ -424,7 +425,6 @@ FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886A24898FD800A0279D /* ObservationToken.swift */; }; FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886C2489909D00A0279D /* AnyConvertible.swift */; }; FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886E2489A53800A0279D /* AppConstants.swift */; }; - 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD2A27C24C9D044009F7B7B /* Globals.swift */; }; FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */; }; FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; @@ -517,9 +517,12 @@ 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; + A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; + A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; + BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -707,7 +710,6 @@ DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = ""; }; DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = ""; }; DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = ""; }; - A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; @@ -876,7 +878,6 @@ FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = ""; }; FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = ""; }; - A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = ""; }; FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = ""; }; @@ -884,7 +885,6 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationToken.swift; sourceTree = ""; }; FCC6886C2489909D00A0279D /* AnyConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = ""; }; FCC6886E2489A53800A0279D /* AppConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; - BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; FCC688702489A57C00A0279D /* Loop Follow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Follow.entitlements"; sourceTree = ""; }; FCD2A27C24C9D044009F7B7B /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbBolusArrays.swift; sourceTree = ""; }; @@ -2445,7 +2445,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = N8YMZJKBLQ; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2497,7 +2497,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = N8YMZJKBLQ; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2731,7 +2731,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = N8YMZJKBLQ; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2756,7 +2756,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = N8YMZJKBLQ; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/LoopFollow/Stats/AggregatedStatsView.swift b/LoopFollow/Stats/AggregatedStatsView.swift index 343831003..6f313826a 100644 --- a/LoopFollow/Stats/AggregatedStatsView.swift +++ b/LoopFollow/Stats/AggregatedStatsView.swift @@ -17,6 +17,7 @@ struct AggregatedStatsView: View { @State private var loadingError = false @State private var loadingTimer: Timer? @State private var timeoutTimer: Timer? + @State private var showEndoReport = false init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) { self.viewModel = viewModel @@ -108,15 +109,25 @@ struct AggregatedStatsView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - Button("Refresh") { - loadingError = false - isLoadingData = true - viewModel.updateDateRange(start: startDate, end: endDate) { - isLoadingData = false + HStack { + Button { + showEndoReport = true + } label: { + Label("Endo Report", systemImage: "doc.richtext") + } + Button("Refresh") { + loadingError = false + isLoadingData = true + viewModel.updateDateRange(start: startDate, end: endDate) { + isLoadingData = false + } } } } } + .sheet(isPresented: $showEndoReport) { + EndoReportView(dataService: viewModel.dataService) + } .onAppear { loadingError = false isLoadingData = true diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift new file mode 100644 index 000000000..69c2bc43d --- /dev/null +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -0,0 +1,763 @@ +// LoopFollow +// EndoReportGenerator.swift + +import PDFKit +import SwiftUI +import UIKit + +enum EndoReportGenerator { + // MARK: - Public entry point + + /// Generates a PDF and returns the file URL, or throws on failure. + static func generate( + patientName: String, + dateOfBirth: String, + providerName: String, + startDate: Date, + endDate: Date, + dataService: StatsDataService + ) throws -> URL { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { + throw ReportError.noData + } + + let agpData = AGPCalculator.calculate(bgData: bgData) + let tirData = TIRCalculator.calculate(bgData: bgData) + let stats = SimpleStats(bgData: bgData, dataService: dataService) + + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) // US Letter + let renderer = UIGraphicsPDFRenderer(bounds: pageRect) + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") + + let data = renderer.pdfData { ctx in + // ── Page 1: Summary + AGP ────────────────────────────────────── + ctx.beginPage() + var cursor = drawHeader( + ctx: ctx.cgContext, + pageRect: pageRect, + patientName: patientName, + dateOfBirth: dateOfBirth, + providerName: providerName, + startDate: startDate, + endDate: endDate + ) + + cursor = drawSectionTitle("Key Metrics", y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawKeyMetrics(stats: stats, y: cursor, in: pageRect, ctx: ctx.cgContext) + + cursor = drawSectionTitle("Time in Range", y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawTIRBar(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawTIRTable(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) + + cursor = drawSectionTitle("Ambulatory Glucose Profile (AGP)", y: cursor, in: pageRect, ctx: ctx.cgContext) + cursor = drawAGPChart(agpData: agpData, y: cursor, in: pageRect, ctx: ctx.cgContext) + drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 1) + + // ── Page 2: Daily stats + Insulin/Carbs ─────────────────────── + ctx.beginPage() + var cursor2 = drawPageContinuationHeader(ctx: ctx.cgContext, pageRect: pageRect, + patientName: patientName, + startDate: startDate, endDate: endDate) + + cursor2 = drawSectionTitle("Daily Glucose Summary", y: cursor2, in: pageRect, ctx: ctx.cgContext) + cursor2 = drawDailyTable(bgData: bgData, y: cursor2, in: pageRect, ctx: ctx.cgContext) + + // Insulin & carbs if available + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + if !boluses.isEmpty || !smbs.isEmpty || !carbs.isEmpty { + cursor2 = drawSectionTitle("Insulin & Carbohydrate Summary", + y: cursor2, in: pageRect, ctx: ctx.cgContext) + cursor2 = drawInsulinCarbSummary(boluses: boluses, smbs: smbs, carbs: carbs, + stats: stats, y: cursor2, + in: pageRect, ctx: ctx.cgContext) + } + + drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 2) + } + + try data.write(to: url) + return url + } + + // MARK: - Errors + + enum ReportError: LocalizedError { + case noData + var errorDescription: String? { + switch self { + case .noData: return "No CGM data available for the selected date range." + } + } + } + + // MARK: - Computed stats helper + + struct SimpleStats { + let avg: Double + let stdDev: Double + let cv: Double + let eA1C: Double + let min: Double + let max: Double + let sensorPct: Double + let readingCount: Int + + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let vals = bgData.map { Double($0.sgv) } + let n = Double(vals.count) + let mean = vals.reduce(0, +) / n + let variance = vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n + avg = mean + stdDev = sqrt(variance) + cv = stdDev / mean * 100 + eA1C = (mean + 46.7) / 28.7 + min = vals.min() ?? 0 + max = vals.max() ?? 0 + readingCount = vals.count + + let days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + let expected = days * 288 + sensorPct = Swift.min(Double(vals.count) / expected * 100, 100) + } + } + + // MARK: - Layout constants + + private static let margin: CGFloat = 36 + private static let bodyFont = UIFont.systemFont(ofSize: 9) + private static let labelFont = UIFont.systemFont(ofSize: 8) + private static let boldFont = UIFont.boldSystemFont(ofSize: 9) + private static let titleFont = UIFont.boldSystemFont(ofSize: 11) + private static let sectionFont = UIFont.boldSystemFont(ofSize: 10) + + private static let colorVeryLow = UIColor(red: 0.957, green: 0.263, blue: 0.212, alpha: 1) + private static let colorLow = UIColor(red: 1.000, green: 0.596, blue: 0.000, alpha: 1) + private static let colorInRange = UIColor(red: 0.298, green: 0.686, blue: 0.314, alpha: 1) + private static let colorHigh = UIColor(red: 1.000, green: 0.757, blue: 0.027, alpha: 1) + private static let colorVeryHigh = UIColor(red: 1.000, green: 0.341, blue: 0.133, alpha: 1) + private static let colorBlue = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 1) + private static let colorDark = UIColor(red: 0.110, green: 0.169, blue: 0.227, alpha: 1) + private static let colorLightGray = UIColor(red: 0.957, green: 0.961, blue: 0.976, alpha: 1) + private static let colorBorder = UIColor(red: 0.867, green: 0.890, blue: 0.925, alpha: 1) + + // MARK: - Header / Footer + + @discardableResult + private static func drawHeader( + ctx: CGContext, + pageRect: CGRect, + patientName: String, + dateOfBirth: String, + providerName: String, + startDate: Date, + endDate: Date + ) -> CGFloat { + let headerH: CGFloat = 52 + ctx.setFillColor(colorDark.cgColor) + ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) + + let titleAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 14), + .foregroundColor: UIColor.white, + ] + "Continuous Glucose Monitor Report".draw(at: CGPoint(x: margin, y: 10), withAttributes: titleAttrs) + + let df = DateFormatter() + df.dateFormat = "MMM d, yyyy" + let rangeStr = "\(df.string(from: startDate)) – \(df.string(from: endDate))" + let subAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 9), + .foregroundColor: UIColor(white: 0.8, alpha: 1), + ] + let rangeSize = (rangeStr as NSString).size(withAttributes: subAttrs) + (rangeStr as NSString).draw( + at: CGPoint(x: pageRect.width - margin - rangeSize.width, y: 12), + withAttributes: subAttrs + ) + + // Patient info bar + let infoY: CGFloat = 28 + let infoAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 8.5), + .foregroundColor: UIColor(white: 0.75, alpha: 1), + ] + "Patient: \(patientName)".draw(at: CGPoint(x: margin, y: infoY), withAttributes: infoAttrs) + if !dateOfBirth.isEmpty { + "DOB: \(dateOfBirth)".draw(at: CGPoint(x: margin + 180, y: infoY), withAttributes: infoAttrs) + } + if !providerName.isEmpty { + let provStr = "Provider: \(providerName)" + let provSize = (provStr as NSString).size(withAttributes: infoAttrs) + (provStr as NSString).draw( + at: CGPoint(x: pageRect.width - margin - provSize.width, y: infoY), + withAttributes: infoAttrs + ) + } + + return headerH + 12 + } + + @discardableResult + private static func drawPageContinuationHeader( + ctx: CGContext, pageRect: CGRect, + patientName: String, startDate _: Date, endDate _: Date + ) -> CGFloat { + let headerH: CGFloat = 32 + ctx.setFillColor(colorDark.cgColor) + ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) + + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 11), + .foregroundColor: UIColor.white, + ] + "CGM Report — \(patientName)".draw(at: CGPoint(x: margin, y: 9), withAttributes: attrs) + return headerH + 12 + } + + private static func drawFooter(ctx: CGContext, pageRect: CGRect, page: Int) { + let footerY = pageRect.height - 28 + ctx.setFillColor(colorLightGray.cgColor) + ctx.fill(CGRect(x: 0, y: footerY, width: pageRect.width, height: 28)) + + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + "LoopFollow CGM Report | For clinical use only | Targets 70–180 mg/dL" + .draw(at: CGPoint(x: margin, y: footerY + 8), withAttributes: attrs) + + let pageStr = "Page \(page)" + let pageSize = (pageStr as NSString).size(withAttributes: attrs) + (pageStr as NSString).draw( + at: CGPoint(x: pageRect.width - margin - pageSize.width, y: footerY + 8), + withAttributes: attrs + ) + } + + // MARK: - Section title + + @discardableResult + private static func drawSectionTitle(_ title: String, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + let attrs: [NSAttributedString.Key: Any] = [ + .font: sectionFont, + .foregroundColor: colorBlue, + ] + (title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attrs) + + let lineY = y + 14 + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: margin, y: lineY)) + ctx.addLine(to: CGPoint(x: pageRect.width - margin, y: lineY)) + ctx.strokePath() + + return lineY + 8 + } + + // MARK: - Key metrics cards + + @discardableResult + private static func drawKeyMetrics(stats: SimpleStats, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + let units = Storage.shared.units.value + let isMMOL = units == "mmol/L" + + func fmtGlucose(_ v: Double) -> String { + isMMOL ? String(format: "%.1f", v * 0.0555) : String(format: "%.0f", v) + } + + let cards: [(String, String, String)] = [ + ("eA1C", String(format: "%.1f%%", stats.eA1C), "Estimated A1C"), + ("TIR", { + // grab from TIR calculator average + let t = TIRCalculator.calculate(bgData: []) // placeholder — we draw this separately + return "—" + }(), "70–180 mg/dL"), + ("Avg Glucose", fmtGlucose(stats.avg), units), + ("CV", String(format: "%.1f%%", stats.cv), "SD \(fmtGlucose(stats.stdDev))"), + ("Sensor Active", String(format: "%.0f%%", stats.sensorPct), "\(stats.readingCount) readings"), + ] + + let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) + let cardH: CGFloat = 52 + let startX = margin + + for (i, card) in cards.enumerated() { + let cardX = startX + CGFloat(i) * cardW + let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) + + // Background + ctx.setFillColor(colorLightGray.cgColor) + ctx.fill(rect) + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.stroke(rect) + + // Value + let valAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 17), + .foregroundColor: colorDark, + ] + let valSize = (card.1 as NSString).size(withAttributes: valAttrs) + let valX = cardX + (cardW - 4 - valSize.width) / 2 + (card.1 as NSString).draw(at: CGPoint(x: valX, y: y + 8), withAttributes: valAttrs) + + // Label + let lblAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + let lblSize = (card.0 as NSString).size(withAttributes: lblAttrs) + (card.0 as NSString).draw( + at: CGPoint(x: cardX + (cardW - 4 - lblSize.width) / 2, y: y + 28), + withAttributes: lblAttrs + ) + + let subSize = (card.2 as NSString).size(withAttributes: lblAttrs) + (card.2 as NSString).draw( + at: CGPoint(x: cardX + (cardW - 4 - subSize.width) / 2, y: y + 39), + withAttributes: lblAttrs + ) + } + + return y + cardH + 12 + } + + // MARK: - TIR bar + + @discardableResult + private static func drawTIRBar(tirData: [TIRDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + guard let avg = tirData.first(where: { $0.period == .average }) else { return y } + + let barW = pageRect.width - margin * 2 + let barH: CGFloat = 20 + var x = margin + + let segments: [(CGFloat, UIColor)] = [ + (CGFloat(avg.veryLow / 100) * barW, colorVeryLow), + (CGFloat(avg.low / 100) * barW, colorLow), + (CGFloat(avg.inRange / 100) * barW, colorInRange), + (CGFloat(avg.high / 100) * barW, colorHigh), + (CGFloat(avg.veryHigh / 100) * barW, colorVeryHigh), + ] + + for (w, clr) in segments { + if w > 0 { + ctx.setFillColor(clr.cgColor) + ctx.fill(CGRect(x: x, y: y, width: w, height: barH)) + } + x += w + } + + // Labels inside bar + x = margin + let pcts = [avg.veryLow, avg.low, avg.inRange, avg.high, avg.veryHigh] + let clrs = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] + for (i, pct) in pcts.enumerated() { + let w = CGFloat(pct / 100) * barW + if pct >= 5 { + let pctStr = String(format: "%.1f%%", pct) + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 8), + .foregroundColor: UIColor.white, + ] + let sz = (pctStr as NSString).size(withAttributes: attrs) + (pctStr as NSString).draw( + at: CGPoint(x: x + (w - sz.width) / 2, y: y + (barH - sz.height) / 2), + withAttributes: attrs + ) + } + x += w + } + + // Legend + let legendY = y + barH + 4 + let legendItems: [(String, UIColor)] = [ + ("Very Low <54", colorVeryLow), + ("Low 54-69", colorLow), + ("In Range 70-180", colorInRange), + ("High 181-250", colorHigh), + ("Very High >250", colorVeryHigh), + ] + let itemW = barW / CGFloat(legendItems.count) + for (i, item) in legendItems.enumerated() { + let ix = margin + CGFloat(i) * itemW + ctx.setFillColor(item.1.cgColor) + ctx.fill(CGRect(x: ix, y: legendY, width: 8, height: 8)) + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7), + .foregroundColor: UIColor.secondaryLabel, + ] + (item.0 as NSString).draw(at: CGPoint(x: ix + 10, y: legendY), withAttributes: attrs) + } + + return legendY + 16 + } + + // MARK: - TIR table + + @discardableResult + private static func drawTIRTable(tirData: [TIRDataPoint], y: CGFloat, in _: CGRect, ctx: CGContext) -> CGFloat { + let cols: [CGFloat] = [100, 100, 70, 80, 70] + let headers = ["Zone", "Range", "Your %", "ADA Target", "Status"] + let rows: [(String, String, Double, String, Bool)] = [ + ("Very Low", "< 54 mg/dL", tirData.first(where: { $0.period == .average })?.veryLow ?? 0, "< 1%", (tirData.first(where: { $0.period == .average })?.veryLow ?? 99) < 1), + ("Low", "54–69 mg/dL", tirData.first(where: { $0.period == .average })?.low ?? 0, "< 4%", (tirData.first(where: { $0.period == .average })?.low ?? 99) < 4), + ("In Range", "70–180 mg/dL", tirData.first(where: { $0.period == .average })?.inRange ?? 0, "> 70%", (tirData.first(where: { $0.period == .average })?.inRange ?? 0) >= 70), + ("High", "181–250 mg/dL", tirData.first(where: { $0.period == .average })?.high ?? 0, "< 25%", (tirData.first(where: { $0.period == .average })?.high ?? 99) < 25), + ("Very High", "> 250 mg/dL", tirData.first(where: { $0.period == .average })?.veryHigh ?? 0, "< 5%", (tirData.first(where: { $0.period == .average })?.veryHigh ?? 99) < 5), + ] + let rowColors = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] + + let rowH: CGFloat = 16 + var curY = y + var curX = margin + + // Header row + ctx.setFillColor(colorDark.cgColor) + let totalW = cols.reduce(0, +) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + for (i, h) in headers.enumerated() { + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 8), + .foregroundColor: UIColor.white, + ] + (h as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) + curX += cols[i] + } + curY += rowH + + // Data rows + for (ri, row) in rows.enumerated() { + ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + + // Zone color swatch in first cell + ctx.setFillColor(rowColors[ri].cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: cols[0], height: rowH)) + + let cells = [row.0, row.1, String(format: "%.1f%%", row.2), row.3, row.4 ? "✓" : "↑"] + curX = margin + for (ci, cell) in cells.enumerated() { + let attrs: [NSAttributedString.Key: Any] = [ + .font: ci == 0 ? UIFont.boldSystemFont(ofSize: 8) : UIFont.systemFont(ofSize: 8), + .foregroundColor: ci == 0 ? UIColor.white : colorDark, + ] + (cell as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) + curX += cols[ci] + } + + // Border + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.3) + ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + curY += rowH + } + + return curY + 12 + } + + // MARK: - AGP chart (drawn natively with Core Graphics) + + @discardableResult + private static func drawAGPChart(agpData: [AGPDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { + guard !agpData.isEmpty else { return y } + + let chartW: CGFloat = pageRect.width - margin * 2 + let chartH: CGFloat = 140 + let chartX: CGFloat = margin + let chartY: CGFloat = y + + // Background + ctx.setFillColor(UIColor(white: 0.98, alpha: 1).cgColor) + ctx.fill(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + + // Target zones + let yRange: CGFloat = 350 // 40–400 + let yMin: CGFloat = 40 + func glucoseToY(_ g: Double) -> CGFloat { + chartY + chartH - (CGFloat(g) - yMin) / yRange * chartH + } + func timeToX(_ minutes: Int) -> CGFloat { + chartX + CGFloat(minutes) / (24 * 60) * chartW + } + + // Very low zone + ctx.setFillColor(colorVeryLow.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: chartX, y: glucoseToY(54), width: chartW, height: glucoseToY(40) - glucoseToY(54))) + + // Low zone + ctx.setFillColor(colorLow.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: chartX, y: glucoseToY(70), width: chartW, height: glucoseToY(54) - glucoseToY(70))) + + // High zone + ctx.setFillColor(colorHigh.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: chartX, y: glucoseToY(250), width: chartW, height: glucoseToY(180) - glucoseToY(250))) + + // Target lines + ctx.setStrokeColor(colorLow.withAlphaComponent(0.6).cgColor) + ctx.setLineWidth(0.8) + ctx.setLineDash(phase: 0, lengths: [4, 3]) + ctx.move(to: CGPoint(x: chartX, y: glucoseToY(70))) + ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(70))) + ctx.strokePath() + + ctx.setStrokeColor(colorHigh.withAlphaComponent(0.6).cgColor) + ctx.move(to: CGPoint(x: chartX, y: glucoseToY(180))) + ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(180))) + ctx.strokePath() + ctx.setLineDash(phase: 0, lengths: []) + + // 5–95 band + let p5Path = CGMutablePath() + let p95Path = CGMutablePath() + var bandPath = CGMutablePath() + + for (i, pt) in agpData.enumerated() { + let x = timeToX(pt.timeOfDay) + let y5 = glucoseToY(pt.p5) + let y95 = glucoseToY(pt.p95) + if i == 0 { + p5Path.move(to: CGPoint(x: x, y: y5)) + p95Path.move(to: CGPoint(x: x, y: y95)) + bandPath.move(to: CGPoint(x: x, y: y95)) + } else { + p5Path.addLine(to: CGPoint(x: x, y: y5)) + p95Path.addLine(to: CGPoint(x: x, y: y95)) + bandPath.addLine(to: CGPoint(x: x, y: y95)) + } + } + // Close band with p5 reversed + for pt in agpData.reversed() { + bandPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p5))) + } + bandPath.closeSubpath() + ctx.setFillColor(colorBlue.withAlphaComponent(0.12).cgColor) + ctx.addPath(bandPath) + ctx.fillPath() + + // 25–75 band + var iqrPath = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let x = timeToX(pt.timeOfDay) + if i == 0 { iqrPath.move(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } + else { iqrPath.addLine(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } + } + for pt in agpData.reversed() { + iqrPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p25))) + } + iqrPath.closeSubpath() + ctx.setFillColor(colorBlue.withAlphaComponent(0.25).cgColor) + ctx.addPath(iqrPath) + ctx.fillPath() + + // Median line + ctx.setStrokeColor(colorBlue.cgColor) + ctx.setLineWidth(1.8) + var first = true + for pt in agpData { + let pt2D = CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p50)) + if first { ctx.move(to: pt2D); first = false } + else { ctx.addLine(to: pt2D) } + } + ctx.strokePath() + + // X axis labels + let axisAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7), + .foregroundColor: UIColor.secondaryLabel, + ] + for h in stride(from: 0, through: 24, by: 3) { + let lbl = String(format: "%02d:00", h) + let lx = timeToX(h * 60) + let lsize = (lbl as NSString).size(withAttributes: axisAttrs) + (lbl as NSString).draw(at: CGPoint(x: lx - lsize.width / 2, y: chartY + chartH + 2), withAttributes: axisAttrs) + + // Vertical grid line + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: lx, y: chartY)) + ctx.addLine(to: CGPoint(x: lx, y: chartY + chartH)) + ctx.strokePath() + } + + // Y axis labels + for bg in [54, 70, 140, 180, 250, 350] { + let ly = glucoseToY(Double(bg)) + let lbl = "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axisAttrs) + (lbl as NSString).draw(at: CGPoint(x: chartX - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axisAttrs) + } + + // Border + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + + // Legend + let lgY = chartY + chartH + 12 + let legendItems: [(String, UIColor, Bool)] = [ + ("Median", colorBlue, false), + ("25–75th %ile", colorBlue.withAlphaComponent(0.4), true), + ("5–95th %ile", colorBlue.withAlphaComponent(0.18), true), + ] + var lgX = chartX + for item in legendItems { + ctx.setFillColor(item.1.cgColor) + ctx.fill(CGRect(x: lgX, y: lgY, width: item.2 ? 14 : 20, height: item.2 ? 8 : 2.5)) + if !item.2 { + // line for median + ctx.setFillColor(item.1.cgColor) + ctx.fill(CGRect(x: lgX, y: lgY + 2, width: 20, height: 2)) + } + let lgAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + let lsz = (item.0 as NSString).size(withAttributes: lgAttrs) + (item.0 as NSString).draw(at: CGPoint(x: lgX + (item.2 ? 18 : 24), y: lgY), withAttributes: lgAttrs) + lgX += lsz.width + (item.2 ? 18 : 24) + 16 + } + + return lgY + 20 + } + + // MARK: - Daily summary table + + @discardableResult + private static func drawDailyTable(bgData: [ShareGlucoseData], y: CGFloat, in _: CGRect, ctx: CGContext) -> CGFloat { + // Group by day + let calendar = dateTimeUtils.displayCalendar() + var byDay: [String: [Double]] = [:] + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd" + + for r in bgData { + let d = df.string(from: Date(timeIntervalSince1970: r.date)) + byDay[d, default: []].append(Double(r.sgv)) + } + + let cols: [CGFloat] = [90, 60, 50, 50, 55, 60, 60] + let headers = ["Date", "Avg", "SD", "Min", "Max", "TIR %", "Readings"] + let totalW = cols.reduce(0, +) + let rowH: CGFloat = 14 + var curY = y + var curX = margin + + // Header + ctx.setFillColor(colorDark.cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + for (i, h) in headers.enumerated() { + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 7.5), + .foregroundColor: UIColor.white, + ] + (h as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: attrs) + curX += cols[i] + } + curY += rowH + + let dayAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: colorDark, + ] + let df2 = DateFormatter() + df2.dateFormat = "EEE MMM d" + + for (ri, day) in byDay.keys.sorted().enumerated() { + let vals = byDay[day]! + let n = Double(vals.count) + let mean = vals.reduce(0, +) / n + let sd = sqrt(vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n) + let tir = vals.filter { $0 >= 70 && $0 <= 180 }.count + let tirPct = Double(tir) / n * 100 + + let date = df.date(from: day) ?? Date() + let cells = [ + df2.string(from: date), + String(format: "%.0f", mean), + String(format: "%.0f", sd), + String(format: "%.0f", vals.min() ?? 0), + String(format: "%.0f", vals.max() ?? 0), + String(format: "%.0f%%", tirPct), + "\(vals.count)", + ] + + ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) + ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + + curX = margin + for (ci, cell) in cells.enumerated() { + (cell as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: dayAttrs) + curX += cols[ci] + } + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.3) + ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) + curY += rowH + } + + return curY + 14 + } + + // MARK: - Insulin & carb summary + + @discardableResult + private static func drawInsulinCarbSummary( + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + stats: SimpleStats, + y: CGFloat, + in pageRect: CGRect, + ctx: CGContext + ) -> CGFloat { + let days = max(stats.sensorPct / 100 * 14, 1) + let totalBolus = boluses.map { $0.value }.reduce(0, +) + + smbs.map { $0.value }.reduce(0, +) + let totalCarbs = carbs.map { $0.value }.reduce(0, +) + + let cards: [(String, String, String)] = [ + ("Avg Daily Bolus", String(format: "%.1f U", totalBolus / days), "Total \(String(format: "%.1f", totalBolus)) U"), + ("Bolus Count", "\(boluses.count + smbs.count)", "Over report period"), + ("Avg Daily Carbs", String(format: "%.0f g", totalCarbs / days), "Total \(String(format: "%.0f", totalCarbs)) g"), + ("Carb Entries", "\(carbs.count)", "Logged entries"), + ] + + let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) + let cardH: CGFloat = 48 + + for (i, card) in cards.enumerated() { + let cardX = margin + CGFloat(i) * cardW + let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) + ctx.setFillColor(colorLightGray.cgColor) + ctx.fill(rect) + ctx.setStrokeColor(colorBorder.cgColor) + ctx.setLineWidth(0.5) + ctx.stroke(rect) + + let valAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 14), + .foregroundColor: colorDark, + ] + let valSz = (card.1 as NSString).size(withAttributes: valAttrs) + (card.1 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - valSz.width) / 2, y: y + 6), withAttributes: valAttrs) + + let lblAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 7.5), + .foregroundColor: UIColor.secondaryLabel, + ] + let lsz = (card.0 as NSString).size(withAttributes: lblAttrs) + (card.0 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - lsz.width) / 2, y: y + 24), withAttributes: lblAttrs) + + let ssz = (card.2 as NSString).size(withAttributes: lblAttrs) + (card.2 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - ssz.width) / 2, y: y + 34), withAttributes: lblAttrs) + } + + return y + cardH + 14 + } +} diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift new file mode 100644 index 000000000..79e1ef4be --- /dev/null +++ b/LoopFollow/Stats/EndoReportView.swift @@ -0,0 +1,222 @@ +// LoopFollow +// EndoReportView.swift + +import SwiftUI + +struct EndoReportView: View { + let dataService: StatsDataService + + @Environment(\.dismiss) private var dismiss + + // Patient info — persisted in UserDefaults so they don't retype each time + @AppStorage("endoReport.patientName") private var patientName = "" + @AppStorage("endoReport.dateOfBirth") private var dateOfBirth = "" + @AppStorage("endoReport.providerName") private var providerName = "" + + // Date range defaults to last 14 days + @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -14, to: Date()) ?? Date() + @State private var endDate: Date = .init() + + @State private var isGenerating = false + @State private var reportURL: URL? + @State private var errorMessage: String? + @State private var showShareSheet = false + @State private var showingDates = false + + var body: some View { + NavigationView { + Form { + // ── Patient info ───────────────────────────────────────── + Section(header: Text("Patient Information") + .font(.caption).textCase(.uppercase)) + { + HStack { + Text("Name") + .foregroundColor(.secondary) + .frame(width: 90, alignment: .leading) + TextField("Full name", text: $patientName) + } + HStack { + Text("Date of Birth") + .foregroundColor(.secondary) + .frame(width: 90, alignment: .leading) + TextField("MM/DD/YYYY", text: $dateOfBirth) + .keyboardType(.numbersAndPunctuation) + } + HStack { + Text("Provider") + .foregroundColor(.secondary) + .frame(width: 90, alignment: .leading) + TextField("Dr. Name", text: $providerName) + } + } + + // ── Date range ─────────────────────────────────────────── + Section(header: Text("Report Period") + .font(.caption).textCase(.uppercase)) + { + // Quick presets + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(presets, id: \.label) { preset in + Button(preset.label) { + withAnimation { + startDate = preset.start + endDate = preset.end + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(isActivePreset(preset) ? .blue : .secondary) + } + } + .padding(.vertical, 4) + } + + DatePicker("Start", selection: $startDate, + in: ...endDate, + displayedComponents: .date) + DatePicker("End", selection: $endDate, + in: startDate..., + displayedComponents: .date) + } + + // ── What's included ────────────────────────────────────── + Section(header: Text("Report Includes") + .font(.caption).textCase(.uppercase)) + { + Label("eA1C / GMI estimate", systemImage: "drop.fill") + Label("Time in Range distribution", systemImage: "chart.bar.fill") + Label("Ambulatory Glucose Profile", systemImage: "waveform.path.ecg") + Label("Daily glucose statistics", systemImage: "calendar") + Label("Insulin & carb summary", systemImage: "syringe.fill") + } + .foregroundColor(.secondary) + .font(.subheadline) + + // ── Error message ──────────────────────────────────────── + if let error = errorMessage { + Section { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.subheadline) + } + } + + // ── Generate button ────────────────────────────────────── + Section { + Button(action: generateReport) { + HStack { + Spacer() + if isGenerating { + ProgressView() + .padding(.trailing, 8) + Text("Generating…") + .fontWeight(.semibold) + } else { + Image(systemName: "doc.richtext") + .padding(.trailing, 4) + Text("Generate PDF Report") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(isGenerating) + .foregroundColor(.white) + .listRowBackground(isGenerating ? Color.blue.opacity(0.5) : Color.blue) + } + } + .navigationTitle("Endo Report") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + } + .sheet(isPresented: $showShareSheet) { + if let url = reportURL { + ShareSheet(items: [url]) + } + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + // MARK: - Presets + + private struct DatePreset { + let label: String + let start: Date + let end: Date + } + + private var presets: [DatePreset] { + let now = Date() + let cal = Calendar.current + return [ + DatePreset(label: "7 days", + start: cal.date(byAdding: .day, value: -7, to: now) ?? now, end: now), + DatePreset(label: "14 days", + start: cal.date(byAdding: .day, value: -14, to: now) ?? now, end: now), + DatePreset(label: "30 days", + start: cal.date(byAdding: .day, value: -30, to: now) ?? now, end: now), + DatePreset(label: "90 days", + start: cal.date(byAdding: .day, value: -90, to: now) ?? now, end: now), + ] + } + + private func isActivePreset(_ preset: DatePreset) -> Bool { + let cal = Calendar.current + return cal.isDate(preset.start, inSameDayAs: startDate) && + cal.isDate(preset.end, inSameDayAs: endDate) + } + + // MARK: - Generate + + private func generateReport() { + errorMessage = nil + isGenerating = true + + // Update the data service date range to match what the user picked + dataService.updateDateRange(start: startDate, end: endDate) + + // Fetch data if needed, then generate + dataService.ensureDataAvailable(onProgress: {}) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let url = try EndoReportGenerator.generate( + patientName: patientName, + dateOfBirth: dateOfBirth, + providerName: providerName, + startDate: startDate, + endDate: endDate, + dataService: dataService + ) + DispatchQueue.main.async { + isGenerating = false + reportURL = url + showShareSheet = true + } + } catch { + DispatchQueue.main.async { + isGenerating = false + errorMessage = error.localizedDescription + } + } + } + } + } +} + +// MARK: - Share sheet wrapper + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} From e5065eabdec951f6d0c7e06425d7e7c320719b7f Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Sat, 23 May 2026 10:14:29 -0700 Subject: [PATCH 2/7] Update PDF Layout --- LoopFollow/Stats/EndoReportGenerator-3.swift | 820 +++++++++++++++++++ LoopFollow/Stats/EndoReportGenerator.swift | 763 ----------------- 2 files changed, 820 insertions(+), 763 deletions(-) create mode 100644 LoopFollow/Stats/EndoReportGenerator-3.swift delete mode 100644 LoopFollow/Stats/EndoReportGenerator.swift diff --git a/LoopFollow/Stats/EndoReportGenerator-3.swift b/LoopFollow/Stats/EndoReportGenerator-3.swift new file mode 100644 index 000000000..3642a1051 --- /dev/null +++ b/LoopFollow/Stats/EndoReportGenerator-3.swift @@ -0,0 +1,820 @@ +// LoopFollow +// EndoReportGenerator-3.swift + +import PDFKit +import UIKit + +enum EndoReportGenerator { + // MARK: - Entry point + + static func generate( + patientName: String, + dateOfBirth: String, + providerName: String, + startDate: Date, + endDate: Date, + dataService: StatsDataService + ) throws -> URL { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { throw ReportError.noData } + + let agpData = AGPCalculator.calculate(bgData: bgData) + let tirData = TIRCalculator.calculate(bgData: bgData) + let stats = SimpleStats(bgData: bgData, dataService: dataService) + let patterns = GlycemicPatterns(bgData: bgData) + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + let basals = dataService.getBasalData() + let simpleVM = SimpleStatsViewModel(dataService: dataService) + simpleVM.calculateStats() + + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) + let renderer = UIGraphicsPDFRenderer(bounds: pageRect) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") + + let data = renderer.pdfData { ctx in + // Page 1 — Summary dashboard + ctx.beginPage() + drawSummaryPage( + ctx: ctx.cgContext, r: pageRect, + patientName: patientName, dateOfBirth: dateOfBirth, + providerName: providerName, startDate: startDate, endDate: endDate, + bgData: bgData, agpData: agpData, tirData: tirData, + stats: stats, patterns: patterns, + boluses: boluses, smbs: smbs, carbs: carbs, + simpleVM: simpleVM + ) + + // Page 2 — Daily breakdowns + ctx.beginPage() + drawDailyPage( + ctx: ctx.cgContext, r: pageRect, + patientName: patientName, startDate: startDate, endDate: endDate, + bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, + stats: stats + ) + } + try data.write(to: url) + return url + } + + enum ReportError: LocalizedError { + case noData + var errorDescription: String? { "No CGM data available for the selected date range." } + } + + // MARK: - Palette + + private static let C_TEAL = UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) + private static let C_TEAL_DARK = UIColor(red: 0.090, green: 0.420, blue: 0.460, alpha: 1) + private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) + private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) + private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) + private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) + private static let C_WHITE = UIColor.white + private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) + private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) + private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) + private static let C_TIGHT = UIColor(red: 0.140, green: 0.780, blue: 0.580, alpha: 1) + private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) + private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) + private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 1) + private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.7) + + // MARK: - Data models + + struct SimpleStats { + let avg, stdDev, cv, eA1C, minBG, maxBG, sensorPct, tir, tightTIR, days: Double + let readingCount: Int + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let v = bgData.map { Double($0.sgv) } + let n = Double(v.count) + let m = v.reduce(0,+) / n + let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n + avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 + minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count + days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) + tir = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 + } + } + + struct GlycemicPatterns { + struct P { let label: String; let avg: Double; let count: Int } + let night, earlyAM, morning, afternoon, evening, late: P + init(bgData: [ShareGlucoseData]) { + func p(_ l: String, _ s: Int, _ e: Int) -> P { + let cal = dateTimeUtils.displayCalendar() + let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } + return P(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) + } + night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) + afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) + } + } + + // MARK: - Page 1 + + private static func drawSummaryPage( + ctx: CGContext, r: CGRect, + patientName: String, dateOfBirth: String, providerName: String, + startDate: Date, endDate: Date, + bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], tirData: [TIRDataPoint], + stats: SimpleStats, patterns: GlycemicPatterns, + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + simpleVM: SimpleStatsViewModel + ) { + let m: CGFloat = 30 + var y = drawHero(ctx: ctx, r: r, patientName: patientName, providerName: providerName, + dateOfBirth: dateOfBirth, startDate: startDate, endDate: endDate, stats: stats) + + y = sectionHdr("GLUCOSE SUMMARY", y: y + 12, m: m, w: r.width, ctx: ctx) + + // 6 stat cards left + TIR bar right + let gridW = r.width - m * 2 - 162 + let cw = gridW / 2 - 3 + let ch: CGFloat = 46 + let cards: [(String, String, Bool)] = [ + ("TIME IN RANGE", String(format: "%.0f%%", stats.tir), true), + ("GMI (EST. A1C)", String(format: "%.1f%%", stats.eA1C), false), + ("AVERAGE", String(format: "%.0f", stats.avg), false), + ("STD DEVIATION", String(format: "%.0f", stats.stdDev), false), + ("CV", String(format: "%.0f%%", stats.cv), false), + ("READINGS", "\(stats.readingCount)", false), + ] + var gy = y + 6 + for (i, card) in cards.enumerated() { + let col = CGFloat(i % 2); let row = CGFloat(i / 2) + statCard(card.0, val: card.1, x: m + col * (cw + 6), y: gy + row * (ch + 4), + w: cw, h: ch, accent: card.2, ctx: ctx) + } + let tirBarH = ch * 3 + 8 + drawTIRBar(tirData: tirData, stats: stats, + x: m + gridW + 12, y: y + 6, w: 150, h: tirBarH, ctx: ctx) + y = gy + CGFloat(3) * (ch + 4) + 8 + + // Time-of-day strip + y = timeOfDayStrip(patterns: patterns, y: y + 4, m: m, w: r.width, ctx: ctx) + + // Insulin + let hasInsulin = !boluses.isEmpty || !smbs.isEmpty + if hasInsulin { + y = sectionHdr("INSULIN DELIVERY", y: y + 10, m: m, w: r.width, ctx: ctx) + y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, + stats: stats, y: y + 4, m: m, w: r.width, ctx: ctx) + } + + // Nutrition + if !carbs.isEmpty { + y = sectionHdr("NUTRITION & MEALS", y: y + 8, m: m, w: r.width, ctx: ctx) + y = nutritionSection(carbs: carbs, stats: stats, y: y + 4, m: m, w: r.width, ctx: ctx) + } + + // AGP strip — clean, fixed height + let agpH: CGFloat = 110 + if !agpData.isEmpty, y + agpH + 50 < r.height { + y = sectionHdr("AMBULATORY GLUCOSE PROFILE (AGP)", y: y + 10, m: m, w: r.width, ctx: ctx) + drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, ctx: ctx) + y += agpH + 18 + } + + footer(ctx: ctx, r: r, startDate: startDate, endDate: endDate, stats: stats, page: 1) + } + + // MARK: - Page 2 — Daily breakdowns + + private static func drawDailyPage( + ctx: CGContext, r: CGRect, + patientName: String, startDate: Date, endDate: Date, + bgData: [ShareGlucoseData], + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + basals: [MainViewController.basalGraphStruct], + stats: SimpleStats + ) { + let m: CGFloat = 30 + var y = drawPageHeader(ctx: ctx, r: r, patientName: patientName, + startDate: startDate, endDate: endDate) + y = sectionHdr("DAILY GLUCOSE BREAKDOWN", y: y + 10, m: m, w: r.width, ctx: ctx) + + // Group BG data by local day + let cal = dateTimeUtils.displayCalendar() + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + var bgByDay: [String: [ShareGlucoseData]] = [:] + for reading in bgData { + let key = df.string(from: Date(timeIntervalSince1970: reading.date)) + bgByDay[key, default: []].append(reading) + } + + // Group boluses by day + var bolusByDay: [String: [MainViewController.bolusGraphStruct]] = [:] + for b in boluses + smbs { + let key = df.string(from: Date(timeIntervalSince1970: b.date)) + bolusByDay[key, default: []].append(b) + } + + // Group basals by day + var basalByDay: [String: [MainViewController.basalGraphStruct]] = [:] + for b in basals { + let key = df.string(from: Date(timeIntervalSince1970: b.date)) + basalByDay[key, default: []].append(b) + } + + let sortedDays = bgByDay.keys.sorted() + let rowH: CGFloat = 82 + let rowGap: CGFloat = 8 + let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d" + + for (i, day) in sortedDays.enumerated() { + // Start new page if needed + if y + rowH + rowGap > r.height - 40 { + footer(ctx: ctx, r: r, startDate: startDate, endDate: endDate, stats: stats, page: 2 + i / 8) + // (caller would need to beginPage — in practice all days fit on 1-2 pages) + break + } + + let dayReadings = bgByDay[day] ?? [] + let dayBoluses = bolusByDay[day] ?? [] + let dayBasals = basalByDay[day] ?? [] + let date = df.date(from: day) ?? Date() + + drawDayRow( + ctx: ctx, x: m, y: y, w: r.width - m * 2, h: rowH, + label: df2.string(from: date), + readings: dayReadings, + boluses: dayBoluses, + basals: dayBasals + ) + y += rowH + rowGap + } + + footer(ctx: ctx, r: r, startDate: startDate, endDate: endDate, stats: stats, page: 2) + } + + // MARK: - Day row (mini chart) + + private static func drawDayRow( + ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + label: String, + readings: [ShareGlucoseData], + boluses: [MainViewController.bolusGraphStruct], + basals: [MainViewController.basalGraphStruct] + ) { + // Card background + ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) + + // Left teal accent + ctx.setFillColor(C_TEAL.cgColor) + ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + + // Day label + let dlA: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 9), + .foregroundColor: C_INK, + ] + label.draw(at: CGPoint(x: x + 10, y: y + 6), withAttributes: dlA) + + // Quick stats on the right side + let vals = readings.map { Double($0.sgv) } + if !vals.isEmpty { + let n = Double(vals.count) + let avg = vals.reduce(0,+) / n + let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + let lo = vals.filter { $0 < 70 }.count + let hi = vals.filter { $0 > 180 }.count + + let statW: CGFloat = 90 + let statX = x + w - statW - 8 + + let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + let sv: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_INK] + let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW + let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: tirC] + + "Avg".draw(at: CGPoint(x: statX, y: y + 8), withAttributes: sa) + "TIR".draw(at: CGPoint(x: statX + 30, y: y + 8), withAttributes: sa) + "Lo".draw(at: CGPoint(x: statX + 60, y: y + 8), withAttributes: sa) + "Hi".draw(at: CGPoint(x: statX + 76, y: y + 8), withAttributes: sa) + + String(format: "%.0f", avg).draw(at: CGPoint(x: statX, y: y + 18), withAttributes: sv) + String(format: "%.0f%%", tir).draw(at: CGPoint(x: statX + 28, y: y + 18), withAttributes: tirA) + "\(lo)".draw(at: CGPoint(x: statX + 60, y: y + 18), withAttributes: sa) + "\(hi)".draw(at: CGPoint(x: statX + 76, y: y + 18), withAttributes: sa) + } + + // Chart area + let chartX: CGFloat = x + 10 + let chartW: CGFloat = w - 110 + let chartY: CGFloat = y + 22 + let chartH: CGFloat = h - 28 + + guard !readings.isEmpty else { return } + + // BG range for this day + let bgMin: CGFloat = 40 + let bgMax: CGFloat = 320 + let bgRange = bgMax - bgMin + + func gY(_ bg: Double) -> CGFloat { + chartY + chartH - (CGFloat(bg) - bgMin) / bgRange * chartH + } + func tX(_ ts: Double) -> CGFloat { + // Map 00:00–24:00 to chartX..chartX+chartW + let cal = dateTimeUtils.displayCalendar() + let date = Date(timeIntervalSince1970: ts) + let comps = cal.dateComponents([.hour, .minute], from: date) + let minuteOfDay = Double((comps.hour ?? 0) * 60 + (comps.minute ?? 0)) + return chartX + CGFloat(minuteOfDay / (24 * 60)) * chartW + } + + // Target zone + ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) + ctx.fill(CGRect(x: chartX, y: gY(180), width: chartW, height: gY(70) - gY(180))) + + // Target lines + ctx.setLineDash(phase: 0, lengths: [3, 2]) + ctx.setLineWidth(0.5) + ctx.setStrokeColor(C_LOW.withAlphaComponent(0.5).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gY(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gY(70))); ctx.strokePath() + ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.5).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gY(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gY(180))); ctx.strokePath() + ctx.setLineDash(phase: 0, lengths: []) + + // Hour grid lines (subtle) + ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.6).cgColor); ctx.setLineWidth(0.3) + for h2 in stride(from: 3, through: 21, by: 3) { + let hx = chartX + CGFloat(h2) / 24 * chartW + ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() + } + + // Basal line (blue, bottom portion) + if !basals.isEmpty { + let basalH: CGFloat = chartH * 0.25 + let basalY = chartY + chartH - basalH + let maxBasal = basals.map { $0.basalRate }.max() ?? 1.0 + let sorted = basals.sorted { $0.date < $1.date } + + ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(1.0) + ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor) + + var path = CGMutablePath() + var first = true + for pt in sorted { + let px = tX(pt.date) + let py = basalY + basalH - CGFloat(pt.basalRate / maxBasal) * basalH + if first { path.move(to: CGPoint(x: px, y: py)); first = false } + else { path.addLine(to: CGPoint(x: px, y: py)) } + } + // Close fill path along bottom + if let last = sorted.last { + let lx = tX(last.date) + path.addLine(to: CGPoint(x: lx, y: basalY + basalH)) + path.addLine(to: CGPoint(x: chartX, y: basalY + basalH)) + path.closeSubpath() + ctx.addPath(path); ctx.fillPath() + } + // Stroke line + var linePath = CGMutablePath(); first = true + for pt in sorted { + let px = tX(pt.date) + let py = basalY + basalH - CGFloat(pt.basalRate / maxBasal) * basalH + if first { linePath.move(to: CGPoint(x: px, y: py)); first = false } + else { linePath.addLine(to: CGPoint(x: px, y: py)) } + } + ctx.addPath(linePath); ctx.strokePath() + } + + // Bolus bars + for bolus in boluses { + let bx = tX(bolus.date) + let maxUnits: Double = 15 + let barH2 = CGFloat(Swift.min(bolus.value / maxUnits, 1.0)) * (chartH * 0.4) + let barW: CGFloat = 2.5 + ctx.setFillColor(C_BOLUS.withAlphaComponent(0.8).cgColor) + ctx.fill(CGRect(x: bx - barW / 2, y: chartY + chartH - barH2, width: barW, height: barH2)) + } + + // BG dots + let sorted = readings.sorted { $0.date < $1.date } + for reading in sorted { + let rx = tX(reading.date) + let ry = gY(Double(reading.sgv)) + let bg = Double(reading.sgv) + let dotColor: UIColor + switch bg { + case ..<54: dotColor = C_VLOW + case ..<70: dotColor = C_LOW + case ...180: dotColor = C_IN + case ...250: dotColor = C_HIGH + default: dotColor = C_VHIGH + } + ctx.setFillColor(dotColor.cgColor) + ctx.fillEllipse(in: CGRect(x: rx - 1.5, y: ry - 1.5, width: 3, height: 3)) + } + + // X-axis hour labels (just a few) + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_SLATE] + for h2 in [0, 6, 12, 18, 24] { + let hx = chartX + CGFloat(h2) / 24 * chartW + let lbl = String(format: "%02d", h2) + let sz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) + } + + // Clip to card area + ctx.resetClip() + } + + // MARK: - Hero header + + @discardableResult + private static func drawHero(ctx: CGContext, r: CGRect, + patientName: String, providerName: String, + dateOfBirth _: String, startDate: Date, endDate: Date, + stats: SimpleStats) -> CGFloat + { + let h: CGFloat = 96 + ctx.setFillColor(C_TEAL.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + ctx.setFillColor(C_TEAL_DARK.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 20)) + + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 1.8] + "LOOPFOLLOW".draw(at: CGPoint(x: 32, y: 5), withAttributes: a1) + + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] + "Endocrinologist Visit Report".draw(at: CGPoint(x: 32, y: 22), withAttributes: a2) + + let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] + "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 32, y: 50), withAttributes: a3) + + let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" + let ds = "\(df.string(from: startDate)) — \(df.string(from: endDate)) (\(Int(stats.days.rounded())) Days)" + let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] + ds.draw(at: CGPoint(x: 32, y: 66), withAttributes: a4) + + let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] + var lines: [String] = [] + if !patientName.isEmpty { lines.append("Patient: \(patientName)") } + if !providerName.isEmpty { lines.append("Provider: \(providerName)") } + for (i, l) in lines.enumerated() { + let sz = (l as NSString).size(withAttributes: a5) + (l as NSString).draw(at: CGPoint(x: r.width - 34 - sz.width, y: 24 + CGFloat(i) * 15), withAttributes: a5) + } + return h + } + + // MARK: - Page 2 header + + @discardableResult + private static func drawPageHeader(ctx: CGContext, r: CGRect, + patientName: String, startDate: Date, endDate: Date) -> CGFloat + { + let h: CGFloat = 36 + ctx.setFillColor(C_TEAL.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] + "Endocrinologist Visit Report".draw(at: CGPoint(x: 32, y: 10), withAttributes: a) + let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" + let sub = "\(patientName) • \(df.string(from: startDate)) – \(df.string(from: endDate))" + let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] + let ssz = (sub as NSString).size(withAttributes: sa) + (sub as NSString).draw(at: CGPoint(x: r.width - 32 - ssz.width, y: 12), withAttributes: sa) + return h + } + + // MARK: - Section header + + @discardableResult + private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat { + ctx.setFillColor(C_TEAL.cgColor) + ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_TEAL, .kern: 0.6] + (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() + return y + 19 + } + + // MARK: - Stat card + + private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, accent: Bool, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + if accent { + ctx.setFillColor(C_TEAL.withAlphaComponent(0.07).cgColor); ctx.fill(r) + ctx.setFillColor(C_TEAL.cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + } + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 19), .foregroundColor: accent ? C_TEAL : C_INK] + (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: la) + (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 16), withAttributes: va) + } + + // MARK: - TIR vertical bar + + private static func drawTIRBar(tirData: [TIRDataPoint], stats: SimpleStats, + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, ctx: CGContext) + { + guard let avg = tirData.first(where: { $0.period == .average }) else { return } + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + + let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) + + let barX = x + 10; let barW: CGFloat = 16 + let barY = y + 20; let barH = h - 38 + + let segs: [(Double, UIColor, String)] = [ + (avg.veryHigh, C_VHIGH, "Very High"), + (avg.high, C_HIGH, "High"), + (avg.inRange, C_IN, "In Range"), + (avg.low, C_LOW, "Low"), + (avg.veryLow, C_VLOW, "Very Low"), + ] + var sy = barY + for (pct, clr, label) in segs { + let sh = CGFloat(pct / 100) * barH + if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: barX, y: sy, width: barW, height: sh)) } + if sh >= 10 { + let ps = String(format: "%.0f%%", pct) + let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: label == "In Range" ? C_TEAL : C_SLATE] + let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + (ps as NSString).draw(at: CGPoint(x: barX + barW + 4, y: sy + sh / 2 - 8), withAttributes: pa) + if sh >= 20 { (label as NSString).draw(at: CGPoint(x: barX + barW + 4, y: sy + sh / 2), withAttributes: sa) } + if label == "In Range", sh >= 30 { + let ts = String(format: "%.0f%% Tight", stats.tightTIR) + let ta2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_TIGHT] + (ts as NSString).draw(at: CGPoint(x: barX + barW + 4, y: sy + sh / 2 + 10), withAttributes: ta2) + } + } + sy += sh + } + let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_SLATE] + "Target: 70-180".draw(at: CGPoint(x: x + 6, y: y + h - 18), withAttributes: na) + "Tight: 70-140".draw(at: CGPoint(x: x + 6, y: y + h - 10), withAttributes: na) + } + + // MARK: - Time-of-day strip + + @discardableResult + private static func timeOfDayStrip(patterns: GlycemicPatterns, y: CGFloat, + m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + "Glucose by Time of Day (mg/dL)".draw(at: CGPoint(x: m, y: y), withAttributes: a) + let periods = [patterns.night, patterns.earlyAM, patterns.morning, + patterns.afternoon, patterns.evening, patterns.late] + let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 42; let cy = y + 13 + for (i, p) in periods.enumerated() { + let cx = m + CGFloat(i) * cw + let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) + guard p.count > 0 else { continue } + let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? C_TEAL : p.avg < 180 ? C_INK : C_HIGH + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 15), .foregroundColor: vc] + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let vs = String(format: "%.0f", p.avg) + let vsz = (vs as NSString).size(withAttributes: va) + let lsz = (p.label as NSString).size(withAttributes: la) + (vs as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) + (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 28), withAttributes: la) + } + return cy + ch + 4 + } + + // MARK: - Insulin section + + @discardableResult + private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + simpleVM: SimpleStatsViewModel, stats _: SimpleStats, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let tdd = simpleVM.totalDailyDose ?? 0 + let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 + let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 + let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), + ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), + ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 42 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 5), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 16), withAttributes: va) + } + let tableY = y + ch + 6 + let total = (boluses + smbs).map { $0.value }.reduce(0,+) + let rows: [(String, String)] = [ + ("Correction Boluses", "\(boluses.count)"), + ("Total Bolus Insulin", String(format: "%.1f U", total)), + ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), + ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), + ("Positive Temp Basal", simpleVM.totalPositiveBasal != nil ? String(format: "+%.2f U/day", simpleVM.totalPositiveBasal!) : "—"), + ("Negative Temp Basal", simpleVM.totalNegativeBasal != nil ? String(format: "%.2f U/day", simpleVM.totalNegativeBasal!) : "—"), + ] + return metricTable(rows, y: tableY, m: m, w: w, ctx: ctx) + } + + // MARK: - Nutrition section + + @discardableResult + private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], + stats: SimpleStats, y: CGFloat, m: CGFloat, w: CGFloat, + ctx: CGContext) -> CGFloat + { + let total = carbs.map { $0.value }.reduce(0,+) + let cards: [(String, String)] = [ + ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), + ("MEALS LOGGED", "\(carbs.count)"), + ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), + ] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 42 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 5), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 16), withAttributes: va) + } + return y + ch + 8 + } + + // MARK: - Metric table + + @discardableResult + private static func metricTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, + w: CGFloat, ctx: CGContext) -> CGFloat + { + let tw = w - m * 2; let hh: CGFloat = 14; let rh: CGFloat = 13; var cy = y + ctx.setFillColor(C_TEAL.withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: hh)) + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: C_TEAL, .kern: 0.4] + "METRIC".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) + "VALUE".draw(at: CGPoint(x: m + tw * 0.65 + 6, y: cy + 3), withAttributes: ha) + cy += hh + for (i, row) in rows.enumerated() { + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_INK] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_TEAL] + (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ka) + (row.1 as NSString).draw(at: CGPoint(x: m + tw * 0.65 + 6, y: cy + 3), withAttributes: va) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: m, y: cy + rh)); ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)); ctx.strokePath() + cy += rh + } + return cy + 6 + } + + // MARK: - AGP strip (clean, no overflow) + + private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, ctx: CGContext) + { + guard !agpData.isEmpty else { return } + // Chart sits inside a padded area — left pad for Y labels, bottom for X labels + let lPad: CGFloat = 28; let bPad: CGFloat = 14 + let cw = w - lPad; let ch = h - bPad + let cx = x + lPad; let cy = y + + // Background + ctx.setFillColor(C_CLOUD.withAlphaComponent(0.5).cgColor) + ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) + + let bgMin: CGFloat = 40; let bgRange: CGFloat = 320 + func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRange * ch } + func tx(_ m2: Int) -> CGFloat { cx + CGFloat(m2) / (24 * 60) * cw } + + // Target zone + ctx.setFillColor(C_IN.withAlphaComponent(0.08).cgColor) + ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) + + // Dashed target lines + ctx.setLineDash(phase: 0, lengths: [3, 2]) + ctx.setLineWidth(0.6) + for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { + ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor) + ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() + } + ctx.setLineDash(phase: 0, lengths: []) + + // 5–95 band + var band = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)) + i == 0 ? band.move(to: p) : band.addLine(to: p) + } + for pt in agpData.reversed() { + band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) + } + band.closeSubpath() + ctx.setFillColor(C_TEAL.withAlphaComponent(0.11).cgColor); ctx.addPath(band); ctx.fillPath() + + // 25–75 IQR + var iqr = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)) + i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) + } + for pt in agpData.reversed() { + iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) + } + iqr.closeSubpath() + ctx.setFillColor(C_TEAL.withAlphaComponent(0.26).cgColor); ctx.addPath(iqr); ctx.fillPath() + + // Median + ctx.setStrokeColor(C_TEAL.cgColor); ctx.setLineWidth(1.6) + var first = true + for pt in agpData { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)) + first ? ctx.move(to: p) : ctx.addLine(to: p); first = false + } + ctx.strokePath() + + // Clip so nothing bleeds outside chart area + ctx.clip(to: CGRect(x: cx, y: cy, width: cw, height: ch)) + ctx.resetClip() + + // Y-axis labels — drawn LEFT of chart, never inside + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + for bg in [70, 140, 180, 250] { + let ly = gy(Double(bg)) + guard ly >= cy, ly <= cy + ch else { continue } + let lbl = "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) + // Subtle grid line + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() + } + + // X-axis labels — drawn BELOW chart, even spacing + for h2 in stride(from: 0, through: 24, by: 3) { + let lx = tx(h2 * 60) + let lbl = String(format: "%02d:00", h2) + let lsz = (lbl as NSString).size(withAttributes: axA) + // Clamp to chart bounds + let drawX = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) + (lbl as NSString).draw(at: CGPoint(x: drawX, y: cy + ch + 2), withAttributes: axA) + // Vertical grid line + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() + } + + // Border + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) + + // Legend — right-aligned, below chart + let lgY = cy + ch + 2 + let lgItems: [(String, UIColor, Bool)] = [("Median", C_TEAL, false), + ("25–75th", C_TEAL.withAlphaComponent(0.4), true), + ("5–95th", C_TEAL.withAlphaComponent(0.18), true)] + var lgX = cx + cw + for item in lgItems.reversed() { + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let lsz = (item.0 as NSString).size(withAttributes: la) + lgX -= lsz.width + (item.0 as NSString).draw(at: CGPoint(x: lgX, y: lgY), withAttributes: la) + lgX -= 16 + if item.2 { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: lgY + 1, width: 12, height: 8)) } + else { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: lgY + 4, width: 12, height: 2)) } + lgX -= 6 + } + } + + // MARK: - Footer + + private static func footer(ctx: CGContext, r: CGRect, + startDate _: Date, endDate _: Date, stats: SimpleStats, page: Int) + { + let fy = r.height - 28 + ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] + let disc = "LoopFollow — for informational purposes only. Not a substitute for professional medical advice." + (disc as NSString).draw(in: CGRect(x: 32, y: fy + 4, width: r.width - 200, height: 20), withAttributes: a) + let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" + let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" + let msz = (meta as NSString).size(withAttributes: a) + (meta as NSString).draw(at: CGPoint(x: r.width - 32 - msz.width, y: fy + 4), withAttributes: a) + } +} diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift deleted file mode 100644 index 69c2bc43d..000000000 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ /dev/null @@ -1,763 +0,0 @@ -// LoopFollow -// EndoReportGenerator.swift - -import PDFKit -import SwiftUI -import UIKit - -enum EndoReportGenerator { - // MARK: - Public entry point - - /// Generates a PDF and returns the file URL, or throws on failure. - static func generate( - patientName: String, - dateOfBirth: String, - providerName: String, - startDate: Date, - endDate: Date, - dataService: StatsDataService - ) throws -> URL { - let bgData = dataService.getBGData() - guard !bgData.isEmpty else { - throw ReportError.noData - } - - let agpData = AGPCalculator.calculate(bgData: bgData) - let tirData = TIRCalculator.calculate(bgData: bgData) - let stats = SimpleStats(bgData: bgData, dataService: dataService) - - let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) // US Letter - let renderer = UIGraphicsPDFRenderer(bounds: pageRect) - - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") - - let data = renderer.pdfData { ctx in - // ── Page 1: Summary + AGP ────────────────────────────────────── - ctx.beginPage() - var cursor = drawHeader( - ctx: ctx.cgContext, - pageRect: pageRect, - patientName: patientName, - dateOfBirth: dateOfBirth, - providerName: providerName, - startDate: startDate, - endDate: endDate - ) - - cursor = drawSectionTitle("Key Metrics", y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawKeyMetrics(stats: stats, y: cursor, in: pageRect, ctx: ctx.cgContext) - - cursor = drawSectionTitle("Time in Range", y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawTIRBar(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawTIRTable(tirData: tirData, y: cursor, in: pageRect, ctx: ctx.cgContext) - - cursor = drawSectionTitle("Ambulatory Glucose Profile (AGP)", y: cursor, in: pageRect, ctx: ctx.cgContext) - cursor = drawAGPChart(agpData: agpData, y: cursor, in: pageRect, ctx: ctx.cgContext) - drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 1) - - // ── Page 2: Daily stats + Insulin/Carbs ─────────────────────── - ctx.beginPage() - var cursor2 = drawPageContinuationHeader(ctx: ctx.cgContext, pageRect: pageRect, - patientName: patientName, - startDate: startDate, endDate: endDate) - - cursor2 = drawSectionTitle("Daily Glucose Summary", y: cursor2, in: pageRect, ctx: ctx.cgContext) - cursor2 = drawDailyTable(bgData: bgData, y: cursor2, in: pageRect, ctx: ctx.cgContext) - - // Insulin & carbs if available - let boluses = dataService.getBolusData() - let smbs = dataService.getSMBData() - let carbs = dataService.getCarbData() - if !boluses.isEmpty || !smbs.isEmpty || !carbs.isEmpty { - cursor2 = drawSectionTitle("Insulin & Carbohydrate Summary", - y: cursor2, in: pageRect, ctx: ctx.cgContext) - cursor2 = drawInsulinCarbSummary(boluses: boluses, smbs: smbs, carbs: carbs, - stats: stats, y: cursor2, - in: pageRect, ctx: ctx.cgContext) - } - - drawFooter(ctx: ctx.cgContext, pageRect: pageRect, page: 2) - } - - try data.write(to: url) - return url - } - - // MARK: - Errors - - enum ReportError: LocalizedError { - case noData - var errorDescription: String? { - switch self { - case .noData: return "No CGM data available for the selected date range." - } - } - } - - // MARK: - Computed stats helper - - struct SimpleStats { - let avg: Double - let stdDev: Double - let cv: Double - let eA1C: Double - let min: Double - let max: Double - let sensorPct: Double - let readingCount: Int - - init(bgData: [ShareGlucoseData], dataService: StatsDataService) { - let vals = bgData.map { Double($0.sgv) } - let n = Double(vals.count) - let mean = vals.reduce(0, +) / n - let variance = vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n - avg = mean - stdDev = sqrt(variance) - cv = stdDev / mean * 100 - eA1C = (mean + 46.7) / 28.7 - min = vals.min() ?? 0 - max = vals.max() ?? 0 - readingCount = vals.count - - let days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 - let expected = days * 288 - sensorPct = Swift.min(Double(vals.count) / expected * 100, 100) - } - } - - // MARK: - Layout constants - - private static let margin: CGFloat = 36 - private static let bodyFont = UIFont.systemFont(ofSize: 9) - private static let labelFont = UIFont.systemFont(ofSize: 8) - private static let boldFont = UIFont.boldSystemFont(ofSize: 9) - private static let titleFont = UIFont.boldSystemFont(ofSize: 11) - private static let sectionFont = UIFont.boldSystemFont(ofSize: 10) - - private static let colorVeryLow = UIColor(red: 0.957, green: 0.263, blue: 0.212, alpha: 1) - private static let colorLow = UIColor(red: 1.000, green: 0.596, blue: 0.000, alpha: 1) - private static let colorInRange = UIColor(red: 0.298, green: 0.686, blue: 0.314, alpha: 1) - private static let colorHigh = UIColor(red: 1.000, green: 0.757, blue: 0.027, alpha: 1) - private static let colorVeryHigh = UIColor(red: 1.000, green: 0.341, blue: 0.133, alpha: 1) - private static let colorBlue = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 1) - private static let colorDark = UIColor(red: 0.110, green: 0.169, blue: 0.227, alpha: 1) - private static let colorLightGray = UIColor(red: 0.957, green: 0.961, blue: 0.976, alpha: 1) - private static let colorBorder = UIColor(red: 0.867, green: 0.890, blue: 0.925, alpha: 1) - - // MARK: - Header / Footer - - @discardableResult - private static func drawHeader( - ctx: CGContext, - pageRect: CGRect, - patientName: String, - dateOfBirth: String, - providerName: String, - startDate: Date, - endDate: Date - ) -> CGFloat { - let headerH: CGFloat = 52 - ctx.setFillColor(colorDark.cgColor) - ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) - - let titleAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 14), - .foregroundColor: UIColor.white, - ] - "Continuous Glucose Monitor Report".draw(at: CGPoint(x: margin, y: 10), withAttributes: titleAttrs) - - let df = DateFormatter() - df.dateFormat = "MMM d, yyyy" - let rangeStr = "\(df.string(from: startDate)) – \(df.string(from: endDate))" - let subAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 9), - .foregroundColor: UIColor(white: 0.8, alpha: 1), - ] - let rangeSize = (rangeStr as NSString).size(withAttributes: subAttrs) - (rangeStr as NSString).draw( - at: CGPoint(x: pageRect.width - margin - rangeSize.width, y: 12), - withAttributes: subAttrs - ) - - // Patient info bar - let infoY: CGFloat = 28 - let infoAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 8.5), - .foregroundColor: UIColor(white: 0.75, alpha: 1), - ] - "Patient: \(patientName)".draw(at: CGPoint(x: margin, y: infoY), withAttributes: infoAttrs) - if !dateOfBirth.isEmpty { - "DOB: \(dateOfBirth)".draw(at: CGPoint(x: margin + 180, y: infoY), withAttributes: infoAttrs) - } - if !providerName.isEmpty { - let provStr = "Provider: \(providerName)" - let provSize = (provStr as NSString).size(withAttributes: infoAttrs) - (provStr as NSString).draw( - at: CGPoint(x: pageRect.width - margin - provSize.width, y: infoY), - withAttributes: infoAttrs - ) - } - - return headerH + 12 - } - - @discardableResult - private static func drawPageContinuationHeader( - ctx: CGContext, pageRect: CGRect, - patientName: String, startDate _: Date, endDate _: Date - ) -> CGFloat { - let headerH: CGFloat = 32 - ctx.setFillColor(colorDark.cgColor) - ctx.fill(CGRect(x: 0, y: 0, width: pageRect.width, height: headerH)) - - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 11), - .foregroundColor: UIColor.white, - ] - "CGM Report — \(patientName)".draw(at: CGPoint(x: margin, y: 9), withAttributes: attrs) - return headerH + 12 - } - - private static func drawFooter(ctx: CGContext, pageRect: CGRect, page: Int) { - let footerY = pageRect.height - 28 - ctx.setFillColor(colorLightGray.cgColor) - ctx.fill(CGRect(x: 0, y: footerY, width: pageRect.width, height: 28)) - - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - "LoopFollow CGM Report | For clinical use only | Targets 70–180 mg/dL" - .draw(at: CGPoint(x: margin, y: footerY + 8), withAttributes: attrs) - - let pageStr = "Page \(page)" - let pageSize = (pageStr as NSString).size(withAttributes: attrs) - (pageStr as NSString).draw( - at: CGPoint(x: pageRect.width - margin - pageSize.width, y: footerY + 8), - withAttributes: attrs - ) - } - - // MARK: - Section title - - @discardableResult - private static func drawSectionTitle(_ title: String, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - let attrs: [NSAttributedString.Key: Any] = [ - .font: sectionFont, - .foregroundColor: colorBlue, - ] - (title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attrs) - - let lineY = y + 14 - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.move(to: CGPoint(x: margin, y: lineY)) - ctx.addLine(to: CGPoint(x: pageRect.width - margin, y: lineY)) - ctx.strokePath() - - return lineY + 8 - } - - // MARK: - Key metrics cards - - @discardableResult - private static func drawKeyMetrics(stats: SimpleStats, y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - let units = Storage.shared.units.value - let isMMOL = units == "mmol/L" - - func fmtGlucose(_ v: Double) -> String { - isMMOL ? String(format: "%.1f", v * 0.0555) : String(format: "%.0f", v) - } - - let cards: [(String, String, String)] = [ - ("eA1C", String(format: "%.1f%%", stats.eA1C), "Estimated A1C"), - ("TIR", { - // grab from TIR calculator average - let t = TIRCalculator.calculate(bgData: []) // placeholder — we draw this separately - return "—" - }(), "70–180 mg/dL"), - ("Avg Glucose", fmtGlucose(stats.avg), units), - ("CV", String(format: "%.1f%%", stats.cv), "SD \(fmtGlucose(stats.stdDev))"), - ("Sensor Active", String(format: "%.0f%%", stats.sensorPct), "\(stats.readingCount) readings"), - ] - - let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) - let cardH: CGFloat = 52 - let startX = margin - - for (i, card) in cards.enumerated() { - let cardX = startX + CGFloat(i) * cardW - let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) - - // Background - ctx.setFillColor(colorLightGray.cgColor) - ctx.fill(rect) - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.stroke(rect) - - // Value - let valAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 17), - .foregroundColor: colorDark, - ] - let valSize = (card.1 as NSString).size(withAttributes: valAttrs) - let valX = cardX + (cardW - 4 - valSize.width) / 2 - (card.1 as NSString).draw(at: CGPoint(x: valX, y: y + 8), withAttributes: valAttrs) - - // Label - let lblAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - let lblSize = (card.0 as NSString).size(withAttributes: lblAttrs) - (card.0 as NSString).draw( - at: CGPoint(x: cardX + (cardW - 4 - lblSize.width) / 2, y: y + 28), - withAttributes: lblAttrs - ) - - let subSize = (card.2 as NSString).size(withAttributes: lblAttrs) - (card.2 as NSString).draw( - at: CGPoint(x: cardX + (cardW - 4 - subSize.width) / 2, y: y + 39), - withAttributes: lblAttrs - ) - } - - return y + cardH + 12 - } - - // MARK: - TIR bar - - @discardableResult - private static func drawTIRBar(tirData: [TIRDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - guard let avg = tirData.first(where: { $0.period == .average }) else { return y } - - let barW = pageRect.width - margin * 2 - let barH: CGFloat = 20 - var x = margin - - let segments: [(CGFloat, UIColor)] = [ - (CGFloat(avg.veryLow / 100) * barW, colorVeryLow), - (CGFloat(avg.low / 100) * barW, colorLow), - (CGFloat(avg.inRange / 100) * barW, colorInRange), - (CGFloat(avg.high / 100) * barW, colorHigh), - (CGFloat(avg.veryHigh / 100) * barW, colorVeryHigh), - ] - - for (w, clr) in segments { - if w > 0 { - ctx.setFillColor(clr.cgColor) - ctx.fill(CGRect(x: x, y: y, width: w, height: barH)) - } - x += w - } - - // Labels inside bar - x = margin - let pcts = [avg.veryLow, avg.low, avg.inRange, avg.high, avg.veryHigh] - let clrs = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] - for (i, pct) in pcts.enumerated() { - let w = CGFloat(pct / 100) * barW - if pct >= 5 { - let pctStr = String(format: "%.1f%%", pct) - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 8), - .foregroundColor: UIColor.white, - ] - let sz = (pctStr as NSString).size(withAttributes: attrs) - (pctStr as NSString).draw( - at: CGPoint(x: x + (w - sz.width) / 2, y: y + (barH - sz.height) / 2), - withAttributes: attrs - ) - } - x += w - } - - // Legend - let legendY = y + barH + 4 - let legendItems: [(String, UIColor)] = [ - ("Very Low <54", colorVeryLow), - ("Low 54-69", colorLow), - ("In Range 70-180", colorInRange), - ("High 181-250", colorHigh), - ("Very High >250", colorVeryHigh), - ] - let itemW = barW / CGFloat(legendItems.count) - for (i, item) in legendItems.enumerated() { - let ix = margin + CGFloat(i) * itemW - ctx.setFillColor(item.1.cgColor) - ctx.fill(CGRect(x: ix, y: legendY, width: 8, height: 8)) - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7), - .foregroundColor: UIColor.secondaryLabel, - ] - (item.0 as NSString).draw(at: CGPoint(x: ix + 10, y: legendY), withAttributes: attrs) - } - - return legendY + 16 - } - - // MARK: - TIR table - - @discardableResult - private static func drawTIRTable(tirData: [TIRDataPoint], y: CGFloat, in _: CGRect, ctx: CGContext) -> CGFloat { - let cols: [CGFloat] = [100, 100, 70, 80, 70] - let headers = ["Zone", "Range", "Your %", "ADA Target", "Status"] - let rows: [(String, String, Double, String, Bool)] = [ - ("Very Low", "< 54 mg/dL", tirData.first(where: { $0.period == .average })?.veryLow ?? 0, "< 1%", (tirData.first(where: { $0.period == .average })?.veryLow ?? 99) < 1), - ("Low", "54–69 mg/dL", tirData.first(where: { $0.period == .average })?.low ?? 0, "< 4%", (tirData.first(where: { $0.period == .average })?.low ?? 99) < 4), - ("In Range", "70–180 mg/dL", tirData.first(where: { $0.period == .average })?.inRange ?? 0, "> 70%", (tirData.first(where: { $0.period == .average })?.inRange ?? 0) >= 70), - ("High", "181–250 mg/dL", tirData.first(where: { $0.period == .average })?.high ?? 0, "< 25%", (tirData.first(where: { $0.period == .average })?.high ?? 99) < 25), - ("Very High", "> 250 mg/dL", tirData.first(where: { $0.period == .average })?.veryHigh ?? 0, "< 5%", (tirData.first(where: { $0.period == .average })?.veryHigh ?? 99) < 5), - ] - let rowColors = [colorVeryLow, colorLow, colorInRange, colorHigh, colorVeryHigh] - - let rowH: CGFloat = 16 - var curY = y - var curX = margin - - // Header row - ctx.setFillColor(colorDark.cgColor) - let totalW = cols.reduce(0, +) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - for (i, h) in headers.enumerated() { - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 8), - .foregroundColor: UIColor.white, - ] - (h as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) - curX += cols[i] - } - curY += rowH - - // Data rows - for (ri, row) in rows.enumerated() { - ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - - // Zone color swatch in first cell - ctx.setFillColor(rowColors[ri].cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: cols[0], height: rowH)) - - let cells = [row.0, row.1, String(format: "%.1f%%", row.2), row.3, row.4 ? "✓" : "↑"] - curX = margin - for (ci, cell) in cells.enumerated() { - let attrs: [NSAttributedString.Key: Any] = [ - .font: ci == 0 ? UIFont.boldSystemFont(ofSize: 8) : UIFont.systemFont(ofSize: 8), - .foregroundColor: ci == 0 ? UIColor.white : colorDark, - ] - (cell as NSString).draw(at: CGPoint(x: curX + 4, y: curY + 4), withAttributes: attrs) - curX += cols[ci] - } - - // Border - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.3) - ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - curY += rowH - } - - return curY + 12 - } - - // MARK: - AGP chart (drawn natively with Core Graphics) - - @discardableResult - private static func drawAGPChart(agpData: [AGPDataPoint], y: CGFloat, in pageRect: CGRect, ctx: CGContext) -> CGFloat { - guard !agpData.isEmpty else { return y } - - let chartW: CGFloat = pageRect.width - margin * 2 - let chartH: CGFloat = 140 - let chartX: CGFloat = margin - let chartY: CGFloat = y - - // Background - ctx.setFillColor(UIColor(white: 0.98, alpha: 1).cgColor) - ctx.fill(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) - - // Target zones - let yRange: CGFloat = 350 // 40–400 - let yMin: CGFloat = 40 - func glucoseToY(_ g: Double) -> CGFloat { - chartY + chartH - (CGFloat(g) - yMin) / yRange * chartH - } - func timeToX(_ minutes: Int) -> CGFloat { - chartX + CGFloat(minutes) / (24 * 60) * chartW - } - - // Very low zone - ctx.setFillColor(colorVeryLow.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: chartX, y: glucoseToY(54), width: chartW, height: glucoseToY(40) - glucoseToY(54))) - - // Low zone - ctx.setFillColor(colorLow.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: chartX, y: glucoseToY(70), width: chartW, height: glucoseToY(54) - glucoseToY(70))) - - // High zone - ctx.setFillColor(colorHigh.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: chartX, y: glucoseToY(250), width: chartW, height: glucoseToY(180) - glucoseToY(250))) - - // Target lines - ctx.setStrokeColor(colorLow.withAlphaComponent(0.6).cgColor) - ctx.setLineWidth(0.8) - ctx.setLineDash(phase: 0, lengths: [4, 3]) - ctx.move(to: CGPoint(x: chartX, y: glucoseToY(70))) - ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(70))) - ctx.strokePath() - - ctx.setStrokeColor(colorHigh.withAlphaComponent(0.6).cgColor) - ctx.move(to: CGPoint(x: chartX, y: glucoseToY(180))) - ctx.addLine(to: CGPoint(x: chartX + chartW, y: glucoseToY(180))) - ctx.strokePath() - ctx.setLineDash(phase: 0, lengths: []) - - // 5–95 band - let p5Path = CGMutablePath() - let p95Path = CGMutablePath() - var bandPath = CGMutablePath() - - for (i, pt) in agpData.enumerated() { - let x = timeToX(pt.timeOfDay) - let y5 = glucoseToY(pt.p5) - let y95 = glucoseToY(pt.p95) - if i == 0 { - p5Path.move(to: CGPoint(x: x, y: y5)) - p95Path.move(to: CGPoint(x: x, y: y95)) - bandPath.move(to: CGPoint(x: x, y: y95)) - } else { - p5Path.addLine(to: CGPoint(x: x, y: y5)) - p95Path.addLine(to: CGPoint(x: x, y: y95)) - bandPath.addLine(to: CGPoint(x: x, y: y95)) - } - } - // Close band with p5 reversed - for pt in agpData.reversed() { - bandPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p5))) - } - bandPath.closeSubpath() - ctx.setFillColor(colorBlue.withAlphaComponent(0.12).cgColor) - ctx.addPath(bandPath) - ctx.fillPath() - - // 25–75 band - var iqrPath = CGMutablePath() - for (i, pt) in agpData.enumerated() { - let x = timeToX(pt.timeOfDay) - if i == 0 { iqrPath.move(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } - else { iqrPath.addLine(to: CGPoint(x: x, y: glucoseToY(pt.p75))) } - } - for pt in agpData.reversed() { - iqrPath.addLine(to: CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p25))) - } - iqrPath.closeSubpath() - ctx.setFillColor(colorBlue.withAlphaComponent(0.25).cgColor) - ctx.addPath(iqrPath) - ctx.fillPath() - - // Median line - ctx.setStrokeColor(colorBlue.cgColor) - ctx.setLineWidth(1.8) - var first = true - for pt in agpData { - let pt2D = CGPoint(x: timeToX(pt.timeOfDay), y: glucoseToY(pt.p50)) - if first { ctx.move(to: pt2D); first = false } - else { ctx.addLine(to: pt2D) } - } - ctx.strokePath() - - // X axis labels - let axisAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7), - .foregroundColor: UIColor.secondaryLabel, - ] - for h in stride(from: 0, through: 24, by: 3) { - let lbl = String(format: "%02d:00", h) - let lx = timeToX(h * 60) - let lsize = (lbl as NSString).size(withAttributes: axisAttrs) - (lbl as NSString).draw(at: CGPoint(x: lx - lsize.width / 2, y: chartY + chartH + 2), withAttributes: axisAttrs) - - // Vertical grid line - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: lx, y: chartY)) - ctx.addLine(to: CGPoint(x: lx, y: chartY + chartH)) - ctx.strokePath() - } - - // Y axis labels - for bg in [54, 70, 140, 180, 250, 350] { - let ly = glucoseToY(Double(bg)) - let lbl = "\(bg)" - let lsz = (lbl as NSString).size(withAttributes: axisAttrs) - (lbl as NSString).draw(at: CGPoint(x: chartX - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axisAttrs) - } - - // Border - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) - - // Legend - let lgY = chartY + chartH + 12 - let legendItems: [(String, UIColor, Bool)] = [ - ("Median", colorBlue, false), - ("25–75th %ile", colorBlue.withAlphaComponent(0.4), true), - ("5–95th %ile", colorBlue.withAlphaComponent(0.18), true), - ] - var lgX = chartX - for item in legendItems { - ctx.setFillColor(item.1.cgColor) - ctx.fill(CGRect(x: lgX, y: lgY, width: item.2 ? 14 : 20, height: item.2 ? 8 : 2.5)) - if !item.2 { - // line for median - ctx.setFillColor(item.1.cgColor) - ctx.fill(CGRect(x: lgX, y: lgY + 2, width: 20, height: 2)) - } - let lgAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - let lsz = (item.0 as NSString).size(withAttributes: lgAttrs) - (item.0 as NSString).draw(at: CGPoint(x: lgX + (item.2 ? 18 : 24), y: lgY), withAttributes: lgAttrs) - lgX += lsz.width + (item.2 ? 18 : 24) + 16 - } - - return lgY + 20 - } - - // MARK: - Daily summary table - - @discardableResult - private static func drawDailyTable(bgData: [ShareGlucoseData], y: CGFloat, in _: CGRect, ctx: CGContext) -> CGFloat { - // Group by day - let calendar = dateTimeUtils.displayCalendar() - var byDay: [String: [Double]] = [:] - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd" - - for r in bgData { - let d = df.string(from: Date(timeIntervalSince1970: r.date)) - byDay[d, default: []].append(Double(r.sgv)) - } - - let cols: [CGFloat] = [90, 60, 50, 50, 55, 60, 60] - let headers = ["Date", "Avg", "SD", "Min", "Max", "TIR %", "Readings"] - let totalW = cols.reduce(0, +) - let rowH: CGFloat = 14 - var curY = y - var curX = margin - - // Header - ctx.setFillColor(colorDark.cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - for (i, h) in headers.enumerated() { - let attrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 7.5), - .foregroundColor: UIColor.white, - ] - (h as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: attrs) - curX += cols[i] - } - curY += rowH - - let dayAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: colorDark, - ] - let df2 = DateFormatter() - df2.dateFormat = "EEE MMM d" - - for (ri, day) in byDay.keys.sorted().enumerated() { - let vals = byDay[day]! - let n = Double(vals.count) - let mean = vals.reduce(0, +) / n - let sd = sqrt(vals.map { ($0 - mean) * ($0 - mean) }.reduce(0, +) / n) - let tir = vals.filter { $0 >= 70 && $0 <= 180 }.count - let tirPct = Double(tir) / n * 100 - - let date = df.date(from: day) ?? Date() - let cells = [ - df2.string(from: date), - String(format: "%.0f", mean), - String(format: "%.0f", sd), - String(format: "%.0f", vals.min() ?? 0), - String(format: "%.0f", vals.max() ?? 0), - String(format: "%.0f%%", tirPct), - "\(vals.count)", - ] - - ctx.setFillColor((ri % 2 == 0 ? UIColor.white : colorLightGray).cgColor) - ctx.fill(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - - curX = margin - for (ci, cell) in cells.enumerated() { - (cell as NSString).draw(at: CGPoint(x: curX + 3, y: curY + 3), withAttributes: dayAttrs) - curX += cols[ci] - } - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.3) - ctx.stroke(CGRect(x: margin, y: curY, width: totalW, height: rowH)) - curY += rowH - } - - return curY + 14 - } - - // MARK: - Insulin & carb summary - - @discardableResult - private static func drawInsulinCarbSummary( - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - carbs: [MainViewController.carbGraphStruct], - stats: SimpleStats, - y: CGFloat, - in pageRect: CGRect, - ctx: CGContext - ) -> CGFloat { - let days = max(stats.sensorPct / 100 * 14, 1) - let totalBolus = boluses.map { $0.value }.reduce(0, +) - + smbs.map { $0.value }.reduce(0, +) - let totalCarbs = carbs.map { $0.value }.reduce(0, +) - - let cards: [(String, String, String)] = [ - ("Avg Daily Bolus", String(format: "%.1f U", totalBolus / days), "Total \(String(format: "%.1f", totalBolus)) U"), - ("Bolus Count", "\(boluses.count + smbs.count)", "Over report period"), - ("Avg Daily Carbs", String(format: "%.0f g", totalCarbs / days), "Total \(String(format: "%.0f", totalCarbs)) g"), - ("Carb Entries", "\(carbs.count)", "Logged entries"), - ] - - let cardW = (pageRect.width - margin * 2) / CGFloat(cards.count) - let cardH: CGFloat = 48 - - for (i, card) in cards.enumerated() { - let cardX = margin + CGFloat(i) * cardW - let rect = CGRect(x: cardX, y: y, width: cardW - 4, height: cardH) - ctx.setFillColor(colorLightGray.cgColor) - ctx.fill(rect) - ctx.setStrokeColor(colorBorder.cgColor) - ctx.setLineWidth(0.5) - ctx.stroke(rect) - - let valAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 14), - .foregroundColor: colorDark, - ] - let valSz = (card.1 as NSString).size(withAttributes: valAttrs) - (card.1 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - valSz.width) / 2, y: y + 6), withAttributes: valAttrs) - - let lblAttrs: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 7.5), - .foregroundColor: UIColor.secondaryLabel, - ] - let lsz = (card.0 as NSString).size(withAttributes: lblAttrs) - (card.0 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - lsz.width) / 2, y: y + 24), withAttributes: lblAttrs) - - let ssz = (card.2 as NSString).size(withAttributes: lblAttrs) - (card.2 as NSString).draw(at: CGPoint(x: cardX + (cardW - 4 - ssz.width) / 2, y: y + 34), withAttributes: lblAttrs) - } - - return y + cardH + 14 - } -} From 68d7805d079e356a1ca37e52ba2e8819ad230e0d Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Sat, 23 May 2026 21:09:18 -0700 Subject: [PATCH 3/7] Updated various componets and added settings --- LoopFollow/Stats/EndoReportGenerator-3.swift | 820 ------------------- LoopFollow/Stats/EndoReportGenerator.swift | 810 ++++++++++++++++++ LoopFollow/Stats/EndoReportView.swift | 465 ++++++++--- 3 files changed, 1176 insertions(+), 919 deletions(-) delete mode 100644 LoopFollow/Stats/EndoReportGenerator-3.swift create mode 100644 LoopFollow/Stats/EndoReportGenerator.swift diff --git a/LoopFollow/Stats/EndoReportGenerator-3.swift b/LoopFollow/Stats/EndoReportGenerator-3.swift deleted file mode 100644 index 3642a1051..000000000 --- a/LoopFollow/Stats/EndoReportGenerator-3.swift +++ /dev/null @@ -1,820 +0,0 @@ -// LoopFollow -// EndoReportGenerator-3.swift - -import PDFKit -import UIKit - -enum EndoReportGenerator { - // MARK: - Entry point - - static func generate( - patientName: String, - dateOfBirth: String, - providerName: String, - startDate: Date, - endDate: Date, - dataService: StatsDataService - ) throws -> URL { - let bgData = dataService.getBGData() - guard !bgData.isEmpty else { throw ReportError.noData } - - let agpData = AGPCalculator.calculate(bgData: bgData) - let tirData = TIRCalculator.calculate(bgData: bgData) - let stats = SimpleStats(bgData: bgData, dataService: dataService) - let patterns = GlycemicPatterns(bgData: bgData) - let boluses = dataService.getBolusData() - let smbs = dataService.getSMBData() - let carbs = dataService.getCarbData() - let basals = dataService.getBasalData() - let simpleVM = SimpleStatsViewModel(dataService: dataService) - simpleVM.calculateStats() - - let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) - let renderer = UIGraphicsPDFRenderer(bounds: pageRect) - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") - - let data = renderer.pdfData { ctx in - // Page 1 — Summary dashboard - ctx.beginPage() - drawSummaryPage( - ctx: ctx.cgContext, r: pageRect, - patientName: patientName, dateOfBirth: dateOfBirth, - providerName: providerName, startDate: startDate, endDate: endDate, - bgData: bgData, agpData: agpData, tirData: tirData, - stats: stats, patterns: patterns, - boluses: boluses, smbs: smbs, carbs: carbs, - simpleVM: simpleVM - ) - - // Page 2 — Daily breakdowns - ctx.beginPage() - drawDailyPage( - ctx: ctx.cgContext, r: pageRect, - patientName: patientName, startDate: startDate, endDate: endDate, - bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, - stats: stats - ) - } - try data.write(to: url) - return url - } - - enum ReportError: LocalizedError { - case noData - var errorDescription: String? { "No CGM data available for the selected date range." } - } - - // MARK: - Palette - - private static let C_TEAL = UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) - private static let C_TEAL_DARK = UIColor(red: 0.090, green: 0.420, blue: 0.460, alpha: 1) - private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) - private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) - private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) - private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) - private static let C_WHITE = UIColor.white - private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) - private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) - private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) - private static let C_TIGHT = UIColor(red: 0.140, green: 0.780, blue: 0.580, alpha: 1) - private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) - private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) - private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 1) - private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.7) - - // MARK: - Data models - - struct SimpleStats { - let avg, stdDev, cv, eA1C, minBG, maxBG, sensorPct, tir, tightTIR, days: Double - let readingCount: Int - init(bgData: [ShareGlucoseData], dataService: StatsDataService) { - let v = bgData.map { Double($0.sgv) } - let n = Double(v.count) - let m = v.reduce(0,+) / n - let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n - avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 - minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count - days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 - sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) - tir = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 - tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 - } - } - - struct GlycemicPatterns { - struct P { let label: String; let avg: Double; let count: Int } - let night, earlyAM, morning, afternoon, evening, late: P - init(bgData: [ShareGlucoseData]) { - func p(_ l: String, _ s: Int, _ e: Int) -> P { - let cal = dateTimeUtils.displayCalendar() - let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } - return P(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) - } - night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) - afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) - } - } - - // MARK: - Page 1 - - private static func drawSummaryPage( - ctx: CGContext, r: CGRect, - patientName: String, dateOfBirth: String, providerName: String, - startDate: Date, endDate: Date, - bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], tirData: [TIRDataPoint], - stats: SimpleStats, patterns: GlycemicPatterns, - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - carbs: [MainViewController.carbGraphStruct], - simpleVM: SimpleStatsViewModel - ) { - let m: CGFloat = 30 - var y = drawHero(ctx: ctx, r: r, patientName: patientName, providerName: providerName, - dateOfBirth: dateOfBirth, startDate: startDate, endDate: endDate, stats: stats) - - y = sectionHdr("GLUCOSE SUMMARY", y: y + 12, m: m, w: r.width, ctx: ctx) - - // 6 stat cards left + TIR bar right - let gridW = r.width - m * 2 - 162 - let cw = gridW / 2 - 3 - let ch: CGFloat = 46 - let cards: [(String, String, Bool)] = [ - ("TIME IN RANGE", String(format: "%.0f%%", stats.tir), true), - ("GMI (EST. A1C)", String(format: "%.1f%%", stats.eA1C), false), - ("AVERAGE", String(format: "%.0f", stats.avg), false), - ("STD DEVIATION", String(format: "%.0f", stats.stdDev), false), - ("CV", String(format: "%.0f%%", stats.cv), false), - ("READINGS", "\(stats.readingCount)", false), - ] - var gy = y + 6 - for (i, card) in cards.enumerated() { - let col = CGFloat(i % 2); let row = CGFloat(i / 2) - statCard(card.0, val: card.1, x: m + col * (cw + 6), y: gy + row * (ch + 4), - w: cw, h: ch, accent: card.2, ctx: ctx) - } - let tirBarH = ch * 3 + 8 - drawTIRBar(tirData: tirData, stats: stats, - x: m + gridW + 12, y: y + 6, w: 150, h: tirBarH, ctx: ctx) - y = gy + CGFloat(3) * (ch + 4) + 8 - - // Time-of-day strip - y = timeOfDayStrip(patterns: patterns, y: y + 4, m: m, w: r.width, ctx: ctx) - - // Insulin - let hasInsulin = !boluses.isEmpty || !smbs.isEmpty - if hasInsulin { - y = sectionHdr("INSULIN DELIVERY", y: y + 10, m: m, w: r.width, ctx: ctx) - y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, - stats: stats, y: y + 4, m: m, w: r.width, ctx: ctx) - } - - // Nutrition - if !carbs.isEmpty { - y = sectionHdr("NUTRITION & MEALS", y: y + 8, m: m, w: r.width, ctx: ctx) - y = nutritionSection(carbs: carbs, stats: stats, y: y + 4, m: m, w: r.width, ctx: ctx) - } - - // AGP strip — clean, fixed height - let agpH: CGFloat = 110 - if !agpData.isEmpty, y + agpH + 50 < r.height { - y = sectionHdr("AMBULATORY GLUCOSE PROFILE (AGP)", y: y + 10, m: m, w: r.width, ctx: ctx) - drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, ctx: ctx) - y += agpH + 18 - } - - footer(ctx: ctx, r: r, startDate: startDate, endDate: endDate, stats: stats, page: 1) - } - - // MARK: - Page 2 — Daily breakdowns - - private static func drawDailyPage( - ctx: CGContext, r: CGRect, - patientName: String, startDate: Date, endDate: Date, - bgData: [ShareGlucoseData], - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - basals: [MainViewController.basalGraphStruct], - stats: SimpleStats - ) { - let m: CGFloat = 30 - var y = drawPageHeader(ctx: ctx, r: r, patientName: patientName, - startDate: startDate, endDate: endDate) - y = sectionHdr("DAILY GLUCOSE BREAKDOWN", y: y + 10, m: m, w: r.width, ctx: ctx) - - // Group BG data by local day - let cal = dateTimeUtils.displayCalendar() - let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" - var bgByDay: [String: [ShareGlucoseData]] = [:] - for reading in bgData { - let key = df.string(from: Date(timeIntervalSince1970: reading.date)) - bgByDay[key, default: []].append(reading) - } - - // Group boluses by day - var bolusByDay: [String: [MainViewController.bolusGraphStruct]] = [:] - for b in boluses + smbs { - let key = df.string(from: Date(timeIntervalSince1970: b.date)) - bolusByDay[key, default: []].append(b) - } - - // Group basals by day - var basalByDay: [String: [MainViewController.basalGraphStruct]] = [:] - for b in basals { - let key = df.string(from: Date(timeIntervalSince1970: b.date)) - basalByDay[key, default: []].append(b) - } - - let sortedDays = bgByDay.keys.sorted() - let rowH: CGFloat = 82 - let rowGap: CGFloat = 8 - let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d" - - for (i, day) in sortedDays.enumerated() { - // Start new page if needed - if y + rowH + rowGap > r.height - 40 { - footer(ctx: ctx, r: r, startDate: startDate, endDate: endDate, stats: stats, page: 2 + i / 8) - // (caller would need to beginPage — in practice all days fit on 1-2 pages) - break - } - - let dayReadings = bgByDay[day] ?? [] - let dayBoluses = bolusByDay[day] ?? [] - let dayBasals = basalByDay[day] ?? [] - let date = df.date(from: day) ?? Date() - - drawDayRow( - ctx: ctx, x: m, y: y, w: r.width - m * 2, h: rowH, - label: df2.string(from: date), - readings: dayReadings, - boluses: dayBoluses, - basals: dayBasals - ) - y += rowH + rowGap - } - - footer(ctx: ctx, r: r, startDate: startDate, endDate: endDate, stats: stats, page: 2) - } - - // MARK: - Day row (mini chart) - - private static func drawDayRow( - ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, - label: String, - readings: [ShareGlucoseData], - boluses: [MainViewController.bolusGraphStruct], - basals: [MainViewController.basalGraphStruct] - ) { - // Card background - ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) - - // Left teal accent - ctx.setFillColor(C_TEAL.cgColor) - ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) - - // Day label - let dlA: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 9), - .foregroundColor: C_INK, - ] - label.draw(at: CGPoint(x: x + 10, y: y + 6), withAttributes: dlA) - - // Quick stats on the right side - let vals = readings.map { Double($0.sgv) } - if !vals.isEmpty { - let n = Double(vals.count) - let avg = vals.reduce(0,+) / n - let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 - let lo = vals.filter { $0 < 70 }.count - let hi = vals.filter { $0 > 180 }.count - - let statW: CGFloat = 90 - let statX = x + w - statW - 8 - - let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] - let sv: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_INK] - let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW - let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: tirC] - - "Avg".draw(at: CGPoint(x: statX, y: y + 8), withAttributes: sa) - "TIR".draw(at: CGPoint(x: statX + 30, y: y + 8), withAttributes: sa) - "Lo".draw(at: CGPoint(x: statX + 60, y: y + 8), withAttributes: sa) - "Hi".draw(at: CGPoint(x: statX + 76, y: y + 8), withAttributes: sa) - - String(format: "%.0f", avg).draw(at: CGPoint(x: statX, y: y + 18), withAttributes: sv) - String(format: "%.0f%%", tir).draw(at: CGPoint(x: statX + 28, y: y + 18), withAttributes: tirA) - "\(lo)".draw(at: CGPoint(x: statX + 60, y: y + 18), withAttributes: sa) - "\(hi)".draw(at: CGPoint(x: statX + 76, y: y + 18), withAttributes: sa) - } - - // Chart area - let chartX: CGFloat = x + 10 - let chartW: CGFloat = w - 110 - let chartY: CGFloat = y + 22 - let chartH: CGFloat = h - 28 - - guard !readings.isEmpty else { return } - - // BG range for this day - let bgMin: CGFloat = 40 - let bgMax: CGFloat = 320 - let bgRange = bgMax - bgMin - - func gY(_ bg: Double) -> CGFloat { - chartY + chartH - (CGFloat(bg) - bgMin) / bgRange * chartH - } - func tX(_ ts: Double) -> CGFloat { - // Map 00:00–24:00 to chartX..chartX+chartW - let cal = dateTimeUtils.displayCalendar() - let date = Date(timeIntervalSince1970: ts) - let comps = cal.dateComponents([.hour, .minute], from: date) - let minuteOfDay = Double((comps.hour ?? 0) * 60 + (comps.minute ?? 0)) - return chartX + CGFloat(minuteOfDay / (24 * 60)) * chartW - } - - // Target zone - ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) - ctx.fill(CGRect(x: chartX, y: gY(180), width: chartW, height: gY(70) - gY(180))) - - // Target lines - ctx.setLineDash(phase: 0, lengths: [3, 2]) - ctx.setLineWidth(0.5) - ctx.setStrokeColor(C_LOW.withAlphaComponent(0.5).cgColor) - ctx.move(to: CGPoint(x: chartX, y: gY(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gY(70))); ctx.strokePath() - ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.5).cgColor) - ctx.move(to: CGPoint(x: chartX, y: gY(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gY(180))); ctx.strokePath() - ctx.setLineDash(phase: 0, lengths: []) - - // Hour grid lines (subtle) - ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.6).cgColor); ctx.setLineWidth(0.3) - for h2 in stride(from: 3, through: 21, by: 3) { - let hx = chartX + CGFloat(h2) / 24 * chartW - ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() - } - - // Basal line (blue, bottom portion) - if !basals.isEmpty { - let basalH: CGFloat = chartH * 0.25 - let basalY = chartY + chartH - basalH - let maxBasal = basals.map { $0.basalRate }.max() ?? 1.0 - let sorted = basals.sorted { $0.date < $1.date } - - ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(1.0) - ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor) - - var path = CGMutablePath() - var first = true - for pt in sorted { - let px = tX(pt.date) - let py = basalY + basalH - CGFloat(pt.basalRate / maxBasal) * basalH - if first { path.move(to: CGPoint(x: px, y: py)); first = false } - else { path.addLine(to: CGPoint(x: px, y: py)) } - } - // Close fill path along bottom - if let last = sorted.last { - let lx = tX(last.date) - path.addLine(to: CGPoint(x: lx, y: basalY + basalH)) - path.addLine(to: CGPoint(x: chartX, y: basalY + basalH)) - path.closeSubpath() - ctx.addPath(path); ctx.fillPath() - } - // Stroke line - var linePath = CGMutablePath(); first = true - for pt in sorted { - let px = tX(pt.date) - let py = basalY + basalH - CGFloat(pt.basalRate / maxBasal) * basalH - if first { linePath.move(to: CGPoint(x: px, y: py)); first = false } - else { linePath.addLine(to: CGPoint(x: px, y: py)) } - } - ctx.addPath(linePath); ctx.strokePath() - } - - // Bolus bars - for bolus in boluses { - let bx = tX(bolus.date) - let maxUnits: Double = 15 - let barH2 = CGFloat(Swift.min(bolus.value / maxUnits, 1.0)) * (chartH * 0.4) - let barW: CGFloat = 2.5 - ctx.setFillColor(C_BOLUS.withAlphaComponent(0.8).cgColor) - ctx.fill(CGRect(x: bx - barW / 2, y: chartY + chartH - barH2, width: barW, height: barH2)) - } - - // BG dots - let sorted = readings.sorted { $0.date < $1.date } - for reading in sorted { - let rx = tX(reading.date) - let ry = gY(Double(reading.sgv)) - let bg = Double(reading.sgv) - let dotColor: UIColor - switch bg { - case ..<54: dotColor = C_VLOW - case ..<70: dotColor = C_LOW - case ...180: dotColor = C_IN - case ...250: dotColor = C_HIGH - default: dotColor = C_VHIGH - } - ctx.setFillColor(dotColor.cgColor) - ctx.fillEllipse(in: CGRect(x: rx - 1.5, y: ry - 1.5, width: 3, height: 3)) - } - - // X-axis hour labels (just a few) - let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_SLATE] - for h2 in [0, 6, 12, 18, 24] { - let hx = chartX + CGFloat(h2) / 24 * chartW - let lbl = String(format: "%02d", h2) - let sz = (lbl as NSString).size(withAttributes: axA) - (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) - } - - // Clip to card area - ctx.resetClip() - } - - // MARK: - Hero header - - @discardableResult - private static func drawHero(ctx: CGContext, r: CGRect, - patientName: String, providerName: String, - dateOfBirth _: String, startDate: Date, endDate: Date, - stats: SimpleStats) -> CGFloat - { - let h: CGFloat = 96 - ctx.setFillColor(C_TEAL.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) - ctx.setFillColor(C_TEAL_DARK.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 20)) - - let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 1.8] - "LOOPFOLLOW".draw(at: CGPoint(x: 32, y: 5), withAttributes: a1) - - let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] - "Endocrinologist Visit Report".draw(at: CGPoint(x: 32, y: 22), withAttributes: a2) - - let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] - "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 32, y: 50), withAttributes: a3) - - let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" - let ds = "\(df.string(from: startDate)) — \(df.string(from: endDate)) (\(Int(stats.days.rounded())) Days)" - let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] - ds.draw(at: CGPoint(x: 32, y: 66), withAttributes: a4) - - let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] - var lines: [String] = [] - if !patientName.isEmpty { lines.append("Patient: \(patientName)") } - if !providerName.isEmpty { lines.append("Provider: \(providerName)") } - for (i, l) in lines.enumerated() { - let sz = (l as NSString).size(withAttributes: a5) - (l as NSString).draw(at: CGPoint(x: r.width - 34 - sz.width, y: 24 + CGFloat(i) * 15), withAttributes: a5) - } - return h - } - - // MARK: - Page 2 header - - @discardableResult - private static func drawPageHeader(ctx: CGContext, r: CGRect, - patientName: String, startDate: Date, endDate: Date) -> CGFloat - { - let h: CGFloat = 36 - ctx.setFillColor(C_TEAL.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] - "Endocrinologist Visit Report".draw(at: CGPoint(x: 32, y: 10), withAttributes: a) - let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" - let sub = "\(patientName) • \(df.string(from: startDate)) – \(df.string(from: endDate))" - let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] - let ssz = (sub as NSString).size(withAttributes: sa) - (sub as NSString).draw(at: CGPoint(x: r.width - 32 - ssz.width, y: 12), withAttributes: sa) - return h - } - - // MARK: - Section header - - @discardableResult - private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat { - ctx.setFillColor(C_TEAL.cgColor) - ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_TEAL, .kern: 0.6] - (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() - return y + 19 - } - - // MARK: - Stat card - - private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, - w: CGFloat, h: CGFloat, accent: Bool, ctx: CGContext) - { - let r = CGRect(x: x, y: y, width: w, height: h) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) - if accent { - ctx.setFillColor(C_TEAL.withAlphaComponent(0.07).cgColor); ctx.fill(r) - ctx.setFillColor(C_TEAL.cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) - } - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 19), .foregroundColor: accent ? C_TEAL : C_INK] - (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: la) - (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 16), withAttributes: va) - } - - // MARK: - TIR vertical bar - - private static func drawTIRBar(tirData: [TIRDataPoint], stats: SimpleStats, - x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, ctx: CGContext) - { - guard let avg = tirData.first(where: { $0.period == .average }) else { return } - let r = CGRect(x: x, y: y, width: w, height: h) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - - let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] - "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) - - let barX = x + 10; let barW: CGFloat = 16 - let barY = y + 20; let barH = h - 38 - - let segs: [(Double, UIColor, String)] = [ - (avg.veryHigh, C_VHIGH, "Very High"), - (avg.high, C_HIGH, "High"), - (avg.inRange, C_IN, "In Range"), - (avg.low, C_LOW, "Low"), - (avg.veryLow, C_VLOW, "Very Low"), - ] - var sy = barY - for (pct, clr, label) in segs { - let sh = CGFloat(pct / 100) * barH - if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: barX, y: sy, width: barW, height: sh)) } - if sh >= 10 { - let ps = String(format: "%.0f%%", pct) - let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: label == "In Range" ? C_TEAL : C_SLATE] - let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - (ps as NSString).draw(at: CGPoint(x: barX + barW + 4, y: sy + sh / 2 - 8), withAttributes: pa) - if sh >= 20 { (label as NSString).draw(at: CGPoint(x: barX + barW + 4, y: sy + sh / 2), withAttributes: sa) } - if label == "In Range", sh >= 30 { - let ts = String(format: "%.0f%% Tight", stats.tightTIR) - let ta2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_TIGHT] - (ts as NSString).draw(at: CGPoint(x: barX + barW + 4, y: sy + sh / 2 + 10), withAttributes: ta2) - } - } - sy += sh - } - let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_SLATE] - "Target: 70-180".draw(at: CGPoint(x: x + 6, y: y + h - 18), withAttributes: na) - "Tight: 70-140".draw(at: CGPoint(x: x + 6, y: y + h - 10), withAttributes: na) - } - - // MARK: - Time-of-day strip - - @discardableResult - private static func timeOfDayStrip(patterns: GlycemicPatterns, y: CGFloat, - m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat - { - let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] - "Glucose by Time of Day (mg/dL)".draw(at: CGPoint(x: m, y: y), withAttributes: a) - let periods = [patterns.night, patterns.earlyAM, patterns.morning, - patterns.afternoon, patterns.evening, patterns.late] - let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 42; let cy = y + 13 - for (i, p) in periods.enumerated() { - let cx = m + CGFloat(i) * cw - let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) - guard p.count > 0 else { continue } - let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? C_TEAL : p.avg < 180 ? C_INK : C_HIGH - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 15), .foregroundColor: vc] - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - let vs = String(format: "%.0f", p.avg) - let vsz = (vs as NSString).size(withAttributes: va) - let lsz = (p.label as NSString).size(withAttributes: la) - (vs as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) - (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 28), withAttributes: la) - } - return cy + ch + 4 - } - - // MARK: - Insulin section - - @discardableResult - private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - simpleVM: SimpleStatsViewModel, stats _: SimpleStats, - y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat - { - let tdd = simpleVM.totalDailyDose ?? 0 - let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 - let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 - let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), - ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), - ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] - let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 42 - for (i, c) in cards.enumerated() { - let cx = m + CGFloat(i) * (cw + 4) - let r2 = CGRect(x: cx, y: y, width: cw, height: ch) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: C_INK] - (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 5), withAttributes: la) - (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 16), withAttributes: va) - } - let tableY = y + ch + 6 - let total = (boluses + smbs).map { $0.value }.reduce(0,+) - let rows: [(String, String)] = [ - ("Correction Boluses", "\(boluses.count)"), - ("Total Bolus Insulin", String(format: "%.1f U", total)), - ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), - ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), - ("Positive Temp Basal", simpleVM.totalPositiveBasal != nil ? String(format: "+%.2f U/day", simpleVM.totalPositiveBasal!) : "—"), - ("Negative Temp Basal", simpleVM.totalNegativeBasal != nil ? String(format: "%.2f U/day", simpleVM.totalNegativeBasal!) : "—"), - ] - return metricTable(rows, y: tableY, m: m, w: w, ctx: ctx) - } - - // MARK: - Nutrition section - - @discardableResult - private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], - stats: SimpleStats, y: CGFloat, m: CGFloat, w: CGFloat, - ctx: CGContext) -> CGFloat - { - let total = carbs.map { $0.value }.reduce(0,+) - let cards: [(String, String)] = [ - ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), - ("MEALS LOGGED", "\(carbs.count)"), - ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), - ] - let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 42 - for (i, c) in cards.enumerated() { - let cx = m + CGFloat(i) * (cw + 4) - let r2 = CGRect(x: cx, y: y, width: cw, height: ch) - ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: C_INK] - (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 5), withAttributes: la) - (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 16), withAttributes: va) - } - return y + ch + 8 - } - - // MARK: - Metric table - - @discardableResult - private static func metricTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, - w: CGFloat, ctx: CGContext) -> CGFloat - { - let tw = w - m * 2; let hh: CGFloat = 14; let rh: CGFloat = 13; var cy = y - ctx.setFillColor(C_TEAL.withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: hh)) - let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: C_TEAL, .kern: 0.4] - "METRIC".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) - "VALUE".draw(at: CGPoint(x: m + tw * 0.65 + 6, y: cy + 3), withAttributes: ha) - cy += hh - for (i, row) in rows.enumerated() { - ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) - let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_INK] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_TEAL] - (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ka) - (row.1 as NSString).draw(at: CGPoint(x: m + tw * 0.65 + 6, y: cy + 3), withAttributes: va) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: m, y: cy + rh)); ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)); ctx.strokePath() - cy += rh - } - return cy + 6 - } - - // MARK: - AGP strip (clean, no overflow) - - private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, - w: CGFloat, h: CGFloat, ctx: CGContext) - { - guard !agpData.isEmpty else { return } - // Chart sits inside a padded area — left pad for Y labels, bottom for X labels - let lPad: CGFloat = 28; let bPad: CGFloat = 14 - let cw = w - lPad; let ch = h - bPad - let cx = x + lPad; let cy = y - - // Background - ctx.setFillColor(C_CLOUD.withAlphaComponent(0.5).cgColor) - ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) - - let bgMin: CGFloat = 40; let bgRange: CGFloat = 320 - func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRange * ch } - func tx(_ m2: Int) -> CGFloat { cx + CGFloat(m2) / (24 * 60) * cw } - - // Target zone - ctx.setFillColor(C_IN.withAlphaComponent(0.08).cgColor) - ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) - - // Dashed target lines - ctx.setLineDash(phase: 0, lengths: [3, 2]) - ctx.setLineWidth(0.6) - for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { - ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor) - ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() - } - ctx.setLineDash(phase: 0, lengths: []) - - // 5–95 band - var band = CGMutablePath() - for (i, pt) in agpData.enumerated() { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)) - i == 0 ? band.move(to: p) : band.addLine(to: p) - } - for pt in agpData.reversed() { - band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) - } - band.closeSubpath() - ctx.setFillColor(C_TEAL.withAlphaComponent(0.11).cgColor); ctx.addPath(band); ctx.fillPath() - - // 25–75 IQR - var iqr = CGMutablePath() - for (i, pt) in agpData.enumerated() { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)) - i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) - } - for pt in agpData.reversed() { - iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) - } - iqr.closeSubpath() - ctx.setFillColor(C_TEAL.withAlphaComponent(0.26).cgColor); ctx.addPath(iqr); ctx.fillPath() - - // Median - ctx.setStrokeColor(C_TEAL.cgColor); ctx.setLineWidth(1.6) - var first = true - for pt in agpData { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)) - first ? ctx.move(to: p) : ctx.addLine(to: p); first = false - } - ctx.strokePath() - - // Clip so nothing bleeds outside chart area - ctx.clip(to: CGRect(x: cx, y: cy, width: cw, height: ch)) - ctx.resetClip() - - // Y-axis labels — drawn LEFT of chart, never inside - let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - for bg in [70, 140, 180, 250] { - let ly = gy(Double(bg)) - guard ly >= cy, ly <= cy + ch else { continue } - let lbl = "\(bg)" - let lsz = (lbl as NSString).size(withAttributes: axA) - (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) - // Subtle grid line - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() - } - - // X-axis labels — drawn BELOW chart, even spacing - for h2 in stride(from: 0, through: 24, by: 3) { - let lx = tx(h2 * 60) - let lbl = String(format: "%02d:00", h2) - let lsz = (lbl as NSString).size(withAttributes: axA) - // Clamp to chart bounds - let drawX = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) - (lbl as NSString).draw(at: CGPoint(x: drawX, y: cy + ch + 2), withAttributes: axA) - // Vertical grid line - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() - } - - // Border - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) - - // Legend — right-aligned, below chart - let lgY = cy + ch + 2 - let lgItems: [(String, UIColor, Bool)] = [("Median", C_TEAL, false), - ("25–75th", C_TEAL.withAlphaComponent(0.4), true), - ("5–95th", C_TEAL.withAlphaComponent(0.18), true)] - var lgX = cx + cw - for item in lgItems.reversed() { - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - let lsz = (item.0 as NSString).size(withAttributes: la) - lgX -= lsz.width - (item.0 as NSString).draw(at: CGPoint(x: lgX, y: lgY), withAttributes: la) - lgX -= 16 - if item.2 { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: lgY + 1, width: 12, height: 8)) } - else { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: lgY + 4, width: 12, height: 2)) } - lgX -= 6 - } - } - - // MARK: - Footer - - private static func footer(ctx: CGContext, r: CGRect, - startDate _: Date, endDate _: Date, stats: SimpleStats, page: Int) - { - let fy = r.height - 28 - ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] - let disc = "LoopFollow — for informational purposes only. Not a substitute for professional medical advice." - (disc as NSString).draw(in: CGRect(x: 32, y: fy + 4, width: r.width - 200, height: 20), withAttributes: a) - let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" - let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" - let msz = (meta as NSString).size(withAttributes: a) - (meta as NSString).draw(at: CGPoint(x: r.width - 32 - msz.width, y: fy + 4), withAttributes: a) - } -} diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift new file mode 100644 index 000000000..7c261886d --- /dev/null +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -0,0 +1,810 @@ +// LoopFollow +// EndoReportGenerator.swift + +import PDFKit +import UIKit + +// MARK: - Config — defined in EndoReportView.swift, used here by reference + +enum EndoReportGenerator { + enum ReportError: LocalizedError { + case noData + var errorDescription: String? { "No CGM data available for the selected date range." } + } + + // MARK: - Entry point + + static func generate(config: EndoReportConfig, dataService: StatsDataService) throws -> URL { + let bgData = dataService.getBGData() + guard !bgData.isEmpty else { throw ReportError.noData } + + let agpData = AGPCalculator.calculate(bgData: bgData) + let tirData = TIRCalculator.calculate(bgData: bgData) + let stats = ReportStats(bgData: bgData, dataService: dataService) + let patterns = TimePatterns(bgData: bgData) + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + let basals = dataService.getBasalData() + let simpleVM = SimpleStatsViewModel(dataService: dataService) + simpleVM.calculateStats() + + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) + let renderer = UIGraphicsPDFRenderer(bounds: pageRect) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") + + // Group days newest-first + let dailyData = groupByDay(bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, carbs: carbs) + .sorted { $0.key > $1.key } + + let data = renderer.pdfData { ctx in + // Page 1 — Summary + ctx.beginPage() + drawSummaryPage(ctx: ctx.cgContext, r: pageRect, cfg: config, + bgData: bgData, agpData: agpData, tirData: tirData, + stats: stats, patterns: patterns, + boluses: boluses, smbs: smbs, carbs: carbs, + simpleVM: simpleVM) + + // Pages 2+ — Daily breakdowns + if config.includeDailyBreakdown && !dailyData.isEmpty { + let rowH: CGFloat = 88 + let rowGap: CGFloat = 6 + let topY: CGFloat = 52 // after page header + let botY: CGFloat = 762 // before footer + let usable = botY - topY + let perPage = Int((usable + rowGap) / (rowH + rowGap)) + let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) + + for p in 0 ..< pages { + ctx.beginPage() + let pageNum = p + 2 + let headerY = drawDailyPageHeader(ctx: ctx.cgContext, r: pageRect, + cfg: config, page: pageNum, + totalPages: pages + 1) + let slice = Array(dailyData[p * perPage ..< min((p + 1) * perPage, dailyData.count)]) + var y = headerY + 8 + for (day, dayData) in slice { + drawDayRow(ctx: ctx.cgContext, x: 28, y: y, + w: pageRect.width - 56, h: rowH, + day: day, dayData: dayData, cfg: config, + simpleVM: simpleVM) + y += rowH + rowGap + } + drawFooter(ctx: ctx.cgContext, r: pageRect, cfg: config, + stats: stats, page: pageNum) + } + } + } + try data.write(to: url) + return url + } + + // MARK: - Data models + + struct ReportStats { + let avg, stdDev, cv, eA1C, minBG, maxBG, sensorPct, tir, tightTIR, days: Double + let readingCount: Int + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let v = bgData.map { Double($0.sgv) }; let n = Double(v.count) + let m = v.reduce(0,+) / n + let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n + avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 + minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count + days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) + tir = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 + } + } + + struct TimePatterns { + struct Period { let label: String; let avg: Double; let count: Int } + let night, earlyAM, morning, afternoon, evening, late: Period + init(bgData: [ShareGlucoseData]) { + func p(_ l: String, _ s: Int, _ e: Int) -> Period { + let cal = dateTimeUtils.displayCalendar() + let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } + return Period(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) + } + night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) + afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) + } + } + + struct DayData { + let bg: [ShareGlucoseData] + let bolus: [MainViewController.bolusGraphStruct] + let smb: [MainViewController.bolusGraphStruct] + let basal: [MainViewController.basalGraphStruct] + let carbs: [MainViewController.carbGraphStruct] + } + + private static func groupByDay( + bgData: [ShareGlucoseData], + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + basals: [MainViewController.basalGraphStruct], + carbs: [MainViewController.carbGraphStruct] + ) -> [String: DayData] { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + var bg: [String: [ShareGlucoseData]] = [:] + var bo: [String: [MainViewController.bolusGraphStruct]] = [:] + var sm: [String: [MainViewController.bolusGraphStruct]] = [:] + var ba: [String: [MainViewController.basalGraphStruct]] = [:] + var ca: [String: [MainViewController.carbGraphStruct]] = [:] + for r in bgData { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bg[k, default: []].append(r) + } + for r in boluses { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bo[k, default: []].append(r) + } + for r in smbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); sm[k, default: []].append(r) + } + for r in basals { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ba[k, default: []].append(r) + } + for r in carbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ca[k, default: []].append(r) + } + var result: [String: DayData] = [:] + for k in bg.keys { + result[k] = DayData(bg: bg[k]!, bolus: bo[k] ?? [], smb: sm[k] ?? [], basal: ba[k] ?? [], carbs: ca[k] ?? []) + } + return result + } + + // MARK: - Colors / fonts + + private static func accent(_ cfg: EndoReportConfig) -> UIColor { cfg.accentColor } + private static func accentDark(_ cfg: EndoReportConfig) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + cfg.accentColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s, brightness: b * 0.72, alpha: a) + } + + private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) + private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) + private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) + private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) + private static let C_WHITE = UIColor.white + private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) + private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) + private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) + private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) + private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) + private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 0.85) + private static let C_SMB = UIColor(red: 0.800, green: 0.200, blue: 0.600, alpha: 0.75) + private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.65) + + private static func bgColor(_ bg: Double) -> UIColor { + switch bg { case ..<54: return C_VLOW; case ..<70: return C_LOW; case ...180: return C_IN; case ...250: return C_HIGH; default: return C_VHIGH } + } + + // MARK: - Page 1: Summary + + private static func drawSummaryPage( + ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], tirData: [TIRDataPoint], + stats: ReportStats, patterns: TimePatterns, + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + simpleVM: SimpleStatsViewModel + ) { + let m: CGFloat = 30 + var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) + + y = sectionHdr("GLUCOSE SUMMARY", y: y + 10, m: m, w: r.width, cfg: cfg, ctx: ctx) + + // Stat grid (left) + TIR bar (right) + let gridW: CGFloat = r.width - m * 2 - 158 + let cw = gridW / 2 - 3; let ch: CGFloat = 44 + let cards: [(String, String, Bool)] = [ + ("TIME IN RANGE", String(format: "%.0f%%", stats.tir), true), + ("GMI (EST. A1C)", String(format: "%.1f%%", stats.eA1C), false), + ("AVERAGE", cfg.fmtBG(stats.avg) + " \(cfg.units)", false), + ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), + ("CV", String(format: "%.0f%%", stats.cv), false), + ("READINGS", "\(stats.readingCount)", false), + ] + var gy = y + 5 + for (i, c) in cards.enumerated() { + statCard(c.0, val: c.1, x: m + CGFloat(i % 2) * (cw + 6), y: gy + CGFloat(i / 2) * (ch + 4), + w: cw, h: ch, accent: c.2, cfg: cfg, ctx: ctx) + } + drawTIRBar(tirData: tirData, stats: stats, x: m + gridW + 10, y: y + 5, + w: 148, h: ch * 3 + 8, cfg: cfg, ctx: ctx) + y = gy + CGFloat(3) * (ch + 4) + 6 + + // Time-of-day strip + y = timeStrip(patterns: patterns, cfg: cfg, y: y + 4, m: m, w: r.width, ctx: ctx) + + // Insulin + if !boluses.isEmpty || !smbs.isEmpty { + y = sectionHdr("INSULIN DELIVERY", y: y + 8, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, + stats: stats, cfg: cfg, y: y + 4, m: m, w: r.width, ctx: ctx) + } + + // Nutrition + if !carbs.isEmpty { + y = sectionHdr("NUTRITION & MEALS", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 4, m: m, w: r.width, ctx: ctx) + } + + // Therapy settings if entered + let hasSettings = !cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty + if hasSettings { + y = sectionHdr("CURRENT THERAPY SETTINGS", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) + var rows: [(String, String)] = [] + if !cfg.carbRatio.isEmpty { rows.append(("Carb Ratio (CR)", "\(cfg.carbRatio) g/U")) } + if !cfg.isf.isEmpty { rows.append(("Insulin Sensitivity (ISF)", "\(cfg.isf) \(cfg.units)/U")) } + if !cfg.basalRate.isEmpty { rows.append(("Basal Rate", "\(cfg.basalRate) U/hr")) } + if !cfg.targetGlucose.isEmpty { rows.append(("Target Glucose", "\(cfg.targetGlucose) \(cfg.units)")) } + y = metricTable(rows, y: y + 4, m: m, w: r.width, cfg: cfg, ctx: ctx) + } + + // Device info + let hasDevice = !cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty + if hasDevice { + y = sectionHdr("DEVICES & INSULIN", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) + var rows: [(String, String)] = [] + if !cfg.aidSystem.isEmpty { rows.append(("AID System", cfg.aidSystem)) } + if !cfg.pumpDevice.isEmpty { rows.append(("Pump", cfg.pumpDevice)) } + if !cfg.cgmDevice.isEmpty { rows.append(("CGM", cfg.cgmDevice)) } + if !cfg.insulinType.isEmpty { rows.append(("Insulin", cfg.insulinType)) } + y = metricTable(rows, y: y + 4, m: m, w: r.width, cfg: cfg, ctx: ctx) + } + + // AGP — fixed layout with reserved space, no overflow + let agpAvail = r.height - y - 50 // leave room for footer + if !agpData.isEmpty, agpAvail >= 100 { + y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) + let agpH = Swift.min(agpAvail - 24, 118) + drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) + } + + drawFooter(ctx: ctx, r: r, cfg: cfg, stats: stats, page: 1) + } + + // MARK: - Hero header + + @discardableResult + private static func drawHero(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, stats: ReportStats) -> CGFloat { + let h: CGFloat = 100; let ac = accent(cfg); let ad = accentDark(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 21)) + + // "LOOP FOLLOW" spaced out + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 3.0] + "LOOP FOLLOW".draw(at: CGPoint(x: 32, y: 5), withAttributes: a1) + + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] + "Endocrinologist Visit Report".draw(at: CGPoint(x: 32, y: 22), withAttributes: a2) + + let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] + "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 32, y: 50), withAttributes: a3) + + let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" + let ds = "\(df.string(from: cfg.startDate)) — \(df.string(from: cfg.endDate)) (\(Int(stats.days.rounded())) Days)" + let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] + ds.draw(at: CGPoint(x: 32, y: 66), withAttributes: a4) + + // AID badge + if !cfg.aidSystem.isEmpty { + let badge = "▶ \(cfg.aidSystem)" + let ba: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_WHITE.withAlphaComponent(0.9)] + let bsz = (badge as NSString).size(withAttributes: ba) + let bx = r.width - 32 - bsz.width - 10; let by: CGFloat = 66 + ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: bx - 4, y: by - 1, width: bsz.width + 12, height: 12)) + (badge as NSString).draw(at: CGPoint(x: bx + 2, y: by), withAttributes: ba) + } + + // Right side info + let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.85)] + var lines: [String] = [] + if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } + if !cfg.providerName.isEmpty { lines.append("Provider: \(cfg.providerName)") } + if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } + if !cfg.diagnosisDate.isEmpty { lines.append("Dx: \(cfg.diagnosisDate)") } + for (i, l) in lines.enumerated() { + let sz = (l as NSString).size(withAttributes: a5) + (l as NSString).draw(at: CGPoint(x: r.width - 34 - sz.width, y: 22 + CGFloat(i) * 14), withAttributes: a5) + } + return h + } + + // MARK: - Daily page header + + @discardableResult + private static func drawDailyPageHeader(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + page: Int, totalPages: Int) -> CGFloat + { + let h: CGFloat = 40; let ac = accent(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] + "Daily Glucose Breakdown".draw(at: CGPoint(x: 28, y: 11), withAttributes: a1) + let sub = "Newest to Oldest • Page \(page) of \(totalPages)" + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] + let sz = (sub as NSString).size(withAttributes: a2) + (sub as NSString).draw(at: CGPoint(x: r.width - 28 - sz.width, y: 14), withAttributes: a2) + return h + } + + // MARK: - Section header + + @discardableResult + private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + ctx.setFillColor(accent(cfg).cgColor) + ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: accent(cfg), .kern: 0.6] + (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() + return y + 19 + } + + // MARK: - Stat card + + private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, accent ac: Bool, + cfg: EndoReportConfig, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + if ac { + ctx.setFillColor(accent(cfg).withAlphaComponent(0.07).cgColor); ctx.fill(r) + ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + } + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: ac ? accent(cfg) : C_INK] + (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 5), withAttributes: la) + (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 16), withAttributes: va) + } + + // MARK: - TIR vertical bar + + private static func drawTIRBar(tirData: [TIRDataPoint], stats _: ReportStats, + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) + { + guard let avg = tirData.first(where: { $0.period == .average }) else { return } + let r = CGRect(x: x, y: y, width: w, height: h) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) + let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) + let bx = x + 10; let bw: CGFloat = 16; let by = y + 22; let bh = h - 40 + let segs: [(Double, UIColor, String)] = [(avg.veryHigh, C_VHIGH, "Very High"), (avg.high, C_HIGH, "High"), + (avg.inRange, C_IN, "In Range"), (avg.low, C_LOW, "Low"), (avg.veryLow, C_VLOW, "Very Low")] + var sy = by + for (pct, clr, label) in segs { + let sh = CGFloat(pct / 100) * bh + if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: bx, y: sy, width: bw, height: sh)) } + if sh >= 10 { + let ps = String(format: "%.0f%%", pct) + let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: label == "In Range" ? accent(cfg) : C_SLATE] + let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_SLATE] + (ps as NSString).draw(at: CGPoint(x: bx + bw + 4, y: sy + sh / 2 - 8), withAttributes: pa) + if sh >= 18 { (label as NSString).draw(at: CGPoint(x: bx + bw + 4, y: sy + sh / 2), withAttributes: sa) } + } + sy += sh + } + let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + "Target: 70-180".draw(at: CGPoint(x: x + 5, y: y + h - 20), withAttributes: na) + "Tight: 70-140".draw(at: CGPoint(x: x + 5, y: y + h - 12), withAttributes: na) + "1% ≈ 15 min".draw(at: CGPoint(x: x + 5, y: y + h - 4), withAttributes: na) + } + + // MARK: - Time-of-day strip + + @discardableResult + private static func timeStrip(patterns: TimePatterns, cfg: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) + let periods = [patterns.night, patterns.earlyAM, patterns.morning, + patterns.afternoon, patterns.evening, patterns.late] + let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 40; let cy = y + 13 + for (i, p) in periods.enumerated() { + let cx = m + CGFloat(i) * cw + let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) + guard p.count > 0 else { continue } + let disp = cfg.fmtBG(p.avg) + let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14), .foregroundColor: vc] + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let vsz = (disp as NSString).size(withAttributes: va) + let lsz = (p.label as NSString).size(withAttributes: la) + (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) + (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 27), withAttributes: la) + } + return cy + ch + 4 + } + + // MARK: - Insulin section + + @discardableResult + private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + simpleVM: SimpleStatsViewModel, stats _: ReportStats, + cfg: EndoReportConfig, y: CGFloat, m: CGFloat, w: CGFloat, + ctx: CGContext) -> CGFloat + { + let tdd = simpleVM.totalDailyDose ?? 0 + let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 + let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 + let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), + ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), + ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 40 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 17), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + } + let ty = y + ch + 5 + let total = (boluses + smbs).map { $0.value }.reduce(0,+) + let rows: [(String, String)] = [ + ("Correction Boluses", "\(boluses.count)"), + ("SMB / Auto-Corrections", "\(smbs.count)"), + ("Total Bolus Insulin", String(format: "%.1f U", total)), + ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), + ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), + ("Positive Temp Basal", simpleVM.totalPositiveBasal != nil ? String(format: "+%.2f U/day", simpleVM.totalPositiveBasal!) : "—"), + ("Negative Temp Basal", simpleVM.totalNegativeBasal != nil ? String(format: "%.2f U/day", simpleVM.totalNegativeBasal!) : "—"), + ] + return metricTable(rows, y: ty, m: m, w: w, cfg: cfg, ctx: ctx) + } + + // MARK: - Nutrition section + + @discardableResult + private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], + stats: ReportStats, cfg _: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let total = carbs.map { $0.value }.reduce(0,+) + let cards: [(String, String)] = [ + ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), + ("MEALS LOGGED", "\(carbs.count)"), + ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), + ] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 40 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 17), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + } + return y + ch + 6 + } + + // MARK: - Metric table + + @discardableResult + private static func metricTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, + w: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = w - m * 2; let hh: CGFloat = 13; let rh: CGFloat = 12; var cy = y + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: hh)) + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] + "METRIC".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) + "VALUE".draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 3), withAttributes: ha) + cy += hh + for (i, row) in rows.enumerated() { + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_INK] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: accent(cfg)] + (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 2), withAttributes: ka) + (row.1 as NSString).draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 2), withAttributes: va) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: m, y: cy + rh)); ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)); ctx.strokePath() + cy += rh + } + return cy + 5 + } + + // MARK: - AGP (clean, no overflow) + + private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) + { + guard !agpData.isEmpty else { return } + let lPad: CGFloat = 26; let bPad: CGFloat = 16 + let cw = w - lPad; let ch = h - bPad + let cx = x + lPad; let cy = y + + ctx.setFillColor(UIColor(white: 0.985, alpha: 1).cgColor) + ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) + + let bgMin: CGFloat = 40; let bgRng: CGFloat = 320 + func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRng * ch } + func tx(_ mins: Int) -> CGFloat { cx + CGFloat(mins) / (24 * 60) * cw } + + // Target zone + ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) + ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) + + // Target lines + ctx.setLineDash(phase: 0, lengths: [3, 2]) + for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { + ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.6) + ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() + } + ctx.setLineDash(phase: 0, lengths: []) + + // 5-95 band + var band = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)); i == 0 ? band.move(to: p) : band.addLine(to: p) + } + for pt in agpData.reversed() { + band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) + } + band.closeSubpath() + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor); ctx.addPath(band); ctx.fillPath() + + // IQR band + var iqr = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)); i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) + } + for pt in agpData.reversed() { + iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) + } + iqr.closeSubpath() + ctx.setFillColor(accent(cfg).withAlphaComponent(0.25).cgColor); ctx.addPath(iqr); ctx.fillPath() + + // Median + ctx.setStrokeColor(accent(cfg).cgColor); ctx.setLineWidth(1.6) + var first = true + for pt in agpData { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)); first ? ctx.move(to: p) : ctx.addLine(to: p); first = false + } + ctx.strokePath() + + // Y labels — strictly left of chart + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + for bg in [70, 140, 180, 250] { + let ly = gy(Double(bg)); guard ly >= cy, ly <= cy + ch else { continue } + let lbl = cfg.isMMOL ? String(format: "%.1f", Double(bg) * 0.0555) : "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) + ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() + } + + // X labels — clamped below chart + for h2 in stride(from: 0, through: 24, by: 3) { + let lx = tx(h2 * 60) + let lbl = String(format: "%02d:00", h2) + let lsz = (lbl as NSString).size(withAttributes: axA) + let dx = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) + (lbl as NSString).draw(at: CGPoint(x: dx, y: cy + ch + 3), withAttributes: axA) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) + ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() + } + + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) + + // Legend — bottom right, inside label zone + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let lgItems: [(String, UIColor, Bool)] = [("Median", accent(cfg), false), + ("25–75th", accent(cfg).withAlphaComponent(0.4), true), + ("5–95th", accent(cfg).withAlphaComponent(0.18), true)] + var lgX = cx + cw + for item in lgItems.reversed() { + let lsz = (item.0 as NSString).size(withAttributes: lgA) + lgX -= lsz.width + (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 3), withAttributes: lgA) + lgX -= 15 + item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 4, width: 12, height: 8)) }() + : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 7, width: 12, height: 2)) }() + lgX -= 5 + } + } + + // MARK: - Day row + + private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + day: String, dayData: DayData, cfg: EndoReportConfig, + simpleVM _: SimpleStatsViewModel) + { + // Card + ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) + ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) + ctx.setFillColor(accent(EndoReportConfig(patientName: "", dateOfBirth: "", diagnosisDate: "", providerName: "", insulinType: "", aidSystem: "", pumpDevice: "", cgmDevice: "", carbRatio: "", isf: "", basalRate: "", targetGlucose: "", units: cfg.units, accentColorHex: cfg.accentColorHex, includeDailyBreakdown: true, includeFatProtein: false, startDate: cfg.startDate, endDate: cfg.endDate)).cgColor) + + // Use cfg directly for accent + ctx.setFillColor(cfg.accentColor.cgColor) + ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + + // Day label + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d, yyyy" + let date = df.date(from: day) ?? Date() + let dlA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_INK] + df2.string(from: date).draw(at: CGPoint(x: x + 10, y: y + 5), withAttributes: dlA) + + // Right-side stats panel + let vals = dayData.bg.map { Double($0.sgv) } + let statsX = x + w - 130 + if !vals.isEmpty { + let n = Double(vals.count) + let avg = vals.reduce(0,+) / n + let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + let totalBolus = (dayData.bolus + dayData.smb).map { $0.value }.reduce(0,+) + // Basal estimate: sum basalRate * duration segments + var basalTotal = 0.0 + let sortedBasal = dayData.basal.sorted { $0.date < $1.date } + for i in 0 ..< sortedBasal.count - 1 { + let dur = (sortedBasal[i + 1].date - sortedBasal[i].date) / 3600 + if dur > 0, dur < 4 { basalTotal += sortedBasal[i].basalRate * dur } + } + let total = totalBolus + basalTotal + let bolusPct = total > 0 ? totalBolus / total * 100 : 0 + let basalPct = total > 0 ? basalTotal / total * 100 : 0 + + let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let sv: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW + let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] + let acA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: cfg.accentColor] + + // Row 1 — labels + let cols: [(String, CGFloat)] = [("Avg", 0), ("TIR", 30), ("Bolus", 62), ("Basal", 95), ("B:Bas", 128)] + for (lbl, ox) in cols { + (lbl as NSString).draw(at: CGPoint(x: statsX + ox, y: y + 5), withAttributes: sa) + } + + // Row 2 — values + cfg.fmtBG(avg).draw(at: CGPoint(x: statsX, y: y + 14), withAttributes: sv) + String(format: "%.0f%%", tir).draw(at: CGPoint(x: statsX + 28, y: y + 14), withAttributes: tirA) + String(format: "%.1fU", totalBolus).draw(at: CGPoint(x: statsX + 60, y: y + 14), withAttributes: sv) + (basalTotal > 0 ? String(format: "%.1fU", basalTotal) : "—").draw(at: CGPoint(x: statsX + 93, y: y + 14), withAttributes: sv) + // Bolus:Basal ratio + let ratioStr = total > 0 ? String(format: "%.0f:%.0f", bolusPct, basalPct) : "—" + ratioStr.draw(at: CGPoint(x: statsX + 126, y: y + 14), withAttributes: acA) + } + + // Chart area + let chartX = x + 10; let chartW = w - 150 + let chartY = y + 26; let chartH = h - 32 + + guard !dayData.bg.isEmpty else { return } + + let bgMin: CGFloat = 40; let bgMax: CGFloat = 320; let bgRng = bgMax - bgMin + func gy(_ bg: Double) -> CGFloat { chartY + chartH - (CGFloat(bg) - bgMin) / bgRng * chartH } + func tx(_ ts: Double) -> CGFloat { + let cal = dateTimeUtils.displayCalendar() + let d = Date(timeIntervalSince1970: ts) + let c = cal.dateComponents([.hour, .minute], from: d) + let min = Double((c.hour ?? 0) * 60 + (c.minute ?? 0)) + return chartX + CGFloat(min / (24 * 60)) * chartW + } + + // Target zone + ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) + ctx.fill(CGRect(x: chartX, y: gy(180), width: chartW, height: gy(70) - gy(180))) + + // Target lines + ctx.setLineDash(phase: 0, lengths: [2, 2]); ctx.setLineWidth(0.4) + ctx.setStrokeColor(C_LOW.withAlphaComponent(0.4).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gy(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(70))); ctx.strokePath() + ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.4).cgColor) + ctx.move(to: CGPoint(x: chartX, y: gy(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(180))); ctx.strokePath() + ctx.setLineDash(phase: 0, lengths: []) + + // Hour grid + ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.25) + for h2 in stride(from: 3, through: 21, by: 3) { + let hx = chartX + CGFloat(h2) / 24 * chartW + ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() + } + + // Basal fill + line (bottom 25% of chart) + if !dayData.basal.isEmpty { + let bH = chartH * 0.25; let bY = chartY + chartH - bH + let sorted = dayData.basal.sorted { $0.date < $1.date } + let maxR = sorted.map { $0.basalRate }.max() ?? 1 + var path = CGMutablePath(); var first = true + for pt in sorted { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? path.move(to: CGPoint(x: px, y: py)) : path.addLine(to: CGPoint(x: px, y: py)); first = false + } + if let last = sorted.last { + path.addLine(to: CGPoint(x: tx(last.date), y: bY + bH)) + path.addLine(to: CGPoint(x: chartX, y: bY + bH)); path.closeSubpath() + ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor); ctx.addPath(path); ctx.fillPath() + } + var lp = CGMutablePath(); first = true + for pt in sorted { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH; first ? lp.move(to: CGPoint(x: px, y: py)) : lp.addLine(to: CGPoint(x: px, y: py)); first = false + } + ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(0.9); ctx.addPath(lp); ctx.strokePath() + } + + // SMB bars (magenta, thinner) + for smb in dayData.smb { + let bx = tx(smb.date); let bh2 = CGFloat(Swift.min(smb.value / 15, 1)) * (chartH * 0.35) + ctx.setFillColor(C_SMB.cgColor) + ctx.fill(CGRect(x: bx - 1.2, y: chartY + chartH - bh2, width: 2.4, height: bh2)) + } + + // Bolus bars (purple) + for bolus in dayData.bolus { + let bx = tx(bolus.date); let bh2 = CGFloat(Swift.min(bolus.value / 15, 1)) * (chartH * 0.4) + ctx.setFillColor(C_BOLUS.cgColor) + ctx.fill(CGRect(x: bx - 1.5, y: chartY + chartH - bh2, width: 3, height: bh2)) + } + + // BG dots + for r in dayData.bg.sorted { $0.date < $1.date } { + let rx = tx(r.date); let ry = gy(Double(r.sgv)) + ctx.setFillColor(bgColor(Double(r.sgv)).cgColor) + ctx.fillEllipse(in: CGRect(x: rx - 1.6, y: ry - 1.6, width: 3.2, height: 3.2)) + } + + // X-axis hour markers (just 00,06,12,18,24) + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + for h2 in [0, 6, 12, 18, 24] { + let hx = chartX + CGFloat(h2) / 24 * chartW + let lbl = String(format: "%02d", h2) + let sz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) + } + + // Legend (first row only to save space) + if day == day { // always show — could gate on first row + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + var lgX = chartX + chartW + 4 + for (lbl, clr) in [("● BG", C_IN), ("▮ Bolus", C_BOLUS), ("▮ SMB", C_SMB), ("— Basal", C_BASAL)] { + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: clr] + (lbl as NSString).draw(at: CGPoint(x: lgX, y: chartY + 2), withAttributes: a) + lgX += (lbl as NSString).size(withAttributes: lgA).width + 5 + if lgX > x + w - 4 { break } + } + } + } + + // MARK: - Footer + + private static func drawFooter(ctx: CGContext, r: CGRect, cfg _: EndoReportConfig, + stats: ReportStats, page: Int) + { + let fy = r.height - 28 + ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] + "Loop Follow — for informational purposes only. Not a substitute for professional medical advice." + .draw(at: CGPoint(x: 30, y: fy + 4), withAttributes: a) + let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" + let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" + let msz = (meta as NSString).size(withAttributes: a) + (meta as NSString).draw(at: CGPoint(x: r.width - 30 - msz.width, y: fy + 4), withAttributes: a) + } +} + +// MARK: - UIColor hex init diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift index 79e1ef4be..443739f0e 100644 --- a/LoopFollow/Stats/EndoReportView.swift +++ b/LoopFollow/Stats/EndoReportView.swift @@ -8,123 +8,188 @@ struct EndoReportView: View { @Environment(\.dismiss) private var dismiss - // Patient info — persisted in UserDefaults so they don't retype each time + // Persisted patient/clinic info @AppStorage("endoReport.patientName") private var patientName = "" @AppStorage("endoReport.dateOfBirth") private var dateOfBirth = "" @AppStorage("endoReport.providerName") private var providerName = "" + @AppStorage("endoReport.insulinType") private var insulinType = "" + @AppStorage("endoReport.diagnosisDate") private var diagnosisDate = "" + @AppStorage("endoReport.aidSystem") private var aidSystem = "Loop" + @AppStorage("endoReport.pumpDevice") private var pumpDevice = "" + @AppStorage("endoReport.cgmDevice") private var cgmDevice = "" + @AppStorage("endoReport.units") private var units = "mg/dL" + @AppStorage("endoReport.accentColorHex") private var accentColorHex = "#23A0AC" + @AppStorage("endoReport.includeDailyBreakdown") private var includeDailyBreakdown = true + @AppStorage("endoReport.includeFatProtein") private var includeFatProtein = false - // Date range defaults to last 14 days + // Therapy settings (manual entry) + @AppStorage("endoReport.carbRatio") private var carbRatio = "" + @AppStorage("endoReport.isf") private var isf = "" + @AppStorage("endoReport.basalRate") private var basalRate = "" + @AppStorage("endoReport.targetGlucose") private var targetGlucose = "" + + // Date range @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -14, to: Date()) ?? Date() @State private var endDate: Date = .init() + // UI state + @StateObject private var profileFetcher = NightscoutProfileFetcher() @State private var isGenerating = false @State private var reportURL: URL? @State private var errorMessage: String? @State private var showShareSheet = false - @State private var showingDates = false + @State private var showColorPicker = false + @State private var pickedColor: Color = .init(hex: "#23A0AC") ?? .teal + @State private var fetchSuccess = false + + let aidOptions = ["Loop", "Trio", "OpenAPS", "Android APS", "CamAPS FX", "Other"] + let unitOptions = ["mg/dL", "mmol/L"] var body: some View { NavigationView { Form { - // ── Patient info ───────────────────────────────────────── - Section(header: Text("Patient Information") - .font(.caption).textCase(.uppercase)) - { - HStack { - Text("Name") - .foregroundColor(.secondary) - .frame(width: 90, alignment: .leading) - TextField("Full name", text: $patientName) + // ── Patient ────────────────────────────────────────────── + Section(header: label("Patient Information", icon: "person.fill")) { + row("Name", placeholder: "Full name", text: $patientName) + row("Date of Birth", placeholder: "MM/DD/YYYY", text: $dateOfBirth) + row("Diagnosed", placeholder: "Year (optional)", text: $diagnosisDate) + row("Provider", placeholder: "Dr. Name", text: $providerName) + } + + // ── Devices & AID ───────────────────────────────────────── + Section(header: label("Devices & System", icon: "gear")) { + Picker("AID System", selection: $aidSystem) { + ForEach(aidOptions, id: \.self) { Text($0) } } - HStack { - Text("Date of Birth") - .foregroundColor(.secondary) - .frame(width: 90, alignment: .leading) - TextField("MM/DD/YYYY", text: $dateOfBirth) - .keyboardType(.numbersAndPunctuation) + row("Pump", placeholder: "e.g. Omnipod 5", text: $pumpDevice) + row("CGM", placeholder: "e.g. Dexcom G7", text: $cgmDevice) + row("Insulin", placeholder: "e.g. Humalog", text: $insulinType) + } + + // ── Therapy settings ────────────────────────────────────── + Section(header: label("Current Therapy Settings", icon: "slider.horizontal.3")) { + // Fetch from Nightscout button + Button(action: fetchFromNightscout) { + HStack { + if profileFetcher.isFetching { + ProgressView().scaleEffect(0.8) + Text("Fetching from Nightscout…") + .font(.subheadline) + } else { + Image(systemName: fetchSuccess ? "checkmark.circle.fill" : "arrow.down.circle") + .foregroundColor(fetchSuccess ? .green : .accentColor) + Text(fetchSuccess ? "Settings Fetched!" : "Auto-Fill from Nightscout") + .font(.subheadline) + } + Spacer() + } } - HStack { - Text("Provider") - .foregroundColor(.secondary) - .frame(width: 90, alignment: .leading) - TextField("Dr. Name", text: $providerName) + .disabled(profileFetcher.isFetching) + + if let fetchErr = profileFetcher.error { + Label(fetchErr, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.caption) } + + row("Carb Ratio (CR)", placeholder: "g/U e.g. 10", text: $carbRatio, keyboard: .decimalPad) + row("ISF", placeholder: "mg/dL per U", text: $isf, keyboard: .decimalPad) + row("Basal Rate", placeholder: "U/hr e.g. 0.85", text: $basalRate, keyboard: .decimalPad) + row("Target Glucose", placeholder: "mg/dL e.g. 100", text: $targetGlucose, keyboard: .decimalPad) } - // ── Date range ─────────────────────────────────────────── - Section(header: Text("Report Period") - .font(.caption).textCase(.uppercase)) - { - // Quick presets + // ── Report period ───────────────────────────────────────── + Section(header: label("Report Period", icon: "calendar")) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(presets, id: \.label) { preset in - Button(preset.label) { - withAnimation { - startDate = preset.start - endDate = preset.end - } + ForEach(presets, id: \.label) { p in + Button(p.label) { + startDate = p.start; endDate = p.end } .buttonStyle(.bordered) .controlSize(.small) - .tint(isActivePreset(preset) ? .blue : .secondary) + .tint(isActive(p) ? .teal : .secondary) } } .padding(.vertical, 4) } + DatePicker("Start", selection: $startDate, in: ...endDate, displayedComponents: .date) + DatePicker("End", selection: $endDate, in: startDate..., displayedComponents: .date) + } + + // ── Report options ──────────────────────────────────────── + Section(header: label("Report Options", icon: "doc.richtext")) { + Picker("Units", selection: $units) { + ForEach(unitOptions, id: \.self) { Text($0) } + } + .pickerStyle(.segmented) - DatePicker("Start", selection: $startDate, - in: ...endDate, - displayedComponents: .date) - DatePicker("End", selection: $endDate, - in: startDate..., - displayedComponents: .date) + Toggle("Include Daily Breakdown", isOn: $includeDailyBreakdown) + Toggle("Include Fat & Protein", isOn: $includeFatProtein) + + HStack { + Text("Accent Color") + Spacer() + Circle() + .fill(pickedColor) + .frame(width: 24, height: 24) + .overlay(Circle().stroke(Color.secondary.opacity(0.3), lineWidth: 1)) + if #available(iOS 16.0, *) { + ColorPicker("", selection: $pickedColor, supportsOpacity: false) + .labelsHidden() + .onChange(of: pickedColor) { newVal in + accentColorHex = newVal.toHex() ?? "#23A0AC" + } + } + } } - // ── What's included ────────────────────────────────────── - Section(header: Text("Report Includes") - .font(.caption).textCase(.uppercase)) - { - Label("eA1C / GMI estimate", systemImage: "drop.fill") - Label("Time in Range distribution", systemImage: "chart.bar.fill") - Label("Ambulatory Glucose Profile", systemImage: "waveform.path.ecg") - Label("Daily glucose statistics", systemImage: "calendar") - Label("Insulin & carb summary", systemImage: "syringe.fill") + // ── What's included ─────────────────────────────────────── + Section(header: label("Report Includes", icon: "checklist")) { + Group { + Label("eA1C / GMI estimate", systemImage: "drop.fill") + Label("Time in Range distribution", systemImage: "chart.bar.fill") + Label("Ambulatory Glucose Profile", systemImage: "waveform.path.ecg") + Label("Glucose by time of day", systemImage: "clock.fill") + Label("Insulin delivery summary", systemImage: "syringe.fill") + if includeDailyBreakdown { + Label("Daily breakdown (newest first)", systemImage: "calendar.day.timeline.left") + } + if includeFatProtein { + Label("Fat & protein entries", systemImage: "fork.knife") + } + } + .font(.subheadline) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) - .font(.subheadline) - // ── Error message ──────────────────────────────────────── - if let error = errorMessage { + // ── Error ───────────────────────────────────────────────── + if let err = errorMessage { Section { - Label(error, systemImage: "exclamationmark.triangle.fill") + Label(err, systemImage: "exclamationmark.triangle.fill") .foregroundColor(.red) .font(.subheadline) } } - // ── Generate button ────────────────────────────────────── + // ── Generate ────────────────────────────────────────────── Section { - Button(action: generateReport) { + Button(action: generate) { HStack { Spacer() if isGenerating { - ProgressView() - .padding(.trailing, 8) - Text("Generating…") - .fontWeight(.semibold) + ProgressView().padding(.trailing, 6) + Text("Generating…").fontWeight(.semibold) } else { - Image(systemName: "doc.richtext") - .padding(.trailing, 4) - Text("Generate PDF Report") - .fontWeight(.semibold) + Image(systemName: "doc.richtext").padding(.trailing, 4) + Text("Generate PDF Report").fontWeight(.semibold) } Spacer() } } .disabled(isGenerating) .foregroundColor(.white) - .listRowBackground(isGenerating ? Color.blue.opacity(0.5) : Color.blue) + .listRowBackground(isGenerating ? Color.teal.opacity(0.5) : Color.teal) } } .navigationTitle("Endo Report") @@ -135,64 +200,110 @@ struct EndoReportView: View { } } .sheet(isPresented: $showShareSheet) { - if let url = reportURL { - ShareSheet(items: [url]) - } + if let url = reportURL { ShareSheet(items: [url]) } + } + .onAppear { + pickedColor = Color(hex: accentColorHex) ?? .teal } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) } - // MARK: - Presets + // MARK: - Helpers + + @ViewBuilder + private func row(_ label: String, placeholder: String, text: Binding, + keyboard: UIKeyboardType = .default) -> some View + { + HStack { + Text(label) + .foregroundColor(.secondary) + .frame(width: 110, alignment: .leading) + TextField(placeholder, text: text) + .keyboardType(keyboard) + } + } - private struct DatePreset { - let label: String - let start: Date - let end: Date + private func label(_ title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(.caption) + .textCase(.uppercase) } - private var presets: [DatePreset] { - let now = Date() - let cal = Calendar.current + // MARK: - Presets + + private struct Preset { let label: String; let start: Date; let end: Date } + private var presets: [Preset] { + let now = Date(); let cal = Calendar.current return [ - DatePreset(label: "7 days", - start: cal.date(byAdding: .day, value: -7, to: now) ?? now, end: now), - DatePreset(label: "14 days", - start: cal.date(byAdding: .day, value: -14, to: now) ?? now, end: now), - DatePreset(label: "30 days", - start: cal.date(byAdding: .day, value: -30, to: now) ?? now, end: now), - DatePreset(label: "90 days", - start: cal.date(byAdding: .day, value: -90, to: now) ?? now, end: now), + Preset(label: "3d", start: cal.date(byAdding: .day, value: -3, to: now)!, end: now), + Preset(label: "7d", start: cal.date(byAdding: .day, value: -7, to: now)!, end: now), + Preset(label: "14d", start: cal.date(byAdding: .day, value: -14, to: now)!, end: now), + Preset(label: "30d", start: cal.date(byAdding: .day, value: -30, to: now)!, end: now), + Preset(label: "90d", start: cal.date(byAdding: .day, value: -90, to: now)!, end: now), ] } - private func isActivePreset(_ preset: DatePreset) -> Bool { - let cal = Calendar.current - return cal.isDate(preset.start, inSameDayAs: startDate) && - cal.isDate(preset.end, inSameDayAs: endDate) + private func isActive(_ p: Preset) -> Bool { + Calendar.current.isDate(p.start, inSameDayAs: startDate) && + Calendar.current.isDate(p.end, inSameDayAs: endDate) + } + + // MARK: - Fetch from Nightscout + + private func fetchFromNightscout() { + fetchSuccess = false + profileFetcher.fetch { settings in + guard let s = settings else { return } + carbRatio = s.carbRatio + isf = s.isf + basalRate = s.basalRate + // Target: show low-high range if both present + if !s.targetLow.isEmpty && !s.targetHigh.isEmpty { + targetGlucose = "\(s.targetLow)–\(s.targetHigh)" + } else { + targetGlucose = s.targetLow.isEmpty ? s.targetHigh : s.targetLow + } + // Auto-set units from profile + units = s.units + fetchSuccess = true + // Reset success indicator after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + fetchSuccess = false + } + } } // MARK: - Generate - private func generateReport() { + private func generate() { errorMessage = nil isGenerating = true - - // Update the data service date range to match what the user picked dataService.updateDateRange(start: startDate, end: endDate) - - // Fetch data if needed, then generate dataService.ensureDataAvailable(onProgress: {}) { DispatchQueue.global(qos: .userInitiated).async { do { - let url = try EndoReportGenerator.generate( + let config = EndoReportConfig( patientName: patientName, dateOfBirth: dateOfBirth, + diagnosisDate: diagnosisDate, providerName: providerName, + insulinType: insulinType, + aidSystem: aidSystem, + pumpDevice: pumpDevice, + cgmDevice: cgmDevice, + carbRatio: carbRatio, + isf: isf, + basalRate: basalRate, + targetGlucose: targetGlucose, + units: units, + accentColorHex: accentColorHex, + includeDailyBreakdown: includeDailyBreakdown, + includeFatProtein: includeFatProtein, startDate: startDate, - endDate: endDate, - dataService: dataService + endDate: endDate ) + let url = try EndoReportGenerator.generate(config: config, dataService: dataService) DispatchQueue.main.async { isGenerating = false reportURL = url @@ -209,14 +320,170 @@ struct EndoReportView: View { } } -// MARK: - Share sheet wrapper +// MARK: - Config struct (passed to generator) + +struct EndoReportConfig { + let patientName: String + let dateOfBirth: String + let diagnosisDate: String + let providerName: String + let insulinType: String + let aidSystem: String + let pumpDevice: String + let cgmDevice: String + let carbRatio: String + let isf: String + let basalRate: String + let targetGlucose: String + let units: String + let accentColorHex: String + let includeDailyBreakdown: Bool + let includeFatProtein: Bool + let startDate: Date + let endDate: Date + + var accentColor: UIColor { + UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) + } + + var isMMOL: Bool { units == "mmol/L" } + func fmtBG(_ mgdl: Double) -> String { + isMMOL ? String(format: "%.1f", mgdl * 0.0555) : String(format: "%.0f", mgdl) + } + + var accentUIColor: UIColor { + UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) + } +} + +// MARK: - Share sheet struct ShareSheet: UIViewControllerRepresentable { let items: [Any] - func makeUIViewController(context _: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) } func updateUIViewController(_: UIActivityViewController, context _: Context) {} } + +// MARK: - Color extensions + +extension Color { + init?(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") + if h.count == 6 { h += "FF" } + guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } + self.init( + red: Double((val >> 24) & 0xFF) / 255, + green: Double((val >> 16) & 0xFF) / 255, + blue: Double((val >> 8) & 0xFF) / 255, + opacity: Double(val & 0xFF) / 255 + ) + } + + func toHex() -> String? { + guard let c = UIColor(self).cgColor.components, c.count >= 3 else { return nil } + return String(format: "#%02X%02X%02X", + Int(c[0] * 255), Int(c[1] * 255), Int(c[2] * 255)) + } +} + +extension UIColor { + convenience init?(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") + if h.count == 6 { h += "FF" } + guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } + self.init( + red: CGFloat((val >> 24) & 0xFF) / 255, + green: CGFloat((val >> 16) & 0xFF) / 255, + blue: CGFloat((val >> 8) & 0xFF) / 255, + alpha: CGFloat(val & 0xFF) / 255 + ) + } +} + +// MARK: - NightscoutProfileFetcher + +class NightscoutProfileFetcher: ObservableObject { + @Published var isFetching = false + @Published var error: String? + @Published var success = false + + struct FetchedSettings { + let carbRatio: String + let isf: String + let basalRate: String + let targetLow: String + let targetHigh: String + let units: String + } + + func fetch(completion: @escaping (FetchedSettings?) -> Void) { + isFetching = true + error = nil + success = false + + NightscoutUtils.executeRequest( + eventType: .profile, + parameters: [:] + ) { [weak self] (result: Result) in + DispatchQueue.main.async { + guard let self else { return } + self.isFetching = false + + switch result { + case let .failure(err): + self.error = err.localizedDescription + completion(nil) + + case let .success(profile): + // Use defaultProfile store, fall back to "default" / "Default" + let store = profile.store[profile.defaultProfile] + ?? profile.store["default"] + ?? profile.store["Default"] + ?? profile.store.values.first + + guard let s = store else { + self.error = "No profile store found in Nightscout response." + completion(nil) + return + } + + let isMMOL = s.units.lowercased().contains("mmol") + + // Pick the first (midnight) entry for each schedule + // and format a readable multi-segment string if >1 entry + func fmtSchedule(_ entries: [T], + value: (T) -> Double, + time: (T) -> String) -> String + { + if entries.count == 1 { + return String(format: isMMOL ? "%.2f" : "%.0f", value(entries[0])) + } + return entries.prefix(6).map { + "\(time($0)): \(String(format: isMMOL ? "%.2f" : "%.0f", value($0)))" + }.joined(separator: " | ") + } + + let cr = fmtSchedule(s.carbratio, value: { $0.value }, time: { $0.time }) + let isf = fmtSchedule(s.sens, value: { $0.value }, time: { $0.time }) + let bas = fmtSchedule(s.basal, value: { $0.value }, time: { $0.time }) + + let targetLow = s.target_low?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + let targetHigh = s.target_high?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + + self.success = true + completion(FetchedSettings( + carbRatio: cr, + isf: isf, + basalRate: bas, + targetLow: targetLow, + targetHigh: targetHigh, + units: isMMOL ? "mmol/L" : "mg/dL" + )) + } + } + } + } +} From 053610775a18eb5b76df4a8e41a168f6ef050121 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Sun, 24 May 2026 00:03:24 -0700 Subject: [PATCH 4/7] Updated UI and PDF Export --- LoopFollow/Stats/EndoReportGenerator.swift | 1173 ++++++++++---------- LoopFollow/Stats/EndoReportView.swift | 489 -------- 2 files changed, 605 insertions(+), 1057 deletions(-) delete mode 100644 LoopFollow/Stats/EndoReportView.swift diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index 7c261886d..435bc4290 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -4,315 +4,328 @@ import PDFKit import UIKit -// MARK: - Config — defined in EndoReportView.swift, used here by reference +// MARK: - Config + +struct EndoReportConfig { + let patientName: String + let dateOfBirth: String + let diagnosisDate: String + let providerName: String + let insulinType: String + let aidSystem: String + let pumpDevice: String + let cgmDevice: String + let carbRatio: String + let isf: String + let basalRate: String + let targetGlucose: String + let units: String // "mg/dL" or "mmol/L" + let accentColorHex: String + + // Toggles + let includeGlucoseSummary: Bool + let includeInsulin: Bool + let includeNutrition: Bool + let includeTherapySettings:Bool + let includeDevices: Bool + let includeAGP: Bool + let includeDailyBreakdown: Bool + let includeFatProtein: Bool + + let startDate: Date + let endDate: Date + + var accentColor: UIColor { + UIColor(hex: accentColorHex) ?? UIColor(red:0.137,green:0.624,blue:0.675,alpha:1) + } + var isMMOL: Bool { units == "mmol/L" } + func convert(_ mgdl: Double) -> Double { isMMOL ? mgdl * 0.0555 : mgdl } + func fmtBG(_ mgdl: Double) -> String { + isMMOL ? String(format:"%.1f", mgdl*0.0555) : String(format:"%.0f", mgdl) + } +} + +// MARK: - Generator enum EndoReportGenerator { + enum ReportError: LocalizedError { case noData var errorDescription: String? { "No CGM data available for the selected date range." } } - // MARK: - Entry point - static func generate(config: EndoReportConfig, dataService: StatsDataService) throws -> URL { - let bgData = dataService.getBGData() + let bgData = dataService.getBGData() guard !bgData.isEmpty else { throw ReportError.noData } - let agpData = AGPCalculator.calculate(bgData: bgData) - let tirData = TIRCalculator.calculate(bgData: bgData) - let stats = ReportStats(bgData: bgData, dataService: dataService) + let agpData = AGPCalculator.calculate(bgData: bgData) + let tirData = TIRCalculator.calculate(bgData: bgData) + let stats = ReportStats(bgData: bgData, dataService: dataService) let patterns = TimePatterns(bgData: bgData) - let boluses = dataService.getBolusData() - let smbs = dataService.getSMBData() - let carbs = dataService.getCarbData() - let basals = dataService.getBasalData() + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + let basals = dataService.getBasalData() let simpleVM = SimpleStatsViewModel(dataService: dataService) simpleVM.calculateStats() - let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) - let renderer = UIGraphicsPDFRenderer(bounds: pageRect) + let pageRect = CGRect(origin:.zero, size:CGSize(width:612,height:792)) + let renderer = UIGraphicsPDFRenderer(bounds:pageRect) let url = FileManager.default.temporaryDirectory .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") - // Group days newest-first - let dailyData = groupByDay(bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, carbs: carbs) + let dailyData = groupByDay(bgData:bgData, boluses:boluses, smbs:smbs, basals:basals, carbs:carbs) .sorted { $0.key > $1.key } let data = renderer.pdfData { ctx in // Page 1 — Summary ctx.beginPage() - drawSummaryPage(ctx: ctx.cgContext, r: pageRect, cfg: config, - bgData: bgData, agpData: agpData, tirData: tirData, - stats: stats, patterns: patterns, - boluses: boluses, smbs: smbs, carbs: carbs, - simpleVM: simpleVM) + drawSummaryPage(ctx:ctx.cgContext, r:pageRect, cfg:config, + bgData:bgData, agpData:agpData, tirData:tirData, + stats:stats, patterns:patterns, + boluses:boluses, smbs:smbs, carbs:carbs, + simpleVM:simpleVM) // Pages 2+ — Daily breakdowns if config.includeDailyBreakdown && !dailyData.isEmpty { - let rowH: CGFloat = 88 - let rowGap: CGFloat = 6 - let topY: CGFloat = 52 // after page header - let botY: CGFloat = 762 // before footer - let usable = botY - topY - let perPage = Int((usable + rowGap) / (rowH + rowGap)) - let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) - - for p in 0 ..< pages { + let rowH:CGFloat = 88 + let rowGap:CGFloat = 6 + let topY:CGFloat = 52 + let botY:CGFloat = 762 + let usable = botY - topY + let perPage = Int((usable + rowGap) / (rowH + rowGap)) + let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) + + for p in 0..= 70 && $0 <= 180 }.count) / n * 100 - tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 + init(bgData:[ShareGlucoseData], dataService:StatsDataService) { + let v=bgData.map{Double($0.sgv)}; let n=Double(v.count) + let m=v.reduce(0,+)/n + let variance=v.map{($0-m)*($0-m)}.reduce(0,+)/n + avg=m; stdDev=sqrt(variance); cv=stdDev/m*100; eA1C=(m+46.7)/28.7 + minBG=v.min() ?? 0; maxBG=v.max() ?? 0; readingCount=v.count + days=Swift.max(dataService.endDate.timeIntervalSince1970-dataService.startDate.timeIntervalSince1970,86400)/86400 + sensorPct=Swift.min(Double(v.count)/(days*288)*100,100) + tir=Double(v.filter{$0>=70&&$0<=180}.count)/n*100 + tightTIR=Double(v.filter{$0>=70&&$0<=140}.count)/n*100 } } struct TimePatterns { - struct Period { let label: String; let avg: Double; let count: Int } - let night, earlyAM, morning, afternoon, evening, late: Period - init(bgData: [ShareGlucoseData]) { - func p(_ l: String, _ s: Int, _ e: Int) -> Period { - let cal = dateTimeUtils.displayCalendar() - let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } - return Period(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) + struct Period { let label:String; let avg:Double; let count:Int } + let night,earlyAM,morning,afternoon,evening,late: Period + init(bgData:[ShareGlucoseData]) { + func p(_ l:String,_ s:Int,_ e:Int)->Period { + let cal=dateTimeUtils.displayCalendar() + let r=bgData.filter{ let h=cal.component(.hour,from:Date(timeIntervalSince1970:$0.date)); return h>=s&&h [String: DayData] { - let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" - var bg: [String: [ShareGlucoseData]] = [:] - var bo: [String: [MainViewController.bolusGraphStruct]] = [:] - var sm: [String: [MainViewController.bolusGraphStruct]] = [:] - var ba: [String: [MainViewController.basalGraphStruct]] = [:] - var ca: [String: [MainViewController.carbGraphStruct]] = [:] - for r in bgData { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); bg[k, default: []].append(r) - } - for r in boluses { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); bo[k, default: []].append(r) - } - for r in smbs { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); sm[k, default: []].append(r) - } - for r in basals { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); ba[k, default: []].append(r) - } - for r in carbs { - let k = df.string(from: Date(timeIntervalSince1970: r.date)); ca[k, default: []].append(r) - } - var result: [String: DayData] = [:] - for k in bg.keys { - result[k] = DayData(bg: bg[k]!, bolus: bo[k] ?? [], smb: sm[k] ?? [], basal: ba[k] ?? [], carbs: ca[k] ?? []) - } + bgData:[ShareGlucoseData], + boluses:[MainViewController.bolusGraphStruct], + smbs:[MainViewController.bolusGraphStruct], + basals:[MainViewController.basalGraphStruct], + carbs:[MainViewController.carbGraphStruct] + ) -> [String:DayData] { + let df=DateFormatter(); df.dateFormat="yyyy-MM-dd" + var bg:[String:[ShareGlucoseData]]=[:] + var bo:[String:[MainViewController.bolusGraphStruct]]=[:] + var sm:[String:[MainViewController.bolusGraphStruct]]=[:] + var ba:[String:[MainViewController.basalGraphStruct]]=[:] + var ca:[String:[MainViewController.carbGraphStruct]]=[:] + for r in bgData { let k=df.string(from:Date(timeIntervalSince1970:r.date)); bg[k,default:[]].append(r) } + for r in boluses { let k=df.string(from:Date(timeIntervalSince1970:r.date)); bo[k,default:[]].append(r) } + for r in smbs { let k=df.string(from:Date(timeIntervalSince1970:r.date)); sm[k,default:[]].append(r) } + for r in basals { let k=df.string(from:Date(timeIntervalSince1970:r.date)); ba[k,default:[]].append(r) } + for r in carbs { let k=df.string(from:Date(timeIntervalSince1970:r.date)); ca[k,default:[]].append(r) } + var result:[String:DayData]=[: ] + for k in bg.keys { result[k]=DayData(bg:bg[k]!,bolus:bo[k] ?? [],smb:sm[k] ?? [],basal:ba[k] ?? [],carbs:ca[k] ?? []) } return result } // MARK: - Colors / fonts - private static func accent(_ cfg: EndoReportConfig) -> UIColor { cfg.accentColor } - private static func accentDark(_ cfg: EndoReportConfig) -> UIColor { - var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - cfg.accentColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) - return UIColor(hue: h, saturation: s, brightness: b * 0.72, alpha: a) + private static func accent(_ cfg:EndoReportConfig) -> UIColor { cfg.accentColor } + private static func accentDark(_ cfg:EndoReportConfig) -> UIColor { + var h:CGFloat=0,s:CGFloat=0,b:CGFloat=0,a:CGFloat=0 + cfg.accentColor.getHue(&h,saturation:&s,brightness:&b,alpha:&a) + return UIColor(hue:h,saturation:s,brightness:b*0.72,alpha:a) } - private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) - private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) - private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) - private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) - private static let C_WHITE = UIColor.white - private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) - private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) - private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) - private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) - private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) - private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 0.85) - private static let C_SMB = UIColor(red: 0.800, green: 0.200, blue: 0.600, alpha: 0.75) - private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.65) - - private static func bgColor(_ bg: Double) -> UIColor { - switch bg { case ..<54: return C_VLOW; case ..<70: return C_LOW; case ...180: return C_IN; case ...250: return C_HIGH; default: return C_VHIGH } + private static let C_INK = UIColor(red:0.133,green:0.157,blue:0.192,alpha:1) + private static let C_SLATE = UIColor(red:0.400,green:0.440,blue:0.490,alpha:1) + private static let C_CLOUD = UIColor(red:0.960,green:0.963,blue:0.970,alpha:1) + private static let C_BORDER = UIColor(red:0.870,green:0.885,blue:0.905,alpha:1) + private static let C_WHITE = UIColor.white + private static let C_VLOW = UIColor(red:0.820,green:0.180,blue:0.180,alpha:1) + private static let C_LOW = UIColor(red:0.929,green:0.490,blue:0.188,alpha:1) + private static let C_IN = UIColor(red:0.200,green:0.670,blue:0.470,alpha:1) + private static let C_HIGH = UIColor(red:0.910,green:0.740,blue:0.220,alpha:1) + private static let C_VHIGH = UIColor(red:0.800,green:0.340,blue:0.340,alpha:1) + private static let C_BOLUS = UIColor(red:0.380,green:0.220,blue:0.780,alpha:0.85) + private static let C_SMB = UIColor(red:0.800,green:0.200,blue:0.600,alpha:0.75) + private static let C_BASAL = UIColor(red:0.102,green:0.451,blue:0.933,alpha:0.65) + + private static func bgColor(_ bg:Double)->UIColor { + switch bg { case ..<54:return C_VLOW; case ..<70:return C_LOW; case ...180:return C_IN; case ...250:return C_HIGH; default:return C_VHIGH } } // MARK: - Page 1: Summary private static func drawSummaryPage( - ctx: CGContext, r: CGRect, cfg: EndoReportConfig, - bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], tirData: [TIRDataPoint], - stats: ReportStats, patterns: TimePatterns, - boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - carbs: [MainViewController.carbGraphStruct], - simpleVM: SimpleStatsViewModel + ctx:CGContext, r:CGRect, cfg:EndoReportConfig, + bgData:[ShareGlucoseData], agpData:[AGPDataPoint], tirData:[TIRDataPoint], + stats:ReportStats, patterns:TimePatterns, + boluses:[MainViewController.bolusGraphStruct], + smbs:[MainViewController.bolusGraphStruct], + carbs:[MainViewController.carbGraphStruct], + simpleVM:SimpleStatsViewModel ) { - let m: CGFloat = 30 - var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) - - y = sectionHdr("GLUCOSE SUMMARY", y: y + 10, m: m, w: r.width, cfg: cfg, ctx: ctx) - - // Stat grid (left) + TIR bar (right) - let gridW: CGFloat = r.width - m * 2 - 158 - let cw = gridW / 2 - 3; let ch: CGFloat = 44 - let cards: [(String, String, Bool)] = [ - ("TIME IN RANGE", String(format: "%.0f%%", stats.tir), true), - ("GMI (EST. A1C)", String(format: "%.1f%%", stats.eA1C), false), - ("AVERAGE", cfg.fmtBG(stats.avg) + " \(cfg.units)", false), - ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), - ("CV", String(format: "%.0f%%", stats.cv), false), - ("READINGS", "\(stats.readingCount)", false), - ] - var gy = y + 5 - for (i, c) in cards.enumerated() { - statCard(c.0, val: c.1, x: m + CGFloat(i % 2) * (cw + 6), y: gy + CGFloat(i / 2) * (ch + 4), - w: cw, h: ch, accent: c.2, cfg: cfg, ctx: ctx) + let m:CGFloat=24 + var y = drawHero(ctx:ctx, r:r, cfg:cfg, stats:stats) + + if cfg.includeGlucoseSummary { + y = sectionHdr("GLUCOSE SUMMARY", y:y+8, m:m, w:r.width, cfg:cfg, ctx:ctx) + + let gridW:CGFloat = r.width - m*2 - 158 + let cw = gridW/2 - 3; let ch:CGFloat=42 + let cards:[(String,String,Bool)] = [ + ("TIME IN RANGE", String(format:"%.0f%%", stats.tir), true), + ("GMI (EST. A1C)", String(format:"%.1f%%", stats.eA1C), false), + ("AVERAGE", cfg.fmtBG(stats.avg)+" \(cfg.units)", false), + ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), + ("CV", String(format:"%.0f%%", stats.cv), false), + ("READINGS", "\(stats.readingCount)", false), + ] + var gy = y+4 + for(i,c) in cards.enumerated() { + statCard(c.0,val:c.1, x:m+CGFloat(i%2)*(cw+6), y:gy+CGFloat(i/2)*(ch+4), + w:cw, h:ch, accent:c.2, cfg:cfg, ctx:ctx) + } + drawTIRBar(tirData:tirData, stats:stats, x:m+gridW+10, y:y+4, + w:148, h:ch*3+8, cfg:cfg, ctx:ctx) + y = gy + CGFloat(3)*(ch+4) + 4 + + y = timeStrip(patterns:patterns, cfg:cfg, y:y+4, m:m, w:r.width, ctx:ctx) } - drawTIRBar(tirData: tirData, stats: stats, x: m + gridW + 10, y: y + 5, - w: 148, h: ch * 3 + 8, cfg: cfg, ctx: ctx) - y = gy + CGFloat(3) * (ch + 4) + 6 - - // Time-of-day strip - y = timeStrip(patterns: patterns, cfg: cfg, y: y + 4, m: m, w: r.width, ctx: ctx) - - // Insulin - if !boluses.isEmpty || !smbs.isEmpty { - y = sectionHdr("INSULIN DELIVERY", y: y + 8, m: m, w: r.width, cfg: cfg, ctx: ctx) - y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, - stats: stats, cfg: cfg, y: y + 4, m: m, w: r.width, ctx: ctx) + + if cfg.includeInsulin && (!boluses.isEmpty || !smbs.isEmpty) { + y = sectionHdr("INSULIN DELIVERY", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) + y = insulinSection(boluses:boluses, smbs:smbs, simpleVM:simpleVM, + stats:stats, cfg:cfg, y:y+4, m:m, w:r.width, ctx:ctx) } - // Nutrition - if !carbs.isEmpty { - y = sectionHdr("NUTRITION & MEALS", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) - y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 4, m: m, w: r.width, ctx: ctx) + if cfg.includeNutrition && !carbs.isEmpty { + y = sectionHdr("NUTRITION & MEALS", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) + y = nutritionSection(carbs:carbs, stats:stats, cfg:cfg, y:y+4, m:m, w:r.width, ctx:ctx) } - // Therapy settings if entered let hasSettings = !cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty - if hasSettings { - y = sectionHdr("CURRENT THERAPY SETTINGS", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) - var rows: [(String, String)] = [] - if !cfg.carbRatio.isEmpty { rows.append(("Carb Ratio (CR)", "\(cfg.carbRatio) g/U")) } - if !cfg.isf.isEmpty { rows.append(("Insulin Sensitivity (ISF)", "\(cfg.isf) \(cfg.units)/U")) } - if !cfg.basalRate.isEmpty { rows.append(("Basal Rate", "\(cfg.basalRate) U/hr")) } - if !cfg.targetGlucose.isEmpty { rows.append(("Target Glucose", "\(cfg.targetGlucose) \(cfg.units)")) } - y = metricTable(rows, y: y + 4, m: m, w: r.width, cfg: cfg, ctx: ctx) + if cfg.includeTherapySettings && hasSettings { + y = sectionHdr("CURRENT THERAPY SETTINGS", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) + var rows:[(String,String)]=[] + if !cfg.carbRatio.isEmpty { rows.append(("Carb Ratio (CR)", cfg.carbRatio)) } + if !cfg.isf.isEmpty { rows.append(("Insulin Sensitivity (ISF)", cfg.isf)) } + if !cfg.basalRate.isEmpty { rows.append(("Basal Rate", cfg.basalRate)) } + if !cfg.targetGlucose.isEmpty{ rows.append(("Target Glucose", cfg.targetGlucose)) } + y = settingsTable(rows, y:y+4, m:m, w:r.width, cfg:cfg, ctx:ctx) } - // Device info let hasDevice = !cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty - if hasDevice { - y = sectionHdr("DEVICES & INSULIN", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) - var rows: [(String, String)] = [] - if !cfg.aidSystem.isEmpty { rows.append(("AID System", cfg.aidSystem)) } - if !cfg.pumpDevice.isEmpty { rows.append(("Pump", cfg.pumpDevice)) } - if !cfg.cgmDevice.isEmpty { rows.append(("CGM", cfg.cgmDevice)) } - if !cfg.insulinType.isEmpty { rows.append(("Insulin", cfg.insulinType)) } - y = metricTable(rows, y: y + 4, m: m, w: r.width, cfg: cfg, ctx: ctx) - } - - // AGP — fixed layout with reserved space, no overflow - let agpAvail = r.height - y - 50 // leave room for footer - if !agpData.isEmpty, agpAvail >= 100 { - y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) - let agpH = Swift.min(agpAvail - 24, 118) - drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) + if cfg.includeDevices && hasDevice { + y = sectionHdr("DEVICES & INSULIN", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) + var rows:[(String,String)]=[] + if !cfg.pumpDevice.isEmpty { rows.append(("Pump", cfg.pumpDevice)) } + if !cfg.cgmDevice.isEmpty { rows.append(("CGM", cfg.cgmDevice)) } + if !cfg.insulinType.isEmpty{ rows.append(("Insulin", cfg.insulinType)) } + y = metricTable(rows, y:y+4, m:m, w:r.width, cfg:cfg, ctx:ctx) + } + + if cfg.includeAGP && !agpData.isEmpty { + let agpAvail = r.height - y - 50 + if agpAvail >= 100 { + y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) + let agpH = Swift.min(agpAvail - 24, 118) + drawAGP(agpData:agpData, x:m, y:y+4, w:r.width-m*2, h:agpH, cfg:cfg, ctx:ctx) + } } - drawFooter(ctx: ctx, r: r, cfg: cfg, stats: stats, page: 1) + drawFooter(ctx:ctx, r:r, cfg:cfg, stats:stats, page:1) } // MARK: - Hero header @discardableResult - private static func drawHero(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, stats: ReportStats) -> CGFloat { - let h: CGFloat = 100; let ac = accent(cfg); let ad = accentDark(cfg) - ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) - ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 21)) - - // "LOOP FOLLOW" spaced out - let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 3.0] - "LOOP FOLLOW".draw(at: CGPoint(x: 32, y: 5), withAttributes: a1) - - let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] - "Endocrinologist Visit Report".draw(at: CGPoint(x: 32, y: 22), withAttributes: a2) - - let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] - "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 32, y: 50), withAttributes: a3) - - let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" - let ds = "\(df.string(from: cfg.startDate)) — \(df.string(from: cfg.endDate)) (\(Int(stats.days.rounded())) Days)" - let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] - ds.draw(at: CGPoint(x: 32, y: 66), withAttributes: a4) - - // AID badge - if !cfg.aidSystem.isEmpty { - let badge = "▶ \(cfg.aidSystem)" - let ba: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_WHITE.withAlphaComponent(0.9)] - let bsz = (badge as NSString).size(withAttributes: ba) - let bx = r.width - 32 - bsz.width - 10; let by: CGFloat = 66 - ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: bx - 4, y: by - 1, width: bsz.width + 12, height: 12)) - (badge as NSString).draw(at: CGPoint(x: bx + 2, y: by), withAttributes: ba) - } + private static func drawHero(ctx:CGContext, r:CGRect, cfg:EndoReportConfig, stats:ReportStats)->CGFloat { + let h:CGFloat=95; let ac=accent(cfg); let ad=accentDark(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x:0,y:0,width:r.width,height:h)) + ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x:0,y:0,width:r.width,height:21)) + + let a1:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8.5),.foregroundColor:C_WHITE.withAlphaComponent(0.8),.kern:3.0] + "LOOP FOLLOW".draw(at:CGPoint(x:26,y:5),withAttributes:a1) + + let a2:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:21),.foregroundColor:C_WHITE] + "Endocrinologist Visit Report".draw(at:CGPoint(x:26,y:26),withAttributes:a2) - // Right side info - let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.85)] - var lines: [String] = [] - if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } + let a3:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:9.5),.foregroundColor:C_WHITE.withAlphaComponent(0.82)] + "Automated Insulin Delivery Performance Summary".draw(at:CGPoint(x:26,y:52),withAttributes:a3) + + let df=DateFormatter(); df.dateFormat="MMMM d, yyyy" + let ds="\(df.string(from:cfg.startDate)) — \(df.string(from:cfg.endDate)) (\(Int(stats.days.rounded())) Days)" + let a4:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:9),.foregroundColor:C_WHITE.withAlphaComponent(0.68)] + ds.draw(at:CGPoint(x:26,y:68),withAttributes:a4) + + let a5:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:8.5),.foregroundColor:C_WHITE.withAlphaComponent(0.95)] + var lines:[String]=[] + if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } if !cfg.providerName.isEmpty { lines.append("Provider: \(cfg.providerName)") } - if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } - if !cfg.diagnosisDate.isEmpty { lines.append("Dx: \(cfg.diagnosisDate)") } - for (i, l) in lines.enumerated() { - let sz = (l as NSString).size(withAttributes: a5) - (l as NSString).draw(at: CGPoint(x: r.width - 34 - sz.width, y: 22 + CGFloat(i) * 14), withAttributes: a5) + if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } + if !cfg.aidSystem.isEmpty { lines.append("AID: \(cfg.aidSystem)") } + if !cfg.diagnosisDate.isEmpty{ lines.append("Dx: \(cfg.diagnosisDate)") } + + for(i,l) in lines.enumerated() { + let sz=(l as NSString).size(withAttributes:a5) + (l as NSString).draw(at:CGPoint(x:r.width-26-sz.width,y:26+CGFloat(i)*13),withAttributes:a5) } return h } @@ -320,491 +333,515 @@ enum EndoReportGenerator { // MARK: - Daily page header @discardableResult - private static func drawDailyPageHeader(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, - page: Int, totalPages: Int) -> CGFloat - { - let h: CGFloat = 40; let ac = accent(cfg) - ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) - let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] - "Daily Glucose Breakdown".draw(at: CGPoint(x: 28, y: 11), withAttributes: a1) - let sub = "Newest to Oldest • Page \(page) of \(totalPages)" - let a2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] - let sz = (sub as NSString).size(withAttributes: a2) - (sub as NSString).draw(at: CGPoint(x: r.width - 28 - sz.width, y: 14), withAttributes: a2) + private static func drawDailyPageHeader(ctx:CGContext, r:CGRect, cfg:EndoReportConfig, + page:Int, totalPages:Int)->CGFloat { + let h:CGFloat=40; let ac=accent(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x:0,y:0,width:r.width,height:h)) + let a1:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:12),.foregroundColor:C_WHITE] + "Daily Glucose Breakdown".draw(at:CGPoint(x:28,y:11),withAttributes:a1) + let sub="Newest to Oldest • Page \(page) of \(totalPages)" + let a2:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:8),.foregroundColor:C_WHITE.withAlphaComponent(0.75)] + let sz=(sub as NSString).size(withAttributes:a2) + (sub as NSString).draw(at:CGPoint(x:r.width-28-sz.width,y:14),withAttributes:a2) return h } // MARK: - Section header @discardableResult - private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, - cfg: EndoReportConfig, ctx: CGContext) -> CGFloat - { + private static func sectionHdr(_ title:String, y:CGFloat, m:CGFloat, w:CGFloat, + cfg:EndoReportConfig, ctx:CGContext)->CGFloat { ctx.setFillColor(accent(cfg).cgColor) - ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: accent(cfg), .kern: 0.6] - (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) + ctx.fill(CGRect(x:m,y:y,width:3,height:14)) + let a:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:9),.foregroundColor:accent(cfg),.kern:0.6] + (title as NSString).draw(at:CGPoint(x:m+8,y:y),withAttributes:a) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() - return y + 19 + ctx.move(to:CGPoint(x:m,y:y+15)); ctx.addLine(to:CGPoint(x:w-m,y:y+15)); ctx.strokePath() + return y+19 } // MARK: - Stat card - private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, - w: CGFloat, h: CGFloat, accent ac: Bool, - cfg: EndoReportConfig, ctx: CGContext) - { - let r = CGRect(x: x, y: y, width: w, height: h) + private static func statCard(_ label:String, val:String, x:CGFloat, y:CGFloat, + w:CGFloat, h:CGFloat, accent ac:Bool, + cfg:EndoReportConfig, ctx:CGContext) { + let r=CGRect(x:x,y:y,width:w,height:h) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) if ac { ctx.setFillColor(accent(cfg).withAlphaComponent(0.07).cgColor); ctx.fill(r) - ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x:x,y:y,width:3,height:h)) } ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: ac ? accent(cfg) : C_INK] - (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 5), withAttributes: la) - (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 16), withAttributes: va) + let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE,.kern:0.5] + let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:17),.foregroundColor:ac ? accent(cfg):C_INK] + (label as NSString).draw(at:CGPoint(x:x+8,y:y+5),withAttributes:la) + (val as NSString).draw(at:CGPoint(x:x+8,y:y+16),withAttributes:va) } // MARK: - TIR vertical bar - private static func drawTIRBar(tirData: [TIRDataPoint], stats _: ReportStats, - x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, - cfg: EndoReportConfig, ctx: CGContext) - { - guard let avg = tirData.first(where: { $0.period == .average }) else { return } - let r = CGRect(x: x, y: y, width: w, height: h) + private static func drawTIRBar(tirData:[TIRDataPoint], stats:ReportStats, + x:CGFloat, y:CGFloat, w:CGFloat, h:CGFloat, + cfg:EndoReportConfig, ctx:CGContext) { + guard let avg=tirData.first(where:{$0.period == .average}) else { return } + let r=CGRect(x:x,y:y,width:w,height:h) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] - "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) - let bx = x + 10; let bw: CGFloat = 16; let by = y + 22; let bh = h - 40 - let segs: [(Double, UIColor, String)] = [(avg.veryHigh, C_VHIGH, "Very High"), (avg.high, C_HIGH, "High"), - (avg.inRange, C_IN, "In Range"), (avg.low, C_LOW, "Low"), (avg.veryLow, C_VLOW, "Very Low")] - var sy = by - for (pct, clr, label) in segs { - let sh = CGFloat(pct / 100) * bh - if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: bx, y: sy, width: bw, height: sh)) } - if sh >= 10 { - let ps = String(format: "%.0f%%", pct) - let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: label == "In Range" ? accent(cfg) : C_SLATE] - let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6), .foregroundColor: C_SLATE] - (ps as NSString).draw(at: CGPoint(x: bx + bw + 4, y: sy + sh / 2 - 8), withAttributes: pa) - if sh >= 18 { (label as NSString).draw(at: CGPoint(x: bx + bw + 4, y: sy + sh / 2), withAttributes: sa) } + + let ta:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:7.5),.foregroundColor:C_SLATE] + "Time in Range".draw(at:CGPoint(x:x+8,y:y+6),withAttributes:ta) + + // Shorten bar height to allow text room + let bx=x+10; let bw:CGFloat=16; let by=y+22; let bh=h-50 + let segs:[(Double,UIColor,String)]=[(avg.veryHigh,C_VHIGH,"Very High"),(avg.high,C_HIGH,"High"), + (avg.inRange,C_IN,"In Range"),(avg.low,C_LOW,"Low"),(avg.veryLow,C_VLOW,"Very Low")] + var sy=by + var lastTextY: CGFloat = 0 + + for(pct,clr,label) in segs { + let sh=CGFloat(pct/100)*bh + if sh>0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x:bx,y:sy,width:bw,height:sh)) } + + // Lowered minimum threshold to > 0.0 to catch "Low" and "Very Low" + if pct > 0.0 { + let ps = String(format:"%.0f%%",pct) + let isTarget = (label == "In Range") + let pa:[NSAttributedString.Key:Any] = [.font:UIFont.boldSystemFont(ofSize:7.5), .foregroundColor: isTarget ? accent(cfg) : C_SLATE] + + let textStr = "\(label) \(ps)" + + var textY = sy + sh/2 - 4 + // Push text down to prevent overlapping labels + if textY < lastTextY + 10 { textY = lastTextY + 10 } + + (textStr as NSString).draw(at:CGPoint(x:bx+bw+6,y:textY),withAttributes:pa) + lastTextY = textY } - sy += sh + sy+=sh } - let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - "Target: 70-180".draw(at: CGPoint(x: x + 5, y: y + h - 20), withAttributes: na) - "Tight: 70-140".draw(at: CGPoint(x: x + 5, y: y + h - 12), withAttributes: na) - "1% ≈ 15 min".draw(at: CGPoint(x: x + 5, y: y + h - 4), withAttributes: na) + + // Expanded text and increased spacing for bottom targets + let na:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] + "Target: 70-180".draw(at:CGPoint(x:x+5,y:y+h-24),withAttributes:na) + "Time in Tight Range: 70-140".draw(at:CGPoint(x:x+5,y:y+h-12),withAttributes:na) } // MARK: - Time-of-day strip @discardableResult - private static func timeStrip(patterns: TimePatterns, cfg: EndoReportConfig, - y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat - { - let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] - "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) - let periods = [patterns.night, patterns.earlyAM, patterns.morning, - patterns.afternoon, patterns.evening, patterns.late] - let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 40; let cy = y + 13 - for (i, p) in periods.enumerated() { - let cx = m + CGFloat(i) * cw - let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) + private static func timeStrip(patterns:TimePatterns, cfg:EndoReportConfig, + y:CGFloat, m:CGFloat, w:CGFloat, ctx:CGContext)->CGFloat { + let ha:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:C_INK] + "Glucose by Time of Day (\(cfg.units))".draw(at:CGPoint(x:m,y:y),withAttributes:ha) + let periods=[patterns.night,patterns.earlyAM,patterns.morning, + patterns.afternoon,patterns.evening,patterns.late] + let cw=(w-m*2)/CGFloat(periods.count); let ch:CGFloat=38; let cy=y+11 + for(i,p) in periods.enumerated() { + let cx=m+CGFloat(i)*cw + let rr=CGRect(x:cx,y:cy,width:cw-2,height:ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) - guard p.count > 0 else { continue } - let disp = cfg.fmtBG(p.avg) - let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14), .foregroundColor: vc] - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - let vsz = (disp as NSString).size(withAttributes: va) - let lsz = (p.label as NSString).size(withAttributes: la) - (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) - (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 27), withAttributes: la) - } - return cy + ch + 4 + guard p.count>0 else { continue } + let disp=cfg.fmtBG(p.avg) + let vc:UIColor = p.avg<70 ? C_LOW : p.avg<140 ? accent(cfg) : p.avg<180 ? C_INK : C_HIGH + let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:13),.foregroundColor:vc] + let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] + let vsz=(disp as NSString).size(withAttributes:va) + let lsz=(p.label as NSString).size(withAttributes:la) + (disp as NSString).draw(at:CGPoint(x:cx+(cw-2-vsz.width)/2,y:cy+5), withAttributes:va) + (p.label as NSString).draw(at:CGPoint(x:cx+(cw-2-lsz.width)/2,y:cy+25),withAttributes:la) + } + return cy+ch+4 } // MARK: - Insulin section @discardableResult - private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], - smbs: [MainViewController.bolusGraphStruct], - simpleVM: SimpleStatsViewModel, stats _: ReportStats, - cfg: EndoReportConfig, y: CGFloat, m: CGFloat, w: CGFloat, - ctx: CGContext) -> CGFloat - { - let tdd = simpleVM.totalDailyDose ?? 0 - let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 - let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 - let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), - ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), - ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] - let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 40 - for (i, c) in cards.enumerated() { - let cx = m + CGFloat(i) * (cw + 4) - let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + private static func insulinSection(boluses:[MainViewController.bolusGraphStruct], + smbs:[MainViewController.bolusGraphStruct], + simpleVM:SimpleStatsViewModel, stats:ReportStats, + cfg:EndoReportConfig, y:CGFloat, m:CGFloat, w:CGFloat, + ctx:CGContext)->CGFloat { + let tdd=simpleVM.totalDailyDose ?? 0 + let basalPct=tdd>0 ? (simpleVM.actualBasal ?? 0)/tdd*100:0 + let bolusPct=tdd>0 ? (simpleVM.avgBolus ?? 0)/tdd*100:0 + let cards:[(String,String)]=[ ("AVG TDD",tdd>0 ? String(format:"%.1fU",tdd):"—"), + ("BASAL", basalPct>0 ? String(format:"%.0f%%",basalPct):"—"), + ("BOLUS", bolusPct>0 ? String(format:"%.0f%%",bolusPct):"—") ] + let cw=(w-m*2)/3-4; let ch:CGFloat=38 + for(i,c) in cards.enumerated() { + let cx=m+CGFloat(i)*(cw+4) + let r2=CGRect(x:cx,y:y,width:cw,height:ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 17), .foregroundColor: C_INK] - (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) - (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) - } - let ty = y + ch + 5 - let total = (boluses + smbs).map { $0.value }.reduce(0,+) - let rows: [(String, String)] = [ - ("Correction Boluses", "\(boluses.count)"), + let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE,.kern:0.4] + let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:16),.foregroundColor:C_INK] + (c.0 as NSString).draw(at:CGPoint(x:cx+8,y:y+4), withAttributes:la) + (c.1 as NSString).draw(at:CGPoint(x:cx+8,y:y+14),withAttributes:va) + } + let ty=y+ch+4 + let total=(boluses+smbs).map{$0.value}.reduce(0,+) + let rows:[(String,String)]=[ + ("Correction Boluses", "\(boluses.count)"), ("SMB / Auto-Corrections", "\(smbs.count)"), - ("Total Bolus Insulin", String(format: "%.1f U", total)), - ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), - ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), - ("Positive Temp Basal", simpleVM.totalPositiveBasal != nil ? String(format: "+%.2f U/day", simpleVM.totalPositiveBasal!) : "—"), - ("Negative Temp Basal", simpleVM.totalNegativeBasal != nil ? String(format: "%.2f U/day", simpleVM.totalNegativeBasal!) : "—"), + ("Total Bolus Insulin", String(format:"%.1f U",total)), + ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format:"%.2f U/day",simpleVM.programmedBasal!):"—"), + ("Actual Basal", simpleVM.actualBasal != nil ? String(format:"%.2f U/day",simpleVM.actualBasal!) :"—"), ] - return metricTable(rows, y: ty, m: m, w: w, cfg: cfg, ctx: ctx) + return metricTable(rows, y:ty, m:m, w:w, cfg:cfg, ctx:ctx) } // MARK: - Nutrition section @discardableResult - private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], - stats: ReportStats, cfg _: EndoReportConfig, - y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat - { - let total = carbs.map { $0.value }.reduce(0,+) - let cards: [(String, String)] = [ - ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), + private static func nutritionSection(carbs:[MainViewController.carbGraphStruct], + stats:ReportStats, cfg:EndoReportConfig, + y:CGFloat, m:CGFloat, w:CGFloat, ctx:CGContext)->CGFloat { + let total=carbs.map{$0.value}.reduce(0,+) + let cards:[(String,String)]=[ + ("DAILY CARBS", String(format:"%.0fg",total/stats.days)), ("MEALS LOGGED", "\(carbs.count)"), - ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), + ("PER MEAL AVG", String(format:"%.0fg",carbs.isEmpty ? 0:total/Double(carbs.count))), ] - let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 40 - for (i, c) in cards.enumerated() { - let cx = m + CGFloat(i) * (cw + 4) - let r2 = CGRect(x: cx, y: y, width: cw, height: ch) + let cw=(w-m*2)/3-4; let ch:CGFloat=38 + for(i,c) in cards.enumerated() { + let cx=m+CGFloat(i)*(cw+4) + let r2=CGRect(x:cx,y:y,width:cw,height:ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 17), .foregroundColor: C_INK] - (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) - (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE,.kern:0.4] + let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:16),.foregroundColor:C_INK] + (c.0 as NSString).draw(at:CGPoint(x:cx+8,y:y+4), withAttributes:la) + (c.1 as NSString).draw(at:CGPoint(x:cx+8,y:y+14),withAttributes:va) } - return y + ch + 6 + return y+ch+4 } - // MARK: - Metric table + // MARK: - Tables @discardableResult - private static func metricTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, - w: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat - { - let tw = w - m * 2; let hh: CGFloat = 13; let rh: CGFloat = 12; var cy = y + private static func metricTable(_ rows:[(String,String)], y:CGFloat, m:CGFloat, + w:CGFloat, cfg:EndoReportConfig, ctx:CGContext)->CGFloat { + let tw=w-m*2; let hh:CGFloat=12; let rh:CGFloat=11; var cy=y + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + ctx.fill(CGRect(x:m,y:cy,width:tw,height:hh)) + let ha:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:7),.foregroundColor:accent(cfg),.kern:0.4] + "METRIC".draw(at:CGPoint(x:m+6,y:cy+2),withAttributes:ha) + "VALUE".draw( at:CGPoint(x:m+tw*0.62+6,y:cy+2),withAttributes:ha) + cy+=hh + for(i,row) in rows.enumerated() { + ctx.setFillColor((i%2==0 ? C_WHITE:C_CLOUD).cgColor) + ctx.fill(CGRect(x:m,y:cy,width:tw,height:rh)) + let ka:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:7),.foregroundColor:C_INK] + let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:7),.foregroundColor:accent(cfg)] + (row.0 as NSString).draw(at:CGPoint(x:m+6,y:cy+1),withAttributes:ka) + (row.1 as NSString).draw(at:CGPoint(x:m+tw*0.62+6,y:cy+1),withAttributes:va) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) + ctx.move(to:CGPoint(x:m,y:cy+rh)); ctx.addLine(to:CGPoint(x:m+tw,y:cy+rh)); ctx.strokePath() + cy+=rh + } + return cy+4 + } + + // Dynamic settings table to handle multi-line text input neatly + @discardableResult + private static func settingsTable(_ rows:[(String,String)], y:CGFloat, m:CGFloat, + w:CGFloat, cfg:EndoReportConfig, ctx:CGContext)->CGFloat { + let tw = w - m * 2 + let headerH: CGFloat = 14 + var cy = y + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: hh)) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: headerH)) + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] - "METRIC".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) - "VALUE".draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 3), withAttributes: ha) - cy += hh + "SETTING".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) + "SCHEDULE / VALUES".draw(at: CGPoint(x: m + tw * 0.35 + 6, y: cy + 3), withAttributes: ha) + cy += headerH + for (i, row) in rows.enumerated() { + let lines = row.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let rh = Swift.max(18.0, CGFloat(lines.count) * 11.0 + 8.0) + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) - let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_INK] - let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: accent(cfg)] - (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 2), withAttributes: ka) - (row.1 as NSString).draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 2), withAttributes: va) - ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: m, y: cy + rh)); ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)); ctx.strokePath() + + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_INK] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: accent(cfg)] + + (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 5), withAttributes: ka) + + var ly = cy + 5 + for line in lines { + (line as NSString).draw(at: CGPoint(x: m + tw * 0.35 + 6, y: ly), withAttributes: va) + ly += 11 + } + + ctx.setStrokeColor(C_BORDER.cgColor) + ctx.setLineWidth(0.3) + ctx.move(to: CGPoint(x: m, y: cy + rh)) + ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)) + ctx.strokePath() + cy += rh } - return cy + 5 + return cy + 4 } - // MARK: - AGP (clean, no overflow) + // MARK: - AGP - private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, - w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) - { + private static func drawAGP(agpData:[AGPDataPoint], x:CGFloat, y:CGFloat, + w:CGFloat, h:CGFloat, cfg:EndoReportConfig, ctx:CGContext) { guard !agpData.isEmpty else { return } - let lPad: CGFloat = 26; let bPad: CGFloat = 16 - let cw = w - lPad; let ch = h - bPad - let cx = x + lPad; let cy = y + let lPad:CGFloat=26; let bPad:CGFloat=16 + let cw=w-lPad; let ch=h-bPad + let cx=x+lPad; let cy=y - ctx.setFillColor(UIColor(white: 0.985, alpha: 1).cgColor) - ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) + ctx.setFillColor(UIColor(white:0.985,alpha:1).cgColor) + ctx.fill(CGRect(x:cx,y:cy,width:cw,height:ch)) - let bgMin: CGFloat = 40; let bgRng: CGFloat = 320 - func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRng * ch } - func tx(_ mins: Int) -> CGFloat { cx + CGFloat(mins) / (24 * 60) * cw } + let bgMin:CGFloat=40; let bgRng:CGFloat=320 + func gy(_ g:Double)->CGFloat { cy+ch-(CGFloat(g)-bgMin)/bgRng*ch } + func tx(_ mins:Int)->CGFloat { cx+CGFloat(mins)/(24*60)*cw } - // Target zone ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) - ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) + ctx.fill(CGRect(x:cx,y:gy(180),width:cw,height:gy(70)-gy(180))) - // Target lines - ctx.setLineDash(phase: 0, lengths: [3, 2]) - for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { + ctx.setLineDash(phase:0,lengths:[3,2]) + for(val,clr) in [(70.0,C_LOW),(180.0,C_HIGH)] { ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.6) - ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() + ctx.move(to:CGPoint(x:cx,y:gy(val))); ctx.addLine(to:CGPoint(x:cx+cw,y:gy(val))); ctx.strokePath() } - ctx.setLineDash(phase: 0, lengths: []) + ctx.setLineDash(phase:0,lengths:[]) - // 5-95 band - var band = CGMutablePath() - for (i, pt) in agpData.enumerated() { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)); i == 0 ? band.move(to: p) : band.addLine(to: p) - } - for pt in agpData.reversed() { - band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) - } + var band=CGMutablePath() + for(i,pt) in agpData.enumerated() { let p=CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p95)); i==0 ? band.move(to:p):band.addLine(to:p) } + for pt in agpData.reversed() { band.addLine(to:CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p5))) } band.closeSubpath() ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor); ctx.addPath(band); ctx.fillPath() - // IQR band - var iqr = CGMutablePath() - for (i, pt) in agpData.enumerated() { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)); i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) - } - for pt in agpData.reversed() { - iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) - } + var iqr=CGMutablePath() + for(i,pt) in agpData.enumerated() { let p=CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p75)); i==0 ? iqr.move(to:p):iqr.addLine(to:p) } + for pt in agpData.reversed() { iqr.addLine(to:CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p25))) } iqr.closeSubpath() ctx.setFillColor(accent(cfg).withAlphaComponent(0.25).cgColor); ctx.addPath(iqr); ctx.fillPath() - // Median ctx.setStrokeColor(accent(cfg).cgColor); ctx.setLineWidth(1.6) - var first = true - for pt in agpData { - let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)); first ? ctx.move(to: p) : ctx.addLine(to: p); first = false - } + var first=true + for pt in agpData { let p=CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p50)); first ? ctx.move(to:p):ctx.addLine(to:p); first=false } ctx.strokePath() - // Y labels — strictly left of chart - let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - for bg in [70, 140, 180, 250] { - let ly = gy(Double(bg)); guard ly >= cy, ly <= cy + ch else { continue } - let lbl = cfg.isMMOL ? String(format: "%.1f", Double(bg) * 0.0555) : "\(bg)" - let lsz = (lbl as NSString).size(withAttributes: axA) - (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) + let axA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] + for bg in [70,140,180,250] { + let ly=gy(Double(bg)); guard ly>=cy && ly<=cy+ch else { continue } + let lbl=cfg.isMMOL ? String(format:"%.1f",Double(bg)*0.0555) : "\(bg)" + let lsz=(lbl as NSString).size(withAttributes:axA) + (lbl as NSString).draw(at:CGPoint(x:x+lPad-lsz.width-3,y:ly-lsz.height/2),withAttributes:axA) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) - ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() + ctx.move(to:CGPoint(x:cx,y:ly)); ctx.addLine(to:CGPoint(x:cx+cw,y:ly)); ctx.strokePath() } - // X labels — clamped below chart - for h2 in stride(from: 0, through: 24, by: 3) { - let lx = tx(h2 * 60) - let lbl = String(format: "%02d:00", h2) - let lsz = (lbl as NSString).size(withAttributes: axA) - let dx = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) - (lbl as NSString).draw(at: CGPoint(x: dx, y: cy + ch + 3), withAttributes: axA) + for h2 in stride(from:0,through:24,by:3) { + let lx=tx(h2*60) + let lbl=String(format:"%02d:00",h2) + let lsz=(lbl as NSString).size(withAttributes:axA) + let dx=Swift.max(cx,Swift.min(cx+cw-lsz.width,lx-lsz.width/2)) + (lbl as NSString).draw(at:CGPoint(x:dx,y:cy+ch+3),withAttributes:axA) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) - ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() + ctx.move(to:CGPoint(x:lx,y:cy)); ctx.addLine(to:CGPoint(x:lx,y:cy+ch)); ctx.strokePath() } ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) - - // Legend — bottom right, inside label zone - let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - let lgItems: [(String, UIColor, Bool)] = [("Median", accent(cfg), false), - ("25–75th", accent(cfg).withAlphaComponent(0.4), true), - ("5–95th", accent(cfg).withAlphaComponent(0.18), true)] - var lgX = cx + cw + ctx.stroke(CGRect(x:cx,y:cy,width:cw,height:ch)) + + let lgA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] + let lgItems:[(String,UIColor,Bool)]=[ ("Median",accent(cfg),false), + ("25–75th",accent(cfg).withAlphaComponent(0.4),true), + ("5–95th", accent(cfg).withAlphaComponent(0.18),true) ] + var lgX=cx+cw for item in lgItems.reversed() { - let lsz = (item.0 as NSString).size(withAttributes: lgA) + let lsz=(item.0 as NSString).size(withAttributes:lgA) lgX -= lsz.width - (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 3), withAttributes: lgA) + (item.0 as NSString).draw(at:CGPoint(x:lgX,y:cy+ch+3),withAttributes:lgA) lgX -= 15 - item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 4, width: 12, height: 8)) }() - : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 7, width: 12, height: 2)) }() + item.2 ? ({ ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x:lgX,y:cy+ch+4,width:12,height:8)) })() + : ({ ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x:lgX,y:cy+ch+7,width:12,height:2)) })() lgX -= 5 } } // MARK: - Day row - private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, - day: String, dayData: DayData, cfg: EndoReportConfig, - simpleVM _: SimpleStatsViewModel) - { - // Card - ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) + private static func drawDayRow(ctx:CGContext, x:CGFloat, y:CGFloat, w:CGFloat, h:CGFloat, + day:String, dayData:DayData, cfg:EndoReportConfig, + simpleVM:SimpleStatsViewModel) { + ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x:x,y:y,width:w,height:h)) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) - ctx.setFillColor(accent(EndoReportConfig(patientName: "", dateOfBirth: "", diagnosisDate: "", providerName: "", insulinType: "", aidSystem: "", pumpDevice: "", cgmDevice: "", carbRatio: "", isf: "", basalRate: "", targetGlucose: "", units: cfg.units, accentColorHex: cfg.accentColorHex, includeDailyBreakdown: true, includeFatProtein: false, startDate: cfg.startDate, endDate: cfg.endDate)).cgColor) - - // Use cfg directly for accent + ctx.stroke(CGRect(x:x,y:y,width:w,height:h)) + ctx.setFillColor(cfg.accentColor.cgColor) - ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) - - // Day label - let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" - let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d, yyyy" - let date = df.date(from: day) ?? Date() - let dlA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_INK] - df2.string(from: date).draw(at: CGPoint(x: x + 10, y: y + 5), withAttributes: dlA) - - // Right-side stats panel - let vals = dayData.bg.map { Double($0.sgv) } - let statsX = x + w - 130 + ctx.fill(CGRect(x:x,y:y,width:3,height:h)) + + let df=DateFormatter(); df.dateFormat="yyyy-MM-dd" + let df2=DateFormatter(); df2.dateFormat="EEEE, MMM d, yyyy" + let date=df.date(from:day) ?? Date() + let dlA:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:9),.foregroundColor:C_INK] + df2.string(from:date).draw(at:CGPoint(x:x+10,y:y+5),withAttributes:dlA) + + let statsX=x+w-175 + let vals=dayData.bg.map{Double($0.sgv)} + if !vals.isEmpty { - let n = Double(vals.count) - let avg = vals.reduce(0,+) / n - let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 - let totalBolus = (dayData.bolus + dayData.smb).map { $0.value }.reduce(0,+) - // Basal estimate: sum basalRate * duration segments - var basalTotal = 0.0 - let sortedBasal = dayData.basal.sorted { $0.date < $1.date } - for i in 0 ..< sortedBasal.count - 1 { - let dur = (sortedBasal[i + 1].date - sortedBasal[i].date) / 3600 - if dur > 0, dur < 4 { basalTotal += sortedBasal[i].basalRate * dur } - } - let total = totalBolus + basalTotal - let bolusPct = total > 0 ? totalBolus / total * 100 : 0 - let basalPct = total > 0 ? basalTotal / total * 100 : 0 - - let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] - let sv: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] - let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW - let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] - let acA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: cfg.accentColor] - - // Row 1 — labels - let cols: [(String, CGFloat)] = [("Avg", 0), ("TIR", 30), ("Bolus", 62), ("Basal", 95), ("B:Bas", 128)] - for (lbl, ox) in cols { - (lbl as NSString).draw(at: CGPoint(x: statsX + ox, y: y + 5), withAttributes: sa) + let n=Double(vals.count) + let avg=vals.reduce(0,+)/n + let tir=Double(vals.filter{$0>=70&&$0<=180}.count)/n*100 + let totalBolus=(dayData.bolus+dayData.smb).map{$0.value}.reduce(0,+) + var basalTotal=0.0 + let sortedBasal=dayData.basal.sorted{$0.date<$1.date} + for i in 0..0&&dur<4 { basalTotal+=sortedBasal[i].basalRate*dur } } + let total=totalBolus+basalTotal + let bolusPct=total>0 ? totalBolus/total*100:0 + let basalPct=total>0 ? basalTotal/total*100:0 + + let sa:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] + let sv:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:C_INK] + let tirC:UIColor=tir>=70 ? C_IN:tir>=50 ? C_HIGH:C_VLOW + let tirA:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:tirC] + let acA:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:cfg.accentColor] - // Row 2 — values - cfg.fmtBG(avg).draw(at: CGPoint(x: statsX, y: y + 14), withAttributes: sv) - String(format: "%.0f%%", tir).draw(at: CGPoint(x: statsX + 28, y: y + 14), withAttributes: tirA) - String(format: "%.1fU", totalBolus).draw(at: CGPoint(x: statsX + 60, y: y + 14), withAttributes: sv) - (basalTotal > 0 ? String(format: "%.1fU", basalTotal) : "—").draw(at: CGPoint(x: statsX + 93, y: y + 14), withAttributes: sv) - // Bolus:Basal ratio - let ratioStr = total > 0 ? String(format: "%.0f:%.0f", bolusPct, basalPct) : "—" - ratioStr.draw(at: CGPoint(x: statsX + 126, y: y + 14), withAttributes: acA) + let cols:[(String,CGFloat)]=[ ("Avg",0),("TIR",30),("Bolus",66),("Basal",106),("B:Bas",144) ] + for(lbl,ox) in cols { (lbl as NSString).draw(at:CGPoint(x:statsX+ox,y:y+5),withAttributes:sa) } + + cfg.fmtBG(avg).draw(at:CGPoint(x:statsX, y:y+14),withAttributes:sv) + String(format:"%.0f%%",tir).draw(at:CGPoint(x:statsX+28, y:y+14),withAttributes:tirA) + String(format:"%.1fU",totalBolus).draw(at:CGPoint(x:statsX+64, y:y+14),withAttributes:sv) + (basalTotal>0 ? String(format:"%.1fU",basalTotal):"—").draw(at:CGPoint(x:statsX+104, y:y+14),withAttributes:sv) + let ratioStr = total>0 ? String(format:"%.0f:%.0f",bolusPct,basalPct):"—" + ratioStr.draw(at:CGPoint(x:statsX+142,y:y+14),withAttributes:acA) } - // Chart area - let chartX = x + 10; let chartW = w - 150 - let chartY = y + 26; let chartH = h - 32 + let chartX=x+10; let chartW=w-190 + let chartY=y+26; let chartH=h-32 guard !dayData.bg.isEmpty else { return } - let bgMin: CGFloat = 40; let bgMax: CGFloat = 320; let bgRng = bgMax - bgMin - func gy(_ bg: Double) -> CGFloat { chartY + chartH - (CGFloat(bg) - bgMin) / bgRng * chartH } - func tx(_ ts: Double) -> CGFloat { - let cal = dateTimeUtils.displayCalendar() - let d = Date(timeIntervalSince1970: ts) - let c = cal.dateComponents([.hour, .minute], from: d) - let min = Double((c.hour ?? 0) * 60 + (c.minute ?? 0)) - return chartX + CGFloat(min / (24 * 60)) * chartW + ctx.saveGState() + ctx.clip(to: CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) + + let bgMin:CGFloat=40; let bgMax:CGFloat=320; let bgRng=bgMax-bgMin + func gy(_ bg:Double)->CGFloat { chartY+chartH-(CGFloat(bg)-bgMin)/bgRng*chartH } + func tx(_ ts:Double)->CGFloat { + let cal=dateTimeUtils.displayCalendar() + let d=Date(timeIntervalSince1970:ts) + let c=cal.dateComponents([.hour,.minute],from:d) + let min=Double((c.hour ?? 0)*60+(c.minute ?? 0)) + return chartX+CGFloat(min/(24*60))*chartW } - // Target zone ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) - ctx.fill(CGRect(x: chartX, y: gy(180), width: chartW, height: gy(70) - gy(180))) + ctx.fill(CGRect(x:chartX,y:gy(180),width:chartW,height:gy(70)-gy(180))) - // Target lines - ctx.setLineDash(phase: 0, lengths: [2, 2]); ctx.setLineWidth(0.4) + ctx.setLineDash(phase:0,lengths:[2,2]); ctx.setLineWidth(0.4) ctx.setStrokeColor(C_LOW.withAlphaComponent(0.4).cgColor) - ctx.move(to: CGPoint(x: chartX, y: gy(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(70))); ctx.strokePath() + ctx.move(to:CGPoint(x:chartX,y:gy(70))); ctx.addLine(to:CGPoint(x:chartX+chartW,y:gy(70))); ctx.strokePath() ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.4).cgColor) - ctx.move(to: CGPoint(x: chartX, y: gy(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(180))); ctx.strokePath() - ctx.setLineDash(phase: 0, lengths: []) + ctx.move(to:CGPoint(x:chartX,y:gy(180))); ctx.addLine(to:CGPoint(x:chartX+chartW,y:gy(180))); ctx.strokePath() + ctx.setLineDash(phase:0,lengths:[]) - // Hour grid ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.25) - for h2 in stride(from: 3, through: 21, by: 3) { - let hx = chartX + CGFloat(h2) / 24 * chartW - ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() + for h2 in stride(from:3,through:21,by:3) { + let hx=chartX+CGFloat(h2)/24*chartW + ctx.move(to:CGPoint(x:hx,y:chartY)); ctx.addLine(to:CGPoint(x:hx,y:chartY+chartH)); ctx.strokePath() } - // Basal fill + line (bottom 25% of chart) if !dayData.basal.isEmpty { - let bH = chartH * 0.25; let bY = chartY + chartH - bH - let sorted = dayData.basal.sorted { $0.date < $1.date } - let maxR = sorted.map { $0.basalRate }.max() ?? 1 - var path = CGMutablePath(); var first = true + let bH=chartH*0.25; let bY=chartY+chartH-bH + let sorted=dayData.basal.sorted{$0.date<$1.date} + let maxR=sorted.map{$0.basalRate}.max() ?? 1 + + var path=CGMutablePath(); var first=true for pt in sorted { - let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH - first ? path.move(to: CGPoint(x: px, y: py)) : path.addLine(to: CGPoint(x: px, y: py)); first = false + let px=tx(pt.date); let py=bY+bH-CGFloat(pt.basalRate/maxR)*bH + first ? path.move(to:CGPoint(x:px,y:py)):path.addLine(to:CGPoint(x:px,y:py)); first=false } - if let last = sorted.last { - path.addLine(to: CGPoint(x: tx(last.date), y: bY + bH)) - path.addLine(to: CGPoint(x: chartX, y: bY + bH)); path.closeSubpath() + if let last=sorted.last { + path.addLine(to:CGPoint(x:tx(last.date),y:bY+bH)) + path.addLine(to:CGPoint(x:chartX,y:bY+bH)); path.closeSubpath() ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor); ctx.addPath(path); ctx.fillPath() } - var lp = CGMutablePath(); first = true - for pt in sorted { - let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH; first ? lp.move(to: CGPoint(x: px, y: py)) : lp.addLine(to: CGPoint(x: px, y: py)); first = false + + var lp=CGMutablePath(); first=true + for (index, pt) in sorted.enumerated() { + let px=tx(pt.date); let py=bY+bH-CGFloat(pt.basalRate/maxR)*bH + first ? lp.move(to:CGPoint(x:px,y:py)):lp.addLine(to:CGPoint(x:px,y:py)); first=false + + if pt.basalRate > 0.05 { + let nextX = index < sorted.count - 1 ? tx(sorted[index+1].date) : chartX + chartW + if nextX - px > 16 { + let rateStr = String(format: "%.1f", pt.basalRate) + let rA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5),.foregroundColor:C_BASAL] + rateStr.draw(at: CGPoint(x: px + 1, y: py - 7), withAttributes: rA) + } + } } ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(0.9); ctx.addPath(lp); ctx.strokePath() } - // SMB bars (magenta, thinner) for smb in dayData.smb { - let bx = tx(smb.date); let bh2 = CGFloat(Swift.min(smb.value / 15, 1)) * (chartH * 0.35) + let bx=tx(smb.date); let bh2=max(CGFloat(Swift.min(smb.value/15,1))*(chartH*0.35), 2.5) ctx.setFillColor(C_SMB.cgColor) - ctx.fill(CGRect(x: bx - 1.2, y: chartY + chartH - bh2, width: 2.4, height: bh2)) + ctx.fill(CGRect(x:bx-2,y:chartY+chartH-bh2,width:4,height:bh2)) } - // Bolus bars (purple) for bolus in dayData.bolus { - let bx = tx(bolus.date); let bh2 = CGFloat(Swift.min(bolus.value / 15, 1)) * (chartH * 0.4) + let bx=tx(bolus.date); let bh2=max(CGFloat(Swift.min(bolus.value/15,1))*(chartH*0.4), 3.0) ctx.setFillColor(C_BOLUS.cgColor) - ctx.fill(CGRect(x: bx - 1.5, y: chartY + chartH - bh2, width: 3, height: bh2)) + ctx.fill(CGRect(x:bx-2.5,y:chartY+chartH-bh2,width:5,height:bh2)) } - // BG dots - for r in dayData.bg.sorted { $0.date < $1.date } { - let rx = tx(r.date); let ry = gy(Double(r.sgv)) + for r in dayData.bg.sorted{$0.date<$1.date} { + let rx=tx(r.date); let ry=gy(Double(r.sgv)) ctx.setFillColor(bgColor(Double(r.sgv)).cgColor) - ctx.fillEllipse(in: CGRect(x: rx - 1.6, y: ry - 1.6, width: 3.2, height: 3.2)) - } - - // X-axis hour markers (just 00,06,12,18,24) - let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - for h2 in [0, 6, 12, 18, 24] { - let hx = chartX + CGFloat(h2) / 24 * chartW - let lbl = String(format: "%02d", h2) - let sz = (lbl as NSString).size(withAttributes: axA) - (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) - } - - // Legend (first row only to save space) - if day == day { // always show — could gate on first row - let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] - var lgX = chartX + chartW + 4 - for (lbl, clr) in [("● BG", C_IN), ("▮ Bolus", C_BOLUS), ("▮ SMB", C_SMB), ("— Basal", C_BASAL)] { - let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: clr] - (lbl as NSString).draw(at: CGPoint(x: lgX, y: chartY + 2), withAttributes: a) - lgX += (lbl as NSString).size(withAttributes: lgA).width + 5 - if lgX > x + w - 4 { break } + ctx.fillEllipse(in:CGRect(x:rx-1.6,y:ry-1.6,width:3.2,height:3.2)) + } + + ctx.restoreGState() + + let axA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5.5),.foregroundColor:C_SLATE] + for h2 in [0,6,12,18,24] { + let hx=chartX+CGFloat(h2)/24*chartW + let lbl=String(format:"%02d",h2) + let sz=(lbl as NSString).size(withAttributes:axA) + (lbl as NSString).draw(at:CGPoint(x:hx-sz.width/2,y:chartY+chartH+2),withAttributes:axA) + } + + if day==day { + let lgA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5.5),.foregroundColor:C_SLATE] + var lgX=chartX+chartW+4 + for(lbl,clr) in [("● BG",C_IN),("▮ Bolus",C_BOLUS),("▮ SMB",C_SMB),("— Basal",C_BASAL)] { + let a:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5.5),.foregroundColor:clr] + (lbl as NSString).draw(at:CGPoint(x:lgX,y:chartY+2),withAttributes:a) + lgX += (lbl as NSString).size(withAttributes:lgA).width+5 + if lgX>x+w-4 { break } } } } // MARK: - Footer - private static func drawFooter(ctx: CGContext, r: CGRect, cfg _: EndoReportConfig, - stats: ReportStats, page: Int) - { - let fy = r.height - 28 - ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) - let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] + private static func drawFooter(ctx:CGContext, r:CGRect, cfg:EndoReportConfig, + stats:ReportStats, page:Int) { + let fy=r.height-28 + ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x:0,y:fy,width:r.width,height:28)) + let a:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_WHITE.withAlphaComponent(0.5)] "Loop Follow — for informational purposes only. Not a substitute for professional medical advice." - .draw(at: CGPoint(x: 30, y: fy + 4), withAttributes: a) - let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" - let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" - let msz = (meta as NSString).size(withAttributes: a) - (meta as NSString).draw(at: CGPoint(x: r.width - 30 - msz.width, y: fy + 4), withAttributes: a) + .draw(at:CGPoint(x:30,y:fy+4),withAttributes:a) + let df=DateFormatter(); df.dateFormat="MMM d, yyyy" + let meta="Generated: \(df.string(from:Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" + let msz=(meta as NSString).size(withAttributes:a) + (meta as NSString).draw(at:CGPoint(x:r.width-30-msz.width,y:fy+4),withAttributes:a) } -} - -// MARK: - UIColor hex init +} \ No newline at end of file diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift deleted file mode 100644 index 443739f0e..000000000 --- a/LoopFollow/Stats/EndoReportView.swift +++ /dev/null @@ -1,489 +0,0 @@ -// LoopFollow -// EndoReportView.swift - -import SwiftUI - -struct EndoReportView: View { - let dataService: StatsDataService - - @Environment(\.dismiss) private var dismiss - - // Persisted patient/clinic info - @AppStorage("endoReport.patientName") private var patientName = "" - @AppStorage("endoReport.dateOfBirth") private var dateOfBirth = "" - @AppStorage("endoReport.providerName") private var providerName = "" - @AppStorage("endoReport.insulinType") private var insulinType = "" - @AppStorage("endoReport.diagnosisDate") private var diagnosisDate = "" - @AppStorage("endoReport.aidSystem") private var aidSystem = "Loop" - @AppStorage("endoReport.pumpDevice") private var pumpDevice = "" - @AppStorage("endoReport.cgmDevice") private var cgmDevice = "" - @AppStorage("endoReport.units") private var units = "mg/dL" - @AppStorage("endoReport.accentColorHex") private var accentColorHex = "#23A0AC" - @AppStorage("endoReport.includeDailyBreakdown") private var includeDailyBreakdown = true - @AppStorage("endoReport.includeFatProtein") private var includeFatProtein = false - - // Therapy settings (manual entry) - @AppStorage("endoReport.carbRatio") private var carbRatio = "" - @AppStorage("endoReport.isf") private var isf = "" - @AppStorage("endoReport.basalRate") private var basalRate = "" - @AppStorage("endoReport.targetGlucose") private var targetGlucose = "" - - // Date range - @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -14, to: Date()) ?? Date() - @State private var endDate: Date = .init() - - // UI state - @StateObject private var profileFetcher = NightscoutProfileFetcher() - @State private var isGenerating = false - @State private var reportURL: URL? - @State private var errorMessage: String? - @State private var showShareSheet = false - @State private var showColorPicker = false - @State private var pickedColor: Color = .init(hex: "#23A0AC") ?? .teal - @State private var fetchSuccess = false - - let aidOptions = ["Loop", "Trio", "OpenAPS", "Android APS", "CamAPS FX", "Other"] - let unitOptions = ["mg/dL", "mmol/L"] - - var body: some View { - NavigationView { - Form { - // ── Patient ────────────────────────────────────────────── - Section(header: label("Patient Information", icon: "person.fill")) { - row("Name", placeholder: "Full name", text: $patientName) - row("Date of Birth", placeholder: "MM/DD/YYYY", text: $dateOfBirth) - row("Diagnosed", placeholder: "Year (optional)", text: $diagnosisDate) - row("Provider", placeholder: "Dr. Name", text: $providerName) - } - - // ── Devices & AID ───────────────────────────────────────── - Section(header: label("Devices & System", icon: "gear")) { - Picker("AID System", selection: $aidSystem) { - ForEach(aidOptions, id: \.self) { Text($0) } - } - row("Pump", placeholder: "e.g. Omnipod 5", text: $pumpDevice) - row("CGM", placeholder: "e.g. Dexcom G7", text: $cgmDevice) - row("Insulin", placeholder: "e.g. Humalog", text: $insulinType) - } - - // ── Therapy settings ────────────────────────────────────── - Section(header: label("Current Therapy Settings", icon: "slider.horizontal.3")) { - // Fetch from Nightscout button - Button(action: fetchFromNightscout) { - HStack { - if profileFetcher.isFetching { - ProgressView().scaleEffect(0.8) - Text("Fetching from Nightscout…") - .font(.subheadline) - } else { - Image(systemName: fetchSuccess ? "checkmark.circle.fill" : "arrow.down.circle") - .foregroundColor(fetchSuccess ? .green : .accentColor) - Text(fetchSuccess ? "Settings Fetched!" : "Auto-Fill from Nightscout") - .font(.subheadline) - } - Spacer() - } - } - .disabled(profileFetcher.isFetching) - - if let fetchErr = profileFetcher.error { - Label(fetchErr, systemImage: "exclamationmark.triangle.fill") - .foregroundColor(.red) - .font(.caption) - } - - row("Carb Ratio (CR)", placeholder: "g/U e.g. 10", text: $carbRatio, keyboard: .decimalPad) - row("ISF", placeholder: "mg/dL per U", text: $isf, keyboard: .decimalPad) - row("Basal Rate", placeholder: "U/hr e.g. 0.85", text: $basalRate, keyboard: .decimalPad) - row("Target Glucose", placeholder: "mg/dL e.g. 100", text: $targetGlucose, keyboard: .decimalPad) - } - - // ── Report period ───────────────────────────────────────── - Section(header: label("Report Period", icon: "calendar")) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(presets, id: \.label) { p in - Button(p.label) { - startDate = p.start; endDate = p.end - } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(isActive(p) ? .teal : .secondary) - } - } - .padding(.vertical, 4) - } - DatePicker("Start", selection: $startDate, in: ...endDate, displayedComponents: .date) - DatePicker("End", selection: $endDate, in: startDate..., displayedComponents: .date) - } - - // ── Report options ──────────────────────────────────────── - Section(header: label("Report Options", icon: "doc.richtext")) { - Picker("Units", selection: $units) { - ForEach(unitOptions, id: \.self) { Text($0) } - } - .pickerStyle(.segmented) - - Toggle("Include Daily Breakdown", isOn: $includeDailyBreakdown) - Toggle("Include Fat & Protein", isOn: $includeFatProtein) - - HStack { - Text("Accent Color") - Spacer() - Circle() - .fill(pickedColor) - .frame(width: 24, height: 24) - .overlay(Circle().stroke(Color.secondary.opacity(0.3), lineWidth: 1)) - if #available(iOS 16.0, *) { - ColorPicker("", selection: $pickedColor, supportsOpacity: false) - .labelsHidden() - .onChange(of: pickedColor) { newVal in - accentColorHex = newVal.toHex() ?? "#23A0AC" - } - } - } - } - - // ── What's included ─────────────────────────────────────── - Section(header: label("Report Includes", icon: "checklist")) { - Group { - Label("eA1C / GMI estimate", systemImage: "drop.fill") - Label("Time in Range distribution", systemImage: "chart.bar.fill") - Label("Ambulatory Glucose Profile", systemImage: "waveform.path.ecg") - Label("Glucose by time of day", systemImage: "clock.fill") - Label("Insulin delivery summary", systemImage: "syringe.fill") - if includeDailyBreakdown { - Label("Daily breakdown (newest first)", systemImage: "calendar.day.timeline.left") - } - if includeFatProtein { - Label("Fat & protein entries", systemImage: "fork.knife") - } - } - .font(.subheadline) - .foregroundColor(.secondary) - } - - // ── Error ───────────────────────────────────────────────── - if let err = errorMessage { - Section { - Label(err, systemImage: "exclamationmark.triangle.fill") - .foregroundColor(.red) - .font(.subheadline) - } - } - - // ── Generate ────────────────────────────────────────────── - Section { - Button(action: generate) { - HStack { - Spacer() - if isGenerating { - ProgressView().padding(.trailing, 6) - Text("Generating…").fontWeight(.semibold) - } else { - Image(systemName: "doc.richtext").padding(.trailing, 4) - Text("Generate PDF Report").fontWeight(.semibold) - } - Spacer() - } - } - .disabled(isGenerating) - .foregroundColor(.white) - .listRowBackground(isGenerating ? Color.teal.opacity(0.5) : Color.teal) - } - } - .navigationTitle("Endo Report") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { dismiss() } - } - } - .sheet(isPresented: $showShareSheet) { - if let url = reportURL { ShareSheet(items: [url]) } - } - .onAppear { - pickedColor = Color(hex: accentColorHex) ?? .teal - } - } - .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) - } - - // MARK: - Helpers - - @ViewBuilder - private func row(_ label: String, placeholder: String, text: Binding, - keyboard: UIKeyboardType = .default) -> some View - { - HStack { - Text(label) - .foregroundColor(.secondary) - .frame(width: 110, alignment: .leading) - TextField(placeholder, text: text) - .keyboardType(keyboard) - } - } - - private func label(_ title: String, icon: String) -> some View { - Label(title, systemImage: icon) - .font(.caption) - .textCase(.uppercase) - } - - // MARK: - Presets - - private struct Preset { let label: String; let start: Date; let end: Date } - private var presets: [Preset] { - let now = Date(); let cal = Calendar.current - return [ - Preset(label: "3d", start: cal.date(byAdding: .day, value: -3, to: now)!, end: now), - Preset(label: "7d", start: cal.date(byAdding: .day, value: -7, to: now)!, end: now), - Preset(label: "14d", start: cal.date(byAdding: .day, value: -14, to: now)!, end: now), - Preset(label: "30d", start: cal.date(byAdding: .day, value: -30, to: now)!, end: now), - Preset(label: "90d", start: cal.date(byAdding: .day, value: -90, to: now)!, end: now), - ] - } - - private func isActive(_ p: Preset) -> Bool { - Calendar.current.isDate(p.start, inSameDayAs: startDate) && - Calendar.current.isDate(p.end, inSameDayAs: endDate) - } - - // MARK: - Fetch from Nightscout - - private func fetchFromNightscout() { - fetchSuccess = false - profileFetcher.fetch { settings in - guard let s = settings else { return } - carbRatio = s.carbRatio - isf = s.isf - basalRate = s.basalRate - // Target: show low-high range if both present - if !s.targetLow.isEmpty && !s.targetHigh.isEmpty { - targetGlucose = "\(s.targetLow)–\(s.targetHigh)" - } else { - targetGlucose = s.targetLow.isEmpty ? s.targetHigh : s.targetLow - } - // Auto-set units from profile - units = s.units - fetchSuccess = true - // Reset success indicator after 3 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - fetchSuccess = false - } - } - } - - // MARK: - Generate - - private func generate() { - errorMessage = nil - isGenerating = true - dataService.updateDateRange(start: startDate, end: endDate) - dataService.ensureDataAvailable(onProgress: {}) { - DispatchQueue.global(qos: .userInitiated).async { - do { - let config = EndoReportConfig( - patientName: patientName, - dateOfBirth: dateOfBirth, - diagnosisDate: diagnosisDate, - providerName: providerName, - insulinType: insulinType, - aidSystem: aidSystem, - pumpDevice: pumpDevice, - cgmDevice: cgmDevice, - carbRatio: carbRatio, - isf: isf, - basalRate: basalRate, - targetGlucose: targetGlucose, - units: units, - accentColorHex: accentColorHex, - includeDailyBreakdown: includeDailyBreakdown, - includeFatProtein: includeFatProtein, - startDate: startDate, - endDate: endDate - ) - let url = try EndoReportGenerator.generate(config: config, dataService: dataService) - DispatchQueue.main.async { - isGenerating = false - reportURL = url - showShareSheet = true - } - } catch { - DispatchQueue.main.async { - isGenerating = false - errorMessage = error.localizedDescription - } - } - } - } - } -} - -// MARK: - Config struct (passed to generator) - -struct EndoReportConfig { - let patientName: String - let dateOfBirth: String - let diagnosisDate: String - let providerName: String - let insulinType: String - let aidSystem: String - let pumpDevice: String - let cgmDevice: String - let carbRatio: String - let isf: String - let basalRate: String - let targetGlucose: String - let units: String - let accentColorHex: String - let includeDailyBreakdown: Bool - let includeFatProtein: Bool - let startDate: Date - let endDate: Date - - var accentColor: UIColor { - UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) - } - - var isMMOL: Bool { units == "mmol/L" } - func fmtBG(_ mgdl: Double) -> String { - isMMOL ? String(format: "%.1f", mgdl * 0.0555) : String(format: "%.0f", mgdl) - } - - var accentUIColor: UIColor { - UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) - } -} - -// MARK: - Share sheet - -struct ShareSheet: UIViewControllerRepresentable { - let items: [Any] - func makeUIViewController(context _: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) - } - - func updateUIViewController(_: UIActivityViewController, context _: Context) {} -} - -// MARK: - Color extensions - -extension Color { - init?(hex: String) { - var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") - if h.count == 6 { h += "FF" } - guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } - self.init( - red: Double((val >> 24) & 0xFF) / 255, - green: Double((val >> 16) & 0xFF) / 255, - blue: Double((val >> 8) & 0xFF) / 255, - opacity: Double(val & 0xFF) / 255 - ) - } - - func toHex() -> String? { - guard let c = UIColor(self).cgColor.components, c.count >= 3 else { return nil } - return String(format: "#%02X%02X%02X", - Int(c[0] * 255), Int(c[1] * 255), Int(c[2] * 255)) - } -} - -extension UIColor { - convenience init?(hex: String) { - var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") - if h.count == 6 { h += "FF" } - guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } - self.init( - red: CGFloat((val >> 24) & 0xFF) / 255, - green: CGFloat((val >> 16) & 0xFF) / 255, - blue: CGFloat((val >> 8) & 0xFF) / 255, - alpha: CGFloat(val & 0xFF) / 255 - ) - } -} - -// MARK: - NightscoutProfileFetcher - -class NightscoutProfileFetcher: ObservableObject { - @Published var isFetching = false - @Published var error: String? - @Published var success = false - - struct FetchedSettings { - let carbRatio: String - let isf: String - let basalRate: String - let targetLow: String - let targetHigh: String - let units: String - } - - func fetch(completion: @escaping (FetchedSettings?) -> Void) { - isFetching = true - error = nil - success = false - - NightscoutUtils.executeRequest( - eventType: .profile, - parameters: [:] - ) { [weak self] (result: Result) in - DispatchQueue.main.async { - guard let self else { return } - self.isFetching = false - - switch result { - case let .failure(err): - self.error = err.localizedDescription - completion(nil) - - case let .success(profile): - // Use defaultProfile store, fall back to "default" / "Default" - let store = profile.store[profile.defaultProfile] - ?? profile.store["default"] - ?? profile.store["Default"] - ?? profile.store.values.first - - guard let s = store else { - self.error = "No profile store found in Nightscout response." - completion(nil) - return - } - - let isMMOL = s.units.lowercased().contains("mmol") - - // Pick the first (midnight) entry for each schedule - // and format a readable multi-segment string if >1 entry - func fmtSchedule(_ entries: [T], - value: (T) -> Double, - time: (T) -> String) -> String - { - if entries.count == 1 { - return String(format: isMMOL ? "%.2f" : "%.0f", value(entries[0])) - } - return entries.prefix(6).map { - "\(time($0)): \(String(format: isMMOL ? "%.2f" : "%.0f", value($0)))" - }.joined(separator: " | ") - } - - let cr = fmtSchedule(s.carbratio, value: { $0.value }, time: { $0.time }) - let isf = fmtSchedule(s.sens, value: { $0.value }, time: { $0.time }) - let bas = fmtSchedule(s.basal, value: { $0.value }, time: { $0.time }) - - let targetLow = s.target_low?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" - let targetHigh = s.target_high?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" - - self.success = true - completion(FetchedSettings( - carbRatio: cr, - isf: isf, - basalRate: bas, - targetLow: targetLow, - targetHigh: targetHigh, - units: isMMOL ? "mmol/L" : "mg/dL" - )) - } - } - } - } -} From b8b0b208511cc5cdf367dca79646a7ea64af7959 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Sun, 24 May 2026 12:03:16 -0700 Subject: [PATCH 5/7] Bug Fixes small updates --- LoopFollow/Stats/EndoReportGenerator.swift | 1148 ++++++++++---------- LoopFollow/Stats/EndoReportView.swift | 896 +++++++++++++++ LoopFollow/Stats/StatsDataFetcher.swift | 12 +- 3 files changed, 1507 insertions(+), 549 deletions(-) create mode 100644 LoopFollow/Stats/EndoReportView.swift diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index 435bc4290..f696d00e7 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -7,325 +7,344 @@ import UIKit // MARK: - Config struct EndoReportConfig { - let patientName: String - let dateOfBirth: String - let diagnosisDate: String - let providerName: String - let insulinType: String - let aidSystem: String - let pumpDevice: String - let cgmDevice: String - let carbRatio: String - let isf: String - let basalRate: String - let targetGlucose: String - let units: String // "mg/dL" or "mmol/L" + let patientName: String + let dateOfBirth: String + let diagnosisDate: String + let providerName: String + let insulinType: String + let aidSystem: String + let pumpDevice: String + let cgmDevice: String + let carbRatio: String + let isf: String + let basalRate: String + let targetGlucose: String + let units: String // "mg/dL" or "mmol/L" let accentColorHex: String - + // Toggles let includeGlucoseSummary: Bool - let includeInsulin: Bool - let includeNutrition: Bool - let includeTherapySettings:Bool - let includeDevices: Bool - let includeAGP: Bool + let includeInsulin: Bool + let includeNutrition: Bool + let includeTherapySettings: Bool + let includeDevices: Bool + let includeAGP: Bool let includeDailyBreakdown: Bool - let includeFatProtein: Bool - + let includeFatProtein: Bool + let startDate: Date - let endDate: Date + let endDate: Date var accentColor: UIColor { - UIColor(hex: accentColorHex) ?? UIColor(red:0.137,green:0.624,blue:0.675,alpha:1) + UIColor(hex: accentColorHex) ?? UIColor(red: 0.137, green: 0.624, blue: 0.675, alpha: 1) } + var isMMOL: Bool { units == "mmol/L" } func convert(_ mgdl: Double) -> Double { isMMOL ? mgdl * 0.0555 : mgdl } func fmtBG(_ mgdl: Double) -> String { - isMMOL ? String(format:"%.1f", mgdl*0.0555) : String(format:"%.0f", mgdl) + isMMOL ? String(format: "%.1f", mgdl * 0.0555) : String(format: "%.0f", mgdl) } } // MARK: - Generator enum EndoReportGenerator { - enum ReportError: LocalizedError { case noData var errorDescription: String? { "No CGM data available for the selected date range." } } static func generate(config: EndoReportConfig, dataService: StatsDataService) throws -> URL { - let bgData = dataService.getBGData() + let bgData = dataService.getBGData() guard !bgData.isEmpty else { throw ReportError.noData } - let agpData = AGPCalculator.calculate(bgData: bgData) - let tirData = TIRCalculator.calculate(bgData: bgData) - let stats = ReportStats(bgData: bgData, dataService: dataService) + let agpData = AGPCalculator.calculate(bgData: bgData) + let tirData = TIRCalculator.calculate(bgData: bgData) + let stats = ReportStats(bgData: bgData, dataService: dataService) let patterns = TimePatterns(bgData: bgData) - let boluses = dataService.getBolusData() - let smbs = dataService.getSMBData() - let carbs = dataService.getCarbData() - let basals = dataService.getBasalData() + let boluses = dataService.getBolusData() + let smbs = dataService.getSMBData() + let carbs = dataService.getCarbData() + let basals = dataService.getBasalData() let simpleVM = SimpleStatsViewModel(dataService: dataService) simpleVM.calculateStats() - let pageRect = CGRect(origin:.zero, size:CGSize(width:612,height:792)) - let renderer = UIGraphicsPDFRenderer(bounds:pageRect) + let pageRect = CGRect(origin: .zero, size: CGSize(width: 612, height: 792)) + let renderer = UIGraphicsPDFRenderer(bounds: pageRect) let url = FileManager.default.temporaryDirectory .appendingPathComponent("EndoReport_\(Int(Date().timeIntervalSince1970)).pdf") - let dailyData = groupByDay(bgData:bgData, boluses:boluses, smbs:smbs, basals:basals, carbs:carbs) + let dailyData = groupByDay(bgData: bgData, boluses: boluses, smbs: smbs, basals: basals, carbs: carbs) .sorted { $0.key > $1.key } let data = renderer.pdfData { ctx in // Page 1 — Summary ctx.beginPage() - drawSummaryPage(ctx:ctx.cgContext, r:pageRect, cfg:config, - bgData:bgData, agpData:agpData, tirData:tirData, - stats:stats, patterns:patterns, - boluses:boluses, smbs:smbs, carbs:carbs, - simpleVM:simpleVM) + drawSummaryPage(ctx: ctx.cgContext, r: pageRect, cfg: config, + bgData: bgData, agpData: agpData, tirData: tirData, + stats: stats, patterns: patterns, + boluses: boluses, smbs: smbs, carbs: carbs, + simpleVM: simpleVM) // Pages 2+ — Daily breakdowns if config.includeDailyBreakdown && !dailyData.isEmpty { - let rowH:CGFloat = 88 - let rowGap:CGFloat = 6 - let topY:CGFloat = 52 - let botY:CGFloat = 762 - let usable = botY - topY - let perPage = Int((usable + rowGap) / (rowH + rowGap)) - let pages = Int(ceil(Double(dailyData.count) / Double(perPage))) - - for p in 0..=70&&$0<=180}.count)/n*100 - tightTIR=Double(v.filter{$0>=70&&$0<=140}.count)/n*100 + init(bgData: [ShareGlucoseData], dataService: StatsDataService) { + let v = bgData.map { Double($0.sgv) }; let n = Double(v.count) + let m = v.reduce(0,+) / n + let variance = v.map { ($0 - m) * ($0 - m) }.reduce(0,+) / n + avg = m; stdDev = sqrt(variance); cv = stdDev / m * 100; eA1C = (m + 46.7) / 28.7 + minBG = v.min() ?? 0; maxBG = v.max() ?? 0; readingCount = v.count + days = Swift.max(dataService.endDate.timeIntervalSince1970 - dataService.startDate.timeIntervalSince1970, 86400) / 86400 + sensorPct = Swift.min(Double(v.count) / (days * 288) * 100, 100) + tir = Double(v.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + tightTIR = Double(v.filter { $0 >= 70 && $0 <= 140 }.count) / n * 100 } } struct TimePatterns { - struct Period { let label:String; let avg:Double; let count:Int } - let night,earlyAM,morning,afternoon,evening,late: Period - init(bgData:[ShareGlucoseData]) { - func p(_ l:String,_ s:Int,_ e:Int)->Period { - let cal=dateTimeUtils.displayCalendar() - let r=bgData.filter{ let h=cal.component(.hour,from:Date(timeIntervalSince1970:$0.date)); return h>=s&&h Period { + let cal = dateTimeUtils.displayCalendar() + let r = bgData.filter { let h = cal.component(.hour, from: Date(timeIntervalSince1970: $0.date)); return h >= s && h < e } + return Period(label: l, avg: r.isEmpty ? 0 : r.map { Double($0.sgv) }.reduce(0,+) / Double(r.count), count: r.count) } - night=p("Night",0,3); earlyAM=p("Early AM",3,6); morning=p("Morning",6,12) - afternoon=p("Afternoon",12,17); evening=p("Evening",17,21); late=p("Late",21,24) + night = p("Night", 0, 3); earlyAM = p("Early AM", 3, 6); morning = p("Morning", 6, 12) + afternoon = p("Afternoon", 12, 17); evening = p("Evening", 17, 21); late = p("Late", 21, 24) } } struct DayData { - let bg: [ShareGlucoseData] - let bolus: [MainViewController.bolusGraphStruct] - let smb: [MainViewController.bolusGraphStruct] - let basal: [MainViewController.basalGraphStruct] - let carbs: [MainViewController.carbGraphStruct] + let bg: [ShareGlucoseData] + let bolus: [MainViewController.bolusGraphStruct] + let smb: [MainViewController.bolusGraphStruct] + let basal: [MainViewController.basalGraphStruct] + let carbs: [MainViewController.carbGraphStruct] } private static func groupByDay( - bgData:[ShareGlucoseData], - boluses:[MainViewController.bolusGraphStruct], - smbs:[MainViewController.bolusGraphStruct], - basals:[MainViewController.basalGraphStruct], - carbs:[MainViewController.carbGraphStruct] - ) -> [String:DayData] { - let df=DateFormatter(); df.dateFormat="yyyy-MM-dd" - var bg:[String:[ShareGlucoseData]]=[:] - var bo:[String:[MainViewController.bolusGraphStruct]]=[:] - var sm:[String:[MainViewController.bolusGraphStruct]]=[:] - var ba:[String:[MainViewController.basalGraphStruct]]=[:] - var ca:[String:[MainViewController.carbGraphStruct]]=[:] - for r in bgData { let k=df.string(from:Date(timeIntervalSince1970:r.date)); bg[k,default:[]].append(r) } - for r in boluses { let k=df.string(from:Date(timeIntervalSince1970:r.date)); bo[k,default:[]].append(r) } - for r in smbs { let k=df.string(from:Date(timeIntervalSince1970:r.date)); sm[k,default:[]].append(r) } - for r in basals { let k=df.string(from:Date(timeIntervalSince1970:r.date)); ba[k,default:[]].append(r) } - for r in carbs { let k=df.string(from:Date(timeIntervalSince1970:r.date)); ca[k,default:[]].append(r) } - var result:[String:DayData]=[: ] - for k in bg.keys { result[k]=DayData(bg:bg[k]!,bolus:bo[k] ?? [],smb:sm[k] ?? [],basal:ba[k] ?? [],carbs:ca[k] ?? []) } + bgData: [ShareGlucoseData], + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + basals: [MainViewController.basalGraphStruct], + carbs: [MainViewController.carbGraphStruct] + ) -> [String: DayData] { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + var bg: [String: [ShareGlucoseData]] = [:] + var bo: [String: [MainViewController.bolusGraphStruct]] = [:] + var sm: [String: [MainViewController.bolusGraphStruct]] = [:] + var ba: [String: [MainViewController.basalGraphStruct]] = [:] + var ca: [String: [MainViewController.carbGraphStruct]] = [:] + for r in bgData { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bg[k, default: []].append(r) + } + for r in boluses { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); bo[k, default: []].append(r) + } + for r in smbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); sm[k, default: []].append(r) + } + for r in basals { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ba[k, default: []].append(r) + } + for r in carbs { + let k = df.string(from: Date(timeIntervalSince1970: r.date)); ca[k, default: []].append(r) + } + var result: [String: DayData] = [:] + for k in bg.keys { + result[k] = DayData(bg: bg[k]!, bolus: bo[k] ?? [], smb: sm[k] ?? [], basal: ba[k] ?? [], carbs: ca[k] ?? []) + } return result } // MARK: - Colors / fonts - private static func accent(_ cfg:EndoReportConfig) -> UIColor { cfg.accentColor } - private static func accentDark(_ cfg:EndoReportConfig) -> UIColor { - var h:CGFloat=0,s:CGFloat=0,b:CGFloat=0,a:CGFloat=0 - cfg.accentColor.getHue(&h,saturation:&s,brightness:&b,alpha:&a) - return UIColor(hue:h,saturation:s,brightness:b*0.72,alpha:a) + private static func accent(_ cfg: EndoReportConfig) -> UIColor { cfg.accentColor } + private static func accentDark(_ cfg: EndoReportConfig) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + cfg.accentColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s, brightness: b * 0.72, alpha: a) } - private static let C_INK = UIColor(red:0.133,green:0.157,blue:0.192,alpha:1) - private static let C_SLATE = UIColor(red:0.400,green:0.440,blue:0.490,alpha:1) - private static let C_CLOUD = UIColor(red:0.960,green:0.963,blue:0.970,alpha:1) - private static let C_BORDER = UIColor(red:0.870,green:0.885,blue:0.905,alpha:1) - private static let C_WHITE = UIColor.white - private static let C_VLOW = UIColor(red:0.820,green:0.180,blue:0.180,alpha:1) - private static let C_LOW = UIColor(red:0.929,green:0.490,blue:0.188,alpha:1) - private static let C_IN = UIColor(red:0.200,green:0.670,blue:0.470,alpha:1) - private static let C_HIGH = UIColor(red:0.910,green:0.740,blue:0.220,alpha:1) - private static let C_VHIGH = UIColor(red:0.800,green:0.340,blue:0.340,alpha:1) - private static let C_BOLUS = UIColor(red:0.380,green:0.220,blue:0.780,alpha:0.85) - private static let C_SMB = UIColor(red:0.800,green:0.200,blue:0.600,alpha:0.75) - private static let C_BASAL = UIColor(red:0.102,green:0.451,blue:0.933,alpha:0.65) - - private static func bgColor(_ bg:Double)->UIColor { - switch bg { case ..<54:return C_VLOW; case ..<70:return C_LOW; case ...180:return C_IN; case ...250:return C_HIGH; default:return C_VHIGH } + private static let C_INK = UIColor(red: 0.133, green: 0.157, blue: 0.192, alpha: 1) + private static let C_SLATE = UIColor(red: 0.400, green: 0.440, blue: 0.490, alpha: 1) + private static let C_CLOUD = UIColor(red: 0.960, green: 0.963, blue: 0.970, alpha: 1) + private static let C_BORDER = UIColor(red: 0.870, green: 0.885, blue: 0.905, alpha: 1) + private static let C_WHITE = UIColor.white + private static let C_VLOW = UIColor(red: 0.820, green: 0.180, blue: 0.180, alpha: 1) + private static let C_LOW = UIColor(red: 0.929, green: 0.490, blue: 0.188, alpha: 1) + private static let C_IN = UIColor(red: 0.200, green: 0.670, blue: 0.470, alpha: 1) + private static let C_HIGH = UIColor(red: 0.910, green: 0.740, blue: 0.220, alpha: 1) + private static let C_VHIGH = UIColor(red: 0.800, green: 0.340, blue: 0.340, alpha: 1) + private static let C_BOLUS = UIColor(red: 0.380, green: 0.220, blue: 0.780, alpha: 0.85) + private static let C_SMB = UIColor(red: 0.800, green: 0.200, blue: 0.600, alpha: 0.75) + private static let C_BASAL = UIColor(red: 0.102, green: 0.451, blue: 0.933, alpha: 0.65) + + private static func bgColor(_ bg: Double) -> UIColor { + switch bg { case ..<54: return C_VLOW; case ..<70: return C_LOW; case ...180: return C_IN; case ...250: return C_HIGH; default: return C_VHIGH } } // MARK: - Page 1: Summary private static func drawSummaryPage( - ctx:CGContext, r:CGRect, cfg:EndoReportConfig, - bgData:[ShareGlucoseData], agpData:[AGPDataPoint], tirData:[TIRDataPoint], - stats:ReportStats, patterns:TimePatterns, - boluses:[MainViewController.bolusGraphStruct], - smbs:[MainViewController.bolusGraphStruct], - carbs:[MainViewController.carbGraphStruct], - simpleVM:SimpleStatsViewModel + ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + bgData _: [ShareGlucoseData], agpData: [AGPDataPoint], tirData: [TIRDataPoint], + stats: ReportStats, patterns: TimePatterns, + boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + carbs: [MainViewController.carbGraphStruct], + simpleVM: SimpleStatsViewModel ) { - let m:CGFloat=24 - var y = drawHero(ctx:ctx, r:r, cfg:cfg, stats:stats) + let m: CGFloat = 24 + var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) if cfg.includeGlucoseSummary { - y = sectionHdr("GLUCOSE SUMMARY", y:y+8, m:m, w:r.width, cfg:cfg, ctx:ctx) - - let gridW:CGFloat = r.width - m*2 - 158 - let cw = gridW/2 - 3; let ch:CGFloat=42 - let cards:[(String,String,Bool)] = [ - ("TIME IN RANGE", String(format:"%.0f%%", stats.tir), true), - ("GMI (EST. A1C)", String(format:"%.1f%%", stats.eA1C), false), - ("AVERAGE", cfg.fmtBG(stats.avg)+" \(cfg.units)", false), - ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), - ("CV", String(format:"%.0f%%", stats.cv), false), - ("READINGS", "\(stats.readingCount)", false), + y = sectionHdr("GLUCOSE SUMMARY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + + let gridW: CGFloat = r.width - m * 2 - 158 + let cw = gridW / 2 - 3; let ch: CGFloat = 36 + let cards: [(String, String, Bool)] = [ + ("TIME IN RANGE", String(format: "%.0f%%", stats.tir), true), + ("GMI (EST. A1C)", String(format: "%.1f%%", stats.eA1C), false), + ("AVERAGE", cfg.fmtBG(stats.avg) + " \(cfg.units)", false), + ("STD DEVIATION", cfg.fmtBG(stats.stdDev), false), + ("CV", String(format: "%.0f%%", stats.cv), false), + ("READINGS", "\(stats.readingCount)", false), ] - var gy = y+4 - for(i,c) in cards.enumerated() { - statCard(c.0,val:c.1, x:m+CGFloat(i%2)*(cw+6), y:gy+CGFloat(i/2)*(ch+4), - w:cw, h:ch, accent:c.2, cfg:cfg, ctx:ctx) + var gy = y + 1 + for (i, c) in cards.enumerated() { + statCard(c.0, val: c.1, x: m + CGFloat(i % 2) * (cw + 6), y: gy + CGFloat(i / 2) * (ch + 4), + w: cw, h: ch, accent: c.2, cfg: cfg, ctx: ctx) } - drawTIRBar(tirData:tirData, stats:stats, x:m+gridW+10, y:y+4, - w:148, h:ch*3+8, cfg:cfg, ctx:ctx) - y = gy + CGFloat(3)*(ch+4) + 4 - - y = timeStrip(patterns:patterns, cfg:cfg, y:y+4, m:m, w:r.width, ctx:ctx) + drawTIRBar(tirData: tirData, stats: stats, x: m + gridW + 10, y: y + 1, + w: 148, h: ch * 3 + 7, cfg: cfg, ctx: ctx) + y = gy + CGFloat(3) * (ch + 4) + 1 + + y = timeStrip(patterns: patterns, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) } if cfg.includeInsulin && (!boluses.isEmpty || !smbs.isEmpty) { - y = sectionHdr("INSULIN DELIVERY", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) - y = insulinSection(boluses:boluses, smbs:smbs, simpleVM:simpleVM, - stats:stats, cfg:cfg, y:y+4, m:m, w:r.width, ctx:ctx) + y = sectionHdr("INSULIN DELIVERY", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = insulinSection(boluses: boluses, smbs: smbs, simpleVM: simpleVM, + stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) } if cfg.includeNutrition && !carbs.isEmpty { - y = sectionHdr("NUTRITION & MEALS", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) - y = nutritionSection(carbs:carbs, stats:stats, cfg:cfg, y:y+4, m:m, w:r.width, ctx:ctx) + y = sectionHdr("NUTRITION & MEALS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) } - let hasSettings = !cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty - if cfg.includeTherapySettings && hasSettings { - y = sectionHdr("CURRENT THERAPY SETTINGS", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) - var rows:[(String,String)]=[] - if !cfg.carbRatio.isEmpty { rows.append(("Carb Ratio (CR)", cfg.carbRatio)) } - if !cfg.isf.isEmpty { rows.append(("Insulin Sensitivity (ISF)", cfg.isf)) } - if !cfg.basalRate.isEmpty { rows.append(("Basal Rate", cfg.basalRate)) } - if !cfg.targetGlucose.isEmpty{ rows.append(("Target Glucose", cfg.targetGlucose)) } - y = settingsTable(rows, y:y+4, m:m, w:r.width, cfg:cfg, ctx:ctx) + let hasDevice = !cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty + if cfg.includeDevices, hasDevice { + y = sectionHdr("DEVICES & INSULIN", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + var rows: [(String, String)] = [] + if !cfg.pumpDevice.isEmpty { rows.append(("Pump", cfg.pumpDevice)) } + if !cfg.cgmDevice.isEmpty { rows.append(("CGM", cfg.cgmDevice)) } + if !cfg.insulinType.isEmpty { rows.append(("Insulin", cfg.insulinType)) } + y = metricTable(rows, y: y + 1, m: m, w: r.width, cfg: cfg, ctx: ctx) } - let hasDevice = !cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty - if cfg.includeDevices && hasDevice { - y = sectionHdr("DEVICES & INSULIN", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) - var rows:[(String,String)]=[] - if !cfg.pumpDevice.isEmpty { rows.append(("Pump", cfg.pumpDevice)) } - if !cfg.cgmDevice.isEmpty { rows.append(("CGM", cfg.cgmDevice)) } - if !cfg.insulinType.isEmpty{ rows.append(("Insulin", cfg.insulinType)) } - y = metricTable(rows, y:y+4, m:m, w:r.width, cfg:cfg, ctx:ctx) + let hasSettings = !cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty + if cfg.includeTherapySettings, hasSettings { + y = sectionHdr("CURRENT THERAPY SETTINGS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + var rows: [(String, String)] = [] + if !cfg.carbRatio.isEmpty { rows.append(("Carb Ratio (CR)", cfg.carbRatio)) } + if !cfg.isf.isEmpty { rows.append(("Insulin Sensitivity (ISF)", cfg.isf)) } + + var basalVal = "" + if !cfg.basalRate.isEmpty { basalVal = formatBasalRateForDisplay(cfg.basalRate) } + if !cfg.targetGlucose.isEmpty { + if !basalVal.isEmpty { basalVal += "\n" } + basalVal += "Target: \(cfg.targetGlucose)" + } + if !basalVal.isEmpty { rows.append((cfg.basalRate.isEmpty ? "Target Glucose" : "Basal & Target", basalVal)) } + + y = settingsTable(rows, y: y + 1, m: m, w: r.width, cfg: cfg, ctx: ctx) } - if cfg.includeAGP && !agpData.isEmpty { - let agpAvail = r.height - y - 50 + if cfg.includeAGP, !agpData.isEmpty { + let agpAvail = r.height - y - 50 if agpAvail >= 100 { - y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y:y+6, m:m, w:r.width, cfg:cfg, ctx:ctx) + y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) let agpH = Swift.min(agpAvail - 24, 118) - drawAGP(agpData:agpData, x:m, y:y+4, w:r.width-m*2, h:agpH, cfg:cfg, ctx:ctx) + drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) } } - drawFooter(ctx:ctx, r:r, cfg:cfg, stats:stats, page:1) + drawFooter(ctx: ctx, r: r, cfg: cfg, stats: stats, page: 1) } // MARK: - Hero header @discardableResult - private static func drawHero(ctx:CGContext, r:CGRect, cfg:EndoReportConfig, stats:ReportStats)->CGFloat { - let h:CGFloat=95; let ac=accent(cfg); let ad=accentDark(cfg) - ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x:0,y:0,width:r.width,height:h)) - ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x:0,y:0,width:r.width,height:21)) + private static func drawHero(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, stats: ReportStats) -> CGFloat { + let h: CGFloat = 90; let ac = accent(cfg); let ad = accentDark(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + ctx.setFillColor(ad.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: 21)) - let a1:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8.5),.foregroundColor:C_WHITE.withAlphaComponent(0.8),.kern:3.0] - "LOOP FOLLOW".draw(at:CGPoint(x:26,y:5),withAttributes:a1) + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.8), .kern: 3.0] + "LOOP FOLLOW".draw(at: CGPoint(x: 26, y: 5), withAttributes: a1) - let a2:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:21),.foregroundColor:C_WHITE] - "Endocrinologist Visit Report".draw(at:CGPoint(x:26,y:26),withAttributes:a2) + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 21), .foregroundColor: C_WHITE] + "Endocrinologist Visit Report".draw(at: CGPoint(x: 26, y: 26), withAttributes: a2) - let a3:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:9.5),.foregroundColor:C_WHITE.withAlphaComponent(0.82)] - "Automated Insulin Delivery Performance Summary".draw(at:CGPoint(x:26,y:52),withAttributes:a3) + let a3: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9.5), .foregroundColor: C_WHITE.withAlphaComponent(0.82)] + "Automated Insulin Delivery Performance Summary".draw(at: CGPoint(x: 26, y: 52), withAttributes: a3) - let df=DateFormatter(); df.dateFormat="MMMM d, yyyy" - let ds="\(df.string(from:cfg.startDate)) — \(df.string(from:cfg.endDate)) (\(Int(stats.days.rounded())) Days)" - let a4:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:9),.foregroundColor:C_WHITE.withAlphaComponent(0.68)] - ds.draw(at:CGPoint(x:26,y:68),withAttributes:a4) + let df = DateFormatter(); df.dateFormat = "MMMM d, yyyy" + let ds = "\(df.string(from: cfg.startDate)) — \(df.string(from: cfg.endDate)) (\(Int(stats.days.rounded())) Days)" + let a4: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9), .foregroundColor: C_WHITE.withAlphaComponent(0.68)] + ds.draw(at: CGPoint(x: 26, y: 68), withAttributes: a4) - let a5:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:8.5),.foregroundColor:C_WHITE.withAlphaComponent(0.95)] - var lines:[String]=[] - if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } + let a5: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8.5), .foregroundColor: C_WHITE.withAlphaComponent(0.95)] + var lines: [String] = [] + if !cfg.patientName.isEmpty { lines.append("Patient: \(cfg.patientName)") } if !cfg.providerName.isEmpty { lines.append("Provider: \(cfg.providerName)") } - if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } - if !cfg.aidSystem.isEmpty { lines.append("AID: \(cfg.aidSystem)") } - if !cfg.diagnosisDate.isEmpty{ lines.append("Dx: \(cfg.diagnosisDate)") } - - for(i,l) in lines.enumerated() { - let sz=(l as NSString).size(withAttributes:a5) - (l as NSString).draw(at:CGPoint(x:r.width-26-sz.width,y:26+CGFloat(i)*13),withAttributes:a5) + if !cfg.dateOfBirth.isEmpty { lines.append("DOB: \(cfg.dateOfBirth)") } + if !cfg.aidSystem.isEmpty { lines.append("AID: \(cfg.aidSystem)") } + if !cfg.diagnosisDate.isEmpty { lines.append("Dx: \(cfg.diagnosisDate)") } + + for (i, l) in lines.enumerated() { + let sz = (l as NSString).size(withAttributes: a5) + (l as NSString).draw(at: CGPoint(x: r.width - 26 - sz.width, y: 24 + CGFloat(i) * 11.5), withAttributes: a5) } return h } @@ -333,457 +352,497 @@ enum EndoReportGenerator { // MARK: - Daily page header @discardableResult - private static func drawDailyPageHeader(ctx:CGContext, r:CGRect, cfg:EndoReportConfig, - page:Int, totalPages:Int)->CGFloat { - let h:CGFloat=40; let ac=accent(cfg) - ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x:0,y:0,width:r.width,height:h)) - let a1:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:12),.foregroundColor:C_WHITE] - "Daily Glucose Breakdown".draw(at:CGPoint(x:28,y:11),withAttributes:a1) - let sub="Newest to Oldest • Page \(page) of \(totalPages)" - let a2:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:8),.foregroundColor:C_WHITE.withAlphaComponent(0.75)] - let sz=(sub as NSString).size(withAttributes:a2) - (sub as NSString).draw(at:CGPoint(x:r.width-28-sz.width,y:14),withAttributes:a2) + private static func drawDailyPageHeader(ctx: CGContext, r: CGRect, cfg: EndoReportConfig, + page: Int, totalPages: Int) -> CGFloat + { + let h: CGFloat = 40; let ac = accent(cfg) + ctx.setFillColor(ac.cgColor); ctx.fill(CGRect(x: 0, y: 0, width: r.width, height: h)) + let a1: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: C_WHITE] + "Daily Glucose Breakdown".draw(at: CGPoint(x: 28, y: 11), withAttributes: a1) + let sub = "Newest to Oldest • Page \(page) of \(totalPages)" + let a2: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_WHITE.withAlphaComponent(0.75)] + let sz = (sub as NSString).size(withAttributes: a2) + (sub as NSString).draw(at: CGPoint(x: r.width - 28 - sz.width, y: 14), withAttributes: a2) return h } // MARK: - Section header @discardableResult - private static func sectionHdr(_ title:String, y:CGFloat, m:CGFloat, w:CGFloat, - cfg:EndoReportConfig, ctx:CGContext)->CGFloat { + private static func sectionHdr(_ title: String, y: CGFloat, m: CGFloat, w: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { ctx.setFillColor(accent(cfg).cgColor) - ctx.fill(CGRect(x:m,y:y,width:3,height:14)) - let a:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:9),.foregroundColor:accent(cfg),.kern:0.6] - (title as NSString).draw(at:CGPoint(x:m+8,y:y),withAttributes:a) + ctx.fill(CGRect(x: m, y: y, width: 3, height: 14)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: accent(cfg), .kern: 0.6] + (title as NSString).draw(at: CGPoint(x: m + 8, y: y), withAttributes: a) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.move(to:CGPoint(x:m,y:y+15)); ctx.addLine(to:CGPoint(x:w-m,y:y+15)); ctx.strokePath() - return y+19 + ctx.move(to: CGPoint(x: m, y: y + 15)); ctx.addLine(to: CGPoint(x: w - m, y: y + 15)); ctx.strokePath() + return y + 16 } // MARK: - Stat card - private static func statCard(_ label:String, val:String, x:CGFloat, y:CGFloat, - w:CGFloat, h:CGFloat, accent ac:Bool, - cfg:EndoReportConfig, ctx:CGContext) { - let r=CGRect(x:x,y:y,width:w,height:h) + private static func statCard(_ label: String, val: String, x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, accent ac: Bool, + cfg: EndoReportConfig, ctx: CGContext) + { + let r = CGRect(x: x, y: y, width: w, height: h) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) if ac { ctx.setFillColor(accent(cfg).withAlphaComponent(0.07).cgColor); ctx.fill(r) - ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x:x,y:y,width:3,height:h)) + ctx.setFillColor(accent(cfg).cgColor); ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) } ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE,.kern:0.5] - let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:17),.foregroundColor:ac ? accent(cfg):C_INK] - (label as NSString).draw(at:CGPoint(x:x+8,y:y+5),withAttributes:la) - (val as NSString).draw(at:CGPoint(x:x+8,y:y+16),withAttributes:va) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.5] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: ac ? accent(cfg) : C_INK] + (label as NSString).draw(at: CGPoint(x: x + 8, y: y + 4), withAttributes: la) + (val as NSString).draw(at: CGPoint(x: x + 8, y: y + 14), withAttributes: va) } // MARK: - TIR vertical bar - private static func drawTIRBar(tirData:[TIRDataPoint], stats:ReportStats, - x:CGFloat, y:CGFloat, w:CGFloat, h:CGFloat, - cfg:EndoReportConfig, ctx:CGContext) { - guard let avg=tirData.first(where:{$0.period == .average}) else { return } - let r=CGRect(x:x,y:y,width:w,height:h) + private static func drawTIRBar(tirData: [TIRDataPoint], stats _: ReportStats, + x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + cfg: EndoReportConfig, ctx: CGContext) + { + guard let avg = tirData.first(where: { $0.period == .average }) else { return } + let r = CGRect(x: x, y: y, width: w, height: h) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r) - - let ta:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:7.5),.foregroundColor:C_SLATE] - "Time in Range".draw(at:CGPoint(x:x+8,y:y+6),withAttributes:ta) - + + let ta: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: C_SLATE] + "Time in Range".draw(at: CGPoint(x: x + 8, y: y + 6), withAttributes: ta) + // Shorten bar height to allow text room - let bx=x+10; let bw:CGFloat=16; let by=y+22; let bh=h-50 - let segs:[(Double,UIColor,String)]=[(avg.veryHigh,C_VHIGH,"Very High"),(avg.high,C_HIGH,"High"), - (avg.inRange,C_IN,"In Range"),(avg.low,C_LOW,"Low"),(avg.veryLow,C_VLOW,"Very Low")] - var sy=by - var lastTextY: CGFloat = 0 - - for(pct,clr,label) in segs { - let sh=CGFloat(pct/100)*bh - if sh>0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x:bx,y:sy,width:bw,height:sh)) } - - // Lowered minimum threshold to > 0.0 to catch "Low" and "Very Low" - if pct > 0.0 { - let ps = String(format:"%.0f%%",pct) - let isTarget = (label == "In Range") - let pa:[NSAttributedString.Key:Any] = [.font:UIFont.boldSystemFont(ofSize:7.5), .foregroundColor: isTarget ? accent(cfg) : C_SLATE] - - let textStr = "\(label) \(ps)" - - var textY = sy + sh/2 - 4 - // Push text down to prevent overlapping labels - if textY < lastTextY + 10 { textY = lastTextY + 10 } - - (textStr as NSString).draw(at:CGPoint(x:bx+bw+6,y:textY),withAttributes:pa) - lastTextY = textY - } - sy+=sh + let bx = x + 10; let bw: CGFloat = 16; let by = y + 22; let bh = h - 50 + let segs: [(Double, UIColor, String)] = [(avg.veryHigh, C_VHIGH, "Very High"), (avg.high, C_HIGH, "High"), + (avg.inRange, C_IN, "In Range"), (avg.low, C_LOW, "Low"), (avg.veryLow, C_VLOW, "Very Low")] + var sy = by + + for (pct, clr, _) in segs { + let sh = CGFloat(pct / 100) * bh + if sh > 0 { ctx.setFillColor(clr.cgColor); ctx.fill(CGRect(x: bx, y: sy, width: bw, height: sh)) } + sy += sh + } + + // Draw a stable legend to avoid overlapping text inside a constrained vertical bar. + let legendX = bx + bw + 8 + let legendY = by + let legendSpacing: CGFloat = 12 + for (index, (pct, _, label)) in segs.filter({ $0.0 > 0.0 }).enumerated() { + let ps = String(format: "%.0f%%", pct) + let isTarget = (label == "In Range") + let pa: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7.5), .foregroundColor: isTarget ? accent(cfg) : C_SLATE] + let textStr = "\(label) \(ps)" + let textY = legendY + CGFloat(index) * legendSpacing + (textStr as NSString).draw(at: CGPoint(x: legendX, y: textY), withAttributes: pa) } - - // Expanded text and increased spacing for bottom targets - let na:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] - "Target: 70-180".draw(at:CGPoint(x:x+5,y:y+h-24),withAttributes:na) - "Time in Tight Range: 70-140".draw(at:CGPoint(x:x+5,y:y+h-12),withAttributes:na) + + let na: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + "Target: 70-180".draw(at: CGPoint(x: x + 5, y: y + h - 24), withAttributes: na) + "Time in Tight Range: 70-140".draw(at: CGPoint(x: x + 5, y: y + h - 12), withAttributes: na) } // MARK: - Time-of-day strip @discardableResult - private static func timeStrip(patterns:TimePatterns, cfg:EndoReportConfig, - y:CGFloat, m:CGFloat, w:CGFloat, ctx:CGContext)->CGFloat { - let ha:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:C_INK] - "Glucose by Time of Day (\(cfg.units))".draw(at:CGPoint(x:m,y:y),withAttributes:ha) - let periods=[patterns.night,patterns.earlyAM,patterns.morning, - patterns.afternoon,patterns.evening,patterns.late] - let cw=(w-m*2)/CGFloat(periods.count); let ch:CGFloat=38; let cy=y+11 - for(i,p) in periods.enumerated() { - let cx=m+CGFloat(i)*cw - let rr=CGRect(x:cx,y:cy,width:cw-2,height:ch) + private static func timeStrip(patterns: TimePatterns, cfg: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + "Glucose by Time of Day (\(cfg.units))".draw(at: CGPoint(x: m, y: y), withAttributes: ha) + let periods = [patterns.night, patterns.earlyAM, patterns.morning, + patterns.afternoon, patterns.evening, patterns.late] + let cw = (w - m * 2) / CGFloat(periods.count); let ch: CGFloat = 38; let cy = y + 11 + for (i, p) in periods.enumerated() { + let cx = m + CGFloat(i) * cw + let rr = CGRect(x: cx, y: cy, width: cw - 2, height: ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(rr) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(rr) - guard p.count>0 else { continue } - let disp=cfg.fmtBG(p.avg) - let vc:UIColor = p.avg<70 ? C_LOW : p.avg<140 ? accent(cfg) : p.avg<180 ? C_INK : C_HIGH - let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:13),.foregroundColor:vc] - let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] - let vsz=(disp as NSString).size(withAttributes:va) - let lsz=(p.label as NSString).size(withAttributes:la) - (disp as NSString).draw(at:CGPoint(x:cx+(cw-2-vsz.width)/2,y:cy+5), withAttributes:va) - (p.label as NSString).draw(at:CGPoint(x:cx+(cw-2-lsz.width)/2,y:cy+25),withAttributes:la) - } - return cy+ch+4 + guard p.count > 0 else { continue } + let disp = cfg.fmtBG(p.avg) + let vc: UIColor = p.avg < 70 ? C_LOW : p.avg < 140 ? accent(cfg) : p.avg < 180 ? C_INK : C_HIGH + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 13), .foregroundColor: vc] + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let vsz = (disp as NSString).size(withAttributes: va) + let lsz = (p.label as NSString).size(withAttributes: la) + (disp as NSString).draw(at: CGPoint(x: cx + (cw - 2 - vsz.width) / 2, y: cy + 5), withAttributes: va) + (p.label as NSString).draw(at: CGPoint(x: cx + (cw - 2 - lsz.width) / 2, y: cy + 25), withAttributes: la) + } + return cy + ch + 2 } // MARK: - Insulin section @discardableResult - private static func insulinSection(boluses:[MainViewController.bolusGraphStruct], - smbs:[MainViewController.bolusGraphStruct], - simpleVM:SimpleStatsViewModel, stats:ReportStats, - cfg:EndoReportConfig, y:CGFloat, m:CGFloat, w:CGFloat, - ctx:CGContext)->CGFloat { - let tdd=simpleVM.totalDailyDose ?? 0 - let basalPct=tdd>0 ? (simpleVM.actualBasal ?? 0)/tdd*100:0 - let bolusPct=tdd>0 ? (simpleVM.avgBolus ?? 0)/tdd*100:0 - let cards:[(String,String)]=[ ("AVG TDD",tdd>0 ? String(format:"%.1fU",tdd):"—"), - ("BASAL", basalPct>0 ? String(format:"%.0f%%",basalPct):"—"), - ("BOLUS", bolusPct>0 ? String(format:"%.0f%%",bolusPct):"—") ] - let cw=(w-m*2)/3-4; let ch:CGFloat=38 - for(i,c) in cards.enumerated() { - let cx=m+CGFloat(i)*(cw+4) - let r2=CGRect(x:cx,y:y,width:cw,height:ch) + private static func insulinSection(boluses: [MainViewController.bolusGraphStruct], + smbs: [MainViewController.bolusGraphStruct], + simpleVM: SimpleStatsViewModel, stats _: ReportStats, + cfg: EndoReportConfig, y: CGFloat, m: CGFloat, w: CGFloat, + ctx: CGContext) -> CGFloat + { + let tdd = simpleVM.totalDailyDose ?? 0 + let basalPct = tdd > 0 ? (simpleVM.actualBasal ?? 0) / tdd * 100 : 0 + let bolusPct = tdd > 0 ? (simpleVM.avgBolus ?? 0) / tdd * 100 : 0 + let cards: [(String, String)] = [("AVG TDD", tdd > 0 ? String(format: "%.1fU", tdd) : "—"), + ("BASAL", basalPct > 0 ? String(format: "%.0f%%", basalPct) : "—"), + ("BOLUS", bolusPct > 0 ? String(format: "%.0f%%", bolusPct) : "—")] + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE,.kern:0.4] - let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:16),.foregroundColor:C_INK] - (c.0 as NSString).draw(at:CGPoint(x:cx+8,y:y+4), withAttributes:la) - (c.1 as NSString).draw(at:CGPoint(x:cx+8,y:y+14),withAttributes:va) - } - let ty=y+ch+4 - let total=(boluses+smbs).map{$0.value}.reduce(0,+) - let rows:[(String,String)]=[ - ("Correction Boluses", "\(boluses.count)"), + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) + } + let ty = y + ch + 2 + let total = (boluses + smbs).map { $0.value }.reduce(0,+) + let rows: [(String, String)] = [ + ("Correction Boluses", "\(boluses.count)"), ("SMB / Auto-Corrections", "\(smbs.count)"), - ("Total Bolus Insulin", String(format:"%.1f U",total)), - ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format:"%.2f U/day",simpleVM.programmedBasal!):"—"), - ("Actual Basal", simpleVM.actualBasal != nil ? String(format:"%.2f U/day",simpleVM.actualBasal!) :"—"), + ("Total Bolus Insulin", String(format: "%.1f U", total)), + ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), + ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), ] - return metricTable(rows, y:ty, m:m, w:w, cfg:cfg, ctx:ctx) + return metricTable(rows, y: ty, m: m, w: w, cfg: cfg, ctx: ctx) } // MARK: - Nutrition section @discardableResult - private static func nutritionSection(carbs:[MainViewController.carbGraphStruct], - stats:ReportStats, cfg:EndoReportConfig, - y:CGFloat, m:CGFloat, w:CGFloat, ctx:CGContext)->CGFloat { - let total=carbs.map{$0.value}.reduce(0,+) - let cards:[(String,String)]=[ - ("DAILY CARBS", String(format:"%.0fg",total/stats.days)), + private static func nutritionSection(carbs: [MainViewController.carbGraphStruct], + stats: ReportStats, cfg _: EndoReportConfig, + y: CGFloat, m: CGFloat, w: CGFloat, ctx: CGContext) -> CGFloat + { + let total = carbs.map { $0.value }.reduce(0,+) + let cards: [(String, String)] = [ + ("DAILY CARBS", String(format: "%.0fg", total / stats.days)), ("MEALS LOGGED", "\(carbs.count)"), - ("PER MEAL AVG", String(format:"%.0fg",carbs.isEmpty ? 0:total/Double(carbs.count))), + ("PER MEAL AVG", String(format: "%.0fg", carbs.isEmpty ? 0 : total / Double(carbs.count))), ] - let cw=(w-m*2)/3-4; let ch:CGFloat=38 - for(i,c) in cards.enumerated() { - let cx=m+CGFloat(i)*(cw+4) - let r2=CGRect(x:cx,y:y,width:cw,height:ch) + let cw = (w - m * 2) / 3 - 4; let ch: CGFloat = 36 + for (i, c) in cards.enumerated() { + let cx = m + CGFloat(i) * (cw + 4) + let r2 = CGRect(x: cx, y: y, width: cw, height: ch) ctx.setFillColor(C_CLOUD.cgColor); ctx.fill(r2) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(r2) - let la:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE,.kern:0.4] - let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:16),.foregroundColor:C_INK] - (c.0 as NSString).draw(at:CGPoint(x:cx+8,y:y+4), withAttributes:la) - (c.1 as NSString).draw(at:CGPoint(x:cx+8,y:y+14),withAttributes:va) + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE, .kern: 0.4] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 16), .foregroundColor: C_INK] + (c.0 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 4), withAttributes: la) + (c.1 as NSString).draw(at: CGPoint(x: cx + 8, y: y + 14), withAttributes: va) } - return y+ch+4 + return y + ch + 2 } // MARK: - Tables @discardableResult - private static func metricTable(_ rows:[(String,String)], y:CGFloat, m:CGFloat, - w:CGFloat, cfg:EndoReportConfig, ctx:CGContext)->CGFloat { - let tw=w-m*2; let hh:CGFloat=12; let rh:CGFloat=11; var cy=y + private static func metricTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, + w: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = w - m * 2; let hh: CGFloat = 12; let rh: CGFloat = 11; var cy = y ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x:m,y:cy,width:tw,height:hh)) - let ha:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:7),.foregroundColor:accent(cfg),.kern:0.4] - "METRIC".draw(at:CGPoint(x:m+6,y:cy+2),withAttributes:ha) - "VALUE".draw( at:CGPoint(x:m+tw*0.62+6,y:cy+2),withAttributes:ha) - cy+=hh - for(i,row) in rows.enumerated() { - ctx.setFillColor((i%2==0 ? C_WHITE:C_CLOUD).cgColor) - ctx.fill(CGRect(x:m,y:cy,width:tw,height:rh)) - let ka:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:7),.foregroundColor:C_INK] - let va:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:7),.foregroundColor:accent(cfg)] - (row.0 as NSString).draw(at:CGPoint(x:m+6,y:cy+1),withAttributes:ka) - (row.1 as NSString).draw(at:CGPoint(x:m+tw*0.62+6,y:cy+1),withAttributes:va) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: hh)) + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] + "METRIC".draw(at: CGPoint(x: m + 6, y: cy + 1), withAttributes: ha) + "VALUE".draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 1), withAttributes: ha) + cy += hh + for (i, row) in rows.enumerated() { + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7), .foregroundColor: C_INK] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] + (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 1), withAttributes: ka) + (row.1 as NSString).draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 1), withAttributes: va) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to:CGPoint(x:m,y:cy+rh)); ctx.addLine(to:CGPoint(x:m+tw,y:cy+rh)); ctx.strokePath() - cy+=rh + ctx.move(to: CGPoint(x: m, y: cy + rh)); ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)); ctx.strokePath() + cy += rh } - return cy+4 + return cy + 1 } - + // Dynamic settings table to handle multi-line text input neatly @discardableResult - private static func settingsTable(_ rows:[(String,String)], y:CGFloat, m:CGFloat, - w:CGFloat, cfg:EndoReportConfig, ctx:CGContext)->CGFloat { + private static func settingsTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, + w: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { let tw = w - m * 2 - let headerH: CGFloat = 14 + let headerH: CGFloat = 12 var cy = y - + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) ctx.fill(CGRect(x: m, y: cy, width: tw, height: headerH)) - + let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] - "SETTING".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) - "SCHEDULE / VALUES".draw(at: CGPoint(x: m + tw * 0.35 + 6, y: cy + 3), withAttributes: ha) + "THERAPY SETTING & VALUES".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) cy += headerH - + for (i, row) in rows.enumerated() { let lines = row.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } - let rh = Swift.max(18.0, CGFloat(lines.count) * 11.0 + 8.0) - + let rh = 11.0 + CGFloat(lines.count) * 10.5 + 4.0 + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) - - let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: C_INK] + + let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: accent(cfg)] - - (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 5), withAttributes: ka) - - var ly = cy + 5 + + (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 3.5), withAttributes: ka) + + var ly = cy + 12.5 for line in lines { - (line as NSString).draw(at: CGPoint(x: m + tw * 0.35 + 6, y: ly), withAttributes: va) - ly += 11 + (line as NSString).draw(at: CGPoint(x: m + 6, y: ly), withAttributes: va) + ly += 10.5 } - + ctx.setStrokeColor(C_BORDER.cgColor) ctx.setLineWidth(0.3) ctx.move(to: CGPoint(x: m, y: cy + rh)) ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)) ctx.strokePath() - + cy += rh } - return cy + 4 + return cy + 2 } // MARK: - AGP - private static func drawAGP(agpData:[AGPDataPoint], x:CGFloat, y:CGFloat, - w:CGFloat, h:CGFloat, cfg:EndoReportConfig, ctx:CGContext) { + private static func drawAGP(agpData: [AGPDataPoint], x: CGFloat, y: CGFloat, + w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) + { guard !agpData.isEmpty else { return } - let lPad:CGFloat=26; let bPad:CGFloat=16 - let cw=w-lPad; let ch=h-bPad - let cx=x+lPad; let cy=y + let lPad: CGFloat = 26; let bPad: CGFloat = 16 + let cw = w - lPad; let ch = h - bPad + let cx = x + lPad; let cy = y - ctx.setFillColor(UIColor(white:0.985,alpha:1).cgColor) - ctx.fill(CGRect(x:cx,y:cy,width:cw,height:ch)) + ctx.setFillColor(UIColor(white: 0.985, alpha: 1).cgColor) + ctx.fill(CGRect(x: cx, y: cy, width: cw, height: ch)) - let bgMin:CGFloat=40; let bgRng:CGFloat=320 - func gy(_ g:Double)->CGFloat { cy+ch-(CGFloat(g)-bgMin)/bgRng*ch } - func tx(_ mins:Int)->CGFloat { cx+CGFloat(mins)/(24*60)*cw } + let bgMin: CGFloat = 40; let bgRng: CGFloat = 320 + func gy(_ g: Double) -> CGFloat { cy + ch - (CGFloat(g) - bgMin) / bgRng * ch } + func tx(_ mins: Int) -> CGFloat { cx + CGFloat(mins) / (24 * 60) * cw } ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) - ctx.fill(CGRect(x:cx,y:gy(180),width:cw,height:gy(70)-gy(180))) + ctx.fill(CGRect(x: cx, y: gy(180), width: cw, height: gy(70) - gy(180))) - ctx.setLineDash(phase:0,lengths:[3,2]) - for(val,clr) in [(70.0,C_LOW),(180.0,C_HIGH)] { + ctx.setLineDash(phase: 0, lengths: [3, 2]) + for (val, clr) in [(70.0, C_LOW), (180.0, C_HIGH)] { ctx.setStrokeColor(clr.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.6) - ctx.move(to:CGPoint(x:cx,y:gy(val))); ctx.addLine(to:CGPoint(x:cx+cw,y:gy(val))); ctx.strokePath() + ctx.move(to: CGPoint(x: cx, y: gy(val))); ctx.addLine(to: CGPoint(x: cx + cw, y: gy(val))); ctx.strokePath() } - ctx.setLineDash(phase:0,lengths:[]) + ctx.setLineDash(phase: 0, lengths: []) - var band=CGMutablePath() - for(i,pt) in agpData.enumerated() { let p=CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p95)); i==0 ? band.move(to:p):band.addLine(to:p) } - for pt in agpData.reversed() { band.addLine(to:CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p5))) } + var band = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p95)); i == 0 ? band.move(to: p) : band.addLine(to: p) + } + for pt in agpData.reversed() { + band.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p5))) + } band.closeSubpath() ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor); ctx.addPath(band); ctx.fillPath() - var iqr=CGMutablePath() - for(i,pt) in agpData.enumerated() { let p=CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p75)); i==0 ? iqr.move(to:p):iqr.addLine(to:p) } - for pt in agpData.reversed() { iqr.addLine(to:CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p25))) } + var iqr = CGMutablePath() + for (i, pt) in agpData.enumerated() { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p75)); i == 0 ? iqr.move(to: p) : iqr.addLine(to: p) + } + for pt in agpData.reversed() { + iqr.addLine(to: CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p25))) + } iqr.closeSubpath() ctx.setFillColor(accent(cfg).withAlphaComponent(0.25).cgColor); ctx.addPath(iqr); ctx.fillPath() ctx.setStrokeColor(accent(cfg).cgColor); ctx.setLineWidth(1.6) - var first=true - for pt in agpData { let p=CGPoint(x:tx(pt.timeOfDay),y:gy(pt.p50)); first ? ctx.move(to:p):ctx.addLine(to:p); first=false } + var first = true + for pt in agpData { + let p = CGPoint(x: tx(pt.timeOfDay), y: gy(pt.p50)); first ? ctx.move(to: p) : ctx.addLine(to: p); first = false + } ctx.strokePath() - let axA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] - for bg in [70,140,180,250] { - let ly=gy(Double(bg)); guard ly>=cy && ly<=cy+ch else { continue } - let lbl=cfg.isMMOL ? String(format:"%.1f",Double(bg)*0.0555) : "\(bg)" - let lsz=(lbl as NSString).size(withAttributes:axA) - (lbl as NSString).draw(at:CGPoint(x:x+lPad-lsz.width-3,y:ly-lsz.height/2),withAttributes:axA) + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + for bg in [70, 140, 180, 250] { + let ly = gy(Double(bg)); guard ly >= cy, ly <= cy + ch else { continue } + let lbl = cfg.isMMOL ? String(format: "%.1f", Double(bg) * 0.0555) : "\(bg)" + let lsz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: x + lPad - lsz.width - 3, y: ly - lsz.height / 2), withAttributes: axA) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) - ctx.move(to:CGPoint(x:cx,y:ly)); ctx.addLine(to:CGPoint(x:cx+cw,y:ly)); ctx.strokePath() + ctx.move(to: CGPoint(x: cx, y: ly)); ctx.addLine(to: CGPoint(x: cx + cw, y: ly)); ctx.strokePath() } - for h2 in stride(from:0,through:24,by:3) { - let lx=tx(h2*60) - let lbl=String(format:"%02d:00",h2) - let lsz=(lbl as NSString).size(withAttributes:axA) - let dx=Swift.max(cx,Swift.min(cx+cw-lsz.width,lx-lsz.width/2)) - (lbl as NSString).draw(at:CGPoint(x:dx,y:cy+ch+3),withAttributes:axA) + for h2 in stride(from: 0, through: 24, by: 3) { + let lx = tx(h2 * 60) + let lbl = String(format: "%02d:00", h2) + let lsz = (lbl as NSString).size(withAttributes: axA) + let dx = Swift.max(cx, Swift.min(cx + cw - lsz.width, lx - lsz.width / 2)) + (lbl as NSString).draw(at: CGPoint(x: dx, y: cy + ch + 3), withAttributes: axA) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.25) - ctx.move(to:CGPoint(x:lx,y:cy)); ctx.addLine(to:CGPoint(x:lx,y:cy+ch)); ctx.strokePath() + ctx.move(to: CGPoint(x: lx, y: cy)); ctx.addLine(to: CGPoint(x: lx, y: cy + ch)); ctx.strokePath() } ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x:cx,y:cy,width:cw,height:ch)) + ctx.stroke(CGRect(x: cx, y: cy, width: cw, height: ch)) - let lgA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] - let lgItems:[(String,UIColor,Bool)]=[ ("Median",accent(cfg),false), - ("25–75th",accent(cfg).withAlphaComponent(0.4),true), - ("5–95th", accent(cfg).withAlphaComponent(0.18),true) ] - var lgX=cx+cw + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let lgItems: [(String, UIColor, Bool)] = [("Median", accent(cfg), false), + ("25–75th", accent(cfg).withAlphaComponent(0.4), true), + ("5–95th", accent(cfg).withAlphaComponent(0.18), true)] + var lgX = cx + cw for item in lgItems.reversed() { - let lsz=(item.0 as NSString).size(withAttributes:lgA) + let lsz = (item.0 as NSString).size(withAttributes: lgA) lgX -= lsz.width - (item.0 as NSString).draw(at:CGPoint(x:lgX,y:cy+ch+3),withAttributes:lgA) + (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 3), withAttributes: lgA) lgX -= 15 - item.2 ? ({ ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x:lgX,y:cy+ch+4,width:12,height:8)) })() - : ({ ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x:lgX,y:cy+ch+7,width:12,height:2)) })() + item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 4, width: 12, height: 8)) }() + : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 7, width: 12, height: 2)) }() lgX -= 5 } } + // MARK: - Format helpers + + private static func formatBasalRateForDisplay(_ input: String) -> String { + let lines = input.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + // Helper to extract a double from a string that might contain units or other text + func extractDouble(_ s: String) -> Double? { + let cleaned = s.replacingOccurrences(of: ",", with: ".") + .components(separatedBy: CharacterSet(charactersIn: "0123456789.").inverted) + .joined() + return Double(cleaned) + } + + if input.contains("=") || (input.contains(":") && lines.count > 1) { + var formatted: [String] = [] + for line in lines { + let sep = line.contains("=") ? "=" : ":" + let parts = line.components(separatedBy: sep).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + if parts.count >= 2, let last = parts.last, let rate = extractDouble(last) { + let timeKey = parts.dropLast().joined(separator: sep) + formatted.append("\(timeKey) = \(String(format: "%.2f", rate))") + } else { + formatted.append(line) + } + } + return formatted.isEmpty ? input : formatted.joined(separator: "\n") + } + + if let value = extractDouble(input) { + return String(format: "%.2f U/hr", value) + } + return input + } + // MARK: - Day row - private static func drawDayRow(ctx:CGContext, x:CGFloat, y:CGFloat, w:CGFloat, h:CGFloat, - day:String, dayData:DayData, cfg:EndoReportConfig, - simpleVM:SimpleStatsViewModel) { - ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x:x,y:y,width:w,height:h)) + private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, + day: String, dayData: DayData, cfg: EndoReportConfig, + simpleVM _: SimpleStatsViewModel) + { + ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) - ctx.stroke(CGRect(x:x,y:y,width:w,height:h)) - + ctx.stroke(CGRect(x: x, y: y, width: w, height: h)) + ctx.setFillColor(cfg.accentColor.cgColor) - ctx.fill(CGRect(x:x,y:y,width:3,height:h)) + ctx.fill(CGRect(x: x, y: y, width: 3, height: h)) + + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let df2 = DateFormatter(); df2.dateFormat = "EEEE, MMM d, yyyy" + let date = df.date(from: day) ?? Date() + let dlA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: C_INK] + df2.string(from: date).draw(at: CGPoint(x: x + 10, y: y + 5), withAttributes: dlA) - let df=DateFormatter(); df.dateFormat="yyyy-MM-dd" - let df2=DateFormatter(); df2.dateFormat="EEEE, MMM d, yyyy" - let date=df.date(from:day) ?? Date() - let dlA:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:9),.foregroundColor:C_INK] - df2.string(from:date).draw(at:CGPoint(x:x+10,y:y+5),withAttributes:dlA) + let statsX = x + w - 120 + let vals = dayData.bg.map { Double($0.sgv) } - let statsX=x+w-175 - let vals=dayData.bg.map{Double($0.sgv)} - if !vals.isEmpty { - let n=Double(vals.count) - let avg=vals.reduce(0,+)/n - let tir=Double(vals.filter{$0>=70&&$0<=180}.count)/n*100 - let totalBolus=(dayData.bolus+dayData.smb).map{$0.value}.reduce(0,+) - var basalTotal=0.0 - let sortedBasal=dayData.basal.sorted{$0.date<$1.date} - for i in 0..0&&dur<4 { basalTotal+=sortedBasal[i].basalRate*dur } + let n = Double(vals.count) + let avg = vals.reduce(0,+) / n + let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 + // removed bolus/basal totals and ratios per request + + let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] + let sv: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] + let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW + let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] + let acA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: cfg.accentColor] + + let cols: [(String, CGFloat)] = [("Avg", 0), ("TIR", 30)] + for (lbl, ox) in cols { + (lbl as NSString).draw(at: CGPoint(x: statsX + ox, y: y + 5), withAttributes: sa) } - let total=totalBolus+basalTotal - let bolusPct=total>0 ? totalBolus/total*100:0 - let basalPct=total>0 ? basalTotal/total*100:0 - - let sa:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_SLATE] - let sv:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:C_INK] - let tirC:UIColor=tir>=70 ? C_IN:tir>=50 ? C_HIGH:C_VLOW - let tirA:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:tirC] - let acA:[NSAttributedString.Key:Any]=[.font:UIFont.boldSystemFont(ofSize:8),.foregroundColor:cfg.accentColor] - - let cols:[(String,CGFloat)]=[ ("Avg",0),("TIR",30),("Bolus",66),("Basal",106),("B:Bas",144) ] - for(lbl,ox) in cols { (lbl as NSString).draw(at:CGPoint(x:statsX+ox,y:y+5),withAttributes:sa) } - cfg.fmtBG(avg).draw(at:CGPoint(x:statsX, y:y+14),withAttributes:sv) - String(format:"%.0f%%",tir).draw(at:CGPoint(x:statsX+28, y:y+14),withAttributes:tirA) - String(format:"%.1fU",totalBolus).draw(at:CGPoint(x:statsX+64, y:y+14),withAttributes:sv) - (basalTotal>0 ? String(format:"%.1fU",basalTotal):"—").draw(at:CGPoint(x:statsX+104, y:y+14),withAttributes:sv) - let ratioStr = total>0 ? String(format:"%.0f:%.0f",bolusPct,basalPct):"—" - ratioStr.draw(at:CGPoint(x:statsX+142,y:y+14),withAttributes:acA) + cfg.fmtBG(avg).draw(at: CGPoint(x: statsX, y: y + 14), withAttributes: sv) + String(format: "%.0f%%", tir).draw(at: CGPoint(x: statsX + 28, y: y + 14), withAttributes: tirA) } - let chartX=x+10; let chartW=w-190 - let chartY=y+26; let chartH=h-32 + let chartX = x + 10; let chartW = w - 140 + let chartY = y + 26; let chartH = h - 32 guard !dayData.bg.isEmpty else { return } ctx.saveGState() ctx.clip(to: CGRect(x: chartX, y: chartY, width: chartW, height: chartH)) - let bgMin:CGFloat=40; let bgMax:CGFloat=320; let bgRng=bgMax-bgMin - func gy(_ bg:Double)->CGFloat { chartY+chartH-(CGFloat(bg)-bgMin)/bgRng*chartH } - func tx(_ ts:Double)->CGFloat { - let cal=dateTimeUtils.displayCalendar() - let d=Date(timeIntervalSince1970:ts) - let c=cal.dateComponents([.hour,.minute],from:d) - let min=Double((c.hour ?? 0)*60+(c.minute ?? 0)) - return chartX+CGFloat(min/(24*60))*chartW + let bgMin: CGFloat = 40; let bgMax: CGFloat = 320; let bgRng = bgMax - bgMin + func gy(_ bg: Double) -> CGFloat { chartY + chartH - (CGFloat(bg) - bgMin) / bgRng * chartH } + func tx(_ ts: Double) -> CGFloat { + let cal = dateTimeUtils.displayCalendar() + let d = Date(timeIntervalSince1970: ts) + let c = cal.dateComponents([.hour, .minute], from: d) + let min = Double((c.hour ?? 0) * 60 + (c.minute ?? 0)) + return chartX + CGFloat(min / (24 * 60)) * chartW } ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) - ctx.fill(CGRect(x:chartX,y:gy(180),width:chartW,height:gy(70)-gy(180))) + ctx.fill(CGRect(x: chartX, y: gy(180), width: chartW, height: gy(70) - gy(180))) - ctx.setLineDash(phase:0,lengths:[2,2]); ctx.setLineWidth(0.4) + ctx.setLineDash(phase: 0, lengths: [2, 2]); ctx.setLineWidth(0.4) ctx.setStrokeColor(C_LOW.withAlphaComponent(0.4).cgColor) - ctx.move(to:CGPoint(x:chartX,y:gy(70))); ctx.addLine(to:CGPoint(x:chartX+chartW,y:gy(70))); ctx.strokePath() + ctx.move(to: CGPoint(x: chartX, y: gy(70))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(70))); ctx.strokePath() ctx.setStrokeColor(C_HIGH.withAlphaComponent(0.4).cgColor) - ctx.move(to:CGPoint(x:chartX,y:gy(180))); ctx.addLine(to:CGPoint(x:chartX+chartW,y:gy(180))); ctx.strokePath() - ctx.setLineDash(phase:0,lengths:[]) + ctx.move(to: CGPoint(x: chartX, y: gy(180))); ctx.addLine(to: CGPoint(x: chartX + chartW, y: gy(180))); ctx.strokePath() + ctx.setLineDash(phase: 0, lengths: []) ctx.setStrokeColor(C_BORDER.withAlphaComponent(0.5).cgColor); ctx.setLineWidth(0.25) - for h2 in stride(from:3,through:21,by:3) { - let hx=chartX+CGFloat(h2)/24*chartW - ctx.move(to:CGPoint(x:hx,y:chartY)); ctx.addLine(to:CGPoint(x:hx,y:chartY+chartH)); ctx.strokePath() + for h2 in stride(from: 3, through: 21, by: 3) { + let hx = chartX + CGFloat(h2) / 24 * chartW + ctx.move(to: CGPoint(x: hx, y: chartY)); ctx.addLine(to: CGPoint(x: hx, y: chartY + chartH)); ctx.strokePath() } if !dayData.basal.isEmpty { - let bH=chartH*0.25; let bY=chartY+chartH-bH - let sorted=dayData.basal.sorted{$0.date<$1.date} - let maxR=sorted.map{$0.basalRate}.max() ?? 1 - - var path=CGMutablePath(); var first=true + let bH = chartH * 0.25; let bY = chartY + chartH - bH + let sorted = dayData.basal.sorted { $0.date < $1.date } + let maxR = Swift.max(sorted.map { $0.basalRate }.max() ?? 1, 0.01) + + var path = CGMutablePath(); var first = true for pt in sorted { - let px=tx(pt.date); let py=bY+bH-CGFloat(pt.basalRate/maxR)*bH - first ? path.move(to:CGPoint(x:px,y:py)):path.addLine(to:CGPoint(x:px,y:py)); first=false + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? path.move(to: CGPoint(x: px, y: py)) : path.addLine(to: CGPoint(x: px, y: py)); first = false } - if let last=sorted.last { - path.addLine(to:CGPoint(x:tx(last.date),y:bY+bH)) - path.addLine(to:CGPoint(x:chartX,y:bY+bH)); path.closeSubpath() + if let last = sorted.last { + path.addLine(to: CGPoint(x: tx(last.date), y: bY + bH)) + path.addLine(to: CGPoint(x: chartX, y: bY + bH)); path.closeSubpath() ctx.setFillColor(C_BASAL.withAlphaComponent(0.15).cgColor); ctx.addPath(path); ctx.fillPath() } - - var lp=CGMutablePath(); first=true - for (index, pt) in sorted.enumerated() { - let px=tx(pt.date); let py=bY+bH-CGFloat(pt.basalRate/maxR)*bH - first ? lp.move(to:CGPoint(x:px,y:py)):lp.addLine(to:CGPoint(x:px,y:py)); first=false - - if pt.basalRate > 0.05 { - let nextX = index < sorted.count - 1 ? tx(sorted[index+1].date) : chartX + chartW - if nextX - px > 16 { - let rateStr = String(format: "%.1f", pt.basalRate) - let rA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5),.foregroundColor:C_BASAL] - rateStr.draw(at: CGPoint(x: px + 1, y: py - 7), withAttributes: rA) + + var lp = CGMutablePath(); first = true + for (index, pt) in sorted.enumerated() { + let px = tx(pt.date); let py = bY + bH - CGFloat(pt.basalRate / maxR) * bH + first ? lp.move(to: CGPoint(x: px, y: py)) : lp.addLine(to: CGPoint(x: px, y: py)); first = false + + if pt.basalRate > 0.01 { + let nextX = index < sorted.count - 1 ? tx(sorted[index + 1].date) : (chartX + chartW) + if (nextX - px) > 14 { + let rateStr = String(format: "%.2f", pt.basalRate) + let rA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 4.2), .foregroundColor: C_BASAL] + rateStr.draw(at: CGPoint(x: px + 1, y: py - 7), withAttributes: rA) } } } @@ -791,57 +850,58 @@ enum EndoReportGenerator { } for smb in dayData.smb { - let bx=tx(smb.date); let bh2=max(CGFloat(Swift.min(smb.value/15,1))*(chartH*0.35), 2.5) + let bx = tx(smb.date); let bh2 = max(CGFloat(Swift.min(smb.value / 15, 1)) * (chartH * 0.35), 2.5) ctx.setFillColor(C_SMB.cgColor) - ctx.fill(CGRect(x:bx-2,y:chartY+chartH-bh2,width:4,height:bh2)) + ctx.fill(CGRect(x: bx - 2, y: chartY + chartH - bh2, width: 4, height: bh2)) } for bolus in dayData.bolus { - let bx=tx(bolus.date); let bh2=max(CGFloat(Swift.min(bolus.value/15,1))*(chartH*0.4), 3.0) + let bx = tx(bolus.date); let bh2 = max(CGFloat(Swift.min(bolus.value / 15, 1)) * (chartH * 0.4), 3.0) ctx.setFillColor(C_BOLUS.cgColor) - ctx.fill(CGRect(x:bx-2.5,y:chartY+chartH-bh2,width:5,height:bh2)) + ctx.fill(CGRect(x: bx - 2.5, y: chartY + chartH - bh2, width: 5, height: bh2)) } - for r in dayData.bg.sorted{$0.date<$1.date} { - let rx=tx(r.date); let ry=gy(Double(r.sgv)) + for r in dayData.bg.sorted { $0.date < $1.date } { + let rx = tx(r.date); let ry = gy(Double(r.sgv)) ctx.setFillColor(bgColor(Double(r.sgv)).cgColor) - ctx.fillEllipse(in:CGRect(x:rx-1.6,y:ry-1.6,width:3.2,height:3.2)) - } - - ctx.restoreGState() - - let axA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5.5),.foregroundColor:C_SLATE] - for h2 in [0,6,12,18,24] { - let hx=chartX+CGFloat(h2)/24*chartW - let lbl=String(format:"%02d",h2) - let sz=(lbl as NSString).size(withAttributes:axA) - (lbl as NSString).draw(at:CGPoint(x:hx-sz.width/2,y:chartY+chartH+2),withAttributes:axA) - } - - if day==day { - let lgA:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5.5),.foregroundColor:C_SLATE] - var lgX=chartX+chartW+4 - for(lbl,clr) in [("● BG",C_IN),("▮ Bolus",C_BOLUS),("▮ SMB",C_SMB),("— Basal",C_BASAL)] { - let a:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:5.5),.foregroundColor:clr] - (lbl as NSString).draw(at:CGPoint(x:lgX,y:chartY+2),withAttributes:a) - lgX += (lbl as NSString).size(withAttributes:lgA).width+5 - if lgX>x+w-4 { break } + ctx.fillEllipse(in: CGRect(x: rx - 1.6, y: ry - 1.6, width: 3.2, height: 3.2)) + } + + ctx.restoreGState() + + let axA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + for h2 in [0, 6, 12, 18, 24] { + let hx = chartX + CGFloat(h2) / 24 * chartW + let lbl = String(format: "%02d", h2) + let sz = (lbl as NSString).size(withAttributes: axA) + (lbl as NSString).draw(at: CGPoint(x: hx - sz.width / 2, y: chartY + chartH + 2), withAttributes: axA) + } + + if day == day { + let lgA: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: C_SLATE] + var lgX = chartX + chartW + 4 + for (lbl, clr) in [("● BG", C_IN), ("▮ Bolus", C_BOLUS), ("▮ SMB", C_SMB), ("— Basal", C_BASAL)] { + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.5), .foregroundColor: clr] + (lbl as NSString).draw(at: CGPoint(x: lgX, y: chartY + 2), withAttributes: a) + lgX += (lbl as NSString).size(withAttributes: lgA).width + 5 + if lgX > x + w - 4 { break } } } } // MARK: - Footer - private static func drawFooter(ctx:CGContext, r:CGRect, cfg:EndoReportConfig, - stats:ReportStats, page:Int) { - let fy=r.height-28 - ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x:0,y:fy,width:r.width,height:28)) - let a:[NSAttributedString.Key:Any]=[.font:UIFont.systemFont(ofSize:6.5),.foregroundColor:C_WHITE.withAlphaComponent(0.5)] + private static func drawFooter(ctx: CGContext, r: CGRect, cfg _: EndoReportConfig, + stats: ReportStats, page: Int) + { + let fy = r.height - 28 + ctx.setFillColor(C_INK.cgColor); ctx.fill(CGRect(x: 0, y: fy, width: r.width, height: 28)) + let a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_WHITE.withAlphaComponent(0.5)] "Loop Follow — for informational purposes only. Not a substitute for professional medical advice." - .draw(at:CGPoint(x:30,y:fy+4),withAttributes:a) - let df=DateFormatter(); df.dateFormat="MMM d, yyyy" - let meta="Generated: \(df.string(from:Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" - let msz=(meta as NSString).size(withAttributes:a) - (meta as NSString).draw(at:CGPoint(x:r.width-30-msz.width,y:fy+4),withAttributes:a) + .draw(at: CGPoint(x: 30, y: fy + 4), withAttributes: a) + let df = DateFormatter(); df.dateFormat = "MMM d, yyyy" + let meta = "Generated: \(df.string(from: Date())) • \(Int(stats.days.rounded())) Days • \(stats.readingCount) readings • Page \(page)" + let msz = (meta as NSString).size(withAttributes: a) + (meta as NSString).draw(at: CGPoint(x: r.width - 30 - msz.width, y: fy + 4), withAttributes: a) } -} \ No newline at end of file +} diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift new file mode 100644 index 000000000..aa2ce95a6 --- /dev/null +++ b/LoopFollow/Stats/EndoReportView.swift @@ -0,0 +1,896 @@ +// LoopFollow +// EndoReportView.swift + +import SwiftUI + +struct EndoReportView: View { + let dataService: StatsDataService + + @Environment(\.dismiss) private var dismiss + + // Persisted patient/clinic info + @AppStorage("endoReport.patientName") private var patientName = "" + @AppStorage("endoReport.dateOfBirth") private var dateOfBirth = "" + @AppStorage("endoReport.providerName") private var providerName = "" + @AppStorage("endoReport.insulinType") private var insulinType = "" + @AppStorage("endoReport.diagnosisDate") private var diagnosisDate = "" + @AppStorage("endoReport.aidSystem") private var aidSystem = "Loop" + @AppStorage("endoReport.pumpDevice") private var pumpDevice = "" + @AppStorage("endoReport.cgmDevice") private var cgmDevice = "" + @AppStorage("endoReport.units") private var units = "mg/dL" + @AppStorage("endoReport.accentColorHex") private var accentColorHex = "#23A0AC" + + // Optional Toggle Modules + @AppStorage("endoReport.includeGlucoseSummary") private var includeGlucoseSummary = true + @AppStorage("endoReport.includeInsulin") private var includeInsulin = true + @AppStorage("endoReport.includeNutrition") private var includeNutrition = true + @AppStorage("endoReport.includeTherapySettings") private var includeTherapySettings = true + @AppStorage("endoReport.includeDevices") private var includeDevices = true + @AppStorage("endoReport.includeAGP") private var includeAGP = true + @AppStorage("endoReport.includeDailyBreakdown") private var includeDailyBreakdown = true + @AppStorage("endoReport.includeFatProtein") private var includeFatProtein = false + + // Therapy settings (manual entry) + @AppStorage("endoReport.carbRatio") private var carbRatio = "" + @AppStorage("endoReport.isf") private var isf = "" + @AppStorage("endoReport.basalRate") private var basalRate = "" + @AppStorage("endoReport.targetGlucose") private var targetGlucose = "" + @AppStorage("endoReport.customAidSystem") private var customAidSystem = "" + + // Date range + @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -14, to: Date()) ?? Date() + @State private var endDate: Date = .init() + + // UI state + @StateObject private var profileFetcher = NightscoutProfileFetcher() + @State private var isGenerating = false + @State private var reportURL: URL? + @State private var errorMessage: String? + @State private var showShareSheet = false + @State private var pickedColor: Color = .init(hex: "#23A0AC") ?? .teal + @State private var fetchSuccess = false + @State private var showTherapyScheduleExamples = false + @State private var therapyMode: TherapyInputMode = .simple + + let aidOptions = ["Trio", "Loop", "iAPS", "Other"] + let unitOptions = ["mg/dL", "mmol/L"] + + var body: some View { + NavigationView { + ScrollView(showsIndicators: false) { + VStack(spacing: 18) { + sectionCard("Report Period", icon: "calendar", color: .blue) { + VStack(spacing: 16) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(presets, id: \.label) { p in + Button(action: { + startDate = p.start; endDate = p.end + }) { + Text(p.label) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(isActive(p) ? .teal : .secondary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(isActive(p) ? Color.teal.opacity(0.16) : Color(UIColor.systemGray5)) + .cornerRadius(12) + } + } + } + .padding(.vertical, 4) + } + + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Start") + .font(.subheadline) + .foregroundColor(.secondary) + DatePicker("", selection: $startDate, in: ...endDate, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .frame(maxWidth: .infinity, alignment: .leading) + } + VStack(alignment: .leading, spacing: 6) { + Text("End") + .font(.subheadline) + .foregroundColor(.secondary) + DatePicker("", selection: $endDate, in: startDate..., displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + sectionCard("Patient Information", icon: "person.fill", color: .indigo) { + VStack(spacing: 16) { + row("Name", placeholder: "Full name", text: $patientName) + row("DOB", placeholder: "MM/DD/YYYY", text: $dateOfBirth) + row("Diagnosed", placeholder: "Year (optional)", text: $diagnosisDate) + row("Provider", placeholder: "Dr. Name", text: $providerName) + } + } + + sectionCard("Devices & System", icon: "iphone.radiowaves.left.and.right", color: .teal) { + VStack(spacing: 16) { + HStack { + Text("AID System") + .foregroundColor(.secondary) + .font(.subheadline) + Spacer() + Picker("AID System", selection: $aidSystem) { + ForEach(aidOptions, id: \.self) { Text($0) } + } + .pickerStyle(.menu) + } + + if aidSystem == "Other" { + row("Custom AID", placeholder: "Enter AID system", text: $customAidSystem) + } + + row("Pump", placeholder: "e.g. Omnipod 5", text: $pumpDevice) + row("CGM", placeholder: "e.g. Dexcom G7", text: $cgmDevice) + row("Insulin", placeholder: "e.g. Humalog", text: $insulinType) + } + } + + sectionCard("Therapy Settings", icon: "slider.horizontal.3", color: .orange) { + VStack(spacing: 16) { + Button(action: fetchFromNightscout) { + HStack { + if profileFetcher.isFetching { + ProgressView().scaleEffect(0.8) + Text("Fetching from Nightscout…") + .font(.subheadline) + } else { + Image(systemName: fetchSuccess ? "checkmark.circle.fill" : "arrow.down.circle") + .foregroundColor(fetchSuccess ? .green : .accentColor) + Text(fetchSuccess ? "Settings Fetched!" : "Auto-Fill from Nightscout") + .font(.subheadline) + } + Spacer() + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + } + .disabled(profileFetcher.isFetching) + + if let fetchErr = profileFetcher.error { + Label(fetchErr, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.caption) + } + + Picker("Input mode", selection: $therapyMode) { + ForEach(TherapyInputMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.vertical, 8) + + Text("Simple values are best for most users. Use schedule mode only if you have time-based settings.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + // Legend for schedule previews + HStack(spacing: 16) { + HStack(spacing: 8) { + Circle().fill(Color.mint).frame(width: 10, height: 10) + Text("Carb Ratio").font(.caption).foregroundColor(.secondary) + } + HStack(spacing: 8) { + Circle().fill(Color.indigo).frame(width: 10, height: 10) + Text("ISF").font(.caption).foregroundColor(.secondary) + } + HStack(spacing: 8) { + Circle().fill(Color.orange).frame(width: 10, height: 10) + Text("Basal Rate").font(.caption).foregroundColor(.secondary) + } + Spacer() + } + + if therapyMode == .simple { + row("Carb Ratio", placeholder: "10", text: $carbRatio, keyboard: .decimalPad) + row("ISF", placeholder: "1.8", text: $isf, keyboard: .decimalPad) + row("Basal Rate (U/hr)", placeholder: "0.80", text: $basalRate, keyboard: .decimalPad) + row("Target BG", placeholder: "100–120", text: $targetGlucose, keyboard: .numbersAndPunctuation) + } else { + therapySettingRow( + "Carb Ratio (g/U)", icon: "leaf.fill", text: $carbRatio, + placeholder: "00:00 = 10", + help: "Schedule one entry per line.", + keyboard: .decimalPad + ) + + // Preview for carb ratio schedule + therapySchedulePreview(for: carbRatio, title: "Carb Ratio Schedule Preview", accent: .mint) + + therapySettingRow( + "ISF (per U)", icon: "drop.fill", text: $isf, + placeholder: "00:00 = 45", + help: "Schedule one entry per line.", + keyboard: .decimalPad + ) + + // Preview for ISF schedule + therapySchedulePreview(for: isf, title: "ISF Schedule Preview", accent: .indigo) + + therapySettingRow( + "Basal Rate (U/hr)", icon: "waveform.path.ecg", text: $basalRate, + placeholder: "00:00 = 0.8", + help: "Schedule one entry per line.", + keyboard: .decimalPad + ) + + // Basal preview (already present) + VStack(spacing: 8) { + therapySchedulePreview(for: basalRate, title: "Basal Rate Schedule Preview", accent: .orange) + Text("Format: HH:MM = rate (e.g., 00:00 = 0.8, 06:00 = 1.0)").font(.caption2).foregroundColor(.secondary) + } + + row("Target BG", placeholder: "100–120", text: $targetGlucose, keyboard: .numbersAndPunctuation) + + DisclosureGroup(isExpanded: $showTherapyScheduleExamples) { + VStack(alignment: .leading, spacing: 8) { + Text("Schedule examples:") + .font(.subheadline) + .fontWeight(.semibold) + Text("00:00 = 10\n06:00 = 9\n12:00 = 11\n18:00 = 10") + .font(.caption) + .foregroundColor(.secondary) + .padding(10) + .background(RoundedRectangle(cornerRadius: 12).fill(Color(UIColor.systemGray6))) + Text("Use this mode only if you need multiple time-based values.") + .font(.caption) + .foregroundColor(.secondary) + } + } label: { + Text("Show schedule entry examples") + .font(.subheadline) + } + .padding(.top, 4) + } + } + } + + sectionCard("Included Report Modules", icon: "checklist", color: .green) { + VStack(spacing: 12) { + toggleRow("Glucose Summary & TIR", isOn: $includeGlucoseSummary) + toggleRow("Insulin Delivery", isOn: $includeInsulin) + toggleRow("Nutrition & Meals", isOn: $includeNutrition) + toggleRow("Current Therapy Settings", isOn: $includeTherapySettings) + toggleRow("Devices & Insulin Type", isOn: $includeDevices) + toggleRow("AGP Chart", isOn: $includeAGP) + toggleRow("Daily Breakdowns", isOn: $includeDailyBreakdown) + } + } + + sectionCard("Report Formatting", icon: "paintpalette.fill", color: .purple) { + VStack(spacing: 18) { + Picker("Units", selection: $units) { + ForEach(unitOptions, id: \.self) { Text($0) } + } + .pickerStyle(.segmented) + + HStack { + Text("Theme Color") + .foregroundColor(.secondary) + Spacer() + Circle() + .fill(pickedColor) + .frame(width: 24, height: 24) + .overlay(Circle().stroke(Color.secondary.opacity(0.3), lineWidth: 1)) + if #available(iOS 16.0, *) { + ColorPicker("", selection: $pickedColor, supportsOpacity: false) + .labelsHidden() + .onChange(of: pickedColor) { newVal in + accentColorHex = newVal.toHex() ?? "#23A0AC" + } + } + } + } + } + + if let err = errorMessage { + Text(err) + .font(.subheadline) + .foregroundColor(.white) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.red.opacity(0.85))) + } + + Button(action: generate) { + HStack { + Spacer() + if isGenerating { + ProgressView().padding(.trailing, 8) + Text("Generating Report…").fontWeight(.semibold) + } else { + Image(systemName: "doc.text.fill").padding(.trailing, 6) + Text("Create PDF").fontWeight(.semibold) + } + Spacer() + } + .padding() + .background(RoundedRectangle(cornerRadius: 18).fill(Color.teal)) + .foregroundColor(.white) + } + .disabled(isGenerating) + .opacity(isGenerating ? 0.7 : 1) + } + .padding() + } + .background(Color(UIColor.systemGroupedBackground).ignoresSafeArea()) + .navigationTitle("Endo Report") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + } + .sheet(isPresented: $showShareSheet) { + if let url = reportURL { ShareSheet(items: [url]) } + } + .onAppear { + pickedColor = Color(hex: accentColorHex) ?? .teal + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + private enum TherapyInputMode: String, CaseIterable, Identifiable { + case simple = "Simple" + case schedule = "Schedule" + + var id: String { rawValue } + } + + // MARK: - Helpers + + @ViewBuilder + private func row(_ label: String, placeholder: String, text: Binding, + keyboard: UIKeyboardType = .default) -> some View + { + HStack(alignment: .top, spacing: 12) { + Text(label) + .foregroundColor(.secondary) + .font(.subheadline) + .frame(width: 110, alignment: .leading) + TextField(placeholder, text: text) + .keyboardType(keyboard) + .padding(12) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + } + .padding(.vertical, 4) + } + + @ViewBuilder + private func therapySettingRow(_ label: String, icon: String, text: Binding, placeholder: String, help: String, keyboard: UIKeyboardType = .default) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color(UIColor.systemGray5)) + .frame(width: 30, height: 30) + Image(systemName: icon) + .foregroundColor(.orange) + .font(.system(size: 14, weight: .semibold)) + } + VStack(alignment: .leading, spacing: 4) { + Text(label) + .foregroundColor(.primary) + .font(.subheadline) + Text(help) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + ZStack(alignment: .topLeading) { + if text.wrappedValue.isEmpty { + Text(placeholder) + .foregroundColor(.secondary.opacity(0.6)) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + TextEditor(text: text) + .keyboardType(keyboard) + .padding(10) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(UIColor.systemGray6))) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + .frame(minHeight: 100) + } + } + .padding(.vertical, 6) + } + + @ViewBuilder + private func therapySchedulePreview(for schedule: String, title: String, accent: Color) -> some View { + let points = parseSchedule(schedule) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + if points.count > 1 { + Text("Min: \(formatted(points.map { $0.value }.min() ?? 0))") + .font(.caption) + .foregroundColor(.secondary) + } + } + + if points.count > 1 { + scheduleGraph(points: points, accent: accent) + HStack { + Text("00:00") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("24:00") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + Text("Enter a schedule like 00:00 = 0.8 to visualize the trend.") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 18).fill(Color(UIColor.systemGray6))) + } + + private func parseSchedule(_ input: String) -> [(hour: Double, value: Double)] { + let lines = input + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + var points: [(Double, Double)] = [] + for line in lines { + let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2, + let value = Double(parts[1].replacingOccurrences(of: ",", with: ".")) + else { + continue + } + + let timeParts = parts[0].split(separator: ":").map { String($0) } + guard timeParts.count == 2, + let hour = Double(timeParts[0]), + let minute = Double(timeParts[1]) + else { + continue + } + let hourValue = max(0, min(24, hour + minute / 60.0)) + points.append((hourValue, value)) + } + + let sorted = points.sorted { $0.0 < $1.0 } + guard let first = sorted.first else { return [] } + + var result = sorted + if sorted.count == 1 { + result = [(0, first.1), (24, first.1)] + } else { + if first.0 > 0 { + result.insert((0, first.1), at: 0) + } + if let last = result.last, last.0 < 24 { + result.append((24, last.1)) + } + } + return result + } + + private func scheduleGraph(points: [(hour: Double, value: Double)], accent: Color) -> some View { + GeometryReader { proxy in + let minValue = points.map { $0.value }.min() ?? 0 + let maxValue = points.map { $0.value }.max() ?? 1 + let range = max(maxValue - minValue, 0.1) + + // Stepped Area Fill + Path { path in + guard let first = points.first else { return } + path.move(to: CGPoint(x: proxy.size.width * CGFloat(first.hour / 24), y: proxy.size.height)) + + for i in 0 ..< points.count { + let x = proxy.size.width * CGFloat(points[i].hour / 24) + let y = proxy.size.height * (1 - CGFloat((points[i].value - minValue) / range)) + if i > 0 { + let prevY = proxy.size.height * (1 - CGFloat((points[i - 1].value - minValue) / range)) + path.addLine(to: CGPoint(x: x, y: prevY)) + } + path.addLine(to: CGPoint(x: x, y: y)) + } + + if let last = points.last { + path.addLine(to: CGPoint(x: proxy.size.width * CGFloat(last.hour / 24), y: proxy.size.height)) + } + path.closeSubpath() + } + .fill(accent.opacity(0.15)) + + // Stepped Line + Path { path in + for index in points.indices { + let point = points[index] + let x = proxy.size.width * CGFloat(point.hour / 24) + let y = proxy.size.height * (1 - CGFloat((point.value - minValue) / range)) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + let prevPoint = points[index - 1] + let prevY = proxy.size.height * (1 - CGFloat((prevPoint.value - minValue) / range)) + path.addLine(to: CGPoint(x: x, y: prevY)) + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(accent, lineWidth: 2) + + ForEach(points.indices, id: \.self) { index in + let point = points[index] + let x = proxy.size.width * CGFloat(point.hour / 24) + let y = proxy.size.height * (1 - CGFloat((point.value - minValue) / range)) + Circle() + .fill(accent) + .frame(width: 8, height: 8) + .position(x: x, y: y) + } + } + .frame(height: 140) + } + + private func formatted(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + return formatter.string(from: NSNumber(value: value)) ?? "0" + } + + @ViewBuilder + private func settingCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(color.opacity(0.18)) + .frame(width: 28, height: 28) + Image(systemName: icon) + .foregroundColor(color) + .font(.system(size: 13, weight: .semibold)) + } + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer() + } + content() + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 20, style: .continuous).fill(Color(UIColor.systemBackground))) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.secondary.opacity(0.12), lineWidth: 1) + ) + } + + private func sectionCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 18) { + sectionLabel(title, icon: icon, color: color) + content() + } + .padding(18) + .background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(UIColor.systemBackground))) + .shadow(color: Color.black.opacity(0.08), radius: 24, x: 0, y: 10) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + @ViewBuilder + private func toggleRow(_ title: String, isOn: Binding) -> some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(isOn.wrappedValue ? Color.teal.opacity(0.18) : Color.secondary.opacity(0.12)) + .frame(width: 30, height: 30) + Image(systemName: toggleIcon(for: title)) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(isOn.wrappedValue ? .teal : .secondary) + } + Toggle(isOn: isOn) { + Text(title) + .font(.subheadline) + .foregroundColor(.primary) + } + .toggleStyle(SwitchToggleStyle(tint: .teal)) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 18).fill(Color(UIColor.systemGray6))) + } + + private func toggleIcon(for title: String) -> String { + let lower = title.lowercased() + if lower.contains("glucose") { return "waveform.path.ecg" } + if lower.contains("insulin") { return "drop.fill" } + if lower.contains("nutrition") { return "fork.knife" } + if lower.contains("therapy") { return "heart.text.square" } + if lower.contains("devices") { return "iphone" } + if lower.contains("agp") { return "chart.bar.doc.horizontal" } + if lower.contains("daily") { return "calendar" } + return "circle.grid.2x2" + } + + private func sectionLabel(_ title: String, icon: String, color: Color) -> some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(color.opacity(0.18)) + .frame(width: 32, height: 32) + Image(systemName: icon) + .foregroundColor(color) + .font(.system(size: 14, weight: .semibold)) + } + Text(title) + .font(.headline) + .foregroundColor(.primary) + Spacer() + } + .padding(.vertical, 4) + } + + // MARK: - Presets + + private struct Preset { let label: String; let start: Date; let end: Date } + private var presets: [Preset] { + let now = Date(); let cal = Calendar.current + return [ + Preset(label: "3d", start: cal.date(byAdding: .day, value: -3, to: now)!, end: now), + Preset(label: "7d", start: cal.date(byAdding: .day, value: -7, to: now)!, end: now), + Preset(label: "14d", start: cal.date(byAdding: .day, value: -14, to: now)!, end: now), + Preset(label: "30d", start: cal.date(byAdding: .day, value: -30, to: now)!, end: now), + Preset(label: "90d", start: cal.date(byAdding: .day, value: -90, to: now)!, end: now), + ] + } + + private func isActive(_ p: Preset) -> Bool { + Calendar.current.isDate(p.start, inSameDayAs: startDate) && + Calendar.current.isDate(p.end, inSameDayAs: endDate) + } + + // MARK: - Fetch from Nightscout + + private func fetchFromNightscout() { + fetchSuccess = false + profileFetcher.fetch { settings in + guard let s = settings else { return } + carbRatio = s.carbRatio + isf = s.isf + basalRate = s.basalRate + if !s.targetLow.isEmpty && !s.targetHigh.isEmpty { + targetGlucose = "\(s.targetLow)–\(s.targetHigh)" + } else { + targetGlucose = s.targetLow.isEmpty ? s.targetHigh : s.targetLow + } + units = s.units + fetchSuccess = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + fetchSuccess = false + } + } + } + + // MARK: - Generate + + private func generate() { + errorMessage = nil + isGenerating = true + + // Strict boundary generation so we capture all 24 hours of start and end days + let cal = Calendar.current + let realStart = cal.startOfDay(for: startDate) + var endComps = DateComponents() + endComps.day = 1 + endComps.second = -1 + let realEnd = cal.date(byAdding: endComps, to: cal.startOfDay(for: endDate)) ?? endDate + + dataService.updateDateRange(start: realStart, end: realEnd) + + dataService.ensureDataAvailable(onProgress: {}) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let config = EndoReportConfig( + patientName: patientName, + dateOfBirth: dateOfBirth, + diagnosisDate: diagnosisDate, + providerName: providerName, + insulinType: insulinType, + aidSystem: aidSystem == "Other" ? customAidSystem : aidSystem, + pumpDevice: pumpDevice, + cgmDevice: cgmDevice, + carbRatio: carbRatio, + isf: isf, + basalRate: basalRate, + targetGlucose: targetGlucose, + units: units, + accentColorHex: accentColorHex, + includeGlucoseSummary: includeGlucoseSummary, + includeInsulin: includeInsulin, + includeNutrition: includeNutrition, + includeTherapySettings: includeTherapySettings, + includeDevices: includeDevices, + includeAGP: includeAGP, + includeDailyBreakdown: includeDailyBreakdown, + includeFatProtein: includeFatProtein, + startDate: realStart, + endDate: realEnd + ) + let url = try EndoReportGenerator.generate(config: config, dataService: dataService) + DispatchQueue.main.async { + isGenerating = false + reportURL = url + showShareSheet = true + } + } catch { + DispatchQueue.main.async { + isGenerating = false + errorMessage = error.localizedDescription + } + } + } + } + } +} + +// MARK: - Share sheet + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} + +// MARK: - Color extensions + +extension Color { + init?(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") + if h.count == 6 { h += "FF" } + guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } + self.init( + red: Double((val >> 24) & 0xFF) / 255, + green: Double((val >> 16) & 0xFF) / 255, + blue: Double((val >> 8) & 0xFF) / 255, + opacity: Double(val & 0xFF) / 255 + ) + } + + func toHex() -> String? { + guard let c = UIColor(self).cgColor.components, c.count >= 3 else { return nil } + return String(format: "#%02X%02X%02X", + Int(c[0] * 255), Int(c[1] * 255), Int(c[2] * 255)) + } +} + +extension UIColor { + convenience init?(hex: String) { + var h = hex.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "") + if h.count == 6 { h += "FF" } + guard h.count == 8, let val = UInt64(h, radix: 16) else { return nil } + self.init( + red: CGFloat((val >> 24) & 0xFF) / 255, + green: CGFloat((val >> 16) & 0xFF) / 255, + blue: CGFloat((val >> 8) & 0xFF) / 255, + alpha: CGFloat(val & 0xFF) / 255 + ) + } +} + +// MARK: - NightscoutProfileFetcher + +class NightscoutProfileFetcher: ObservableObject { + @Published var isFetching = false + @Published var error: String? + @Published var success = false + + struct FetchedSettings { + let carbRatio: String + let isf: String + let basalRate: String + let targetLow: String + let targetHigh: String + let units: String + } + + func fetch(completion: @escaping (FetchedSettings?) -> Void) { + isFetching = true + error = nil + success = false + + NightscoutUtils.executeRequest( + eventType: .profile, + parameters: [:] + ) { [weak self] (result: Result) in + DispatchQueue.main.async { + guard let self else { return } + self.isFetching = false + + switch result { + case let .failure(err): + self.error = err.localizedDescription + completion(nil) + + case let .success(profile): + let store = profile.store[profile.defaultProfile] + ?? profile.store["default"] + ?? profile.store["Default"] + ?? profile.store.values.first + + guard let s = store else { + self.error = "No profile store found in Nightscout response." + completion(nil) + return + } + + let isMMOL = s.units.lowercased().contains("mmol") + + func fmtValue(_ value: Double) -> String { + if value == floor(value) { + return String(format: "%.0f", value) + } + let raw = String(format: "%.2f", value) + return raw.replacingOccurrences(of: "\\.?0+$", with: "", options: .regularExpression) + } + + func fmtSchedule(_ entries: [T], + value: (T) -> Double, + time: (T) -> String) -> String + { + if entries.count == 1 { + return fmtValue(value(entries[0])) + } + // Output joined by newlines so it populates the multi-line UI cleanly + return entries.map { + "\(time($0)) = \(fmtValue(value($0)))" + }.joined(separator: "\n") + } + + let cr = fmtSchedule(s.carbratio, value: { $0.value }, time: { $0.time }) + let isf = fmtSchedule(s.sens, value: { $0.value }, time: { $0.time }) + let bas = fmtSchedule(s.basal, value: { $0.value }, time: { $0.time }) + + let targetLow = s.target_low?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + let targetHigh = s.target_high?.first.map { String(format: isMMOL ? "%.1f" : "%.0f", $0.value) } ?? "" + + self.success = true + completion(FetchedSettings( + carbRatio: cr, + isf: isf, + basalRate: bas, + targetLow: targetLow, + targetHigh: targetHigh, + units: isMMOL ? "mmol/L" : "mg/dL" + )) + } + } + } + } +} diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index ff61d6eef..850de12ea 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -91,9 +91,11 @@ class StatsDataFetcher { NightscoutUtils.executeRequest(eventType: .profile, parameters: [:]) { (result: Result) in switch result { case let .success(profileData): - let profileStore = profileData.store["default"] ?? + let profileStore = profileData.store.values.first(where: { $0.basal.count > 0 }) ?? + profileData.store[profileData.defaultProfile] ?? + profileData.store["default"] ?? profileData.store["Default"] ?? - profileData.store[profileData.defaultProfile] + profileData.store.values.first DispatchQueue.main.async { if let profileStore { @@ -351,9 +353,9 @@ class StatsDataFetcher { let dateTimeStamp = dateParsed.timeIntervalSince1970 if dateTimeStamp < cutoffTime { continue } - guard let basalRate = currentEntry["absolute"] as? Double else { - continue - } + let basalRate = (currentEntry["absolute"] as? Double) ?? + (currentEntry["rate"] as? Double) ?? + Double(currentEntry["absolute"] as? String ?? "") ?? 0.0 var duration = currentEntry["duration"] as? Double ?? 0.0 From cb352c1263b69a6a19507f1fa6d9333700b22731 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Sun, 24 May 2026 12:15:11 -0700 Subject: [PATCH 6/7] Added Total Insulin and Programmed Basal to Daily breakdowns --- LoopFollow/Stats/EndoReportGenerator.swift | 49 +++++++++++++++++---- LoopFollow/Stats/SimpleStatsViewModel.swift | 28 +----------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index f696d00e7..ac783ce26 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -66,6 +66,7 @@ enum EndoReportGenerator { let smbs = dataService.getSMBData() let carbs = dataService.getCarbData() let basals = dataService.getBasalData() + let basalProfile = dataService.getBasalProfile() // Get basal profile here let simpleVM = SimpleStatsViewModel(dataService: dataService) simpleVM.calculateStats() @@ -107,8 +108,7 @@ enum EndoReportGenerator { for (day, dayData) in slice { drawDayRow(ctx: ctx.cgContext, x: 28, y: y, w: pageRect.width - 56, h: rowH, - day: day, dayData: dayData, cfg: config, - simpleVM: simpleVM) + day: day, dayData: dayData, cfg: config, basalProfile: basalProfile) y += rowH + rowGap } drawFooter(ctx: ctx.cgContext, r: pageRect, cfg: config, @@ -739,11 +739,33 @@ enum EndoReportGenerator { return input } + // MARK: - Basal Profile Helpers + + static func calculateDailyProgrammedBasal(basalProfile: [MainViewController.basalProfileStruct]) -> Double { + guard !basalProfile.isEmpty else { return 0.0 } + + let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds } + + var totalBasal = 0.0 + let secondsInDay = 24 * 60 * 60 + + for i in 0 ..< sortedProfile.count { + let current = sortedProfile[i] + let currentTime = Double(current.timeAsSeconds) + + let nextTime: Double = (i < sortedProfile.count - 1) ? Double(sortedProfile[i + 1].timeAsSeconds) : Double(secondsInDay) + let durationHours = (nextTime - currentTime) / 3600.0 + totalBasal += current.value * durationHours + } + + return totalBasal + } + // MARK: - Day row private static func drawDayRow(ctx: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat, day: String, dayData: DayData, cfg: EndoReportConfig, - simpleVM _: SimpleStatsViewModel) + basalProfile: [MainViewController.basalProfileStruct]) { ctx.setFillColor(C_WHITE.cgColor); ctx.fill(CGRect(x: x, y: y, width: w, height: h)) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.5) @@ -761,25 +783,34 @@ enum EndoReportGenerator { let statsX = x + w - 120 let vals = dayData.bg.map { Double($0.sgv) } + // Calculate total insulin for the day + let totalBolus = dayData.bolus.map { $0.value }.reduce(0, +) + let totalSMB = dayData.smb.map { $0.value }.reduce(0, +) + let totalInsulin = totalBolus + totalSMB + + // Calculate total scheduled basal for the day + let dailyProgrammedBasal = calculateDailyProgrammedBasal(basalProfile: basalProfile) + if !vals.isEmpty { let n = Double(vals.count) let avg = vals.reduce(0,+) / n let tir = Double(vals.filter { $0 >= 70 && $0 <= 180 }.count) / n * 100 - // removed bolus/basal totals and ratios per request let sa: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 6.5), .foregroundColor: C_SLATE] let sv: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: C_INK] let tirC: UIColor = tir >= 70 ? C_IN : tir >= 50 ? C_HIGH : C_VLOW let tirA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: tirC] - let acA: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: cfg.accentColor] - let cols: [(String, CGFloat)] = [("Avg", 0), ("TIR", 30)] + let statsXAdjusted = x + w - 200 // Adjust to make space for new columns + let cols: [(String, CGFloat)] = [("Avg", 0), ("TIR", 40), ("Insulin", 80), ("Basal", 120)] for (lbl, ox) in cols { - (lbl as NSString).draw(at: CGPoint(x: statsX + ox, y: y + 5), withAttributes: sa) + (lbl as NSString).draw(at: CGPoint(x: statsXAdjusted + ox, y: y + 5), withAttributes: sa) } - cfg.fmtBG(avg).draw(at: CGPoint(x: statsX, y: y + 14), withAttributes: sv) - String(format: "%.0f%%", tir).draw(at: CGPoint(x: statsX + 28, y: y + 14), withAttributes: tirA) + cfg.fmtBG(avg).draw(at: CGPoint(x: statsXAdjusted, y: y + 14), withAttributes: sv) + String(format: "%.0f%%", tir).draw(at: CGPoint(x: statsXAdjusted + 38, y: y + 14), withAttributes: tirA) + String(format: "%.1f U", totalInsulin).draw(at: CGPoint(x: statsXAdjusted + 78, y: y + 14), withAttributes: sv) + String(format: "%.1f U", dailyProgrammedBasal).draw(at: CGPoint(x: statsXAdjusted + 118, y: y + 14), withAttributes: sv) } let chartX = x + 10; let chartW = w - 140 diff --git a/LoopFollow/Stats/SimpleStatsViewModel.swift b/LoopFollow/Stats/SimpleStatsViewModel.swift index 841f4ca2a..f28e103b8 100644 --- a/LoopFollow/Stats/SimpleStatsViewModel.swift +++ b/LoopFollow/Stats/SimpleStatsViewModel.swift @@ -103,7 +103,7 @@ class SimpleStatsViewModel: ObservableObject { } let basalProfile = dataService.getBasalProfile() - let dailyProgrammedBasal = calculateProgrammedBasalFromProfile(basalProfile: basalProfile) + let dailyProgrammedBasal = EndoReportGenerator.calculateDailyProgrammedBasal(basalProfile: basalProfile) programmedBasal = dailyProgrammedBasal // Calculate actual basal using temp basal adjustments @@ -308,30 +308,4 @@ class SimpleStatsViewModel: ObservableObject { return min(uniqueDays.count, requestedDays) } - - private func calculateProgrammedBasalFromProfile(basalProfile: [MainViewController.basalProfileStruct]) -> Double { - guard !basalProfile.isEmpty else { return 0.0 } - - let sortedProfile = basalProfile.sorted { $0.timeAsSeconds < $1.timeAsSeconds } - - var totalBasal = 0.0 - let secondsInDay = 24 * 60 * 60 - - for i in 0 ..< sortedProfile.count { - let current = sortedProfile[i] - let currentTime = Double(current.timeAsSeconds) - - let nextTime: Double - if i < sortedProfile.count - 1 { - nextTime = Double(sortedProfile[i + 1].timeAsSeconds) - } else { - nextTime = Double(secondsInDay) - } - - let durationHours = (nextTime - currentTime) / 3600.0 - totalBasal += current.value * durationHours - } - - return totalBasal - } } From 85341df857be3690a4813d1dda09d421d6ecb5a5 Mon Sep 17 00:00:00 2001 From: greyghost99 <164137251+greyghost99@users.noreply.github.com> Date: Sun, 24 May 2026 14:04:54 -0700 Subject: [PATCH 7/7] Changed finished PDF layout and UI when entering info --- LoopFollow/Stats/EndoReportGenerator.swift | 129 ++++++++++++--------- LoopFollow/Stats/EndoReportView.swift | 39 ++++--- 2 files changed, 96 insertions(+), 72 deletions(-) diff --git a/LoopFollow/Stats/EndoReportGenerator.swift b/LoopFollow/Stats/EndoReportGenerator.swift index ac783ce26..b7a1f12d4 100644 --- a/LoopFollow/Stats/EndoReportGenerator.swift +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -272,39 +272,31 @@ enum EndoReportGenerator { y = nutritionSection(carbs: carbs, stats: stats, cfg: cfg, y: y + 1, m: m, w: r.width, ctx: ctx) } - let hasDevice = !cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty - if cfg.includeDevices, hasDevice { - y = sectionHdr("DEVICES & INSULIN", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) - var rows: [(String, String)] = [] - if !cfg.pumpDevice.isEmpty { rows.append(("Pump", cfg.pumpDevice)) } - if !cfg.cgmDevice.isEmpty { rows.append(("CGM", cfg.cgmDevice)) } - if !cfg.insulinType.isEmpty { rows.append(("Insulin", cfg.insulinType)) } - y = metricTable(rows, y: y + 1, m: m, w: r.width, cfg: cfg, ctx: ctx) - } - - let hasSettings = !cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty - if cfg.includeTherapySettings, hasSettings { - y = sectionHdr("CURRENT THERAPY SETTINGS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) - var rows: [(String, String)] = [] - if !cfg.carbRatio.isEmpty { rows.append(("Carb Ratio (CR)", cfg.carbRatio)) } - if !cfg.isf.isEmpty { rows.append(("Insulin Sensitivity (ISF)", cfg.isf)) } - - var basalVal = "" - if !cfg.basalRate.isEmpty { basalVal = formatBasalRateForDisplay(cfg.basalRate) } - if !cfg.targetGlucose.isEmpty { - if !basalVal.isEmpty { basalVal += "\n" } - basalVal += "Target: \(cfg.targetGlucose)" + let hasDevice = cfg.includeDevices && (!cfg.pumpDevice.isEmpty || !cfg.cgmDevice.isEmpty || !cfg.insulinType.isEmpty) + let hasSettings = cfg.includeTherapySettings && (!cfg.carbRatio.isEmpty || !cfg.isf.isEmpty || !cfg.basalRate.isEmpty || !cfg.targetGlucose.isEmpty) + + if hasDevice || hasSettings { + y = sectionHdr("SYSTEM & THERAPY SETTINGS", y: y + 2, m: m, w: r.width, cfg: cfg, ctx: ctx) + var gridItems: [(String, String)] = [] + if hasDevice { + if !cfg.pumpDevice.isEmpty { gridItems.append(("Pump", cfg.pumpDevice)) } + if !cfg.cgmDevice.isEmpty { gridItems.append(("CGM", cfg.cgmDevice)) } + if !cfg.insulinType.isEmpty { gridItems.append(("Insulin", cfg.insulinType)) } } - if !basalVal.isEmpty { rows.append((cfg.basalRate.isEmpty ? "Target Glucose" : "Basal & Target", basalVal)) } - - y = settingsTable(rows, y: y + 1, m: m, w: r.width, cfg: cfg, ctx: ctx) + if hasSettings { + if !cfg.carbRatio.isEmpty { gridItems.append(("CR", cfg.carbRatio)) } + if !cfg.isf.isEmpty { gridItems.append(("ISF", cfg.isf)) } + if !cfg.basalRate.isEmpty { gridItems.append(("Basal", formatBasalRateForDisplay(cfg.basalRate))) } + if !cfg.targetGlucose.isEmpty { gridItems.append(("Target", cfg.targetGlucose)) } + } + y = drawSettingsGrid(gridItems, x: m, y: y + 1, width: r.width - m * 2, cfg: cfg, ctx: ctx) } if cfg.includeAGP, !agpData.isEmpty { - let agpAvail = r.height - y - 50 - if agpAvail >= 100 { + let agpAvail = r.height - y - 40 + if agpAvail >= 80 { y = sectionHdr("AMBULATORY GLUCOSE PROFILE", y: y + 6, m: m, w: r.width, cfg: cfg, ctx: ctx) - let agpH = Swift.min(agpAvail - 24, 118) + let agpH = Swift.min(agpAvail - 20, 130) drawAGP(agpData: agpData, x: m, y: y + 4, w: r.width - m * 2, h: agpH, cfg: cfg, ctx: ctx) } } @@ -508,7 +500,7 @@ enum EndoReportGenerator { ("Programmed Basal", simpleVM.programmedBasal != nil ? String(format: "%.2f U/day", simpleVM.programmedBasal!) : "—"), ("Actual Basal", simpleVM.actualBasal != nil ? String(format: "%.2f U/day", simpleVM.actualBasal!) : "—"), ] - return metricTable(rows, y: ty, m: m, w: w, cfg: cfg, ctx: ctx) + return metricTable(rows, x: m, y: ty, width: w - m * 2, cfg: cfg, ctx: ctx) } // MARK: - Nutrition section @@ -541,25 +533,25 @@ enum EndoReportGenerator { // MARK: - Tables @discardableResult - private static func metricTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, - w: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + private static func metricTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { - let tw = w - m * 2; let hh: CGFloat = 12; let rh: CGFloat = 11; var cy = y + let tw = width; let hh: CGFloat = 12; let rh: CGFloat = 11; var cy = y ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: hh)) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: hh)) let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] - "METRIC".draw(at: CGPoint(x: m + 6, y: cy + 1), withAttributes: ha) - "VALUE".draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 1), withAttributes: ha) + "METRIC".draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ha) + "VALUE".draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: ha) cy += hh for (i, row) in rows.enumerated() { ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7), .foregroundColor: C_INK] let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] - (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 1), withAttributes: ka) - (row.1 as NSString).draw(at: CGPoint(x: m + tw * 0.62 + 6, y: cy + 1), withAttributes: va) + (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 1), withAttributes: ka) + (row.1 as NSString).draw(at: CGPoint(x: x + tw * 0.58 + 6, y: cy + 1), withAttributes: va) ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: m, y: cy + rh)); ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)); ctx.strokePath() + ctx.move(to: CGPoint(x: x, y: cy + rh)); ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)); ctx.strokePath() cy += rh } return cy + 1 @@ -567,18 +559,18 @@ enum EndoReportGenerator { // Dynamic settings table to handle multi-line text input neatly @discardableResult - private static func settingsTable(_ rows: [(String, String)], y: CGFloat, m: CGFloat, - w: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + private static func settingsTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { - let tw = w - m * 2 + let tw = width let headerH: CGFloat = 12 var cy = y ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: headerH)) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: headerH)) let ha: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg), .kern: 0.4] - "THERAPY SETTING & VALUES".draw(at: CGPoint(x: m + 6, y: cy + 3), withAttributes: ha) + "THERAPY SETTING & VALUES".draw(at: CGPoint(x: x + 6, y: cy + 3), withAttributes: ha) cy += headerH for (i, row) in rows.enumerated() { @@ -586,23 +578,23 @@ enum EndoReportGenerator { let rh = 11.0 + CGFloat(lines.count) * 10.5 + 4.0 ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) - ctx.fill(CGRect(x: m, y: cy, width: tw, height: rh)) + ctx.fill(CGRect(x: x, y: cy, width: tw, height: rh)) let ka: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 7.5), .foregroundColor: C_SLATE] let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 8), .foregroundColor: accent(cfg)] - (row.0 as NSString).draw(at: CGPoint(x: m + 6, y: cy + 3.5), withAttributes: ka) + (row.0 as NSString).draw(at: CGPoint(x: x + 6, y: cy + 3.5), withAttributes: ka) var ly = cy + 12.5 for line in lines { - (line as NSString).draw(at: CGPoint(x: m + 6, y: ly), withAttributes: va) + (line as NSString).draw(at: CGPoint(x: x + 6, y: ly), withAttributes: va) ly += 10.5 } ctx.setStrokeColor(C_BORDER.cgColor) ctx.setLineWidth(0.3) - ctx.move(to: CGPoint(x: m, y: cy + rh)) - ctx.addLine(to: CGPoint(x: m + tw, y: cy + rh)) + ctx.move(to: CGPoint(x: x, y: cy + rh)) + ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)) ctx.strokePath() cy += rh @@ -616,7 +608,7 @@ enum EndoReportGenerator { w: CGFloat, h: CGFloat, cfg: EndoReportConfig, ctx: CGContext) { guard !agpData.isEmpty else { return } - let lPad: CGFloat = 26; let bPad: CGFloat = 16 + let lPad: CGFloat = 26; let bPad: CGFloat = 24 let cw = w - lPad; let ch = h - bPad let cx = x + lPad; let cy = y @@ -695,14 +687,45 @@ enum EndoReportGenerator { for item in lgItems.reversed() { let lsz = (item.0 as NSString).size(withAttributes: lgA) lgX -= lsz.width - (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 3), withAttributes: lgA) + (item.0 as NSString).draw(at: CGPoint(x: lgX, y: cy + ch + 11), withAttributes: lgA) lgX -= 15 - item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 4, width: 12, height: 8)) }() - : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 7, width: 12, height: 2)) }() + item.2 ? { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 12, width: 12, height: 8)) }() + : { ctx.setFillColor(item.1.cgColor); ctx.fill(CGRect(x: lgX, y: cy + ch + 15, width: 12, height: 2)) }() lgX -= 5 } } + @discardableResult + private static func drawSettingsGrid(_ items: [(String, String)], x: CGFloat, y: CGFloat, width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat { + let count = CGFloat(items.count) + guard count > 0 else { return y } + let spacing: CGFloat = 4 + let cw = (width - (count - 1) * spacing) / count + var maxH: CGFloat = 0 + + for (i, item) in items.enumerated() { + let cx = x + CGFloat(i) * (cw + spacing) + let lines = item.1.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + let h = 12.0 + CGFloat(lines.count) * 9.5 + 4.0 + maxH = max(maxH, h) + + ctx.setFillColor(C_CLOUD.cgColor) + ctx.fill(CGRect(x: cx, y: y, width: cw, height: h)) + ctx.setStrokeColor(C_BORDER.cgColor); ctx.setLineWidth(0.4); ctx.stroke(CGRect(x: cx, y: y, width: cw, height: h)) + + let la: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 5.8), .foregroundColor: C_SLATE] + let va: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 7), .foregroundColor: accent(cfg)] + + (item.0 as NSString).draw(at: CGPoint(x: cx + 4, y: y + 2.5), withAttributes: la) + var ly = y + 10.5 + for line in lines { + (line as NSString).draw(at: CGPoint(x: cx + 4, y: ly), withAttributes: va) + ly += 9.5 + } + } + return y + maxH + 4 + } + // MARK: - Format helpers private static func formatBasalRateForDisplay(_ input: String) -> String { diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift index aa2ce95a6..9f1562617 100644 --- a/LoopFollow/Stats/EndoReportView.swift +++ b/LoopFollow/Stats/EndoReportView.swift @@ -38,8 +38,8 @@ struct EndoReportView: View { @AppStorage("endoReport.customAidSystem") private var customAidSystem = "" // Date range - @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -14, to: Date()) ?? Date() - @State private var endDate: Date = .init() + @State private var startDate: Date = StatsDateRange.lastComplete(days: 14).start + @State private var endDate: Date = StatsDateRange.lastComplete(days: 14).end // UI state @StateObject private var profileFetcher = NightscoutProfileFetcher() @@ -58,7 +58,7 @@ struct EndoReportView: View { var body: some View { NavigationView { ScrollView(showsIndicators: false) { - VStack(spacing: 18) { + VStack(spacing: 12) { sectionCard("Report Period", icon: "calendar", color: .blue) { VStack(spacing: 16) { ScrollView(.horizontal, showsIndicators: false) { @@ -369,7 +369,7 @@ struct EndoReportView: View { .stroke(Color.secondary.opacity(0.16), lineWidth: 1) ) } - .padding(.vertical, 4) + .padding(.vertical, 2) } @ViewBuilder @@ -413,7 +413,7 @@ struct EndoReportView: View { .frame(minHeight: 100) } } - .padding(.vertical, 6) + .padding(.vertical, 3) } @ViewBuilder @@ -564,7 +564,7 @@ struct EndoReportView: View { @ViewBuilder private func settingCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 10) { HStack(spacing: 12) { ZStack { Circle() @@ -582,8 +582,9 @@ struct EndoReportView: View { } content() } - .padding(16) - .background(RoundedRectangle(cornerRadius: 20, style: .continuous).fill(Color(UIColor.systemBackground))) + .padding(12) + .background(Color(UIColor.systemBackground).overlay(color.opacity(0.1))) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(Color.secondary.opacity(0.12), lineWidth: 1) @@ -591,12 +592,13 @@ struct EndoReportView: View { } private func sectionCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 12) { sectionLabel(title, icon: icon, color: color) content() } - .padding(18) - .background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(UIColor.systemBackground))) + .padding(14) + .background(Color(UIColor.systemBackground).overlay(color.opacity(0.1))) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) .shadow(color: Color.black.opacity(0.08), radius: 24, x: 0, y: 10) .padding(.horizontal, 16) .padding(.vertical, 8) @@ -620,7 +622,7 @@ struct EndoReportView: View { } .toggleStyle(SwitchToggleStyle(tint: .teal)) } - .padding(14) + .padding(10) .background(RoundedRectangle(cornerRadius: 18).fill(Color(UIColor.systemGray6))) } @@ -651,20 +653,19 @@ struct EndoReportView: View { .foregroundColor(.primary) Spacer() } - .padding(.vertical, 4) + .padding(.vertical, 2) } // MARK: - Presets private struct Preset { let label: String; let start: Date; let end: Date } private var presets: [Preset] { - let now = Date(); let cal = Calendar.current return [ - Preset(label: "3d", start: cal.date(byAdding: .day, value: -3, to: now)!, end: now), - Preset(label: "7d", start: cal.date(byAdding: .day, value: -7, to: now)!, end: now), - Preset(label: "14d", start: cal.date(byAdding: .day, value: -14, to: now)!, end: now), - Preset(label: "30d", start: cal.date(byAdding: .day, value: -30, to: now)!, end: now), - Preset(label: "90d", start: cal.date(byAdding: .day, value: -90, to: now)!, end: now), + Preset(label: "3d", start: StatsDateRange.lastComplete(days: 3).start, end: StatsDateRange.lastComplete(days: 3).end), + Preset(label: "7d", start: StatsDateRange.lastComplete(days: 7).start, end: StatsDateRange.lastComplete(days: 7).end), + Preset(label: "14d", start: StatsDateRange.lastComplete(days: 14).start, end: StatsDateRange.lastComplete(days: 14).end), + Preset(label: "30d", start: StatsDateRange.lastComplete(days: 30).start, end: StatsDateRange.lastComplete(days: 30).end), + Preset(label: "90d", start: StatsDateRange.lastComplete(days: 90).start, end: StatsDateRange.lastComplete(days: 90).end), ] }