Architecture¶
C4 Diagrams¶
Level 1 — System Context¶
C4Context
title System Context Diagram — Drink-E
Person(user, "User", "Tracks daily water intake and hydration goals")
System(drinkE, "Drink-E", "iOS hydration tracking app with SwiftUI and SwiftData")
System_Ext(notifications, "iOS Notifications", "Local notification scheduling via UNUserNotificationCenter")
System_Ext(widget, "iOS Widget", "Home screen widget showing hydration progress")
System_Ext(cutie, "CutiE SDK", "In-app feedback and analytics platform")
Rel(user, drinkE, "Logs water intake, sets goals, views history")
Rel(drinkE, notifications, "Schedules hydration reminders")
Rel(drinkE, widget, "Shares progress data via App Groups")
Rel(drinkE, cutie, "Sends feedback and analytics")
Rel(notifications, user, "Delivers reminder notifications")
Rel(widget, user, "Displays progress on Home Screen")
Level 2 — Containers¶
C4Container
title Container Diagram — Drink-E iOS App
Person(user, "User", "Tracks daily hydration")
Container_Boundary(app, "Drink-E iOS App") {
Container(views, "SwiftUI Views", "SwiftUI", "HomeView, HistoryView, SettingsView, OnboardingView with ProgressRingView and CelebrationView")
Container(appState, "AppState", "ObservableObject", "Global state: daily goal, unit preference, reminders, onboarding flag. Persists to UserDefaults")
Container(swiftData, "SwiftData Store", "ModelContainer", "Persists WaterEntry records with amount, beverage type, timestamp")
Container(notifService, "NotificationService", "UNUserNotificationCenter", "Schedules repeating hydration reminders during 08:00-22:00")
Container(widgetProvider, "WidgetDataProvider", "App Groups", "Bridges app data to widget via shared UserDefaults suite")
Container(csvService, "CSVExportService", "Swift", "Exports water entries as timestamped CSV files")
Container(cutieService, "CutiEService", "CutiE SDK", "In-app feedback, analytics consent, push notification routing")
}
Container_Boundary(widgetExt, "Widget Extension") {
Container(widget, "DrinkEWidget", "WidgetKit", "Small and medium widgets displaying hydration progress, streak, and goal")
}
Rel(user, views, "Interacts with")
Rel(views, appState, "Reads preferences, triggers calculations")
Rel(views, swiftData, "Queries and inserts WaterEntry via @Query and modelContext")
Rel(appState, notifService, "Triggers reminder updates on interval change")
Rel(views, widgetProvider, "Updates widget data after each entry")
Rel(widgetProvider, widget, "Writes WidgetData JSON to shared UserDefaults")
Rel(views, csvService, "Triggers CSV export from SettingsView")
Rel(views, cutieService, "Presents feedback UI, reports app metadata")
Rel(widget, user, "Displays progress on Home Screen")
Level 3 — Components (iOS App)¶
C4Component
title Component Diagram — Drink-E iOS App (Internal Components)
Container_Boundary(app_boundary, "Drink-E iOS Application (no.invotek.DrinkE)") {
Component(home_view, "HomeView\n(Views/Home/HomeView.swift)", "SwiftUI View\n@EnvironmentObject\n@Query", "Primary screen.\n@Query(sort: timestamp, desc) → allEntries\nComputed today filter: calendar.isDateInToday\ntodayTotal = sum of today's amounts\nprogress = todayTotal / dailyGoal (0–1)\nstreak = appState.calculateStreak(allEntries)\nProgressRingView showing live progress %.\nQuickAddButton row: 250ml, 350ml, 500ml\nwith BeverageType picker.\nCustom entry sheet: free-form ml input.\nGoal-reached CelebrationView (confetti)\nfired once per day (celebratedToday guard).\nWidget sync via WidgetDataProvider.shared\n.updateWidgetData after every entry add/delete.")
Component(history_view, "HistoryView\n(Views/History/HistoryView.swift)", "SwiftUI View\n@Query", "@Query(sort: timestamp, desc)\nGroups entries by calendar day section.\nDisplays beverage icon + displayAmount\n(unit-aware, via appState.displayAmount).\nSwipe-to-delete via modelContext.delete.\nCSV export via CSVExportService.exportURL\npresented through ShareLink / UIActivityVC.")
Component(settings_view, "SettingsView\n(Views/Settings/SettingsView.swift)", "SwiftUI View", "Daily goal picker (ml or fl oz).\nUnit toggle: ml ↔ fl oz (UnitPreference).\nReminder interval picker:\n Off / Every 1h / Every 2h / Every 3h.\nChanging reminderInterval triggers\n AppState.didSet → NotificationService\n .updateReminders(interval:) immediately.\nNotification permission status indicator.\nCutiE feedback inbox sheet.\nOnboarding reset for testing.")
Component(onboarding_view, "OnboardingView\n(Views/Onboarding/OnboardingView.swift)", "SwiftUI View", "First-launch walkthrough.\nSets initial dailyGoal (default 2000ml).\nRequests notification permission via\nNotificationService.requestPermission.\nSets appState.hasCompletedOnboarding = true.")
Component(progress_ring, "ProgressRingView\n(Views/Components/ProgressRingView.swift)", "SwiftUI View\nReusable Component", "Animated circular progress arc\n(0.0–1.0).\nColour transitions:\n blue (0–79%), green (80–99%), gold (100%).\nUsed in HomeView and widget views.")
Component(quick_add, "QuickAddButton\n(Views/Components/QuickAddButton.swift)", "SwiftUI View\nReusable Component", "Preset amount buttons on HomeView.\nAmounts: 250ml, 350ml, 500ml.\nBeverage type: water, tea, coffee, juice.\nTap inserts WaterEntry via modelContext.")
Component(celebration, "CelebrationView\n(Views/Components/CelebrationView.swift)", "SwiftUI View\nOverlay", "Confetti particle animation overlaid\non HomeView when todayTotal first\nreaches dailyGoal.\ncelebratedToday flag prevents repeat\ntriggers within same calendar day.")
Component(app_state, "AppState\n(Models/AppState.swift)", "@MainActor ObservableObject\nUserDefaults", "dailyGoal: Int\n Default 2000ml if not set.\n UserDefaults 'dailyGoal' (Int)\n\nunit: UnitPreference (.ml | .floz)\n JSON-encoded in UserDefaults 'unit'\n\nreminderInterval: ReminderInterval\n (.off | .oneHour | .twoHours | .threeHours)\n JSON-encoded in UserDefaults\n 'reminderInterval'\n didSet → NotificationService\n .updateReminders(interval:)\n\nhasCompletedOnboarding: Bool\n UserDefaults 'hasCompletedOnboarding'\n\ntodayTotal(entries) → Int\n Filters to today, sums .amount\n\ntodayProgress(entries) → Double\n todayTotal / dailyGoal, capped 1.0\n\ncalculateStreak(entries) → Int\n Check if today's total >= dailyGoal\n If yes: streak starts at 1, walk back\n If no: start from yesterday\n Walk backward calendar days while\n daily total >= dailyGoal; break on gap\n\ndisplayAmount(ml: Int) → String\n ml: '\(ml) ml'\n floz: '\(ml/29.5735) fl oz' (1 dp)\n\nconvertedAmount(ml) → Double\n Numeric value in current unit\n\ntoMl(value: Double) → Int\n Reverse: floz * 29.5735")
Component(notification_svc, "NotificationService\n(Services/NotificationService.swift)", "Singleton\nUNUserNotificationCenter", "requestPermission() → Bool\n Requests .alert + .sound + .badge\n\ncheckPermissionStatus() → UNAuthorizationStatus\n\nupdateReminders(interval: ReminderInterval)\n 1. cancelAllReminders()\n 2. Guard: intervalSeconds != nil\n 3. Request permission if not yet granted\n 4. scheduleRepeatingReminders:\n Waking hours window: 08:00–22:00\n At each hourly slot within interval:\n UNCalendarNotificationTrigger\n (dateComponents with hour, repeats:true)\n Identifier: 'hydration-{hour}'\n Rotates through 4 body messages\n Intervals: 1h (slots: 8,9,10...21),\n 2h (slots: 8,10,12...20),\n 3h (slots: 8,11,14,17,20)\n\ncancelAllReminders()\n getPendingNotificationRequests\n Removes all with prefix 'hydration-'")
Component(widget_provider, "WidgetDataProvider\n(Services/WidgetDataProvider.swift)", "Singleton\nApp Group UserDefaults Writer", "Suite: group.no.invotek.DrinkE\nKey: 'widgetData'\n\nWidgetData struct (Codable):\n todayTotal: Int (ml)\n dailyGoal: Int (ml)\n lastEntryTime: Date?\n streak: Int\n\nupdateWidgetData(todayTotal, dailyGoal,\n lastEntryTime, streak)\n Encodes WidgetData as JSON\n Writes to App Group UserDefaults\n WidgetCenter.shared.reloadAllTimelines()\n\nreadWidgetData() → WidgetData\n Fallback: {0ml, 2000ml, nil, 0}\n (main app also reads for display)")
Component(csv_export, "CSVExportService\n(Services/CSVExportService.swift)", "Static struct", "generateCSV(entries, unit) → String\n Header: Date, Time, Amount(unit),\n Beverage, Note\n Sorted ascending by timestamp\n Date: yyyy-MM-dd, Time: HH:mm\n Amount: ml as Int or floz as 1dp string\n Note: commas replaced with semicolons\n\nexportURL(entries, unit) → URL?\n Writes CSV to tmp dir as\n 'drink-e-export-yyyy-MM-dd.csv'\n Returns URL for ShareLink")
Component(cutie_svc, "CutiEService\n(Services/CutiEService.swift)", "Singleton\nCutiE SDK wrapper", "Initialises CutiE SDK with App ID.\nPresentInboxSheet() triggered from\nSettingsView for in-app feedback.")
Component(swiftdata_store, "SwiftData Store\n(Models/WaterEntry.swift)", "SwiftData / SQLite\niOS 17+", "WaterEntry @Model entity:\n id: UUID (generated on init)\n amount: Int (always stored in ml)\n beverageType: String (rawValue)\n → BeverageType enum:\n water / tea / coffee / juice\n Each has displayName, SF icon,\n and SwiftUI Color\n timestamp: Date (default: .now)\n note: String? (optional)\n\nbeverageTypeEnum: BeverageType\n Computed: BeverageType(rawValue:) ?? .water\n\nAll display conversions (ml→floz)\nhappen in AppState/CSVExportService,\nnot in the model layer.")
}
Container_Boundary(widget_boundary, "DrinkEWidget Extension") {
Component(widget_provider_ext, "WidgetDataProvider\n(WidgetDataProvider.swift)", "Read-only copy\nApp Group UserDefaults Reader", "Reads 'widgetData' from\ngroup.no.invotek.DrinkE.\nDoc comment confirms it mirrors\nmain app's provider.\nFallback: {0ml, 2000ml, nil, 0}")
Component(widget_timeline, "DrinkEProvider\n(DrinkEWidget.swift)", "TimelineProvider\nWidgetKit", "getTimeline()\n Reads WidgetData via WidgetDataProvider\n Creates DrinkEEntry:\n progress = todayTotal / dailyGoal\n (0–1, computed property on entry)\n Refresh policy: .after(now + 900s)\n (15-minute fixed refresh — no\n per-minute countdown needed)")
Component(small_widget, "SmallWidgetView\n(Views/SmallWidgetView.swift)", "SwiftUI\n.systemSmall", "ProgressRingView (progress %)\ntoday total in current unit\n'of goal' label\nStreak badge if streak > 0")
Component(medium_widget, "MediumWidgetView\n(Views/MediumWidgetView.swift)", "SwiftUI\n.systemMedium", "Ring (left) + text column (right):\n today total, goal, progress %,\n streak, last entry time.\nBeverage breakdown chart (if space).")
}
System_Ext(apns_ext, "Apple APNs", "Local notifications")
Rel(home_view, app_state, "dailyGoal, unit, calculateStreak\ndisplayAmount, displayGoal\n@EnvironmentObject")
Rel(home_view, swiftdata_store, "Insert WaterEntry\n(modelContext.insert)\nDelete WaterEntry\n(modelContext.delete)\n@Query read")
Rel(home_view, widget_provider, "updateWidgetData after\neach add/delete")
Rel(home_view, progress_ring, "Renders with progress")
Rel(home_view, quick_add, "Embeds for preset adds")
Rel(home_view, celebration, "Overlays on goal reached")
Rel(history_view, swiftdata_store, "@Query read")
Rel(history_view, app_state, "displayAmount (unit conversion)")
Rel(history_view, csv_export, "exportURL(entries, unit)")
Rel(settings_view, app_state, "Mutates dailyGoal, unit,\nreminderInterval, onboarding")
Rel(settings_view, notification_svc, "Indirect: AppState.didSet\ntriggers updateReminders")
Rel(settings_view, cutie_svc, "presentInboxSheet()")
Rel(onboarding_view, app_state, "Sets dailyGoal,\nhasCompletedOnboarding")
Rel(onboarding_view, notification_svc, "requestPermission()")
Rel(app_state, notification_svc, "reminderInterval.didSet\n→ updateReminders(interval:)")
Rel(notification_svc, apns_ext, "UNCalendarNotificationTrigger\nrepeating daily by hour slot", "UserNotifications")
Rel(widget_provider, widget_provider_ext, "JSON in App Group\nUserDefaults", "group.no.invotek.DrinkE")
Rel(widget_provider_ext, widget_timeline, "readWidgetData()")
Rel(widget_timeline, small_widget, "Renders")
Rel(widget_timeline, medium_widget, "Renders")
Pattern¶
MVVM with a service layer, built on SwiftUI and SwiftData. Requires iOS 17+ (SwiftData dependency). Fully local-first — no backend, no Firebase, no network calls except the CutiE SDK for in-app feedback.
DrinkEApp (entry point)
├── ModelContainer (SwiftData — WaterEntry)
├── AppState (ObservableObject — goal, unit, reminders, onboarding)
└── Services
├── NotificationService (hydration reminders)
├── WidgetDataProvider (App Groups bridge)
├── CutiEService (feedback SDK)
└── CSVExportService (data export)
Data Flow¶
DrinkEAppcreates aModelContainerforWaterEntryand injects it via.modelContainer(). In UI testing mode, uses an in-memory store.AppStateis injected as@EnvironmentObjectinto the view hierarchy viaContentView.- Views use
@Queryfor read access toWaterEntryand callmodelContext.insert()for writes. AppStatepersists user preferences (goal, unit, reminders, onboarding) toUserDefaultsviadidSetobservers.- Widget reads shared state from App Groups (
UserDefaultssuitegroup.no.invotek.DrinkE), written byWidgetDataProvider.
Models¶
WaterEntry (@Model)¶
The core persisted entity, stored in SwiftData.
| Property | Type | Purpose |
|---|---|---|
id |
UUID |
Unique identifier |
amount |
Int |
Intake amount in ml (always stored as ml) |
beverageType |
String |
Raw value of BeverageType enum |
timestamp |
Date |
When the entry was logged |
note |
String? |
Optional user note |
Computed property: beverageTypeEnum converts the stored string back to BeverageType (falls back to .water).
BeverageType (enum)¶
Supported beverage types: water, tea, coffee, juice. Each case provides displayName, icon (SF Symbol), and color.
UnitPreference (enum, Codable)¶
Display unit selection: ml or floz. Persisted to UserDefaults via AppState. All internal storage remains in ml; conversion happens at display time using the factor 29.5735 ml/fl oz.
ReminderInterval (enum, Codable)¶
Hydration reminder frequency: off, 1h, 2h, 3h. Changing this triggers NotificationService.updateReminders().
AppState (ObservableObject)¶
Global app state managing user preferences. All properties persist to UserDefaults via didSet.
| Property | Type | Default | Purpose |
|---|---|---|---|
dailyGoal |
Int |
2000 |
Target intake in ml |
unit |
UnitPreference |
.ml |
Display unit |
hasCompletedOnboarding |
Bool |
false |
Onboarding gate |
reminderInterval |
ReminderInterval |
.off |
Notification frequency |
Provides calculation methods:
todayTotal(entries:)— sums today's entries in mltodayProgress(entries:)— progress toward goal (0.0–1.0)calculateStreak(entries:)— counts consecutive days where goal was met, looking backward from todaydisplayAmount(_:)/displayGoal()— formats ml values in the user's preferred unitconvertedAmount(_:)/toMl(_:)— bidirectional unit conversion
Services¶
NotificationService¶
Schedules repeating local notifications via UNUserNotificationCenter. Reminders are calendar-based triggers during waking hours (8:00–22:00), spaced by the selected interval. Rotating motivational messages. All reminders use the hydration- identifier prefix for targeted cancellation.
WidgetDataProvider¶
Bridges app data to the widget extension via a shared UserDefaults suite (group.no.invotek.DrinkE). Writes a WidgetData struct (today's total, daily goal, last entry time, streak) as JSON and triggers WidgetCenter.shared.reloadAllTimelines() on each update.
CutiEService¶
Configures and manages the CutiE SDK for in-app feedback. Handles:
- SDK initialization with app ID from
Info.plist - Analytics consent prompts (one-time)
- Push notification delegation (device token forwarding, notification routing)
- Inbox presentation and unread badge count (polled every 60 seconds)
- App metadata reporting (version + build number)
CSVExportService¶
Exports all WaterEntry records as a CSV file. Columns: Date, Time, Amount (in user's preferred unit), Beverage, Note. Generates a timestamped temp file for sharing via UIActivityViewController.
Views¶
Navigation Structure¶
ContentView gates on hasCompletedOnboarding:
- Not onboarded →
OnboardingView(goal selection from 1500/2000/2500/3000 ml + Get Started) - Onboarded →
MainTabViewwith 3 tabs:
| Tab | View | Purpose |
|---|---|---|
| Today | HomeView |
Progress ring, quick-add buttons, today's entry list, streak badge, goal celebration |
| History | HistoryView |
Weekly bar chart (Swift Charts), entries grouped by day with swipe-to-delete, daily totals |
| Settings | SettingsView |
Goal stepper, unit picker, reminder interval, CSV export, CutiE feedback/inbox, analytics toggle, about links |
Key View Details¶
HomeView — The main interaction surface. Displays a ProgressRingView with today's total and percentage. Quick-add buttons for 150/250/500 ml and a custom entry sheet (amount + beverage picker). Triggers a CelebrationView overlay with haptic feedback when the daily goal is reached (once per day). Updates the widget via WidgetDataProvider after each entry.
HistoryView — Uses @Query to fetch all entries sorted by timestamp. Groups entries by day using DayGroup. Renders a 7-day bar chart with a dashed goal line via Swift Charts. Supports swipe-to-delete on individual entries.
OnboardingView — Single-screen flow. Four preset goal options with activity-level descriptions. Sets dailyGoal and hasCompletedOnboarding on completion.
Reusable Components¶
| Component | Purpose |
|---|---|
ProgressRingView |
Circular progress indicator for daily intake |
QuickAddButton |
Styled button for preset intake amounts |
CelebrationView |
Animated overlay when goal is reached |
ShareSheet |
UIActivityViewController wrapper for CSV export |
Widget Extension¶
DrinkEWidget supports .systemSmall and .systemMedium families using StaticConfiguration.
| Component | Purpose |
|---|---|
DrinkEProvider |
TimelineProvider — reads from WidgetDataProvider, refreshes every 15 minutes |
DrinkEEntry |
TimelineEntry with todayTotal, dailyGoal, streak, and computed progress |
SmallWidgetView |
Compact progress display |
MediumWidgetView |
Extended view with additional stats |
The widget reads data from the shared App Groups UserDefaults suite — it cannot access SwiftData directly across targets.
Persistence Strategy¶
| Data | Storage | Why |
|---|---|---|
| Water entries | SwiftData (WaterEntry @Model) |
Structured data, queried by date, supports sorting and filtering |
| User preferences | UserDefaults (standard) |
Simple key-value pairs, fast access, no migration needed |
| Widget data | UserDefaults (App Groups suite) |
Cross-target sharing — SwiftData ModelContainer can't be shared between app and widget |
| Celebration state | UserDefaults (lastCelebrationDate) |
Prevents duplicate celebrations per day |
AppDelegate¶
Serves two purposes:
- Sets
UNUserNotificationCenterDelegatefor foreground notification handling - Forwards push notification events to
CutiE.shared.pushNotificationsfor the feedback SDK
Testing Support¶
UI testing mode (-UITesting launch argument) clears UserDefaults and uses an in-memory ModelContainer, ensuring a clean state for each test run. The -hasCompletedOnboarding 1 argument bypasses onboarding for tests that need to start on the main screen.