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..b7a1f12d4 --- /dev/null +++ b/LoopFollow/Stats/EndoReportGenerator.swift @@ -0,0 +1,961 @@ +// LoopFollow +// EndoReportGenerator.swift + +import PDFKit +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 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." } + } + + 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 basalProfile = dataService.getBasalProfile() // Get basal profile here + 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 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 + 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 ..< 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, basalProfile: basalProfile) + 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 = 24 + var y = drawHero(ctx: ctx, r: r, cfg: cfg, stats: stats) + + if cfg.includeGlucoseSummary { + 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 + 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 + 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 + 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 + 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 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 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 - 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 - 20, 130) + 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 = 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 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 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.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 + } + + // 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 + 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) + 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: 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) + 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) + + // 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 + + 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) + } + + 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) + 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 + 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 = 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 + 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!) : "—"), + ] + return metricTable(rows, x: m, y: ty, width: w - m * 2, 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 = 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) + } + return y + ch + 2 + } + + // MARK: - Tables + + @discardableResult + private static func metricTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + 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: 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: 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: 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: 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: x, y: cy + rh)); ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)); ctx.strokePath() + cy += rh + } + return cy + 1 + } + + // Dynamic settings table to handle multi-line text input neatly + @discardableResult + private static func settingsTable(_ rows: [(String, String)], x: CGFloat, y: CGFloat, + width: CGFloat, cfg: EndoReportConfig, ctx: CGContext) -> CGFloat + { + let tw = width + let headerH: CGFloat = 12 + var cy = y + + ctx.setFillColor(accent(cfg).withAlphaComponent(0.10).cgColor) + 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: x + 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 = 11.0 + CGFloat(lines.count) * 10.5 + 4.0 + + ctx.setFillColor((i % 2 == 0 ? C_WHITE : C_CLOUD).cgColor) + 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: x + 6, y: cy + 3.5), withAttributes: ka) + + var ly = cy + 12.5 + for line in lines { + (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: x, y: cy + rh)) + ctx.addLine(to: CGPoint(x: x + tw, y: cy + rh)) + ctx.strokePath() + + cy += rh + } + return cy + 2 + } + + // MARK: - AGP + + 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 = 24 + 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 } + + ctx.setFillColor(C_IN.withAlphaComponent(0.07).cgColor) + 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.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: []) + + 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))) + } + 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 + } + 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) + 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() + } + + 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)) + + 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 + 11), withAttributes: lgA) + lgX -= 15 + 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 { + 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: - 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, + 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) + 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)) + + 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) } + + // 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 + + 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 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: statsXAdjusted + ox, y: y + 5), withAttributes: sa) + } + + 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 + 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 + } + + ctx.setFillColor(C_IN.withAlphaComponent(0.06).cgColor) + 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.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: []) + + 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() + } + + 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 = 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 + } + 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.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) + } + } + } + ctx.setStrokeColor(C_BASAL.cgColor); ctx.setLineWidth(0.9); ctx.addPath(lp); ctx.strokePath() + } + + 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) + ctx.setFillColor(C_SMB.cgColor) + 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) + ctx.setFillColor(C_BOLUS.cgColor) + 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)) + 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 } + } + } + } + + // 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) + } +} diff --git a/LoopFollow/Stats/EndoReportView.swift b/LoopFollow/Stats/EndoReportView.swift new file mode 100644 index 000000000..9f1562617 --- /dev/null +++ b/LoopFollow/Stats/EndoReportView.swift @@ -0,0 +1,897 @@ +// 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 = StatsDateRange.lastComplete(days: 14).start + @State private var endDate: Date = StatsDateRange.lastComplete(days: 14).end + + // 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: 12) { + 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, 2) + } + + @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, 3) + } + + @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: 10) { + 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(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) + ) + } + + private func sectionCard(_ title: String, icon: String, color: Color, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + sectionLabel(title, icon: icon, color: color) + content() + } + .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) + } + + @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(10) + .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, 2) + } + + // MARK: - Presets + + private struct Preset { let label: String; let start: Date; let end: Date } + private var presets: [Preset] { + return [ + 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), + ] + } + + 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/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 - } } 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