loupe

SwiftUI app, source only

Dosewise, a medication reminder

A small iPhone app with eight planted defects and six more found by the checker, built against the iOS 26 SDK with an iOS 17 minimum. No simulator: loupe read the Swift source.

Input
SwiftUI source, Info.plist and project file
Fixture
evals/heldout/fixtures/native

Claude Opus: loupe’s report

Blind judges: 3 of 3 preferred this report, 0 preferred the one written without loupe.

<!-- model: claude-opus; skill commit: 7559367 -->

Dosewise: design review (loupe)

1. Summary

Reviewed: the whole app (4 tabs: Today, Medicines, Add, History; 7 Swift files), from native SwiftUI source only, with no simulator. There was no .loupe.md, so I worked from an inferred profile (assumptions below) and did not write the file.

Verdict: The screens are built from correct stock SwiftUI parts, but the app doesn't yet do its one job. It asks for notification permission, then never schedules a reminder. Doses exist only for the day a medicine was added. Nothing survives a relaunch. Two status colours fail text contrast.

Most important: Rework 1, make the schedule a persistent first-class object that generates each day's doses and reminders. It clears findings 4, 5, 12 and 17. Separately, fix the two contrast Blockers (1, 2), which are small token changes.

Assumptions (owner unavailable, so I proceeded on defaults):

  • purpose: "Remind a person to take their medicines on schedule and keep an honest record of what was taken or skipped." Inferred from the empty-state copy, TodayView.swift:12 and HistoryView.swift:12.
  • feeling: calm and certain ("I know whether I took it"). Inferred.
  • audience: occasional-public. A consumer who uses it daily, but not a professional.
  • register: utilitarian. It's a frequent check-and-act task.
  • motion_personality: serious, relaxed.
  • platforms: ios (iPhone only: TARGETED_DEVICE_FAMILY = 1, portrait only in Info.plist).
  • build_sdk: unknown. There's no LastUpgradeCheck, .xcode-version, CI config or Xcode-26-only API, and SDKROOT = iphoneos has no version.
  • deployment_targets.ios: 17.0 (project.pbxproj). design_compatibility_opt_out: false (key absent).
  • release_stage: development (as the request states).
  • density: comfortable.
  • brand: none. There's no asset catalog, AccentColor, custom font or app icon.
  • voice:
    • Traits inferred as "plain but not clinical".
    • Casing follows Apple's native defaults (Title Case buttons). The app's current strings are mixed, see finding 22.
    • spelling: undetermined (no -our/-or or -ise/-ize words), so nothing is flagged.
    • humor: none. we_policy: avoid.
  • category: medication reminders and adherence. peers: none given; the landscape step chose them.
  • direction: empty, so section 5 proposes directions.
  • waivers: none.

Questions I'd have asked in one batch (no answers, defaults above):

  1. Purpose and feeling in one sentence each.
  2. Who is the user: the patient, a caregiver, or both?
  3. Which apps do you measure yourselves against?
  4. Which Xcode/SDK do you build with?
  5. Are persistence and notification scheduling planned but not yet written? This decides whether findings 4 to 6 are bugs or unfinished work.
  6. Spelling preference.
  7. Humor: none or light?
  8. Which direction in section 5 do you prefer?
  9. Saving .loupe.md: not done, per the instructions.

