Architecture¶
C4 Level 1 — System Context¶
C4Context
title Heart-E — System Context
Person(user, "User", "Tracks blood pressure readings,\nmonitors trends, generates\nreports for their doctor.")
System(app, "Heart-E iOS App", "Blood pressure tracker.\nLogs readings with position,\narm, and pulse. AHA category\nclassification. Offline-first,\nSwiftData backed.")
System_Ext(healthkit, "Apple HealthKit", "On-device health data store.\nHeart-E reads and writes\nblood pressure correlations\nand heart rate samples.")
System_Ext(apns, "Apple Push Notification\nService (APNs)", "Delivers daily morning (08:00)\nand evening (20:00) reminders\nto log a BP reading.")
System_Ext(appstore, "Apple App Store", "App distribution.\nNo in-app purchases —\nHeart-E is currently free.")
System_Ext(cutie_e, "Cuti-E SDK", "Character-driven in-app\nfeedback and review prompts.\nOpt-in analytics consent\nafter onboarding.")
System_Ext(doctor, "Doctor / Patient", "Receives printed or\ndigital PDF reports\ngenerated by the app.")
Rel(user, app, "Logs BP readings,\nviews trends and\nstatistics")
Rel(app, healthkit, "Writes BP correlations\n+ heart rate samples.\nImports existing readings.", "HealthKit API")
Rel(app, apns, "Schedules daily\nBP reminders", "UserNotifications")
Rel(app, cutie_e, "Sends feedback,\nshows review prompt", "Cuti-E SDK")
Rel(user, doctor, "Shares PDF report\nvia export / print")
Rel(doctor, app, "May request\na date-range report")
UpdateRelStyle(user, app, $offsetX="-40")
C4 Level 2 — Containers¶
C4Container
title Heart-E — Containers
Person(user, "User", "iPhone running Heart-E")
Container_Boundary(ios, "iOS Application") {
Container(swiftui_views, "SwiftUI Views", "SwiftUI / iOS 17+", "UI layer:\nMainTabView (4 tabs),\nAddReadingView, HistoryView,\nStatsView, SettingsView,\nOnboardingView, ReportGeneratorView")
Container(app_state, "AppState", "ObservableObject\n@EnvironmentObject", "App-wide settings in UserDefaults:\npreferredUnit (mmHg/kPa),\nhealthKitEnabled, remindersEnabled,\ndefaultPosition, defaultArm,\nonboarding status, reminder times")
ContainerDb(swiftdata, "SwiftData Store", "SwiftData / SQLite\niOS 17+", "Single entity: BPReading\n(systolic, diastolic, pulse?,\ntimestamp, position, arm,\nnotes?, healthKitUUID?)\nAll values stored in mmHg.")
Container(healthkit_svc, "HealthKitService", "Swift singleton\n@MainActor", "Writes HKCorrelation(.bloodPressure)\n= systolic + diastolic samples.\nOptionally writes HKQuantity(.heartRate).\nImports HKCorrelations since date.")
Container(stats_svc, "StatisticsService", "Swift singleton\npure functions", "calculate(readings:) → BPStatistics\n(averages, min/max, category distribution)\nfilterReadings(_:period:) → [BPReading]\nPeriods: 7d / 30d / 90d / all")
Container(notification_svc, "NotificationService", "Swift singleton\nUNUserNotificationCenter", "Schedules 2 repeating daily reminders:\nheart-e-morning-reminder (default 08:00)\nheart-e-evening-reminder (default 20:00)\nUNCalendarNotificationTrigger, repeats: true")
Container(export_svc, "ExportService", "Swift singleton", "Exports readings to CSV.\nColumns: Date, Systolic, Diastolic,\nPulse, Category, Position, Arm, Notes.\nUnit-aware (mmHg or kPa).\nFile: heart-e-readings-<ISO8601>.csv")
Container(pdf_svc, "PDFReportService", "Swift singleton\nUIGraphicsPDFRenderer", "Generates multi-page US Letter PDF.\nSections: title, patient info,\nsummary statistics, readings table,\nmedical disclaimer.\nAuto-paginates at 60pt margin.")
Container(widget_provider_app, "WidgetDataProvider", "Swift singleton\nApp Group writer", "Writes 5 most recent readings\nas JSON to App Group container:\ngroup.no.invotek.HeartE/\nwidget-readings.json\nCalls WidgetCenter.reloadAllTimelines()")
Container(cutie_svc, "CutiEService", "Swift singleton\nCuti-E SDK wrapper", "Triggers analytics consent after\nonboarding. Manages in-app\nfeedback inbox sheet.")
}
Container_Boundary(widget_ext, "HeartEWidget Extension") {
Container(widget_provider_ext, "WidgetDataProvider", "Swift\n(read-only copy)", "Reads widget-readings.json\nfrom App Group container.\nDuplicated because widget\nextension cannot import main app.")
Container(widget_timeline, "HeartEWidgetProvider", "WidgetKit\nTimelineProvider", "getTimeline(): loads readings,\ncreates single entry,\nrefreshes after 30 minutes.")
Container(small_widget, "SmallWidgetView", "SwiftUI\n.systemSmall", "Latest reading:\nsystolic/diastolic,\ncategory colour badge")
Container(medium_widget, "MediumWidgetView", "SwiftUI\n.systemMedium", "Latest reading (large) +\nlast 3 readings list\nwith category colours")
}
System_Ext(healthkit, "Apple HealthKit", "On-device health store.\nHKCorrelation(.bloodPressure)")
System_Ext(apns, "APNs", "Push notifications")
System_Ext(cutie_e_sdk, "Cuti-E SDK", "github.com/cuti-e/ios-sdk")
Rel(user, swiftui_views, "Enters readings,\nviews history and stats", "Touch / SwiftUI")
Rel(swiftui_views, app_state, "Reads/writes\napp settings", "@EnvironmentObject")
Rel(swiftui_views, swiftdata, "Inserts, queries,\ndeletes readings", "@Query / modelContext")
Rel(swiftui_views, stats_svc, "Requests statistics\nand filtered readings", "Swift call")
Rel(swiftui_views, healthkit_svc, "Triggers import\nor writes new reading", "async/await")
Rel(swiftui_views, export_svc, "Requests CSV export", "Swift call")
Rel(swiftui_views, pdf_svc, "Requests PDF report", "Swift call")
Rel(swiftui_views, notification_svc, "Schedules/cancels\ndaily reminders", "Swift call")
Rel(swiftui_views, cutie_svc, "Shows feedback\ninbox sheet", "Swift call")
Rel(healthkit_svc, healthkit, "Reads/writes\nBP correlations\nand heart rate", "HealthKit API")
Rel(notification_svc, apns, "Registers local\npush notifications", "UserNotifications")
Rel(widget_provider_app, swiftdata, "Reads 5 latest\nreadings after save", "Swift call")
Rel(widget_provider_ext, widget_timeline, "Loads readings\nfor timeline entry", "Swift call")
Rel(widget_timeline, small_widget, "Renders small\nwidget view")
Rel(widget_timeline, medium_widget, "Renders medium\nwidget view")
Rel(cutie_svc, cutie_e_sdk, "Sends feedback events", "SDK")
UpdateLayoutConfig($c4ShapeInRow="4")
C4 Level 3 — Components (iOS App)¶
C4Component
title Component Diagram — Heart-E iOS App (Internal Components)
Container_Boundary(app_boundary, "Heart-E iOS Application (no.invotek.HeartE)") {
Component(add_reading_view, "AddReadingView\n(Views/AddReadingView.swift)", "SwiftUI Sheet", "Form to log a new BP reading.\nInputs: systolic, diastolic,\npulse (optional), timestamp,\nposition (sitting/standing/lying),\narm (left/right), notes.\nDefaults from AppState:\n defaultPosition, defaultArm.\nOn save:\n 1. modelContext.insert(BPReading)\n 2. If healthKitEnabled:\n HealthKitService.saveReading\n → stores returned UUID in\n reading.healthKitUUID\n 3. WidgetDataProvider.updateWidget\n (all readings, triggers reload)")
Component(history_view, "HistoryView\n(Views/HistoryView.swift)", "SwiftUI View\n@Query", "@Query(sort: timestamp, desc)\nDisplays BPReading list grouped\nby date sections.\nCategory colour badge per row\n(ReadingCategory.color).\nSwipe-to-delete: modelContext.delete\n+ WidgetDataProvider.updateWidget.\nNavigates to ReadingDetailView\nfor full edit.")
Component(stats_view, "StatsView\n(Views/StatsView.swift)", "SwiftUI View\n@Query", "Period picker: 7d/30d/90d/all.\nCalls StatisticsService.shared\n .filterReadings(_:period:) →\n .calculate(readings:) → BPStatistics\nDisplays:\n avg systolic/diastolic/pulse\n min/max systolic/diastolic\n categoryDistribution pie/bar chart\n (5 AHA categories with colours)")
Component(settings_view, "SettingsView\n(Views/Settings/SettingsView.swift)", "SwiftUI View", "Unit: mmHg / kPa toggle.\nDefault position + arm pickers.\nReminders: enable/disable toggle\n + morning time picker (default 08:00)\n + evening time picker (default 20:00)\nChanging time → NotificationService\n .scheduleReminders(morning, evening).\nHealthKit: enable toggle +\n import button (HealthKitService\n .importReadings(since:)).\nExport: CSV (ExportService)\n + PDF report (PDFReportService).\nCutiE feedback inbox.")
Component(app_state, "AppState\n(Models/AppState.swift)", "ObservableObject\nUserDefaults", "8 persisted settings:\n\npreferredUnit: BPUnit\n .mmHg | .kPa\n UserDefaults 'preferredUnit' (String)\n\ndefaultPosition: MeasurementPosition\n .sitting | .standing | .lying\n UserDefaults 'defaultPosition'\n\ndefaultArm: MeasurementArm\n .left | .right\n UserDefaults 'defaultArm'\n\nhealthKitEnabled: Bool\n UserDefaults 'healthKitEnabled'\n\nremindersEnabled: Bool\n UserDefaults 'remindersEnabled'\n\nmorningReminderTime: Date\n UserDefaults as timeIntervalSince1970\n Default: 08:00\n\neveningReminderTime: Date\n UserDefaults as timeIntervalSince1970\n Default: 20:00\n\nhasCompletedOnboarding: Bool\n UserDefaults 'hasCompletedOnboarding'")
Component(aha_engine, "AHA Classification Engine\n(Models/ReadingCategory.swift)", "Pure Swift enum\nNo dependencies", "ReadingCategory.classify(\n systolic: Int, diastolic: Int\n) → ReadingCategory\n\nRules (highest severity wins,\nchecked in order):\n\nCrisis: SYS > 180 OR DIA > 120\nStage 2: SYS >= 140 OR DIA >= 90\nStage 1: SYS >= 130 OR DIA >= 80\nElevated: SYS >= 120 (AND DIA < 80)\nNormal: SYS < 120 AND DIA < 80\n\nPer AHA 2017 guidelines.\nOR logic at Crisis/Stage2/Stage1\n(worst of the two measures wins).\nElevated requires systolic 120–129\nAND diastolic below 80.\n\nEach case also provides:\n color: SwiftUI Color (green→dark red)\n textColor: .primary or .white\n systemImage: SF Symbol name\n advice: String for user display\n\nBPReading.category is a computed\nproperty that calls this engine.")
Component(statistics_svc, "StatisticsService\n(Services/StatisticsService.swift)", "Singleton\nPure computation", "calculate(readings: [BPReading])\n → BPStatistics?\n Guard: not empty\n avgSystolic = sum / count\n avgDiastolic = sum / count\n avgPulse = sum / non-nil count\n (nil if no pulse readings)\n min/max systolic and diastolic\n categoryDistribution:\n [ReadingCategory: Int]\n Initialised to 0 for all 5 cases\n Incremented per reading.category\n\nfilterReadings(_: period: StatsPeriod)\n → [BPReading]\n .week: now - 7 days\n .month: now - 1 month\n .threeMonths: now - 3 months\n .all: unfiltered")
Component(healthkit_svc, "HealthKitService\n(Services/HealthKitService.swift)", "ObservableObject\nHKHealthStore", "Share types requested:\n bloodPressureSystolic\n bloodPressureDiastolic\n heartRate\n bloodPressureCorrelation\nRead types: same set.\n\nsaveReading(reading: BPReading)\n → String? (HK UUID)\n Creates HKQuantitySample (mmHg)\n for systolic and diastolic.\n Wraps in HKCorrelation(.bloodPressure)\n Saves correlation to HKHealthStore.\n If pulse != nil: separately saves\n HKQuantitySample(.heartRate, bpm).\n Returns correlation.uuid.uuidString\n (stored in reading.healthKitUUID).\n\nimportReadings(since startDate)\n → [BPReading] (3-month default)\n HKSampleQuery on bloodPressure\n correlation type.\n Extracts systolic + diastolic\n HKQuantitySamples from each\n HKCorrelation.\n Maps to BPReading with\n healthKitUUID = correlation UUID.")
Component(notification_svc, "NotificationService\n(Services/NotificationService.swift)", "Singleton\nUNUserNotificationCenter", "requestPermission() → Bool\n .alert + .sound + .badge\n\nscheduleReminders(morning: Date,\n evening: Date)\n cancelAllReminders() first\n scheduleDailyReminder x2:\n Identifiers:\n 'heart-e-morning-reminder'\n 'heart-e-evening-reminder'\n Content:\n Morning: 'Morning BP Check'\n Evening: 'Evening BP Check'\n Trigger: UNCalendarNotification-\n Trigger(dateComponents: [.hour,\n .minute], repeats: true)\n\ncancelAllReminders()\n Removes both identifiers")
Component(widget_data_provider, "WidgetDataProvider\n(Services/WidgetDataProvider.swift)", "Singleton\nApp Group File Writer", "App Group: group.no.invotek.HeartE\nFile: widget-readings.json\n\nWidgetReading struct (Codable):\n systolic: Int\n diastolic: Int\n pulse: Int?\n timestamp: Date (ISO8601)\n category: String (rawValue)\n\nupdateWidget(readings: [BPReading])\n Sort descending by timestamp\n Take prefix(5)\n Map to [WidgetReading]\n Encode with JSONEncoder\n (dateEncodingStrategy: .iso8601)\n Write to App Group container URL\n appendingPathComponent(fileName)\n WidgetCenter.shared.reloadAllTimelines()\n\nloadReadings() → [WidgetReading]\n Read file from App Group container\n Decode with JSONDecoder\n (dateDecodingStrategy: .iso8601)\n Returns [] on missing file or error")
Component(export_svc, "ExportService\n(Services/ExportService.swift)", "Singleton", "exportToCSV(readings, unit) → URL?\n Header: Date, Systolic(unit),\n Diastolic(unit), Pulse, Category,\n Position, Arm, Notes\n Sorted descending by timestamp\n Date/time: medium/short locale format\n Unit-aware formatting via BPUnit:\n .mmHg: value as Int string\n .kPa: convert from mmHg\n Notes: comma → semicolons\n File: 'heart-e-readings-<ISO8601>.csv'\n Written to tmp directory")
Component(pdf_svc, "PDFReportService\n(Services/PDFReportService.swift)", "Singleton\nUIGraphicsPDFRenderer", "generateReport(readings, patientName,\n startDate, endDate, unit) → URL?\n\nPage: US Letter 612×792pt, 50pt margin.\nSections drawn in order:\n 1. Title: 'Blood Pressure Report'\n 2. Patient info: name, date range\n 3. Summary statistics block:\n StatisticsService.calculate\n on date-filtered + sorted readings\n avg systolic/diastolic/pulse,\n min/max systolic/diastolic\n 4. Readings table:\n Date | SYS | DIA | Pulse |\n Category | Position | Arm\n 5. Medical disclaimer footer\nAuto-paginates: new context.beginPage\nwhen y > pageHeight - margin.\nMetadata: creator=Heart-E,\n author=patientName, title=report")
Component(cutie_svc, "CutiEService\n(Services/CutiEService.swift)", "Singleton\nCutiE SDK", "Initialises SDK with App ID.\nAnalytics consent prompt.\npresentInboxSheet() for SettingsView.")
Component(swiftdata_store, "SwiftData Store\n(Models/BPReading.swift)", "SwiftData @Model\nSQLite / iOS 17+", "BPReading entity:\n systolic: Int (mmHg)\n diastolic: Int (mmHg)\n pulse: Int? (bpm, optional)\n timestamp: Date\n position: MeasurementPosition\n .sitting | .standing | .lying\n arm: MeasurementArm\n .left | .right\n notes: String? (optional)\n healthKitUUID: String?\n (nil until written to HealthKit)\n\nComputed:\n category: ReadingCategory\n → ReadingCategory.classify(\n systolic:, diastolic:)\n\nAll values stored in mmHg.\nUnit conversion happens in\nExportService + PDFReportService\nvia BPUnit parameter.")
}
Container_Boundary(widget_boundary, "HeartEWidget Extension") {
Component(widget_reader_ext, "WidgetDataProvider\n(WidgetDataProvider.swift)", "Read-only\nApp Group File Reader", "loadReadings() → [WidgetReading]\n Reads widget-readings.json from\n group.no.invotek.HeartE container\n ISO8601 date decoding\n Returns [] on missing file / error\n\n(Write path only in main app.\nDuplicated — extension cannot\nimport main app module.)")
Component(widget_timeline, "HeartEWidgetProvider\n(HeartEWidget.swift)", "TimelineProvider\nWidgetKit", "getTimeline()\n WidgetDataProvider.shared.loadReadings()\n Creates single HeartEWidgetEntry\n { date: now, readings: [WidgetReading] }\n Refresh: .after(now + 30min)\n\ngetSnapshot() → same entry\n (for widget gallery preview)")
Component(small_widget_view, "SmallWidgetView\n(Views/SmallWidgetView.swift)", "SwiftUI\n.systemSmall", "Latest reading (readings.first):\n systolic / diastolic large text\n category colour badge\n timestamp (relative)")
Component(medium_widget_view, "MediumWidgetView\n(Views/MediumWidgetView.swift)", "SwiftUI\n.systemMedium", "Latest reading (large, left)\n+ last 3 readings list (right)\nEach row: SYS/DIA + category dot\n+ relative timestamp")
}
System_Ext(healthkit_ext, "Apple HealthKit", "HKCorrelation + HKQuantitySample")
System_Ext(apns_ext, "Apple APNs", "Local notifications")
Rel(add_reading_view, swiftdata_store, "modelContext.insert(BPReading)\nwidget sync after save")
Rel(add_reading_view, app_state, "Reads defaultPosition,\ndefaultArm, healthKitEnabled")
Rel(add_reading_view, healthkit_svc, "saveReading(reading)\n→ stores UUID in reading")
Rel(add_reading_view, widget_data_provider, "updateWidget(all readings)\nafter every save")
Rel(history_view, swiftdata_store, "@Query(sort: timestamp, desc)")
Rel(history_view, widget_data_provider, "updateWidget after delete")
Rel(history_view, aha_engine, "Reads reading.category\n(computed via engine)")
Rel(stats_view, swiftdata_store, "@Query all readings")
Rel(stats_view, statistics_svc, "filterReadings(period:)\nthen calculate(readings:)")
Rel(stats_view, aha_engine, "categoryDistribution display")
Rel(settings_view, app_state, "Mutates all settings")
Rel(settings_view, notification_svc, "scheduleReminders when\ntimes change")
Rel(settings_view, healthkit_svc, "requestAuthorization\nimportReadings(since:)")
Rel(settings_view, export_svc, "exportToCSV → ShareLink")
Rel(settings_view, pdf_svc, "generateReport → ShareLink")
Rel(settings_view, cutie_svc, "presentInboxSheet()")
Rel(swiftdata_store, aha_engine, "reading.category\ncalls classify()")
Rel(healthkit_svc, healthkit_ext, "HKCorrelation write\nHKSampleQuery read", "HealthKit API")
Rel(notification_svc, apns_ext, "UNCalendarNotificationTrigger\nrepeating (hour+minute)", "UserNotifications")
Rel(pdf_svc, statistics_svc, "calculate(readings:)\nfor summary section")
Rel(widget_data_provider, widget_reader_ext, "widget-readings.json\nin App Group container", "group.no.invotek.HeartE")
Rel(widget_reader_ext, widget_timeline, "loadReadings()")
Rel(widget_timeline, small_widget_view, "Renders with entry.readings")
Rel(widget_timeline, medium_widget_view, "Renders with entry.readings")
Pattern: MVVM with Service Layer¶
Views (SwiftUI)
│ @Query / @Environment(\.modelContext)
▼
SwiftData (BPReading)
│
├── HealthKitService → HealthKit (read + write)
├── StatisticsService → computed BPStatistics
├── NotificationService → UNUserNotificationCenter
├── ExportService → CSV file (temp directory)
├── PDFReportService → PDF file (temp directory)
├── WidgetDataProvider → App Group shared container
└── CutiEService → Cutie-E review/feedback SDK
| Layer | Implementation | Responsibility |
|---|---|---|
| Views | SwiftUI with @Query |
UI rendering, user input |
| Services | Singleton classes | Business logic, I/O side effects |
| Persistence | SwiftData (@Model) |
Local storage of all readings |
| App Group | group.no.invotek.HeartE |
Widget data sharing |
Views query SwiftData directly with @Query; they do not go through a view model object. Services are singletons injected via .environmentObject or accessed as Service.shared.
Data Models¶
BPReading (SwiftData @Model)¶
The only persisted entity. All properties stored as-is in mmHg.
| Property | Type | Notes |
|---|---|---|
systolic |
Int |
Systolic pressure in mmHg |
diastolic |
Int |
Diastolic pressure in mmHg |
pulse |
Int? |
Optional heart rate (bpm) |
timestamp |
Date |
When the reading was taken |
position |
MeasurementPosition |
.sitting / .standing / .lying |
arm |
MeasurementArm |
.left / .right |
notes |
String? |
Free-text user notes |
healthKitUUID |
String? |
UUID of the synced HKCorrelation; nil if not synced |
Computed property: category: ReadingCategory — calls ReadingCategory.classify(systolic:diastolic:) on every access.
ReadingCategory (enum)¶
AHA blood pressure classification. Stored as String (raw value) in WidgetReading. Classification uses "highest severity wins" — if systolic and diastolic fall in different categories, the higher category is returned.
| Case | Raw Value | Systolic | Diastolic | Color |
|---|---|---|---|---|
.normal |
"Normal" |
< 120 | AND < 80 | .green |
.elevated |
"Elevated" |
120–129 | AND < 80 | .yellow |
.highStage1 |
"High Stage 1" |
130–139 | OR 80–89 | .orange |
.highStage2 |
"High Stage 2" |
≥ 140 | OR ≥ 90 | .red |
.crisis |
"Hypertensive Crisis" |
> 180 | OR > 120 | dark red (0.55, 0, 0) |
Each case also exposes systemImage: String, textColor: Color, and advice: String (user-facing guidance).
BPUnit (enum)¶
Display unit preference. All readings are stored in mmHg; conversion is display-only.
| Case | Conversion |
|---|---|
.mmHg |
Identity (no conversion) |
.kPa |
multiply mmHg × 0.133322 |
format(_ mmHgValue: Int) -> String applies the conversion and returns a formatted display string ("1.2f" for kPa, plain integer for mmHg).
MeasurementPosition (enum)¶
.sitting / .standing / .lying. Each case has systemImage: String for SF Symbols.
MeasurementArm (enum)¶
.left / .right. rawValue is the display label.
AppState (ObservableObject)¶
App-wide settings persisted in UserDefaults. Injected as @EnvironmentObject.
| Property | Type | Default | Key |
|---|---|---|---|
hasCompletedOnboarding |
Bool |
false |
"hasCompletedOnboarding" |
preferredUnit |
BPUnit |
.mmHg |
"preferredUnit" |
defaultPosition |
MeasurementPosition |
.sitting |
"defaultPosition" |
defaultArm |
MeasurementArm |
.left |
"defaultArm" |
healthKitEnabled |
Bool |
false |
"healthKitEnabled" |
remindersEnabled |
Bool |
false |
"remindersEnabled" |
morningReminderTime |
Date |
08:00 | "morningReminderTime" |
eveningReminderTime |
Date |
20:00 | "eveningReminderTime" |
Views¶
Navigation¶
ContentView is the root. It shows OnboardingView until AppState.hasCompletedOnboarding becomes true, then switches to MainTabView.
MainTabView contains four tabs:
| Tag | Tab | Root View | Purpose |
|---|---|---|---|
| 0 | Log | AddReadingView |
Enter a new BP reading |
| 1 | History | HistoryView |
Scrollable list of past readings |
| 2 | Stats | StatsView |
Trend chart + statistics |
| 3 | Settings | SettingsView |
Preferences, HealthKit, reminders, export |
Key Views¶
AddReadingView—Formwith steppers for systolic, diastolic, optional pulse, position picker, arm picker, notes field, timestamp picker. Shows a liveCategoryBadgethat updates as values change.HistoryView—@Query-driven list sorted by timestamp descending. Tapping a row opensReadingDetailView.ReadingDetailView— Read-only detail with edit/delete actions.StatsView—StatsPeriodselector (7/30/90/all days),BPTrendChartView(Swift Charts), and aBPStatisticssummary card.BPTrendChartView— Line chart of systolic and diastolic over time, coloured by category.SettingsView— Unit picker, HealthKit toggle →HealthKitSettingsView, reminder toggles, reminder time pickers, export/report buttons →ReportGeneratorView.HealthKitSettingsView— Authorization status and manual import trigger.ReportGeneratorView— Patient name field, date range pickers, CSV and PDF export buttons.CategoryBadge— Reusable view showing category colour, icon, label, and advice text.OnboardingView— First-launch flow that setshasCompletedOnboarding.FeedbackToolbarButtons— Toolbar buttons for Cutie-E feedback/inbox.
Services¶
HealthKitService¶
@MainActor singleton. Optional — only active when AppState.healthKitEnabled is true and the user has granted permission.
Permissions requested (share + read):
- HKQuantityType(.bloodPressureSystolic)
- HKQuantityType(.bloodPressureDiastolic)
- HKQuantityType(.heartRate)
- HKCorrelationType(.bloodPressure)
Write flow (saveReading(_:)):
1. Create HKQuantitySample for systolic (mmHg) at reading.timestamp.
2. Create HKQuantitySample for diastolic (mmHg) at reading.timestamp.
3. Bundle both into an HKCorrelation(.bloodPressure).
4. Save correlation to HKHealthStore. Returns the correlation's UUID string, stored in BPReading.healthKitUUID.
5. If reading.pulse != nil, save a separate HKQuantitySample(.heartRate) in count/min.
Import flow (importReadings(since:)):
- Fetches HKCorrelation(.bloodPressure) samples since the given date (default: last 3 months).
- Extracts systolic and diastolic from each correlation, converts to Int mmHg, returns [BPReading] with healthKitUUID populated.
- Pulse is not imported (heart rate samples are independent objects in HealthKit).
StatisticsService¶
Pure-function singleton. No state.
calculate(readings:) -> BPStatistics?— returnsnilfor empty input; otherwise computes averages, min/max, and category distribution.filterReadings(_:period:) -> [BPReading]— filters a reading array to the givenStatsPeriod.
BPStatistics struct fields:
count, avgSystolic, avgDiastolic, avgPulse?, minSystolic, maxSystolic, minDiastolic, maxDiastolic, categoryDistribution: [ReadingCategory: Int].
StatsPeriod enum: .week (7 days), .month (30 days), .threeMonths (90 days), .all.
NotificationService¶
Singleton. Manages two fixed daily reminders via UNUserNotificationCenter.
| Identifier | Default time | Title |
|---|---|---|
heart-e-morning-reminder |
08:00 | "Morning BP Check" |
heart-e-evening-reminder |
20:00 | "Evening BP Check" |
Reminders use UNCalendarNotificationTrigger with repeats: true — they fire every day at the configured hour/minute. scheduleReminders(morning:evening:) cancels all pending requests before rescheduling. Permission is requested with .alert, .sound, .badge.
ExportService¶
Singleton. Exports readings as a UTF-8 CSV file to the system temporary directory.
CSV columns: Date, Systolic (unit), Diastolic (unit), Pulse, Category, Position, Arm, Notes.
- Readings are sorted newest-first.
- Values are converted via
BPUnit.format()so the file reflects the user's display preference. - Commas in the
Notesfield are escaped as semicolons. - File name:
heart-e-readings-<ISO8601>.csv.
PDFReportService¶
Singleton. Generates a multi-page US Letter PDF (612 × 792 pt) using UIGraphicsPDFRenderer.
Report structure: 1. Title: "Blood Pressure Report" 2. Patient name, date range, generation date. 3. Summary statistics (count, averages, systolic/diastolic range, optional average pulse). 4. Readings table (columns: Date, Sys, Dia, Pulse, Category, Position, Arm) — new page auto-inserted at 60 pt from bottom. 5. Medical disclaimer on each final page.
PDF metadata includes creator (Heart-E), author (patient name), and title. File name: HeartE-Report-<ISO8601>.pdf.
WidgetDataProvider (app side)¶
Writes data to the App Group container after every new reading. Stores the 5 most recent readings as a JSON array at group.no.invotek.HeartE/widget-readings.json. Calls WidgetCenter.shared.reloadAllTimelines() after writing.
WidgetReading Codable struct: systolic, diastolic, pulse?, timestamp (ISO8601), category (raw string).
CutiEService¶
Thin wrapper around the Cutie-E SDK (CutiE). Triggers analytics consent prompt after onboarding and manages the in-app feedback inbox sheet.
Widget¶
Target: HeartEWidget (WidgetKit extension).
HeartEWidgetBundle
└── HeartEWidget (TimelineProvider)
├── SmallWidgetView — latest reading + category colour
└── MediumWidgetView — latest reading + last 3 readings list
The widget extension contains its own copy of WidgetDataProvider (read-only) and a mirror of ReadingCategory (including color). These are duplicated because the widget extension cannot import the main app target.
Data flow:
Main app saves BPReading
→ WidgetDataProvider.updateWidget(readings:)
→ encodes [WidgetReading] as JSON (ISO8601 dates)
→ writes to App Group container: group.no.invotek.HeartE/widget-readings.json
→ WidgetCenter.shared.reloadAllTimelines()
→ HeartEWidget.getTimeline() calls WidgetDataProvider.loadReadings()
→ SmallWidgetView / MediumWidgetView render
Key Design Decisions¶
| Decision | Rationale |
|---|---|
| SwiftData over Core Data | iOS 17+ only; simpler @Query-driven views, no NSFetchedResultsController |
| No backend | Zero running costs; all data stays on device |
| mmHg as canonical storage unit | Medical standard; kPa is display-only via BPUnit.format() |
| "Highest severity wins" classification | Matches AHA guidelines; a reading where diastolic crosses into Stage 1 while systolic is still Normal is correctly flagged |
| Optional HealthKit | Not all users want health data sharing; explicit opt-in in Settings |
| PDF over plain text for reports | Doctors prefer printable summaries; UIGraphicsPDFRenderer handles multi-page layout without third-party deps |
Duplicate ReadingCategory in widget |
WidgetKit extensions cannot import the main app target; the enum is small and stable enough to maintain in two places |
| App Group JSON file for widget | Simpler than CoreData sharing; the widget only needs the 5 latest readings, well within a single JSON file |
Principles¶
- Offline-First — Everything works without a network connection.
- Privacy — No data leaves the device except via explicit user export or Cutie-E analytics (opt-in).
- KISS — Views query SwiftData directly; no intermediate repository layer.
- Medical disclaimer everywhere — PDF reports carry a disclaimer; the app does not position itself as a medical device.