Run notes:

  • Tools:
    • Read the source in dosewise/ and the skill references.
    • Ran scripts/contrast.py on source-known colour pairs. Apple's published system colour values were used for .green and the grouped-cell backgrounds (light #FFFFFF, dark #1C1C1E), and #4B5563 comes from Models.swift:68.
    • Computed OKLCH for the sketch values with an inline Python conversion.
    • Web research for the landscape (WebSearch and WebFetch, 2026-10-26). These requests went to third-party sites.
  • Not used:
    • No simulator was available, so there are no screenshots and no appearance or content-size changes were made.
    • Playwright, measure-runner.js and type-eval.js were not run. The target is native, and no specimen page was built because the app ships only the system face.
  • Side effects: none. No files were edited apart from this report, and no .loupe.md was written.
  • Measured results:
    • "Taken" label: .green (#34C759) on a white cell is 2.22:1. With Increase Contrast (#248A3D) it's 4.40:1, which still fails for 17 pt text.
    • doseTakenText #4B5563 on a dark cell (#1C1C1E) is 2.25:1. On white it's 7.56:1.
  • Whole-app split: none. The app has 4 screens and 7 view and model files, under the split threshold, so every pass ran inline.
  • 26 gate: the SDK is unknown, so I gated on the iOS 17.0 deployment target and skipped the (26+) checks (see Unverified).

2. Findings

# Finding Where Impact Apple Fix
1 The "Taken" status label and its checkmark use .green. That's #34C759 on a white inset-grouped cell: 2.22:1 for 17 pt text (WCAG 1.4.3 needs 4.5:1), and the icon also fails 1.4.11's 3:1. Even with Increase Contrast it's 4.40:1. It's the confirmation the user scans for. Today, DoseRow, TodayView.swift:53-54 Blocker SHOULD Keep the text in .primary or .secondary and put colour only on the symbol, at a value of at least 3:1 in both appearances. Or define a "taken" colour set with light/dark/IC values of at least 4.5:1 (for example #1E7A35-class greens in light; check with contrast.py).
2 The time text of a taken dose uses the literal Color(hex: 0x4B5563), which has no dark or increased-contrast variant. On the dark cell #1C1C1E it's 2.25:1 at 13 pt, and on #2C2C2E (elevated) it's 1.84:1. Today, TodayView.swift:37; token at Models.swift:68 Blocker SHOULD Delete doseTakenText and use .secondary. That already marks the row as done and adapts to dark mode and Increase Contrast. If a distinct role is wanted, add a Color Set with four values.
3 The Medicines tab has no empty state. A new user sees a blank list under "Medicines" with no explanation and no way to add one from there. Apple says an empty tab section explains why it is empty. Medicines, MedicinesView.swift:8-16 Major MUST Add ContentUnavailableView("No Medicines", systemImage: "pills", description: Text("Add a medicine and its times to get reminders.")) with an "Add Medicine" button (see Rework 2).
4 Reminders are never scheduled. The app requests .alert/.sound/.badge permission (DosewiseApp.swift:9) and the Today empty state promises "Add a medicine to start getting reminders", but no UNNotificationRequest exists anywhere in the source. The product's purpose (reminding) doesn't happen. App-wide; Models.swift:43-51 Major SHOULD When a medicine is saved, schedule one repeating UNCalendarNotificationTrigger per dose time. Set interruptionLevel = .timeSensitive and add Taken and Skip actions. Remove or update the requests on edit and delete. Until then, remove the "reminders" promise from the copy.
5 Doses exist only for the day a medicine is added. add() creates today's instances once and nothing creates tomorrow's, so from day 2 Today shows "No doses today. Add a medicine to start getting reminders." to someone who has medicines. Adding a medicine at 20:00 with an 08:00 time also creates an already-overdue pending 08:00 dose. Today; Models.swift:27-30, 43-51 Major SHOULD Derive each day's doses from the medicine's times (Rework 1). Don't create instances for times already past on the day of adding, or mark them as not due.
6 Nothing persists. MedStore is an in-memory @Observable (Models.swift:22-25). The types are Codable but are never encoded, so medicines and history vanish when iOS ends the process. App-wide Major SHOULD Persist with SwiftData or a JSON file (encode on change, load at launch). In development this may be unfinished (see Unverified); in a release build it is data loss and a Blocker.
7 "Add" is a tab (plus.circle.fill) that performs an action. Apple: tab bars are "navigation only, never actions (no center '+' compose tab)". It also leaves the Add form living as a permanent destination. Tab bar, RootView.swift:10-11 Major SHOULD Remove the Add tab. Put an "Add Medicine" (plus) toolbar button on Medicines that opens a sheet with Cancel and Add (Rework 2).
8 Dose times are typed as free text "HH:MM" with a .numbersAndPunctuation keyboard instead of a time picker. The list renders times with hard-coded %02d:%02d 24-hour formatting, which ignores the user's 12-hour locale, and an added time can't be removed. Add, AddMedicineView.swift:25-35 Major SHOULD Use DatePicker("Time", selection:, displayedComponents: .hourAndMinute) (compact). Show the added times as Text(date, style: .time) rows with .onDelete. Store the hour and minute.
9 Save stays disabled with no reason given. The time field is prefilled "08:00", so it reads as already set, but the time only counts after tapping "Add time". A user who types a name and looks for Save finds it dimmed. Fails the cognitive walkthrough at "notice the correct action". Add, AddMedicineView.swift:7, 29-34, 45 Major SHOULD With a picker (finding 8), seed times with one default time so the form starts valid. Otherwise put a footer under Schedule: "Add at least one time."
10 Saving gives no feedback. The form silently clears and stays on the Add tab, so the user can't tell whether the medicine was saved, or saves it twice. Add, AddMedicineView.swift:41-44 Major SHOULD Dismiss the add sheet and show the new row in Medicines and the new dose in Today (Rework 2). In the interim, switch to Today after saving.
11 Taken and Skip are one-tap and permanent. There's no undo, and no way to change a logged dose on Today or History. A mis-tap records a dose that wasn't taken (risking a missed dose) or a skip that was taken (risking a double dose). Today, TodayView.swift:43-48; Models.swift:37-41 Major SHOULD Make a resolved row's status tappable: a menu with "Mark as Taken", "Mark as Skipped" and "Mark as Not Taken" (back to pending). Also add swipe actions in History. Support shake-to-undo through UndoManager for the last resolve.
12 Deleting a medicine is swipe-only, with no Edit button, confirmation or undo. It also deletes every dose ever logged for it (doses.removeAll, Models.swift:56), erasing the adherence history. The "Removed medicine" fallback in History (HistoryView.swift:17) can therefore never appear. Medicines, MedicinesView.swift:15; Models.swift:53-57 Major SHOULD Add an EditButton. Keep the history and archive the medicine instead of deleting it. Confirm with "Delete Metformin? Its reminders stop. Your history stays." Buttons: "Delete Medicine" and Cancel.
13 The dose time is set in .system(size: 13), which ignores Dynamic Type. The one fact the row exists to show stays at 13 pt while the name grows to 53 pt at AX5. Today, TodayView.swift:36 Major SHOULD Use .font(.subheadline) (or .footnote) with .monospacedDigit().
14 Skip is an icon-only forward.circle button with no label. VoiceOver reads the symbol's derived name, not "Skip". Voice Control can't match "Tap Skip". The glyph-sized hit area is under the 44 pt goal (estimated from source). Today, TodayView.swift:43-47 Major SHOULD Use Button("Skip", systemImage: "forward") with .labelStyle(.titleAndIcon), or at least add .accessibilityLabel("Skip"). Pad the hit area to 44×44 pt with .contentShape.
15 History shows taken versus skipped only by the icon shape (checkmark.circle versus forward.circle). The Label icon isn't announced, so VoiceOver users hear the name and time but not the outcome. History, HistoryView.swift:15-24 Major SHOULD Add the outcome as text, for example "Taken at 8:04 AM" or "Skipped" in the subtitle, which also surfaces resolvedAt (finding 25). Or set .accessibilityValue(dose.status == .taken ? "Taken" : "Skipped").
16 Notification permission is requested in App.init on first launch, before any medicine exists and without saying why. The result is ignored, so if the user declines, nothing in the app ever says reminders are off. First launch, DosewiseApp.swift:8-10 Major SHOULD Ask after the first medicine is saved, with context: "Get a reminder at each dose time." If permission is denied, show an inline row on Today, "Reminders are off", with a button that opens Settings.
17 Medicine rows are dead ends: no detail and no edit. The schedule times and the Notes field are never shown anywhere, so Notes is write-only. Changing a time means deleting the medicine, which also deletes its history (finding 12). Reviewer judgement. Medicines, MedicinesView.swift:9-14; AddMedicineView.swift:37-39 Major SHOULD Make rows navigate to a detail screen showing strength, times, notes, the last 7 days and an Edit button (Rework 1 and 2). Apple Purpose: "every screen visibly serves" the purpose.
18 Overdue doses look the same as upcoming ones. A pending 08:00 dose at 15:00 has the same style, the same prominent "Take" button and no "late" cue, so Today doesn't answer "did I miss one?". Today, DoseRow, TodayView.swift:31-58 Major SHOULD Split Today into "Due / Missed" and "Later", or add a "Due 7 h ago" subtitle with an icon and text (not colour alone). Agency: "The system shows status, meaning what is happening now."
19 Slop cluster: 7 catalogued tells across 3 or more channels (layout idiom, iconography, sound and haptics): an action "+" tab (7), a disabled Save with no hint (9), a swipe-only destructive gesture (12), an icon-only control (14), a permission ask on first launch (16), placeholder-only form labels (24), and no haptic or other character on the core Take moment. Three of them sit in "Components and feedback". Nothing in the UI is traceable to medication. App-wide Major Taste Fix the individual rows first. Then choose a direction from section 5 (Rework 3) instead of swapping tells one by one.
20 Every pending row carries its own .borderedProminent "Take", so five doses means five prominent buttons and no single primary action. Today, TodayView.swift:48-49 Minor SHOULD Make "Take" prominent only on doses that are due now. Use .bordered for later doses (this pairs with finding 18).
21 Today's empty state has no action, and its icon (checkmark.seal) reads as "all done". The same copy shows on first run and on a day whose doses are finished. Apple: an empty state "gives a next step, with a button". Today, TodayView.swift:10-12 Minor SHOULD First run: title "No Medicines Yet", body "Add a medicine and its times. Doses appear here each day.", button "Add Medicine". Day done: title "All Done for Today", body "Next dose: Metformin at 8:00 AM."
22 Button casing is mixed: "Save medicine" and "Add time" (sentence case) versus "Clear History" and the "Add Medicine" title (Title Case). Apple's native push-button labels are Title Case. Add, AddMedicineView.swift:31, 41; History, HistoryView.swift:36 Minor SHOULD "Save Medicine", "Add Time".
23 Form fields use placeholders as their only labels: "Name", "Strength, e.g. 500 mg", "HH:MM", "Optional". Once text is typed, the example and the field's meaning disappear. Add, AddMedicineView.swift:21-22, 29, 38 Minor CONSIDER Use LabeledContent or a leading label ("Strength" plus placeholder "500 mg"). Rename the Notes placeholder to "Taken with food, etc."
24 The dose row is one fixed HStack (name/time, Spacer, Skip, Take). At accessibility sizes the name gets squeezed beside two buttons. Judged from source. Today, TodayView.swift:31-59 Minor CONSIDER Use ViewThatFits, or switch to a VStack when dynamicTypeSize.isAccessibilitySize is true, with the buttons on their own line.
25 History is one flat list with no date sections and shows only the scheduled time. resolvedAt is recorded but never shown, so "taken 3 hours late" is invisible. Reviewer judgement. History, HistoryView.swift:14-25 Minor Taste Add a Section per day (header "Today", "Yesterday", date) and a subtitle "Taken 10:42 AM · due 8:00 AM".
26 +2 polish: (a) the tab symbols read oddly: pills marks Today and cross.vial (a lab test) marks Medicines, so pills belongs on Medicines and Today wants a day or checklist symbol; (b) an empty strength leaves a blank secondary line in Medicines rows (MedicinesView.swift:12). Tab bar, Medicines Polish Taste (a) Swap to pills for Medicines and checklist for Today. (b) Hide the line when empty.

waived: 0

3. Trust

I found no deceptive patterns in the source: no confirmshaming, fake urgency, preselection or invented counts. The notification request at launch (finding 16) is a timing and rationale problem, not a deceptive one, because it uses the system alert with nothing pre-selected.

4. Landscape

Category: medication reminders and adherence (nearest seed playbooks: Health, and Consumer utilities). Checked 2026-10-26.

Conventions to keep:

  • The reminder is a notification that carries the decision. In Apple Health you "tap the notification, then tap Skipped or Taken", and on Apple Watch "Remind Me in 10 Minutes" is also offered (Apple Support 105064).
  • Both taken and skipped are recorded (Apple; MyTherapy: "documents every intake, whether taken or skipped").
  • Doses recur daily from a schedule, and the record persists.

Frontier moves:

  • Follow-up reminders when a dose isn't logged 30 minutes after it's due. Apple offers optional Critical Alerts per medication that sound through Focus and mute (Apple Support 105064, Macworld).
  • Resending until resolved (MyTherapy, Healthify [S]).
  • Escalation to a caregiver about 1 hour after an unlogged dose (Medisafe "Medfriend", help center), or 15 minutes after (Hero).
  • Adherence reports shared with clinicians as PDF (Medisafe, [S]).

Convergence to avoid:

  • A caregiver feature as the headline when the core reminder isn't reliable.
  • Stock health teal or blue with a pill glyph as the whole identity (unverified: peers' identity combinations weren't checked visually).

Openings (unverified: no peer was confirmed to lack these):

  • Show taken time against due time as a plain, judgement-free record ("taken 2 h late"). Dosewise already stores resolvedAt.
  • A day modelled as the physical artefact people already use to organise doses, a weekly pill box or blister card (Direction A).

Core-task step count (log a due dose):

  • Apple Health: 2 inputs (tap notification, tap Taken). Sourced, Apple Support 105064.
  • Dosewise: 2 inputs (open app, tap Take), but only if the person remembers unprompted, because no reminder ever fires (finding 4).

The input counts are equal, so the peer-gap bar isn't met. The gap is reliability, not steps.

5. Distinctiveness verdict

default-assembled.

Channels (none authored):

Channel Source Evidence
Type Platform default Stock text styles, plus one literal 13 pt (TodayView.swift:36)
Color Platform default System tint, .green, .secondary, one hex (Models.swift:68); no AccentColor
Shape Platform default Inset grouped and Form
Motion Platform default None authored
Iconography Platform default Stock SF Symbols
Imagery None
Sound and haptics Platform default None. This is a slop-catalog tell (Apple-clone, "Restraint copied as emptiness"): the core Take moment would benefit from character.
Layout idiom Trend or platform default Includes the catalogued "+" action tab
Voice Platform default Plain and serviceable, but nothing a competitor wouldn't sign, e.g. "No history yet"
  • Authored channels: 0 of 9. Signature element: none. Signature moments: 0, visual or behavioural.
  • Catalogued tells: 7, across layout idiom, iconography and sound and haptics (finding 19). That meets the slop-cluster threshold, which forces this verdict.
  • Logo-off test (judged from source; no screenshots): nothing identifies the product.
  • Competitor-swap test: with "Medisafe" or "MyTherapy" on these screens, nothing looks wrong.
  • Gate: every flow has open Majors, so pleasure-tier work waits for the fixes. Directions are proposed now so the team can choose.
  • Default check (the theme I'd generate for "medication reminder app"): a teal or health-blue accent, white rounded cards, a pill glyph, green checkmarks, and SF Rounded. Both directions below avoid it on colour and on layout idiom.

Inferred candidate principles (no brand doc; finding "No authored point of view" is folded into 19):

  1. Certain over cheerful: state is always explicit.
  2. The record is honest: late is shown as late, never hidden or scolded.
  3. Quiet until due: nothing competes with the next dose.
Direction A: "Blister card"

Source sentence: many people organise doses in a pill box or blister card, where each pocket is one dose and an empty pocket is unmistakable.

Register: utilitarian, with one behavioural moment. Motion: serious, relaxed.

Color:

Role Value Trace Contrast
Surface System grouped backgrounds Task flows stay system n/a
Taken fill (pocket emptied, non-text) Amber oklch(78.7% 0.147 73) #F2A93B Amber prescription vials, US pharmacy vernacular Glyph #1C1C1E on it: 8.52:1. Never used as text on white (2.0:1).
Taken text, light oklch(49.4% 0.109 67) #8A5300 Same amber 6.33:1 on white
Taken text, dark oklch(81.4% 0.143 77) #F5B547 Same amber 9.38:1 on #1C1C1E

The three values derive from one input (hue around 70); the increased-contrast variant uses the light-text value on both fills.

Type: system face in every role, plus .monospacedDigit() on times. No new face, so nothing to license or measure.

Shape: each day row shows a strip of round "pockets", one per dose. Each pocket is filled while pending and emptied (outline) when taken. Skipped is marked with a slash and the text "Skipped". Radius is circular for pockets only; everything else stays system.

Motion:

  • Productive: system defaults.
  • Expressive: the pocket empties with a short scale-down and fade (about 0.2 s, ease-out) plus .sensoryFeedback(.success).
  • Reduce Motion: fade only.

Iconography: custom pocket symbols drawn from an SF template, with accessibility labels.

Signature element: the pocket strip, which recurs on Today, the widget, notifications (as a thumbnail) and the icon. Signature moment: emptying the pocket on Take.

Voice samples:

  • Button: "Take"
  • Empty state: "No doses left today. Next: Metformin at 8:00 AM."
  • Error: "Reminders are off. Turn them on in Settings to get a reminder at each dose time."

Landscape position:

  • vs Apple Health: a physical-artefact day model instead of a list (Apple Health's look was not checked visually, unverified).
  • vs Medisafe and MyTherapy: amber vial vernacular instead of their brand hues (unverified).
  • vs Hero: borrows its "one dose, one slot" idea as UI, not hardware.
Direction B: "Administration record"

Source sentence: care homes and wards track this exact job on a medication administration record (MAR), a grid of times against days, initialled per dose, with exceptions marked in red.

Register: utilitarian, dense. Motion: serious, fast.

Color: monochrome ink, with one hue reserved for one meaning.

Role Value Trace Contrast
Text System label Ink n/a
Missed or late, light oklch(50.1% 0.178 29) #B3261E Red pen for exceptions on paper MARs 6.54:1 on white
Missed or late, dark oklch(75.9% 0.144 26) #FF8A80 Same red pen 7.45:1 on #1C1C1E

There's no accent for "taken": a tick plus the time in the label colour. Red never appears without text ("Missed", "Late").

Type: system face. Weight does the hierarchy, and tabular digits make the time column the spine.

Shape: square-cornered grid cells in History (days × dose times). Today remains a list.

Motion:

  • Productive only. Taking a dose ticks instantly with .sensoryFeedback(.impact(weight: .light)).
  • Reduce Motion needs nothing to change.

Signature element: the week grid in History, also in the PDF export (Enhancement 6). Signature moment (behavioural): every dose is ticked with its real time, the same way, every time.

Voice samples:

  • Button: "Take"
  • Empty state: "Nothing due today."
  • Error: "Reminders are off. Turn them on in Settings."

Landscape position:

  • vs Apple Health: a clinical-record grid instead of a consumer list (unverified).
  • vs Medisafe: an honest record without a caregiver-first framing.
  • vs MyTherapy: no brand hue at all.
  • vs Hero: a record-first model, not device-first.

How A and B differ: colour (amber state fill versus red exceptions only), shape (pockets versus grid), motion (expressive moment versus productive only), and layout idiom.

6. Type

Face: SF Pro (system), stock weights via text styles. Roles: UI, data (times)
Legibility: PASS. x-height n/a (not measured); confusables not measured (native, no specimen run); weights Regular, Semibold (headline); Bold Text yes for text styles, the literal .system(size: 13) also follows Bold Text but not Dynamic Type
Figures and features: tabular available (.monospacedDigit()), not used on dose times; distinct zero n/a (no codes shown)
Coverage: system, all scripts; locale issues: hard-coded 24-hour "%02d:%02d" times (finding 8)
Scaling: Dynamic Type ok except TodayView.swift:36 (finding 13); 1.4.12 n/a native
Delivery (web): n/a
Distinctiveness: default tier yes; identity carried by nothing
Verdict: Keep (system face is right for a utilitarian task flow). Tune: .monospacedDigit() on times, a text style instead of size 13
Evidence: source only; type-eval.js not run (native target, system face only)

7. Reworks

Rework 1: The schedule as a first-class, persistent object
  • What changes:
    • Before: Medicine.times is copied once into today's Dose instances at add time. Nothing recurs, nothing reminds, nothing can be edited without orphaning doses, and deletion takes history with it.
    • After: a persisted Schedule (per medicine: times and active days) is the single source. Today's doses and the repeating notification triggers are derived from it. Logged doses are an append-only record keyed to the medicine, which is archived rather than deleted.
  • Bar: Cluster. Findings 4, 5, 12 and 17 share one cause: there's no schedule entity, only one-shot dose instances. Per-row fixes (for example regenerating doses at midnight) leave the cause in place, and editing a time would still orphan doses.
  • Evidence: findings 4, 5, 12 and 17; Models.swift:43-57. Apple Health and MyTherapy both treat recurrence plus notification as baseline (section 4).
  • Cost:
    • Models.swift (new Schedule and persistence), plus TodayView, HistoryView, MedicinesView and the add/edit form.
    • A notification scheduler.
    • No learnability risk (the UI shape barely changes). There's no user-data migration, because nothing persists today.
  • Unlocks: reminders, a second day that works, editing, honest history, and the widget, intents and PDF enhancements, which all read from the schedule.
  • Smallest experiment: build the Schedule plus derived-today in a branch. Run it on a device for 3 days with 2 medicines and confirm that Today, notifications and History agree each morning.
Rework 2: Medicines becomes the medicine's home (remove the Add tab)
  • What changes:
    • Before: four tabs, one of which is an action. The list rows are inert, and saving silently resets a form.
    • After: three tabs (Today, Medicines, History). Medicines has a + toolbar button that opens an "Add Medicine" sheet (Cancel / Add), an empty state with "Add Medicine", and rows that open a detail screen with Edit.
  • Bar: Cluster. Findings 3, 7 and 10 share one cause: creating a medicine was split into its own tab instead of being an action on the Medicines collection. So the collection has no create path or empty-state action, and the form has nowhere to return to.
  • Evidence: findings 3, 7 and 10 (and 17, which this rework's detail screen also resolves). Apple tab bar rule (SHOULD).
  • Cost: RootView (drop one tab), MedicinesView (toolbar, empty state, navigation), AddMedicineView (becomes a sheet with dismiss), plus one new detail view. Learnability risk is low, because this is the standard iOS pattern.
  • Unlocks: save feedback for free (the sheet dismisses onto the new row), an edit path, and a tab bar that maps to the product's three nouns.
  • Smallest experiment: a throwaway prototype of the sheet flow. Ask 5 people to "add your evening tablet, then check it's there" and count hesitations against the current build.
Rework 3: Identity from the subject (after Reworks 1 and 2)
  • What changes:
    • Before: platform defaults throughout, with nothing owned.
    • After: one direction from section 5 applied to Today, History, the empty states, notifications and the icon. Task flows stay standard.
  • Bar: Identity. The verdict is default-assembled with 7 tells, and fixing tells one by one would only swap in the next default.
  • Evidence: finding 19; section 5's channel table; the logo-off and swap tests are both empty.
  • Cost: Today's row component, History's layout, one colour set, custom symbols, a haptic, and the app icon. It's held behind the gate until the Majors are fixed.
  • Unlocks: a reason to choose Dosewise over the free built-in Apple Health medications feature, and a defence against 4.3 "indistinguishable" risk in a saturated category.
  • Smallest experiment: mock Today in Direction A and Direction B with the same 3 doses. Show both to 5 people who take daily medication, and ask which tells them faster what's left today.

8. Enhancements

Evidence was strong enough for 6 of the 8 allowed.

Platform capabilities

  1. Act from the reminder (Agency). Give the dose notification "Taken", "Skip" and "Remind in 10 Min" actions, mirroring Apple Health's watch actions (section 4). Logging then takes two inputs without opening the app, which matches the best peer.
  2. Follow up once if unlogged (Responsibility). Send one follow-up reminder 30 minutes after an unresolved dose, as Apple Health does. Offer it per medicine, honestly labelled, and never repeated beyond that. Critical alerts need an entitlement, so treat that as a later decision.
  3. Next dose at a glance (Flexibility). Add a Lock Screen and Home Screen widget showing the next dose and how many are left today, plus a Control or App Intent "Log Next Dose". The Today list is exactly the glanceable, time-bound state these surfaces exist for.

Experience and flows 4. Take a whole time slot at once (Simplicity). People on several medicines take them together at 08:00. Group Today by dose time with one "Take All" per group (and individual overrides), which cuts N taps to 1. Apple Health has a comparable bulk log (unverified).

Motion and feedback 5. A felt "taken" (Craft). Add .sensoryFeedback(.success, trigger: dose.status) on Take, a distinct lighter haptic on Skip, and an animated status change that respects Reduce Motion. Today, Take is the core moment and it's silent. This is the seed of either direction's signature moment.

Information architecture 6. A record worth showing a clinician (Purpose). Turn History into a per-day record of due time versus taken time (from resolvedAt, already stored) with a "Share Report" PDF via the share sheet. Medisafe offers PDF adherence reports (section 4, [S]). Dosewise's honest late-or-taken record is the version no studied peer was confirmed to show (unverified).

9. Unverified

  • Rendering: no screens were rendered (no simulator). Light and dark appearance, layout, truncation and real tap-target sizes weren't seen. To verify, boot a simulator, run the app, and capture each tab in light, dark and AX5 with xcrun simctl.
  • Contrast on iOS 26: the ratios use Apple's published system colour values for iOS 17/18 (#34C759, #248A3D, #1C1C1E). If the app is built with the iOS 26 SDK, re-measure on device screenshots with scripts/png_contrast.py.
  • (26+) checks: Liquid Glass tab bar behaviour, tab label tint and icon appearances were skipped. The build SDK is unknown and the gate fell back to the iOS 17.0 deployment target. To verify, confirm the Xcode version or CI image.
  • Possibly unfinished:
    • Findings 4, 5 and 6 (no reminders, no recurrence, no persistence) may be unfinished development rather than design decisions. They're rated Major under release_stage: development; data loss (finding 6) becomes a Blocker in a release build.
    • No app icon or asset catalog exists. That's a placeholder note in development, not a finding.
  • Stress states:
    • The dose row at AX5, with long medicine names, under pseudo-localisation (+40%) and in RTL, was judged from source only.
    • Increased Contrast and Reduce Motion weren't seen. Reduce Motion is irrelevant today, because the app has no custom motion.
  • Loading, error and offline: none exist for local data. Notification-permission denial has no UI path (finding 16). To verify, deny permission on a fresh install.
  • VoiceOver: the actual name read for the forward.circle Skip button wasn't heard (finding 14). To verify, use VoiceOver on a device or Accessibility Inspector.
  • Peer visuals: peer identity combinations (accent hue, typeface, layout) weren't checked visually. Section 5's landscape positions are (unverified).
  • Third-party sources: the MyTherapy and Medisafe report claims come from third-party reviews [S], not from the products themselves.

10. Offer

I can fix these in code now, if you want (nothing has been edited):

  • Contrast and colour tokens: 1, 2
  • Dynamic Type and accessibility labels: 13, 14, 15, 24
  • Copy and casing rewrites: 21, 22, 23
  • Small structural fixes that don't need Rework 1: 3 (empty state), 8 (time picker), 9 (valid default time), 16 (move the permission request), 20 (prominence on due doses only), 25, 26

These need a design decision first:

  • 11: undo versus editable status.
  • 12: archive versus delete.
  • 18: due/missed sections versus a subtitle.
  • Rework 1 covers 4, 5, 6 and 17; Rework 2 covers 7 and 10; Rework 3 needs a direction pick (A or B).

Enhancements and reworks are your call. I can build an enhancement (Enhancement 1 or 5 are the cheapest), or a throwaway prototype of a rework's smallest experiment (Rework 2's sheet flow, or Rework 3's two Today mocks).

Optional input that would let me verify more:

  • A booted simulator with the app running, to capture light, dark and AX5 and re-measure contrast from pixels.
  • The Xcode version, to run or rule out the (26+) checks.
  • Answers to the profile questions in the Summary.

Which would you like me to do?

Claude Opus without loupe: the review it wrote with no skill
<!-- model: claude-opus; skill commit: 7559367 -->

Dosewise design review (UI and UX)

Scope: every file in dosewise/: DosewiseApp.swift, RootView.swift, Models.swift, TodayView.swift, MedicinesView.swift, AddMedicineView.swift, HistoryView.swift, Info.plist and the project.pbxproj fragment. That is about 300 lines in all. Method: I read the source only. There was no simulator, so everything about rendering is inferred from SwiftUI's default behaviour. Build context: in development. It builds with Xcode 26 against the iOS 26 SDK and targets iOS 17.0. It is iPhone only and locked to portrait.

Assumptions (the owner was not available)

  1. Dosewise is a personal medication reminder and adherence log for one person on a few recurring daily medicines. Its main promise is "remind me, and let me record that I took it." The empty-state copy says "start getting reminders", which supports this reading.
  2. The intended audience includes older users and people managing chronic conditions. Large Dynamic Type sizes, VoiceOver and low-vision use are therefore core cases, not edge cases.
  3. Mistakes have consequences. A wrong "taken" record, or a missing reminder, can lead to a missed or double dose. I rate severity with that in mind.
  4. Data stays on the device for now. There is no account and no sync.
  5. The iOS 17 deployment target is deliberate. Recommendations either work on iOS 17 or are marked as needing a newer OS.

Severity scale

  • Critical: the core promise fails, or the user's data is lost or wrong. Must fix before any external testing.
  • High: a frequent task is broken or misleading, or a common group of users (accessibility users) is excluded. Fix before release.
  • Medium: a noticeable friction or a convention break. Fix before release if possible.
  • Low: polish.

Summary

Visually, the app is a clean, conventional SwiftUI scaffold: NavigationStack, inset-grouped lists, ContentUnavailableView empty states, and a confirmation before history is cleared. Those parts are fine.

The product underneath does not yet work:

  • It never sends a reminder. Notification permission is requested at launch, but no notification is ever scheduled.
  • The schedule lasts one day. Doses are created only for the day a medicine is added, so from the next morning Today says "No doses today".
  • Nothing is saved. The store lives in memory, so every medicine and every history entry is lost when iOS terminates the app.
  • Missed doses disappear. They never reach History, and deleting a medicine erases its taken-dose records.

Beyond those, the main problems are:

  • time entry is a free-text "HH:MM" field instead of a time picker
  • "Add" is a tab rather than an action
  • dose actions cannot be undone, and Skip is an unlabeled icon
  • medicines cannot be edited
  • some text uses a fixed font size and some a hard-coded colour that fails contrast in dark mode

The four Critical issues are product logic, but they are also UX: the screens currently promise behaviour the app does not deliver.


Critical

C1. No reminders are ever scheduled, but the UI promises them
  • Where: DosewiseApp.swift:9, TodayView.swift:12
  • What: requestAuthorization runs in App.init, but nothing in the codebase creates a UNNotificationRequest. The Today empty state says "Add a medicine to start getting reminders." A user who follows that advice gets no reminders and gets no sign that anything is wrong.
  • Also wrong: the permission prompt appears on a cold first launch, before the user has seen the app or added anything. Apple's guidance is to ask in context, when the benefit is clear. Asked out of context, the prompt is more likely to be declined, and for a reminder app a declined prompt is close to fatal. The completion handler ignores the result, so a denial is never shown in the UI either.
  • Fix:
    • Schedule a repeating UNCalendarNotificationTrigger(dateMatching: [hour, minute], repeats: true) for each time of each medicine. Reschedule when a medicine is added, edited or deleted.
    • Request authorization when the first medicine is saved, with one line explaining why ("Dosewise will remind you at 8:00 AM").
    • If permission is denied, show a persistent banner on Today ("Reminders are off. Turn on in Settings") that opens the app's page in Settings.
    • Add a notification category with "Taken" and "Skip" actions so a dose can be logged from the notification. This is the most-used flow in competing apps.
    • Consider .timeSensitive interruption for dose reminders. It needs the Time Sensitive entitlement, and the user can still turn it off.
C2. The schedule covers only the day a medicine is added
  • Where: Models.swift:43-51
  • What: add(_:) creates one Dose per time, using of: .now, and only for today. Nothing generates doses for later days. From the second day on, Today shows "No doses today" even though the Medicines tab lists active medicines. Adding a medicine at 3 PM with an 8 AM time also creates an 8 AM dose that is already overdue, with no explanation.
  • Fix:
    • Treat Medicine.times as the source of truth and build "today's doses" from it, starting from the medicine's start date. Store a Dose record only once the user acts on it (taken or skipped).
    • Recompute when the date changes: refresh on scenePhase becoming active and on .NSCalendarDayChanged / UIApplication.significantTimeChangeNotification. Otherwise a Today screen left open past midnight goes stale.
    • Let the user choose whether a time already past today counts: "First dose: today / tomorrow".
C3. Nothing persists
  • Where: Models.swift:22-25, DosewiseApp.swift:6
  • What: MedStore is a plain in-memory @Observable class. When iOS terminates the app, which happens routinely in the background, every medicine and every history entry is gone. For an adherence log, that is silent data loss.
  • Fix: use SwiftData. It is available on the iOS 17 target and fits these models. Alternatively, write Codable JSON to Application Support after each change; the models are already Codable. Either way, data must survive termination, and file protection should be on, because this is health information.
C4. Missed doses vanish, and deleting a medicine erases its history
  • Where: Models.swift:31-33, Models.swift:53-57, MedicinesView.swift:15
  • What:
    • history includes only .taken and .skipped doses. A dose the user never acted on stays pending forever and never appears in History. The log therefore overstates adherence: the one fact a clinician most needs, that a dose was missed, is never recorded.
    • delete(at:) removes all of a medicine's doses, including taken ones, after a single swipe with no confirmation and no undo. History has a "Removed medicine" fallback (HistoryView.swift:17), but it can never trigger, because the records are deleted along with the medicine.
  • Fix:
    • Add a missed state. A pending dose becomes missed after a grace window (for example 2 hours, or at end of day) and appears in History, marked differently from skipped.
    • Replace delete with "Stop taking" (archive). Archived medicines move to an "Archived" section and their history is kept. Keep a true delete behind a confirmation that says what will be lost ("Delete Metformin and 42 history entries?").

High

H1. Time entry is a free-text "HH:MM" field
  • Where: AddMedicineView.swift:7-15, 25-35
  • What:
    • The user types a time on a .numbersAndPunctuation keyboard. The field is prefilled with "08:00", but that time has not been added. Save stays disabled until the user finds and taps "Add time", and nothing explains why.
    • Added times are shown as %02d:%02d, which ignores the user's 12/24-hour setting. A US user sees "20:00".
    • An added time cannot be removed: there is no onDelete and no edit mode.
    • Duplicate times are accepted, and times are not sorted.
  • Fix:
    • Replace the field with DatePicker("Time", selection:, displayedComponents: .hourAndMinute) rows.
    • Start new medicines with one default row (8:00 AM) that already counts as added, plus a "+ Add Time" row.
    • Make rows swipe-deletable, de-duplicate and sort them, and format times with Date.FormatStyle(time: .shortened).
    • Offer quick presets ("Once daily", "Twice daily", "Morning / Evening") so the common case takes one tap.
H2. "Add" is a tab
  • Where: RootView.swift:10-11
  • What: Tab bars are for moving between sections, not for actions; Apple's HIG says so directly. An "Add" tab also keeps its half-filled form when the user switches away and comes back. After Save, the fields clear with no confirmation, no haptic and no navigation, so the user cannot tell whether it worked. The icon is plus.circle.fill, a filled variant in a bar where the other icons are outlined. The tab bar applies its own fill rules, so the hand-picked variant is inconsistent.
  • Fix:
    • Remove the tab and leave three tabs: Today, Medicines, History.
    • Put a + toolbar button on Medicines, and in the empty states of Medicines and Today. The button presents AddMedicineView as a sheet with Cancel and Add buttons in the toolbar (.cancellationAction / .confirmationAction). The sheet dismisses on save, which is the confirmation.
    • Add .interactiveDismissDisabled when the form has unsaved input.
    • Move Save out of the bottom of the form, where the keyboard can hide it.
H3. Dose actions are instant, permanent and partly unlabeled
  • Where: TodayView.swift:40-58
  • What:
    • No undo: "Take" and Skip resolve the dose at once and remove the buttons. A mistaken tap cannot be undone or corrected. A wrong "Taken" record is exactly how someone ends up skipping a dose they needed, or taking a second one.
    • Skip is unlabeled: Skip is a bare forward.circle image with no text and no accessibilityLabel. VoiceOver reads the symbol name ("Forward"), and sighted users have to guess what it does. The glyph usually means "next" or "fast-forward", not "skip this dose".
    • Small target: the borderless icon is about 22 pt, under the 44 × 44 pt minimum. It sits 16 pt from the prominent Take button, which raises the chance of hitting the wrong one.
    • Wrong time recorded: the time shown is the scheduled time. When a dose was actually taken (resolvedAt) is stored but never displayed. A user cannot record a dose taken earlier (for example "took it at 7:40, logging now").
  • Fix:
    • Make a tap on a taken or skipped row open a small menu: "Mark as not taken", "Change time taken", "Mark skipped".
    • Show a 5-second "Undo" snackbar or toolbar banner after each action.
    • Give Skip a text label, or move it into a swipe action (.swipeActions { Button("Skip") }) and the row's context menu, so the row has one clear primary button.
    • Show "Taken at 8:12 AM" on resolved rows.
    • Add .sensoryFeedback(.success, trigger:) when a dose is marked taken.
H4. The Today screen does not show what is due
  • Where: TodayView.swift
  • What: Every pending dose looks the same, whether it is three hours overdue or due tonight. There is no overdue state, no "next dose", no progress for the day and no date. The rows do not show strength ("Metformin" without "500 mg"), even though two strengths of one drug at different times is common. Notes, such as "take with food", are collected but shown nowhere in the app.
  • Fix:
    • Group doses into sections by time slot ("8:00 AM", "8:00 PM"), or into Overdue / Due now / Later / Done.
    • Mark overdue doses with an icon and text label ("Overdue · 2h"), not colour alone.
    • Add a header with the date and a "2 of 4 taken" count.
    • Show strength and the first line of notes in the row.
    • For a single tap in the morning, consider "Take all 8:00 AM doses" on each time section.
H5. Fixed font size and a hard-coded colour break Dynamic Type and dark mode
  • Where: TodayView.swift:36-37, Models.swift:62-68
  • What:
    • .font(.system(size: 13)) does not scale with Dynamic Type. Dose times, the most important detail in the row, stay small at accessibility sizes while the medicine name grows around them.
    • Color.doseTakenText is a fixed sRGB #4B5563 with no dark-mode variant. In dark mode, on the inset-grouped cell background (about #1C1C1E), it measures roughly 2.3:1 contrast, below the 4.5:1 WCAG AA minimum for text of this size. In light mode it is fine (about 7.5:1).
    • Emphasis is also reversed: taken doses get darker text than pending ones (.secondary), so finished items stand out more than the ones still to do.
    • The hex value is Tailwind's gray-600, which reads as a web template carried into a native app.
  • Fix:
    • Use .font(.subheadline) or .footnote, or @ScaledMetric if a custom size is truly needed.
    • Delete Color(hex:) and doseTakenText. Use .secondary or .tertiary for resolved rows, or an Asset Catalog colour with light, dark and high-contrast variants.
    • Check the layout at AX5: at accessibility sizes, switch the row from HStack to VStack using ViewThatFits or dynamicTypeSize.isAccessibilitySize.
H6. Medicines cannot be viewed or edited
  • Where: MedicinesView.swift
  • What:
    • Rows are not tappable. A dose change, which is common, can only be made by deleting the medicine (which also wipes its history, see C4) and adding it again.
    • The list does not show times, so the user cannot check the schedule.
    • There is no empty state. A new user sees a blank grouped list with only a title.
    • If strength is left empty, the row shows an empty second line, because Text("") still takes up space.
  • Fix:
    • Wrap rows in a NavigationLink to a detail and edit screen that reuses the add form and shows recent history for that medicine.
    • Show the times in the row ("500 mg · 8:00 AM, 8:00 PM").
    • Add a ContentUnavailableView with an "Add Medicine" button.
    • Hide the strength line when it is empty.

Medium

M1. History is a flat, hard-to-scan list, and the Clear button is risky
  • Where: HistoryView.swift
  • What:
    • Entries are not grouped by day, which is how people check ("did I take it Tuesday?"). There is no per-medicine filter.
    • Status is shown only by glyph: checkmark.circle vs forward.circle, both in the same colour, with no text. VoiceOver users may hear only the name and date, or a symbol name.
    • Entries show the scheduled time, not when the dose was actually taken.
    • A destructive "Clear" for the whole health log sits permanently in the navigation bar. The confirmation dialog is correctly in place, but one swipe-able tab away is a lot of exposure for an action this rare and this destructive.
  • Fix:
    • Use sections by day (Section(header: Text(day, format: .dateTime.weekday().month().day()))).
    • Give each row a status label ("Taken 8:12 AM" / "Skipped" / "Missed") with icon and colour, and a combined accessibilityElement(children: .combine).
    • Add a medicine filter through a toolbar Menu.
    • Move "Clear History" into a settings screen, or into an overflow Menu with the destructive role.
    • Add an Export / Share option, such as PDF or CSV for a doctor. This is a common reason to keep a log at all.
M2. Accessibility labels and grouping in rows
  • Where: TodayView.swift:31-58, HistoryView.swift:15-24
  • What: Dose rows are not combined into one element, so VoiceOver steps through name, time, Skip and Take as separate stops. The Taken and Skipped status labels rely partly on colour.
  • Fix:
    • Use .accessibilityElement(children: .combine) on the text block, with an accessibilityLabel such as "Metformin 500 mg, 8:00 AM, overdue".
    • Expose Take and Skip as .accessibilityAction(named:) so VoiceOver users can act from the row.
    • Add .accessibilityInputLabels for Voice Control ("Take", "Skip").
M3. Form validation is permissive and silent
  • Where: AddMedicineView.swift:41-45
  • What: name.isEmpty accepts a name made only of spaces. Save is disabled with no hint about what is missing. The keyboard has no submit flow from Name to Strength.
  • Fix:
    • Trim whitespace before validating.
    • Show a footer under the Schedule section ("Add at least one time") while it is empty.
    • Use .submitLabel(.next) and @FocusState to move between fields.
    • Turn off autocorrection on Name (.autocorrectionDisabled()), because drug names get "corrected" into ordinary words.
    • Use .textInputAutocapitalization(.words).
M4. Tab icons do not match their tabs
  • Where: RootView.swift
  • What: "Today" uses pills, which describes Medicines better. "Medicines" uses cross.vial, a lab or test glyph.
  • Fix: Today → checklist or calendar. Medicines → pills. History → clock.arrow.circlepath (keep). Once Add moves out, the tab bar has three clear sections.
M5. Schedule model only supports "every day"
  • Where: Models.swift:4-10
  • What: There are no options for specific weekdays, every N days, as-needed (PRN), start or end dates for a short course (for example a 7-day antibiotic), or tapering. Many real regimens cannot be entered at all. This matters for the user experience because the user then has to fake the schedule or keep a second tracker.
  • Fix: Add, at minimum:
    • a frequency choice (Every day / Specific days / As needed)
    • an optional end date
    • for as-needed medicines, a "Log a dose" button instead of scheduled rows

Low

  • L1. Portrait lock (Info.plist:6). This is acceptable for a utility, but it gives up landscape for people who mount their phone sideways or use a large keyboard. Low cost to allow, once the layouts adapt.
  • L2. No visual identity. There is no asset catalog, AccentColor or app icon in the bundle, and the default blue tint is used throughout. Pick an accent colour that does not suggest an alert (not red or orange) and use it consistently on the Take button and progress display.
  • L3. "Unknown" fallback (TodayView.swift:33). This name should never appear. Fix the data model so a dose cannot lose its medicine.
  • L4. iOS 26 look. The app is built with the iOS 26 SDK, so on iOS 26 devices system bars and controls automatically pick up the new Liquid Glass appearance. Avoid custom backgrounds behind the tab and navigation bars, and test the .borderedProminent Take button inside list rows on iOS 26 as well as iOS 17–18.
  • L5. Placeholder bundle ID (com.example.dosewise). This is not a UX issue, but it blocks TestFlight, and notification categories and any future App Group (needed for widgets) depend on it.

Opportunities after the fixes

These are not defects. They are the platform features that make a medication app reliable in daily use.

  1. Actionable notifications. "Taken" and "Snooze 10 min" directly in the notification (see C1). This is the highest-value addition.
  2. Widgets and Lock Screen. A "Next dose" widget and a Lock Screen accessory showing the next time and how many doses are left today. An interactive widget (iOS 17+) with a Take button.
  3. App Intents and Siri. "Log my morning meds" and "What's my next dose?" Shortcuts.
  4. Apple Health. iOS 26 adds a HealthKit Medications API (HKUserAnnotatedMedication, HKMedicationDoseEvent). Users already track medicines in the Health app, and Dosewise competes with that built-in feature. On iOS 26 and later, reading the user's existing medication list could replace manual entry. Check the current write and authorization rules against Apple's documentation before designing around them; developer reports suggest third-party write access is limited.
  5. Refills. Track pill count and warn when supply is low. This is a common feature in this category.
  6. Safety copy. Add a short line in onboarding or settings that the app is a reminder aid and not medical advice. This also needs reviewing for App Store guideline 1.4.1 (medical apps).

Fix order

  1. Make it work: C3 persistence → C2 recurring schedule → C1 scheduled notifications with in-context permission → C4 missed state and archive.
  2. Make the main flows safe and clear: H3 undo and labelled Skip → H1 time picker → H2 Add as a sheet → H6 edit medicine.
  3. Make it readable for everyone: H5 Dynamic Type and colour → M2 VoiceOver → H4 Today structure.
  4. The remaining Medium and Low items, then the opportunities (actionable notifications first).

Sources

Claude Sonnet: loupe’s report

Blind judges: 3 of 3 preferred this report, 0 preferred the one written without loupe.

<!-- model: claude-sonnet; skill commit: 7559367 -->

Loupe review: Dosewise (SwiftUI, iPhone)

Loupe is independent and not affiliated with Apple.

1. Summary

Reviewed: the whole app (4 screens, 7 Swift files, inline, no subagents), from source only (no simulator, no running build), against an inferred profile.

Verdict: the interface is tidy stock SwiftUI, but the product does not yet do its job. It is a medication reminder that sends no reminders, loses everything on relaunch, stops showing doses after the day they were added, and can silently erase a person's dose history.

Do first: the schedule-first model rework (Rework 1), then findings 1 to 4 (data loss and two measured contrast failures), then 5 to 9 (the Today screen cannot be trusted for a medicine you take twice a day).

Assumptions (no owner, so defaults)
  • purpose: log and be reminded of daily medicine doses (inferred from the code and the empty-state copy). Audience: adults managing one or more regular medicines, glanced at several times a day, some older or low-vision (inferred from the category; unconfirmed).
  • platforms: iOS, iPhone only (TARGETED_DEVICE_FAMILY = 1). release_stage: development (user said not yet released). So App Review 2.1/4.2 checks are not run, and dead controls are Major plus an Unverified line.
  • register: utilitarian (frequent check-and-act task). Motion personality: quiet. Humor: none. Casing: sentence case in body strings, Apple title case on tab labels and titles (as inferred). Voice: plain, second person, no "we".
  • Build SDK: Xcode 26 / iOS 26 SDK (project.pbxproj header comment and XCODE_VERSION_ACTUAL = 2600). UIDesignRequiresCompatibility is absent from Info.plist, so the (26+) gate passes and (26+) checks were run. Deployment target 17.0.
  • direction: none, so section 5 proposes two. waived: 0 (no profile).
  • .loupe.md was not written (per instruction). Questions I would have asked, recorded here, answered by the defaults above:
    1. Is the purpose "reminders plus logging", or logging only? 2. Who is the audience (self, caregiver, older adults)? 3. Is this going to App Review soon, and is a medical-device or clinical claim intended? 4. Should data sync (iCloud) or stay on device? 5. Any brand assets (icon, colors, typeface) that exist outside the repo? 6. Peers the team measures itself against? 7. iPad or Apple Watch intended?
Run notes
  • Tools: file reads, scripts/contrast.py on source colors, WebSearch (2 queries) for the landscape. No Playwright (native source, no web). No simulator was booted or touched, so no global appearance or content-size changes were made. No files in the reviewed folder were edited. Only this report was written.
  • Evidence: source files in dosewise/ (line numbers below). Contrast results: 4B5563 on FFFFFF 7.56:1; 4B5563 on 1C1C1E 2.25:1 (fail); 4B5563 on 000000 2.78:1 (fail); 34C759 on FFFFFF 2.22:1 (fail); 30D158 on 1C1C1E 8.42:1; secondary label rgba(60,60,67,.6) on white 3.44:1. System color values are from Apple's published iOS palette, not rendered; row backgrounds assumed to be the standard inset-grouped fills (white light, 1C1C1E dark).
  • Landscape step used the web (third-party sites); see section 4.

2. Findings

Whole-app cap 40; 23 findings, none grouped; waived: 0.

# Finding Where Impact Apple Fix
1 Nothing is persisted. MedStore keeps medicines and doses in memory only; the models are Codable but never encoded. Quitting the app deletes every medicine, schedule and the whole adherence history. Reviewer judgement. Models.swift:22-69 (MedStore); whole app Blocker (data loss) Taste Persist with SwiftData, or JSON in Application Support as the smallest step; load on launch; save on every mutation.
2 Swiping a medicine away in the list deletes it and all of its doses, including taken and skipped history, with no confirmation and no undo. History's "Removed medicine" fallback can never be reached because the doses are gone. A typo in a name has no edit path (finding 14), so deleting is the only correction. MedicinesView.swift:15; Models.swift:53-58 Blocker (data loss) SHOULD Keep history: mark the medicine archived instead of removing its doses, and show "Removed medicine" rows honestly. Confirm with a count ("Delete Metformin? 14 logged doses stay in History."), or offer an Undo toast. Add an Edit button as the on-screen alternative to the swipe.
3 Taken-dose time uses a hard-coded hex Color(hex: 0x4B5563) with no dark or increased-contrast variant. In dark mode it is 2.25:1 on the row (needs 4.5:1; 13 pt). WCAG 1.4.3 fails, measured from source colors. In light mode it is darker than the pending times' .secondary, so a finished dose looks more prominent than a pending one. Models.swift:68; TodayView.swift:37 Blocker (1.4.3) SHOULD Use .secondary (or a named asset color with light, dark and increased-contrast values) for the taken time; let the "Taken" label carry the state.
4 "Taken" label and its checkmark.circle.fill glyph are Color.green (system green #34C759 in light mode): 2.22:1 on white for 17 pt text, and below 3:1 for the icon. Fails 1.4.3 and 1.4.11. Assumes standard row fill and system value; not rendered. TodayView.swift:53-54 Blocker (1.4.3) SHOULD Use an asset color with an AA variant per appearance (for example #1D6B4F on white 6.43:1, #4FD1A0 on #1C1C1E 8.88:1), or keep .primary text with a green glyph only. Keep the word "Taken".
5 The app promises reminders and sends none. DosewiseApp.init requests notification permission at launch (before the person has added anything), ignores the result, and no UNNotificationRequest is scheduled anywhere. The empty state says "Add a medicine to start getting reminders". Denial is never surfaced. Copy claims a capability that does not exist. DosewiseApp.swift:9; TodayView.swift:10-12; whole project (no scheduling) Major SHOULD Schedule a repeating local notification per medicine time when it is saved; ask for permission at that moment with one plain sentence of why; show an inline "Reminders are off" row (link to Settings) when denied. Until built, change the empty state to "Add a medicine to log your doses."
6 Doses exist only for the day the medicine was added. add creates today's Doses once; nothing creates tomorrow's. From day 2, Today says "No doses today" for a medicine taken daily. Also, a time earlier than now (add at 15:00 an 08:00 dose) appears as a normal pending dose. Models.swift:43-52, :28 Major Taste Generate doses from Medicine.times for each day (see Rework 1).
7 Today cannot tell due, overdue and upcoming apart. Rows are one flat list sorted by time, all identical when pending, with no "now" cue, no "Missed" state and no sections. Someone who cannot remember whether they took the 08:00 dose must read every row. Reviewer judgement; double-dose risk. TodayView.swift:12-14; Models.swift:12-17 (DoseStatus has no missed) Major Taste Group as Overdue, Next, Later, Done; add a missed state set when a dose passes by a grace window; give the next due row the only filled button.
8 A dose row never shows the medicine's strength, only the name. Two entries "Metformin 500 mg" and "Metformin 850 mg" are indistinguishable on Today and History, though strength is the field the Add form asks for. TodayView.swift:33; HistoryView.swift:17 Major Taste Show name then strength as the secondary line: "500 mg · 08:00".
9 Take and Skip are one tap each, final, and unreadable afterward: no undo, no way to change a mistaken "Taken" back to pending, and resolvedAt is stored but never shown, so the log cannot say when a dose was actually taken. Models.swift:37-42; TodayView.swift:39-50 Major SHOULD Add Undo (a toast or the row's swipe action "Mark as not taken"); show "Taken 08:12" on the row and in History.
10 The Skip control is an unlabeled SF Symbol button (forward.circle, about 22 pt glyph, .borderless): under Apple's 44 pt goal, and its default name is the symbol's description, not "Skip". VoiceOver and Voice Control also hear the same "Take" on every row with no medicine name, so "Tap Take" is ambiguous. WCAG 2.5.8 passes on spacing; 4.1.2 passes with a weak name, so judged. TodayView.swift:43-48 Major SHOULD Button("Skip", systemImage: "forward.circle") with .labelStyle(.titleAndIcon) or a swipe action plus a menu; .accessibilityLabel("Take \(name)") and "Skip \(name)"; .contentShape to 44 pt; .accessibilityElement(children: .combine) on the row.
11 Dose time is .font(.system(size: 13)), a fixed size that ignores Dynamic Type, and it is the key data on the row. The row is a horizontal HStack of name, spacer, icon and filled button with no layout change at accessibility sizes (not seen; no simulator). TodayView.swift:36, :23-50 Major SHOULD .font(.subheadline) (or .footnote); at isAccessibilityCategory stack buttons under the text (ViewThatFits or dynamicTypeSize branch).
12 "Add" is a tab with an action icon (plus.circle.fill) holding a form that clears itself on save with no confirmation and no navigation. After "Save medicine" the fields empty silently; the person cannot tell it worked until they open Medicines or Today. "Save medicine" is a form row that looks like a list item, not a button or toolbar action. RootView.swift:10-11; AddMedicineView.swift:40-46 Major SHOULD Remove the tab; add "+" in the Medicines toolbar that opens the form as a sheet with Cancel and Save in the toolbar; on save, dismiss and show the new medicine (and today's doses). Tabs left: Today, Medicines, History.
13 Adding a time is a trap. The field is prefilled "08:00" but only counts after tapping the separate "Add time" button; "Save medicine" stays disabled with no reason shown. Time is typed free text on a punctuation keyboard, shown as hard-coded 24-hour %02d:%02d (Today uses the locale format, so the two disagree for 12-hour users), and added times cannot be edited, removed or de-duplicated. AddMedicineView.swift:5-6, :11-14, :24-35, :45 Major SHOULD DatePicker("Time", selection:, displayedComponents: .hourAndMinute) in a "Times" section with swipe to remove; format with Date.FormatStyle; show a footer "Add at least one time" while Save is disabled; a medicine with the default time already visible counts without an extra step.
14 Medicines list is read-only and thin. It shows name and strength only: no times, no notes (the Notes field is stored and never displayed anywhere), no detail screen, no edit. First run shows a blank list (no empty state), and the Today empty state has no button to add a first medicine. MedicinesView.swift:7-16; TodayView.swift:10-12; AddMedicineView.swift:37-39 Major SHOULD Add ContentUnavailableView with an "Add medicine" button; make rows navigate to a detail with times, notes, edit and delete (delete with the safeguards in finding 2); or delete the Notes field until it is shown.
15 Eight or more pending doses show eight filled "Take" buttons; Apple allows at most one or two prominent buttons per view, and the filled style no longer marks the likely action. TodayView.swift:48-49 Minor SHOULD .bordered for later doses; filled only on the next due dose.
16 History says Taken or Skipped with a glyph only (checkmark.circle, forward.circle), no word and no grouping by day; each row repeats a full date. HistoryView.swift:19-25 Minor SHOULD Group by day (Section), put "Taken 08:12" / "Skipped" text in the row, keep the glyph as support.
17 "Clear" in History erases all adherence data after a dialog that states no count ("Clear all history? This can't be undone."). HistoryView.swift:30-41 Minor Research "Delete 42 logged doses? This can't be undone." and name the button "Delete 42 Doses".
18 Fields rely on placeholders as labels ("Name", "Strength, e.g. 500 mg", "Optional"): the label vanishes while typing. No capitalization or return-key behavior set on Name. AddMedicineView.swift:19-20, :38 Minor CONSIDER TextField("Name", text:) inside LabeledContent or a visible label; .textInputAutocapitalization(.words); .submitLabel(.next).
19 "No doses today" uses checkmark.seal, a completion symbol, for a state that means nothing is scheduled (and, from finding 6, also for a broken schedule). TodayView.swift:10-12 Minor Taste Use a neutral symbol; write two states: nothing added, all done.
20 The Add tab symbol is filled (plus.circle.fill) while Today, Medicines and History are outline symbols, so the tab bar reads unevenly. Largely moot once finding 12 removes the tab. RootView.swift:6-11 Minor CONSIDER Consistent symbol weight (system fills the selected one).
21 Orientation is locked to portrait. Info.plist:5 Polish SHOULD Allow landscape for iPhone unless a reason is recorded.
22 No haptic and no row animation when a dose is taken; the row just swaps. Feedback is single-channel. TodayView.swift:48-57 Polish SHOULD .sensoryFeedback(.success, trigger:) and withAnimation on resolve (fade under Reduce Motion).
23 Unreachable fallbacks "Unknown" and "Removed medicine" signal a model that allows orphan doses. TodayView.swift:33; HistoryView.swift:17 Polish Taste Resolved by finding 2's archive approach.

(26+) checks run: standard TabView, List and NavigationStack adopt the system look; no custom glass, no colored tab labels, no custom bar backgrounds. No hits.

3. Trust

No deceptive design patterns found in what was seen (no confirmshaming, urgency, pre-selection, hidden costs or fake social proof). The unsupported reminder promise is reported as finding 5 (an accuracy defect, not a dark pattern).

4. Landscape

Category: personal medication reminder and dose log. Web used; sources are partly secondhand, marked [S].

  • Apple Health, Medications. Why: the platform baseline for this job. Logging: Health, Browse, Medications, "+", Taken or Skipped, Done; forgotten doses can be added later; optional follow-up reminder if not logged after 30 minutes; optional Critical Alerts. Source: https://support.apple.com/en-gb/HT213351 and https://tidbits.com/?p=59847 (search results, 2026-10-26; page text not fully fetched).
  • Medisafe. Why: leading dedicated app. Reminder lock-screen actions "Take All" and "Snooze All" with the medicine name withheld for privacy; wrong choice can be undone from the pill box. [S] https://www.techenhancedlife.com/research_content/how-use-medisafe-app/ (page updated 2023, checked 2026-10-26).
  • MyTherapy. Why: challenger. Pop-up to take, skip or snooze 30 minutes; re-sends until answered. [S] https://healthify.nz/apps/m/mytherapy-meds-pill-reminder-app (not iOS-specific, 2026-10-26).
  • Adjacent exemplar: not researched. Seed playbook only (Health): "rest treated as valid", AVOID guilt-based streak notifications (unverified).

Conventions to keep: Taken / Skipped vocabulary (Apple's own); a way to log late; reminder actions that do not need opening the app (Medisafe, MyTherapy [S]); undo of a wrong log (Medisafe [S]; Apple allows editing). Convergence to avoid: a pill-icon health-blue app with a progress ring and streak score; guilt copy (unverified). Frontier moves: follow-up reminder 30 minutes later (Apple); critical alerts for must-not-miss doses (Apple); snooze (MyTherapy [S]). Openings (from the subject): medicine is physically organised by day-part in a weekly pill organiser (dosette); no studied app (from what I read) structures Today that way. (unverified: only three products, none opened). Best peer step count for the core task "log a dose": Apple Health from Health open: Browse, Medications, "+", Taken, Done (5 steps). Dosewise: 1 tap on Today once the app is open (it has no reminder to open it). Peer gap: none; the rework bar's peer-gap condition is not met.

5. Distinctiveness verdict

Verdict: default-assembled. Platform default on every one of the nine channels (type: SF Pro text styles plus one fixed 13 pt size; color: system blue, system green and one stray hex; shape, motion, iconography, imagery, sound and haptics, layout idiom: stock inset-grouped lists and tab bar; voice: generic "Add a medicine to start getting reminders"). Authored: 0 of 9. Signature element: none (the one-tap Take is a candidate behavioural element but is not carried through). Signature moments: 0. Slop-catalog tells: below the cluster threshold (platform conventions are never counted). Logo-off: nothing identifies the product. Competitor swap: renaming the screens "Medisafe" looks no more wrong. For a utilitarian product, platform default controls are correct; the verdict flags only that identity is not carried by any channel yet. Gate: Blockers and Majors are open on every flow, so the directions below are held until after the fixes.

direction is empty, so two directions. Default check: the generic theme for this category is a teal or blue gradient, pill icon, progress ring. Both avoid it.

Direction A: Dosette (day-part compartments).

  • Trace: the weekly pill organiser's morning, noon, evening, night compartments; amber pharmacy dispensing glass.
  • Color roles: accent amber #9A4D00 (oklch .51 .126 55) light, #FFB547 (oklch .82 .149 73) dark: 6.11:1 on white and 9.68:1 on #1C1C1E. Taken: #1D6B4F (oklch .47 .088 165) light 6.43:1, #4FD1A0 (oklch .78 .135 165) dark 8.88:1; state also carried by word and glyph. Overdue: #B3261E (oklch .50 .178 29) 6.54:1 on white. Neutrals: system backgrounds. Derives from two inputs: amber hue and green hue.
  • Type: system SF Pro for all text; candidate display face for the next-dose time only: Atkinson Hyperlegible Next (open license; trace: legibility for older or low-vision users). Not yet measured with type-eval.js; measure before adoption. Dynamic Type kept.
  • Layout idiom (signature element): Today as four compartments; a taken dose fills its slot. Moment: Take fills the compartment (brief fade under Reduce Motion).
  • Shape and motion: continuous-corner compartments, productive motion only.
  • Voice samples: button "Take Metformin"; empty state "Nothing scheduled today. Add a medicine to see its doses here."; error "Reminders are off. Turn them on in Settings so Dosewise can alert you."
  • Landscape position: differs from Apple Health's per-medicine log and from Medisafe and MyTherapy's reminder-list model by structuring the day.

Direction B: Label (prescription-label vernacular).

  • Trace: the dispensing label on the box: name, strength, instructions.
  • Color: warm paper #F6EFE2 (oklch .95 .019 83) in light with the same amber accent #9A4D00 (5.35:1 on paper); dark mode derived from the system dark set with #FFB547. Differs from A on color use and layout.
  • Type: system text; tabular figures for times and strengths (.monospacedDigit()), no brand face.
  • Layout idiom (signature element): every row prints "name, strength, time" as one label block; strength is never omitted (also fixes finding 8).
  • Moment: a stamped "Taken 08:12" line appears in place of the Take button.
  • Voice samples: button "Take"; empty "No medicines yet. Add one to start your list."; error as above.
  • Landscape position: the most plainly documentary of the studied peers. Pick one, or keep platform defaults and adopt only the behavioural signature (strength always shown, one-tap Take).

6. Type

Face: SF Pro (system) via text styles. Roles: UI, reading, data (times)
Legibility: PASS (system face); fixed 13 pt time is a CONCERN for scaling, not shape
Figures and features: tabular available (.monospacedDigit); distinct zero n/a
Coverage: system; locale issues none seen; times hard-coded %02d:%02d in Add (finding 13)
Scaling: Dynamic Type issue: TodayView.swift:36 fixed size; Bold Text not seen (no simulator)
Distinctiveness: default tier yes; identity carried by nothing
Verdict: Tune (replace .system(size: 13) with .subheadline; use monospacedDigit for times)
Evidence: source only; type-eval.js not run (no web target)

Candidate face Atkinson Hyperlegible Next: not measured (offline from fonts); verdict deferred.

7. Reworks

Rework 1: Schedule-first model (persisted schedule generates doses and reminders).

  • What changes: before, add writes one day's Dose objects into memory and nothing else. After, a persisted Medicine schedule generates each day's doses on demand (and their reminders), and logged doses are the only stored history, kept when a medicine is archived.
  • Bar: Cluster. Findings 1, 2, 5, 6 and 7 share one cause: dose records are one-day, in-memory snapshots rather than the product of a stored schedule.
  • Evidence: findings 1, 2, 5, 6, 7; Models.swift:43-52; no scheduling or storage code in the project.
  • Cost: Models.swift, TodayView, MedicinesView, HistoryView, DosewiseApp; no new screen. Risk: store schema decision (SwiftData vs file) before any user data exists, which is the cheapest moment.
  • Unlocks: Today is correct on day 2; reminders; missed state; history survives deletes; later widgets and Siri can read one source of truth.
  • Smallest experiment: persist medicines plus a computed dosesFor(day:) and run it for a simulated week.

No other rework clears the bar. Moving Add off the tab bar (finding 12) is a fix. Peer gap not met (section 4).

8. Enhancements

Fewer than eight; evidence is thin without a running build.

Experience and flows

  1. Take and Skip from the reminder itself (notification actions). Principle: Agency. Mechanism: a notification category with "Take" and "Skip" actions that call resolve; the name can be withheld on the lock screen like Medisafe [S]. Unlocks: the core job without opening the app; builds on finding 5 and Rework 1.
  2. Today ordered around "what is due next". Principle: Simplicity. Mechanism: Overdue, Next, Later, Done sections; the next dose carries the one filled button. Traced to the 2-to-4-times-a-day pattern of the data model (times: [DateComponents]).

Platform capabilities 3. A Lock Screen or Control Center control that logs the next dose. Principle: Flexibility. Mechanism: App Intent "Take next dose" (the noun is Dose, the verb is Take). Source: seed platform capabilities in landscape.md (unverified for this app's OS target). 4. Offer a late log: "I took this earlier" with a time picker. Principle: Responsibility. Mechanism: Apple's Medications supports adding forgotten doses (source above). Unlocks an honest log, because resolvedAt already exists.

Motion and feedback 5. Success haptic plus a short fill on Take. Principle: Craft. Mechanism: sensoryFeedback; fade only under Reduce Motion.

Brand and identity 6. Carry one behavioural signature: strength and time always visible, one tap to log. Principle: Purpose. Traced to the label and dosette directions in section 5. Held until the Blockers and Majors above are closed.

9. Unverified

  • Everything rendered: layout, Dynamic Type up to AX5 (finding 11 is from source), dark mode and Increased Contrast, Reduce Motion, tab bar and large-title behavior. Verify by booting the app in a simulator.
  • Contrast for findings 3 and 4 is computed from known system and literal colors on assumed standard row fills; confirm with a screenshot and scripts/png_contrast.py.
  • Notification delivery, denial and re-prompt behavior: nothing is scheduled (finding 5, possibly unfinished given development); verify after scheduling is built.
  • The "Add time" button inside the form row (tap behavior next to a text field), the swipe-delete gesture, Voice Control and VoiceOver reading order: need a device or simulator.
  • Pseudo-localization, long names and RTL: not run. The code has no String(localized:) and no localization resources. Long medicine names are only limited by default wrapping.
  • Midnight rollover, time-zone and daylight-saving changes for scheduled doses.
  • Pending doses and the Today empty/loading states over time (no loading states exist; storage is in memory).
  • Landscape: step counts for Medisafe and MyTherapy [S], and the adjacent exemplar, were not confirmed from primary sources. iPad and Apple Watch were not reviewed (not targeted).
  • App Review (2.1, 4.2, and medical-app expectations) not assessed (development).
  • Fonts in section 5 not measured.

10. Offer

Fixable in code now, grouped:

  • Data safety: 1 (needs the storage choice), 2, 9, 17.
  • Contrast and accessibility tokens: 3, 4, 10, 11, 16.
  • Today row content and layout: 7, 8, 15, 22 (7 needs a decision on the grace window for "missed").
  • Add flow: 12, 13, 18, 20 (12 needs a design call: sheet from Medicines).
  • Empty states and copy rewrites: 5 (copy only), 14, 19.
  • Larger: 5, 6 (scheduling) need Rework 1 first. 21, 23 are trivial.

Enhancements and reworks need your call: say if you want Rework 1's smallest experiment prototyped, or any enhancement built. Which should I do?

Optional extra input that would let me verify more: a booted simulator with the app (for screenshots, AX5, dark mode, Increased Contrast), the app icon and any brand assets, and answers to the seven questions in section 1.

Claude Sonnet without loupe: the review it wrote with no skill
<!-- model: claude-sonnet; skill commit: 7559367 -->

Dosewise design review (UI and UX)

Scope: 7 Swift files (about 277 lines), iPhone only, iOS 17+, portrait only. Source read only; nothing was run, so contrast and layout claims are computed or inferred, not seen on a device.

Assumptions (owner unavailable): this is a daily medication reminder and dose log for an individual adult, possibly also used by a carer. It is pre-release, so I treat "not built yet" gaps as findings only where the UI already promises the behaviour. Severity: Critical = breaks the core job or can cause harm; High = likely user failure; Medium = clear friction; Low = polish.

Summary

The structure is sensible (4 tabs, native List/Form, ContentUnavailableView empty states). But the core promise, reliable reminders, is not delivered. The app asks for notification permission and never schedules anything. Data is lost on every launch. Doses exist only for the day a medicine was added. In a medication app these are product-safety issues, not polish. Below that sit accessibility problems (an unlabeled icon button, hard-coded font size and colours, colour-only status) and a weak Add flow (typed "HH:MM" text, no validation feedback, no confirmation after saving).

Critical

C1. Reminders are never scheduled. DosewiseApp.init requests notification authorization, but nothing creates a UNNotificationRequest. The Today empty state says "Add a medicine to start getting reminders", which is false. A user who relies on this will miss doses. Change: schedule a repeating calendar notification per medicine time on save, cancel on delete, and add actionable Take/Skip notification buttons. Move the permission request from app launch to the first successful save, with a one-line explanation shown first. If permission is denied, show an inline banner on Today with a link to Settings.

C2. Nothing persists. medicines and doses are in-memory arrays. Force-quitting or a reboot erases the medicine list and the dose history. History is a medical record the user may show a clinician. Change: persist (SwiftData, or Codable to file). Until then do not ship.

C3. The schedule does not roll over. add() creates doses only for today, using today's date. Tomorrow today is empty and the screen says "No doses today" even though medicines exist, so the daily schedule silently stops. Times already past at the moment of adding create instantly "overdue" pending doses. Medicine rows have no link to their dose times. Change: generate doses lazily for each day from the medicine's times (on launch, on foreground and at midnight), and decide a rule for times earlier than "now" on the day of adding (e.g. start tomorrow, or ask).

High

H1. Unlabeled icon-only Skip button. In DoseRow the skip control is Image(systemName: "forward.circle") with no label. VoiceOver reads the symbol name; sighted users must guess that a skip icon means "I'm not taking this". It sits 16 pt from the primary Take button, a mis-tap risk for a dose log, and the glyph is under 44 pt tall. Change: use a text button "Skip" (secondary, .bordered), or a swipe action plus a labelled Menu. Add .accessibilityLabel("Skip \(name) dose") and on Take .accessibilityLabel("Mark \(name) taken"). Ensure 44 pt hit targets.

H2. No undo or correction for Taken/Skipped. resolve is one-way. A mis-tap on Take (adjacent to Skip) is permanent and there is no way to change it, and the History row is not tappable. The user can neither fix the log nor see when they actually took it (resolvedAt is stored and never shown). Change: show a brief undo toast, make the resolved row tappable to "Change to Taken/Skipped/Pending", and show "Taken at 8:12" in the row.

H3. Status relies on colour and weak contrast. Taken is green text (.green on white is about 2.3:1, fails WCAG AA 4.5:1 for small text); the label text is the same size as the secondary text. doseTakenText 0x4B5563 is a fixed grey that does not adapt to dark mode: on a near-black background it is roughly 2.3:1. The name "doseTaken" colour is also darker than .secondary on light, so "taken" rows look more prominent than pending ones, the opposite of the intent. History distinguishes taken/skipped only by two similar outline glyphs, no colour or text. Change: use semantic/adaptive colours (.secondary, asset-catalog colours with dark variants, or Color.green darkened variant), keep the text label, and add distinct fills (checkmark.circle.fill vs minus.circle) plus a text status in History ("Taken"/"Skipped").

H4. Time entry is free text. Add Medicine asks the user to type "HH:MM" in a numbers-and-punctuation keyboard. 12/24-hour locale is ignored, "8:5" parses, "8.00" silently fails, and the only feedback is the disabled "Add time" button with no explanation. Typed "8:00" becomes a time of day with no AM/PM. Change: use DatePicker("Time", selection:, displayedComponents: .hourAndMinute) (compact style) and an "Add time" button. This removes parsedTime entirely.

H5. Entered times can be lost or can't be fixed. Typed times in the list cannot be deleted or edited (ForEach has no onDelete), are not sorted, and duplicates are accepted. A wrong time forces the user to restart the form. Change: .onDelete/EditButton, sort ascending, ignore duplicates.

H6. No edit for existing medicines. The Medicines list rows are not tappable, so a change of dose or time requires deleting the medicine, which also deletes all its dose history (delete removes every dose, including taken ones). Swipe-to-delete has no confirmation. The result is silent loss of medical history,; the "Removed medicine" fallback in History is therefore dead code. Change: tap to edit (reuse the Add form), confirm destructive deletes, and keep history by archiving instead of deleting.

Medium

M1. "Add" as a peer tab. Adding a medicine is an action, not a destination. Using plus.circle.fill as a tab item mixes iconography with the tab bar's outline style, and the form stays populated state-wise as a screen you can leave half-filled. Apple's tab guidance is for navigation between sections. Change: three tabs (Today, Medicines, History) and a + toolbar button on Medicines (and Today's empty state) opening the form as a sheet with Cancel/Save in the navigation bar.

M2. Saving gives no feedback. "Save medicine" clears the fields and stays on the tab. Nothing confirms it worked, and the user cannot tell whether it saved. The Save button is a plain row in the form, far from the nav bar convention, and its disabled state has no explanation (needs a name and at least one time). Change: dismiss the sheet and show the new medicine in the list; mark required fields; put Save in the toolbar.

M3. No validation or safety affordances. Name and strength are free text, no duplicate check, no dose amount or unit field ("Strength" and "dose" are conflated: strength 500 mg is not the quantity per intake), no frequency options beyond daily times (no days of the week, as-needed, end date), notes never appear anywhere after saving (not on Medicines, not on Today). Change: add "Dose" (e.g. 1 tablet), days of the week, and show notes and dose on Today rows.

M4. Today screen lacks hierarchy for the core question "what do I take next?" Rows are a flat time-sorted list. There is no overdue state (a 08:00 pending dose at 15:00 looks identical to a future one), no grouping (Morning/Afternoon/Evening or Upcoming/Done), no progress summary, and the time is 13 pt secondary text, lower than the medicine name, though time is what the user scans for. Change: section "Overdue / Upcoming / Done", highlight the next dose, show "2 of 4 taken", and make the time larger and primary-ish.

M5. Hard-coded type. .font(.system(size: 13)) in DoseRow does not scale with Dynamic Type; at larger text sizes the time stays tiny while the name grows. The row's HStack with a name plus two buttons has no layout fallback, so at accessibility sizes the name will truncate against "Take". Change: .font(.footnote), and switch to a vertical layout via ViewThatFits or dynamicTypeSize.isAccessibilitySize.

M6. Row interaction pitfalls. Buttons inside a List row with .borderless is the correct workaround, but the outer .buttonStyle(.borderless) also overrides the intended .borderedProminent on Take in a way that depends on modifier order; verify on device that Take renders as prominent. Prefer swipe actions (Take leading, Skip trailing) as an additional route.

M7. Notification permission asked cold at launch. The system prompt appears before the user knows why (see C1).

Low

  • L1. History "Clear" is a red destructive text button in the nav bar, always visible; fine as it is confirmed with a dialog, but "Clear" next to a medical log is easy to hit. Move it into a "..." menu. Add grouping by day headers and filters; there is no way to see adherence over time.
  • L2. History row shows the scheduled time but not the actual action time or outcome in words.
  • L3. Empty state on Today uses checkmark.seal, which suggests "all done" rather than "nothing set up"; with no medicines it needs a button "Add medicine". Medicines tab has no empty state at all (blank list).
  • L4. Tab icons: pills, cross.vial, clock.arrow.circlepath are fine; the first two are visually close. Consider list.bullet/pills for Medicines.
  • L5. UILaunchScreen is an empty dict (white flash in dark mode); there is no app icon or accent colour defined, so the app uses default system blue and looks like a template. Choose a brand accent and verify contrast in both modes.
  • L6. Portrait-only and iPhone-only is acceptable for v1, but it disables landscape for users with mounted devices and removes iPad. State this as a decision.
  • L7. Copy: "Doses you take or skip will appear here" is good; "Unknown" and "Removed medicine" labels will confuse; use consistent terms (medicine vs medication, "dose" vs "time").
  • L8. No haptic or animation on Take (.sensoryFeedback(.success, trigger:)), no animated row transition when a dose is resolved.
  • L9. No localisation (strings hard-coded), no accessibility audit of VoiceOver order in rows (name, time, then buttons; fine once labelled).

Not assessed

Rendering, real contrast in the final palette, Dynamic Type behaviour, VoiceOver, and performance were not tested (no simulator). Medical or regulatory review (clinical safety of dose logging) is out of scope but C1-C3 and H6 should be handled before any user test with real medication.

Suggested order of work

  1. C1-C3 (reminders, persistence, daily generation).
  2. H1-H3 (accessible Take/Skip, undo, contrast and status).
  3. H4-H6 and M1-M2 (DatePicker, edit and delete flow, Add as a sheet).
  4. M3-M5, then Low items.

Claude Haiku: loupe’s report

Blind judges: 1 of 3 preferred this report, 2 preferred the one written without loupe.

<!-- model: claude-haiku; skill commit: 7559367 -->

Loupe review: Dosewise (SwiftUI, iPhone, development build)

1. Summary

  • Reviewed: whole app, inline (5 screens, 7 Swift files). Input: native source only (dosewise/*.swift, Info.plist, project.pbxproj). No simulator, no running build, so every rendered check is Unverified or judged from code.
  • Verdict: a functional prototype built almost entirely from stock SwiftUI parts. Two Blockers: dark-mode text contrast on taken doses, and swipe-delete that silently erases a medicine's dose history. The main gaps are missing features the screens imply (reminders, editing, persistence) and an Add flow with no feedback or proper time entry.
  • Most important: fix the two Blockers, then decide whether reminders are in scope for this build, since the Today empty state promises them.
  • Rework: No rework warranted. The defects are fixable per row, and the missing pieces are features (enhancements), not a structural cluster or a wrong core flow.

Assumptions (no owner answers; no .loupe.md, not written):

  • release_stage: development (default). App Review checks (4.2, 2.1) are not gated; placeholder-like copy is noted in Unverified.
  • Build SDK: Xcode 26 / iOS 26 SDK (project.pbxproj line 2 comment Toolchain: Xcode 26 (iOS 26 SDK)). UIDesignRequiresCompatibility absent from Info.plist, so the (26+) gate passes and (26+) checks were considered. The app uses stock components, so there is no custom Liquid Glass to flag.
  • Deployment target iOS 17.0; TARGETED_DEVICE_FAMILY = 1 (iPhone only); portrait only.
  • purpose: personal medication dose tracker (inferred from MedicinesView, TodayView, HistoryView).
  • register: utilitarian (frequent check-and-act health task).
  • humor: none. voice: plain, sentence case, second person. direction: empty, so distinctiveness gives a verdict and offers directions.
  • density: comfortable (iOS 17 pt body).
  • audience: an individual managing their own medicines, several times a day.
  • platforms: iPhone.

Run notes

  • Tools used: Bash (file listing, reading, contrast arithmetic in Python with -I), WebSearch (1 query). No Playwright or simulator (native source only). No scripts/measure*.js or scripts/png_contrast.py runs, since there is no rendered page or screenshot.
  • Contrast figures were computed from the hex values in source (WCAG relative luminance formula) against assumed backgrounds. They are not measured on a device.
  • Landscape web research was done. Skill files read: SKILL.md, references/report-template.md, workflow-intake.md, workflow-passes.md, augment/red-team.md, augment/landscape.md, plus targeted greps of references/apple/*.md.
  • Side effects: none on the user's files. Nothing edited. The only file written is this report.

2. Findings

# Finding Where Impact Apple Fix
1 Taken doses show their scheduled time in a hard-coded #4B5563 text color. In dark mode this is 2.25:1 on the inset cell (about #1C1C1E), and 2.78:1 on pure black. Text is 13 pt regular, so 4.5:1 is required (WCAG 1.4.3). The light-mode ratio is 7.56:1, so it only fails in dark mode. TodayView.swift DoseRow (.foregroundStyle(dose.status == .taken ? Color.doseTakenText : .secondary)); Models.swift Color.doseTakenText Blocker (source-confirmed WCAG 1.4.3) SHOULD (content-a11y.md:22 "Contrast meets WCAG AA"; content-a11y.md:24 "Flag hard-coded hex values on semantic surfaces"; visual.md:211 "no hard-coded values") Drop the hex. Use .secondary, or an asset-catalog color with light and dark variants that both pass 4.5:1. Re-check in dark mode with Accessibility Inspector.
2 Swipe-delete on a medicine silently removes every dose for it, including taken and skipped history. No confirmation. The History "Removed medicine" fallback can never show, because the doses are deleted with the medicine. MedicinesView.swift:10 .onDelete { store.delete(at: $0) }; Models.swift delete(at:) (doses.removeAll { ids.contains($0.medicineID) }) Blocker (data loss) SHOULD (components-presentation.md:67 "Reserve alerts for uncommon and irreversible destructive actions"). Confirmation dialog needed because the loss is irreversible. Keep the history: stop deleting doses, or archive the medicine and keep its name for History. If deletion stays, add a confirmationDialog stating that history will be lost. Then the "Removed medicine" branch becomes reachable.
3 "Taken" label uses .green (system green) on the light background. About 2.2:1 for body-size text, which fails 1.4.3. Dark mode passes (about 8.4:1). TodayView.swift DoseRow case .taken (Label("Taken", …).foregroundStyle(.green)) Major, Blocker candidate. Upgrade to Blocker once measured on a device in light mode. The system green RGB (#34C759) is recalled, not confirmed in-session. SHOULD (content-a11y.md:22) Use a darker text tone for the "Taken" label, or keep green only on the icon and make the text .primary. Measure in light mode with Accessibility Inspector.
4 Nothing persists. MedStore is an in-memory @Observable. Medicines and doses are lost on every relaunch. No UserDefaults, SwiftData or file storage in the eight source files. Models.swift MedStore; DosewiseApp.swift @State private var store = MedStore() Major in this development build. Becomes a Blocker (data loss) before release. Taste (reviewer judgement; Apple silent on persistence) Persist to SwiftData or a Codable file. Add a relaunch test before any release.
5 The Today empty state promises reminders ("Add a medicine to start getting reminders"). No UNNotificationRequest is scheduled anywhere. The app requests notification permission at launch, before any value is shown. TodayView.swift ContentUnavailableView description; DosewiseApp.swift init() calls requestAuthorization Major (false capability claim; dead promise). Not a dark pattern. SHOULD (content-a11y.md:154 "No permission prompts at launch unless the app cannot work without that permission") Either schedule local notifications for each dose and request permission in context (e.g. after the first medicine is saved), or change the copy to what the app does. Honest copy: "Add a medicine to see today's doses."
6 The Skip control is an icon-only button (forward.circle) with no visible label. VoiceOver reads only the symbol name, which is not a description of the action. TodayView.swift DoseRow Button { store.resolve(dose, as: .skipped) } label: { Image(systemName: "forward.circle") } Major (judged, not an accessibility failure: a name exists) SHOULD (content-a11y.md:192 "Every key element has a label that describes function") Use Button("Skip", systemImage: "forward") or add .accessibilityLabel("Skip dose"). Keep the text visible if space allows at AX sizes.
7 Take and Skip are final on tap. There is no undo, and a skipped dose cannot go back to pending. Accidental taps are permanent. Models.swift resolve(_:as:); TodayView.swift buttons Major Research (components-presentation.md:12 offers undo for a common undoable action; that line is untagged, so Research shows) Add a short undo affordance after each Take or Skip (a row action or a toast with Undo), or allow reverting a dose to pending from the row.
8 Dose time uses a fixed .system(size: 13) font. It does not scale with Dynamic Type, so users' text-size settings are ignored on this line. TodayView.swift DoseRow (.font(.system(size: 13))) Major (accessibility need; cannot be measured without a device) SHOULD (visual.md:68 "Prefer built-in text styles (Dynamic Type comes free)") Use .font(.subheadline) or .footnote.
9 "Add" is a tab. It is an action, not a destination. RootView.swift Label("Add", systemImage: "plus.circle.fill") tab Major SHOULD (components-structure.md:429 "Navigation only, never actions (… actions go in a toolbar)") Move the add action to a + toolbar button on the Today and Medicines screens. Keep three tabs (Today, Medicines, History).
10 The Medicines screen has no add button, no empty state, and does not show a medicine's times or notes. A blank list is shown when there are no medicines. The times and notes the user enters on Add are saved but never displayed anywhere. MedicinesView.swift (rows show name and strength only; no toolbar; no ContentUnavailableView); AddMedicineView.swift saves times and notes Major SHOULD (components-structure.md:429, for the toolbar add button). Empty-state guidance is CONSIDER (patterns.md:240). Showing schedule and notes is Taste (reviewer judgement). Show a schedule summary under each medicine name (e.g. "08:00, 20:00") and the notes if present. Add a + toolbar button and a ContentUnavailableView with the add action.
11 Saving a medicine gives no confirmation. The form clears silently and the user stays on the Add tab. AddMedicineView.swift Save medicine action (name = ""; strength = ""; times = []; notes = "") Major SHOULD (beyond-hig.md:30 "Every action gives some confirmation feedback") Navigate to Today or Medicines after save, or show a brief confirmation. Dismiss the form if it is presented as a sheet.
12 Dose times are typed as free text ("HH:MM", .numbersAndPunctuation keyboard) instead of a time picker. The format is hard-coded 24-hour (String(format: "%02d:%02d")), while Today uses the locale time style. Users cannot see the 12/24-hour setting they chose. AddMedicineView.swift TextField("HH:MM", …), parsedTime, Text(String(format: "%02d:%02d", …)); TodayView.swift Text(dose.scheduled, style: .time) Major SHOULD (components-presentation.md:206 "iOS date picker: … Use compact when space is tight") Use DatePicker("Time", selection:, displayedComponents: .hourAndMinute) and format with .time style everywhere.
13 A dose scheduled earlier today stays Pending forever once its time passes. There is no Missed state. A medicine added after its time today gets a dose in the past. Models.swift add(_:) (schedules cal.date(bySettingHour:…, of: .now)); TodayView.swift shows any pending dose Major Taste (reviewer judgement; Apple silent on missed-dose state) Mark past-due pending doses as Missed (or Overdue) with a distinct style, and skip creating doses before the current time for a newly added medicine.
14 Times cannot be removed from the Add form once added, and duplicates are allowed. A wrong time means clearing the whole form. AddMedicineView.swift ForEach(times.indices) (display only); Button("Add time") appends with no duplicate check Minor Taste Add .onDelete on the times list and ignore duplicate times.
15 Save medicine is disabled until a name and a time exist, with no reason shown. The Add time button is disabled on invalid HH:MM with no message. AddMedicineView.swift `.disabled(name.isEmpty times.isEmpty), .disabled(parsedTime == nil)` Minor
16 History rows show taken or skipped only as an icon (checkmark.circle or forward.circle). No text says "Taken" or "Skipped". HistoryView.swift Label { … } icon: { Image(systemName: …) } Minor Taste Add a status line such as "Taken" or "Skipped" under the time, or use the same Label style as Today.

Trust: No deceptive patterns found in what was seen. Finding 5 is a false capability claim, not a dark pattern. It is listed as Major, not as a Trust-gate Blocker.

waived: 0 (no profile waivers).

3. Trust

No deceptive patterns found in what was seen. The reminders copy in finding 5 is an inaccurate description of a feature that does not exist yet. Rewrite: "Add a medicine to see today's doses."

4. Landscape

Category: personal medication reminder and dose tracking, iOS.

Studied (sources checked 2026-10-26; from search snippets, not full page fetches, so landscape claims are not fully verified):

  • Medisafe Pill Reminder (medication reminders, drug-interaction database). Source: https://apps.apple.com/th/app/id573916946 (store page, checked 2026-10-26). A 2026 roundup calls it the largest name in pure medication reminders: https://digitalhealth.folio3.com/blog/?p=15182 (checked 2026-10-26; secondary, third-party roundup).
  • MyTherapy Medication Reminder (reminders plus a health diary to share with a doctor). Source: https://apple.co/2FB72Zp (App Store, checked 2026-10-26). Reviews praise its reminders; a recurring complaint is that family members cannot see which medicines were taken or missed (secondary roundup and reviews, (unverified) detail).
  • Apple Health (adjacent exemplar: no-cost health log). Source: https://digitalhealth.folio3.com/blog/?p=15182 (same roundup; secondary).
  • Round Health: not found in search. Not studied.
  • Caveat: https://bearable.app/?p=19925 is a comparison by Bearable's own team, disclosed as such; not used for ranking.

Four lists (landscape step, (unverified) where marked):

  • Conventions to keep: a dose list ordered by time with Take and Skip actions ((unverified) from roundups); a history log of taken and skipped doses.
  • Convergence to avoid: none measured. The product is already the stock template, which is itself the risk in the Distinctiveness verdict.
  • Frontier moves: reminders delivered as notifications with Take and Skip actions (Apple notifications allow up to four actions, components-presentation.md:419); an adherence summary.
  • Openings: sharing a schedule and taken status with a caregiver. MyTherapy's reviews name the caregiver gap as a complaint ((unverified)); this fits the product's audience of people managing other people's medicines. Do not add it without a product decision.
  • Core-task step count (peer): not sourced. The rework bar's peer-gap condition is not met.

5. Distinctiveness verdict

default-assembled. Evidence:

  • Channels: platform default (stock TabView, NavigationStack, List with .insetGrouped, ContentUnavailableView, .borderedProminent, SF Symbols throughout). Library default: none. Trend default: none. Authored: only the hard-coded #4B5563 (Models.swift), which is the one custom color and breaks dark mode (finding 1).
  • The slop-catalog was not counted tell by tell (no rendering), so no numeric tell count is given. The verdict rests on the channel review: one authored choice, and it is a defect.
  • Logo-off and competitor-swap tests need rendered screens: Unverified.
  • No direction is set in the profile, so directions are offered below.

Directions (2, for the user to pick; none adopted):

  • A. Calm clinical: neutral system palette, one accent (a teal or blue) used only for the due dose and the Take action, system text styles, minimal chrome. Differs from default on: accent role, density of the Today screen (grouped Due / Done sections).
  • B. Quiet routine: schedule-first layout where the day reads as a timeline with dose ticks; accent reserved for taken. Differs on: layout idiom (timeline vs list) and motion (one restrained tick animation on Take).
  • Neither is a look to copy from a peer. Both must be checked against the product's audience and the Blocker fixes first.

6. Type

System font only (no UIAppFonts in Info.plist). One fixed size: .system(size: 13) in TodayView.swift (finding 8). Other text uses built-in styles (.headline, .subheadline, .caption, default). The type-eval script was not run (no rendered page). Unverified.

7. Reworks

No rework warranted. Reason: the defects are fixable per row (findings 1 to 16), and the largest gaps (reminders, persistence, editing) are missing features, which are enhancements under the rework bar's premise rule. No root cause is shared by three or more findings that per-row fixes cannot remove.

8. Enhancements

Up to 8. Fewer than that, because the evidence is thin without a rendered build.

Experience and flows

  1. Reminders as notifications with Take and Skip actions. Principle: Responsibility. Product hook: the Today empty state already promises reminders; the Take and Skip actions match the existing row actions. Mechanism: one UNNotificationRequest per dose with two actions; request permission in context after the first medicine is saved. Unlocks: the app does what its copy says, and doses can be taken without opening the app. Peers: frontier move (roundup sources above, (unverified)).
  2. Due and Done grouping on Today. Principle: Simplicity. Product hook: today's list mixes pending, taken and skipped doses sorted only by time (TodayView.swift). Mechanism: two sections, "Due" (pending, sorted by time) and "Done" (taken or skipped). Unlocks: the next dose is the first thing seen.
  3. Haptic on Take. Principle: Craft. Product hook: the Take button gives no feedback beyond the row changing. Mechanism: .sensoryFeedback(.success, trigger:) on the taken state. Unlocks: confirms the action without another screen.

Information architecture 4. Adherence summary at the top of History. Principle: Purpose. Product hook: History lists rows with no totals. Mechanism: a single line such as "This week: 12 taken, 2 skipped" above the list. Unlocks: the question "am I on track" is answered without scanning rows.

Content and voice 5. Dose time shown as a single, locale-aware string everywhere (see finding 12). Principle: Familiarity. Mechanism: .time style in Add and Medicines as well as Today. Unlocks: consistent time format with the user's system setting.

Platform capabilities 6. Home Screen or Lock Screen widget for today's next dose. Principle: Flexibility. Only once finding 5 is done and persistence exists. Unlocks: a glance at the next dose without opening the app. Landscape: not checked beyond the roundups. 7. Editable medicines (tap a row to edit name, strength, times, notes). Principle: Agency. Product hook: the only way to correct a medicine is to delete it, which loses history (finding 2). Unlocks: corrections without data loss. 8. Caregiver view of today's doses. Principle: Responsibility. Opening only; needs a product decision and a privacy review before any design. Not proposed as a build item.

9. Unverified

  • Dark-mode contrast of finding 1: the background (#1C1C1E) is assumed. Verify with Accessibility Inspector on a dark-mode device or simulator; ratios would change if the cell background differs.
  • Light-mode contrast of finding 3: system green RGB recalled from memory. Verify on a device; if it fails, upgrade finding 3 to Blocker.
  • Skip and Take hit area: the .borderless icon buttons have no explicit frame, so they may be smaller than the 44x44 pt control size (content-a11y.md:31). Verify with Accessibility Inspector frames.
  • Dynamic Type at AX5: TodayView DoseRow uses an HStack with two buttons and a long medicine name. Truncation or overlap is unseen. Verify by setting the largest accessibility text size on a device.
  • Empty, error, loading and offline states: no loading or error state exists in source (synchronous in-memory store). Empty states for Today and History were read from code and not seen rendered. Verify by launching with no medicines.
  • Dark mode, increased contrast, reduced motion, RTL, pseudo-localized text, narrow and very wide widths: not seen (no simulator). Verify by running the app.
  • Transient feedback: none in source for Save, Take or Skip (no toast, haptic or animation). Verify by taking a dose on a device and watching for a visible change.
  • Notification delivery: no scheduling code exists, so delivery cannot be verified from source. Verify once finding 5 is implemented.
  • Past-due behavior (finding 13): read from add(_:) and the Today filter; not exercised. Verify by adding a medicine with a time earlier than now.
  • App Review (4.2, 2.1): not gated (release_stage: development). The reminders copy would be a placeholder-like issue at release. Re-check before submission.
  • Landscape: based on search snippets and roundups (some from a vendor's own comparison, disclosed). Step counts for peers were not sourced, so the peer-gap rework condition is not met.
  • Localization: no .strings or .xcstrings in the project. Localization not reviewed.

10. Offer

Fixable in code now, if the user picks findings:

  • Contrast tokens: findings 1 and 3.
  • Copy rewrites: findings 5 (empty-state line), 15 (validation footer text), 16 (status text).
  • Accessibility labels and text styles: findings 6 and 8.
  • Add flow and navigation: findings 9, 10 (add button, empty state, schedule summary), 11 (confirmation after save), 12 (time picker), 14 (remove times).
  • Data loss fix: finding 2 (choice needed: keep history on delete, or confirm the loss).

Design decisions needed first:

  • Finding 2: keep history after deleting a medicine, or confirm the loss? (Affects the data model.)
  • Finding 4 and enhancement 1: persistence and notifications are scope decisions, not bug fixes.
  • Finding 13: Missed state wording and visual treatment.
  • The two directions in section 5.

Enhancements and reworks: the user decides. Enhancements 1 to 8 are offered as options. No reworks.

Optional input that would let the skill verify more: a simulator build or a booted device (for dark mode, Dynamic Type and hit areas), and the Xcode project's .xcassets color sets if any exist (none were in the source provided).

Not written: no .loupe.md. No files in the user's project were edited.

Claude Haiku without loupe: the review it wrote with no skill
<!-- model: claude-haiku; skill commit: 7559367 -->

Dosewise UI/UX Design Review (development build)

Scope: the 8 Swift/plist files in dosewise (about 277 lines of Swift). Source only, no simulator, so nothing was seen rendered. Findings come from reading the code; visual claims (contrast, layout) are inferred and should be confirmed on device.

Defaults assumed (owner unavailable)

  • iPhone only, portrait only (per Info.plist). iPad and landscape not reviewed.
  • Minimum iOS 17, inferred from @Observable (Observation framework). The .pbxproj was not in the folder, so this is not confirmed.
  • The product promise is a medication reminder app. The app copy says "Add a medicine to start getting reminders", so reminders are treated as a core feature, not a nice-to-have.
  • English only. Light and dark mode both matter.
  • Single user on a single device. No sync, no caregivers.
  • Medication-adherence data is treated as sensitive, so loss or silent corruption is rated high severity.

Summary

The screens are built from standard SwiftUI parts (TabView, List, Form, ContentUnavailableView, confirmationDialog), which is a good base. The app does not yet do its main job. Reminders are never scheduled, nothing is saved, and today's doses are generated once and never again. Any one of these makes the build unfit for a real user taking medication. Fix the blockers before any further UI polish.

Severity scale: Blocker (the app fails its core promise or can cause a patient to miss or double a dose), High (a common task goes wrong or a HIG rule is clearly broken), Medium (friction, inconsistency, or weak accessibility), Low (polish).


1. Blockers

B1. No reminders are ever scheduled
  • Evidence: DosewiseApp.swift:9 requests notification authorization. Nothing in the project calls UNUserNotificationCenter.add(...) or creates a UNCalendarNotificationTrigger. MedStore.add (Models.swift:43-51) only appends Dose rows to memory.
  • Impact: the app's whole reason to exist does nothing. The user sets up a medicine, gets no alert, and misses doses. The empty state ("Add a medicine to start getting reminders", TodayView.swift:12) promises something the app does not do.
  • Change: on save, schedule one repeating UNCalendarNotificationTrigger per medicine time (hour/minute, repeats daily). Use a notification category with "Take" and "Skip" actions, handle the response in a UNUserNotificationCenterDelegate, and remove or reschedule the requests on edit or delete. Add a test or manual checklist for the notification path.
B2. Nothing is persisted
  • Evidence: MedStore (Models.swift:22-60) holds medicines and doses as in-memory arrays. There is no file, SwiftData, or UserDefaults code.
  • Impact: every force-quit, crash, or reboot erases all medicines and all adherence history. For a medication tracker this is a safety failure, not an edge case.
  • Change: Medicine, Dose and DoseStatus already conform to Codable. Save the store as JSON in Application Support, using atomic writes, and load on launch. Save after every mutation (add, resolve, delete, clearHistory). SwiftData is a reasonable alternative, but the JSON route is the smaller change.
B3. Doses are generated once, on the day the medicine is added
  • Evidence: MedStore.add uses cal.date(bySettingHour:..., of: .now) (Models.swift:47). No code creates doses for any other day.
  • Impact: after midnight, today is empty for every medicine. Tomorrow's doses never appear. The Today screen shows "No doses today" with the copy "Add a medicine to start getting reminders" even though the user already has medicines, which is wrong and confusing.
  • Change: generate doses for today (and a short horizon) whenever the app becomes active and whenever the day changes. Make generation idempotent, keyed on medicine ID, time, and calendar day, so it never duplicates rows.
B4. Missed doses disappear without a record
  • Evidence: today filters to isDateInToday (Models.swift:27-30). history only includes doses that are not .pending (Models.swift:31-33). A dose still .pending when the day ends is in neither list.
  • Impact: adherence history is silently wrong. The app can never show the user or a clinician that a dose was missed, which is the most important thing such a log can record.
  • Change: add a .missed status. When a day rolls over (see B3), mark any still-pending past dose as missed, and include missed doses in History.
B5. A dose can be taken or skipped early, with no confirmation and no undo
  • Evidence: TodayView.swift:40-51 enables "Take" and "forward" on every pending dose, including doses scheduled hours in the future. resolve (Models.swift:37-41) is final: there is no way to change a status back.
  • Impact: a mis-tap marks a dose taken and the record is wrong. Taking a dose early on a schedule can lead to a double dose. There is no path to correct the record in the UI.
  • Change: allow "Take" only from a window around the scheduled time, or ask "Take early?" when it is outside that window. Show an undo toast for a few seconds after Take or Skip. Allow a status change later from the row (context menu or swipe) so a wrong record can be fixed.

2. High

H1. Deleting a medicine silently erases its history
  • Evidence: MedStore.delete(at:) (Models.swift:53-57) runs doses.removeAll { ids.contains($0.medicineID) }. This deletes taken and skipped history too, and no confirmation is shown. Swipe-to-delete is the only path (MedicinesView.swift:15).
  • Also: the code path for a missing medicine (TodayView.swift:33 "Unknown", HistoryView.swift:17 "Removed medicine") can never be reached in a meaningful way, because the doses are deleted with the medicine. The design and the implementation disagree about what should happen to history.
  • Change: confirm the delete ("Delete Ben's medicine? Its history will be kept as 'Removed medicine'" or similar). Decide the rule and implement it: keep history with the name snapshotted onto each dose, or delete everything and say so. Do not do both.
H2. Reminder permission is asked on cold launch with no context
  • Evidence: DosewiseApp.swift:8-10 requests notifications in App.init, before the user has done anything. The result is ignored ({ _, _ in }), so a denial is never handled and the Today screen never explains that reminders are off.
  • HIG reference: request permission in context, at the moment the user needs the feature, and explain the value first. Handle denial with a visible state and a way to open Settings.
  • Change: move the request to the first successful medicine save, with a short explanation. Show a banner on Today when notifications are denied, with a button to open Settings.
H3. Medicine time entry is free text
  • Evidence: AddMedicineView.swift:28-35 takes "HH:MM" as typed text with .numbersAndPunctuation. Invalid input just leaves the button disabled, with no message. The same time can be added twice (no duplicate check). The times list has no delete (AddMedicineView.swift:25-27), so a wrong entry means starting over.
  • Change: use a DatePicker with .hourAndMinute (native, always valid, locale-aware 12/24h). Add a "Add time" button that appends the picked time, and dedupe and sort the list. Give each time a swipe-to-delete. Show why "Save" is disabled (for example, a footer: "Add at least one time").
H4. The Add tab is an action, not a destination
  • Evidence: RootView.swift:10-11 makes "Add" its own tab with plus.circle.fill.
  • HIG reference: tab bars are for switching between sections. An action such as "add" belongs in the content it acts on, usually a + in the navigation bar of Medicines, presented as a sheet.
  • Impact: the tab is almost empty the rest of the time, takes a permanent slot in the tab bar, and gives no feedback after saving (the form just clears, AddMedicineView.swift:43). The user stays on the Add tab, with no confirmation that anything happened.
  • Change: move the form into a sheet from the Medicines + button, with Cancel and Save. On save, dismiss and show the new medicine in the list (or a short confirmation). Reduce the tabs to Today, Medicines, History.
H5. Icon-only Skip control and ambiguous symbol
  • Evidence: TodayView.swift:43-47 is an icon-only button (forward.circle) with no label. forward.circle is a generic "next" symbol, not a clear "skip dose". History uses the same icon for skipped doses (HistoryView.swift:23).
  • Impact: VoiceOver gets an unlabeled or unclear control. Sighted users can't tell "skip" from "next" or "fast forward".
  • Change: use a text button "Skip" with a secondary style, or at minimum add .accessibilityLabel("Skip dose") and a visible label. Use the same wording everywhere.
H6. Status in History is conveyed by icon alone
  • Evidence: HistoryView.swift:15-24 shows taken vs skipped only through a checkmark.circle or forward.circle icon. The subtitle is a date and time, not the status.
  • Impact: status is not readable by VoiceOver in a meaningful way, and icons alone are weak for a record like this. The resolvedAt value is stored (Models.swift:19, resolve sets it) but never shown.
  • Change: add a text status ("Taken at 08:14", "Skipped", "Missed") as the subtitle. Show the time it was actually taken, not only the scheduled time.
H7. Fixed font size breaks Dynamic Type
  • Evidence: TodayView.swift:36 uses .font(.system(size: 13)). Fixed sizes ignore the user's text size setting.
  • Change: use a text style (.subheadline or .footnote). Check the layout at the largest accessibility sizes. Rows with a two-button HStack will need to wrap or stack at those sizes.
H8. Today has no notion of time or order
  • Evidence: TodayView.swift:14-16 lists all of today's doses in one flat list sorted by time. Past-due pending doses look identical to doses due later. "Take" is available for all of them.
  • Impact: the user cannot see at a glance what is overdue, what is next, or what is finished. This is the screen used most.
  • Change: group into sections: Overdue (pending, past time), Upcoming, Done. Show a "Next: 08:00 Ibuprofen" summary at the top when there is one. Use a clearly distinct style for overdue.

3. Medium

M1. Hard-coded gray breaks dark mode and is used inconsistently
  • Evidence: Models.swift:68 defines Color.doseTakenText = Color(hex: 0x4B5563), a fixed value. TodayView.swift:37 uses it for the time of a taken dose, while the status label next to it is green (TodayView.swift:53-54).
  • Impact: a fixed dark gray on a dark background has poor contrast. The color is also applied only to the time text, so the row mixes two color treatments for one state.
  • Change: use .secondary (adaptive) or define the color in the asset catalog with light and dark variants, and check 4.5:1 contrast for text. Remove the one-off hex extension, or move it out of the model file (Models.swift is not a place for UI colors).
M2. "Taken" and "Skipped" rows lose the action affordance without explanation
  • Evidence: TodayView.swift:52-57 replaces the buttons with a plain label. There is no way to reverse it (see B5).
  • Change: covered by the undo and status-change fix in B5. Here, keep the status label but make it tappable to change.
M3. Medicines list shows too little and offers no way forward
  • Evidence: MedicinesView.swift:9-14 shows name and strength only. Schedule times, notes, and the reason a medicine exists are not shown. There is no empty state (a new user sees a blank screen with a title) and no add action on this screen. There is no edit at all.
  • Change: show the times under each name ("08:00, 20:00"). Add an empty state with an "Add medicine" button. Add a + in the nav bar and make rows tappable to edit.
M4. Save feedback and form state
  • Evidence: AddMedicineView.swift:40-46 clears the form on save with no message. Strength is optional, but for a medicine it is normally part of the dose; there is no "dose amount" (for example "1 tablet"), and no "days of week" or "as needed" option.
  • Change: confirm on save (covered by H4). Decide with the owner whether dose amount and days are in scope for v1; if yes, add them now because they change the model.
M5. Button labels and roles
  • Evidence: "Take" (TodayView.swift:48) and "Save medicine" (AddMedicineView.swift:41) are fine, but "Take" alone is a bit vague for a medication action. The Clear button in History is a destructive nav-bar item with a confirmation (HistoryView.swift:29-39), which is correct.
  • Change: "Mark taken" is clearer than "Take" for a record-keeping action. Keep the destructive confirmation pattern as is.
M6. Row accessibility is not structured
  • Evidence: DoseRow (TodayView.swift:25-60) has no accessibilityElement or accessibilityLabel. VoiceOver reads each text and button separately in the order they appear.
  • Change: group the text into one element with a label like "Ibuprofen 400 mg, due 08:00, pending", and expose Take and Skip as accessibility actions or keep them as clearly labeled buttons.
M7. Empty states are inconsistent
  • Evidence: Today's empty state uses checkmark.seal with "No doses today" (TodayView.swift:10-12). The same icon could read as "all done", and the description is wrong when medicines exist (see B3). History's empty state is fine (HistoryView.swift:11-12). Medicines has none.
  • Change: differentiate "no medicines yet" (show onboarding and an add action) from "all doses done today" (show a positive message). Fix the copy so it matches the state.
M8. Permission-free tab labels and icons
  • Evidence: "Medicines" uses cross.vial, a lab glyph rather than a medicine glyph. "Today" uses pills, which is clear.
  • Change: consider pills.fill or a custom glyph for Medicines, or keep "cross.vial" if the owner likes it. Low priority.

4. Low

  • L1. List(...).listStyle(.insetGrouped) on Today (TodayView.swift:17) is already the default for a plain List on iOS 14+. Harmless, but it can be removed.
  • L2. The name field is not set to capitalize words and has no return key or focus chain. Add .textInputAutocapitalization(.words) and .submitLabel.
  • L3. No haptic on Take or Skip. A light UIImpactFeedbackGenerator or .sensoryFeedback on iOS 17 adds confirmation without UI cost.
  • L4. Strings are hard-coded in English. Fine for a development build; move to a String Catalog before any translation work.
  • L5. Notification authorization result is discarded (DosewiseApp.swift:9). Even before B1 is fixed, log or surface the result (see H2).
  • L6. Color(hex:) extension and the doseTakenText constant live in Models.swift, mixing UI and model code. Move to a separate file or the asset catalog (see M1).

5. What is working

  • Native structure: TabView, NavigationStack per tab, List with swipe-to-delete, Form for entry, ContentUnavailableView for empty states, confirmationDialog for the destructive Clear action. These are the right platform components and should stay.
  • Destructive clear in History is confirmed and labeled "This can't be undone." Good.
  • The Take button is the prominent, bordered action and Skip is borderless, so the primary action is visually dominant.
  • Time display uses Text(date, style: .time) and formatted(date:time:), which follow the user's locale and 12/24-hour setting.
  • The Add form validates time ranges (0-23, 0-59) before accepting input.
  • The app is portrait-only and has a plain Info.plist with a display name. Nothing wrong there for a v1.

6. Recommended order

  1. Persist data (B2) and generate doses per day with midnight rollover and missed status (B3, B4). Without these, nothing else can be trusted.
  2. Schedule real notifications with Take and Skip actions, and handle denial (B1, H2).
  3. Add a confirmation and undo for Take and Skip, a window for early taking, and the ability to correct a status (B5, M2).
  4. Fix the Add flow: sheet from Medicines, real time picker, time list editing, save feedback (H3, H4, M4).
  5. Make Today time-aware with sections for overdue, upcoming, and done (H8).
  6. Accessibility pass: labels on icon-only controls, text status in History, Dynamic Type, adaptive colors, grouped row elements (H5, H6, H7, M1, M6).
  7. Medicine delete with a confirmation and a decided rule for history (H1), and a richer Medicines list (M3).
  8. Low items as time allows.

7. Open questions for the owner (answered with defaults above)

  • Should history of a deleted medicine be kept? Default assumed: kept, labeled "Removed medicine", which the code already expects.
  • Is dose amount ("1 tablet") in scope for v1? Default assumed: not in scope, but flagged in M4.
  • Early-taking window: default assumed 60 minutes before to 2 hours after scheduled time, with a confirmation outside it.
  • Minimum iOS version: default assumed iOS 17.

8. Not verified

  • No rendering, so contrast, Dynamic Type layout, and tab bar appearance are inferred from code.
  • The Xcode project file was not in the folder, so deployment target, device family, and capabilities (notifications entitlement, background modes) are unknown.
  • No testing of the reminder path, since there is no code for it yet.