Per-screen breakdown of all SwiftUI views in Simply Filter SMS.
For MVVM patterns and conventions, see ../CLAUDE.md. For the screen map and index, see ../ARCHITECTURE.md.
Files: Simply_Filter_SMSApp.swift, View Layer/Screens/AppHomeView.swift
Role: Main screen and app entry point. Hosts all primary navigation.
The App owns Home’s ViewModel as @StateObject and passes it in (AppHomeView is @ObservedObject). That keeps one instance for the process so launch work in init does not re-run when Home’s view appears again (the system color picker can do that).
A NavigationView containing a List with three sections:
-
Automatic Filtering — Single
NavigationLinkto.automaticBlocking(LanguageListView in automatic mode). Displays an ON/OFF badge. Entire section is disabled when "block all unknown" rule is active. -
Smart Filters —
ForEachovermodel.rules: [StatefulItem<RuleType>]. Each rule renders as aToggle. Toggling calls throughStatefulItem.didSet-> ViewModel'ssetAutomaticRuleState->AutomaticFilterManager. The.shortSenderrule has an additionalMenupicker for character threshold (3-6). The.countryAllowlistrule has a button that opensCountryListView(selected countries summary when on). All rules except.allUnknownare disabled when "block all unknown" is active. -
User Filters — Three
NavigationLinkrows (allow, deny, denyLanguage) each showing an active filter count badge. Links toFilterListViewwith the correspondingFilterType.
Trailing Menu (ellipsis, .tint(.primary)) with items:
- Debug Tools (DEBUG only)
- Add Filter submenu
- Filter Tools submenu — test, report, reporting extension, export (picker, then share on that screen), import
- Help, About, Accent Color, Tip Jar
- What's New (only if
WhatsNewEntry.allCasesis non-empty)
EmbeddedFooterView— App version + copyright at bottom. Tap opens About sheet. Uses iOS 26glassEffectwithultraThinMaterialfallback.EmbeddedNotificationView— Toast banner at top with spring animation. iOS 26+ clear Liquid Glass (toast + action chip); older iOS ultra-thin material. Shows offline status, sync completion, import/export, and filter update notifications.
Published state:
filters: [Filter]— All user filter records (for count badges)title: String— Navigation titleisAppFirstRun: Bool— Triggers onboarding sheet on first launchisAutomaticFilteringOn: Bool— Whether any automatic filtering is activeisAllUnknownFilteringOn: Bool— Whether the "block all unknown" rule is on (disables other rules/filters)shortSenderChoice: Int— Current threshold for short sender rulesubtitle: String— Summary of active automatic filtersrules: [StatefulItem<RuleType>]— Smart filter toggles with two-way bindingnotification: NotificationView.ViewModel— Toast notification statenavigationScreen,sheetScreen— Navigation drivers. Sheets go throughFlowManager.
Key methods:
refresh()— Reloads all state from managers. Called on every navigation pop, sheet dismiss, and notification.runLaunchFlowIfNeeded()— Called from Home ViewModelinit(the App@StateObject, once per process). Skipped in SwiftUI previews. Starts observers, enables automatic What's New, then tip / reporting toasts. Not tied to Home.onAppear(the system color picker can make Home appear again).startMonitoring()— RegistersNotificationCenterobservers (once) for.cloudSyncOperationComplete,.networkStatusChange,.filtersStateChanged,.automaticFiltersUpdated. Then compares store fingerprint to last UI fingerprint andrefresh()es if the store is ahead (CloudKit import finished before observers were registered).showNotification(_:)— Queues notifications if a sheet/modal is active (pendingNotification). Some notifications auto-dismiss after a timeout.tryRequestReview()— PromptsSKStoreReviewControllerafter 7+ days and 5+ sessions. Triggered when user pops back from a navigation screen.activeCount(for:)— Returns count of filters for a givenFilterType.exportFilters()—queueExport()thenrequestSheet(.filterExport). Disabled in the menu when there are no filters.
File: View Layer/Screens/EnableExtensionView.swift
Role: Onboarding screen shown on first launch. Guides the user through enabling the Message Filter Extension in iOS Settings via an animated step-by-step walkthrough.
Both Screen.onboarding and Screen.enableExtension map to this same view.
Presented as a sheet (.interactiveDismissDisabled()). Contains:
- Description text
- A
ForEachoverEnableExtensionStep.allCasesrenderingEnableExtensionStepViewrows — one per step, animated sequentially in a looping cycle - CTA button (
FilledButton) pinned viasafeAreaInset(edge: .bottom)— deep-links to iOS Settings viaUIApplication.openSettingsURLString - Toolbar X button to dismiss
A looping Task (attached via .task {}) cycles through all steps sequentially, setting @State private var activeStep: Int. Each EnableExtensionStepView receives isActive: Bool based on whether its step number is <= activeStep. When VoiceOver is active, all steps are shown at full opacity immediately and the loop does not run.
isAppFirstRun: Bool—@PublishedwithdidSetwriting back toDefaultsManager. Both dismiss paths (X button and Settings button) set this tofalse.
- EnableExtensionStep (
Others/EnableExtensionStep.swift) —CaseIterable, Hashableenum with 6 cases (settings,messages,unknownSenders,screenUnknownSenders,filterSpam,textMessageFilter). ProvidesstepNumber,title,description,symbolName,symbolColor,showsAppIcon,isToggle, andisLastcomputed properties. - EnableExtensionStepView (
Others/EnableExtensionStepView.swift) — Renders a single step row: numbered circle with connector line, icon (SF Symbol, app icon, or none), title, description, and an optional decorative toggle for steps that require enabling a setting. Animations respectaccessibilityReduceMotion. Toggle activation uses.task(id: isActive)to avoid race conditions.
File: View Layer/Screens/HelpView.swift
Role: FAQ and support screen. Presented as a sheet from AppHomeView's menu.
A NavigationView + List (.insetGrouped), same language as About:
- Get Started — Enable Filtering and Enable Reporting Extension. Each uses
onRequestScreen, then dismisses Help. Home presents the matching onboarding after Help closes. - FAQ —
DisclosureGroupperHelpFAQ. One open at a time. Footer isfaq_subtitle. - Contact — Email + GitHub. Same as About: Mail composer if Mail is set up, otherwise copy the address and toast. Envelope icon uses
.foregroundStyle(.tint). - Toolbar X. No footer overlay to About.
- VoiceOver: section headers in the rotor; Get Started/FAQ/email hints; decorative icons hidden; GitHub and email rows combined. Dynamic Type via text styles and
@ScaledMetricicon frames. Reduce Motion zeros FAQ expand animation. Clipboard toast uses the same announcement as About.
List owns row-height and scroll offset. A custom accordion in ScrollView jumped the scroll position when a question opened.
expandedFAQ: HelpFAQ?— which question is open.composeMailScreen/result— mail composer.notification— clipboard toast when email is copied.onRequestScreen— Home sets this so Get Started rows can present after Help dismisses.
HelpFAQ is a CaseIterable enum on HelpView.swift. Copy lives in Localizable strings, not AppManager.
File: View Layer/Screens/AboutView.swift
Role: App info, credits, and external links. Presented as a sheet from AppHomeView.
A NavigationView containing a VStack with:
- Header — App logo image, app name as large bold title, version + build number.
- List (
.groupedstyle) with two sections:- About — Markdown-rendered about text (
AttributedString(markdown:)with inline-only parsing, fallback to plain text). - Links — Five rows, each with an icon + title + subtitle:
- GitHub -> external
LinktoappGithubURL - Email -> opens
MailViewsheet if mail is available, otherwise copieskSupportEmailto clipboard viasetClipboard()and shows a toast notification. Envelope icon uses.foregroundStyle(.tint). - Twitter -> external
LinktoappTwitterURL - Icon designer credit -> external
LinktoiconDesignerURL(Instagram) - App Store review -> external
LinktoappReviewURL
- GitHub -> external
- About — Markdown-rendered about text (
EmbeddedNotificationView— Toast banner for clipboard copy confirmation. Uses the same notification system as AppHomeView but scoped to this screen.- Toolbar X button to dismiss.
composeMailScreen: Bool— DrivesMailViewsheet.result: Result<MFMailComposeResult, Error>?— Mail compose result.notification: NotificationView.ViewModel— Owns its own notification model for clipboard toasts.setClipboard(content:displayName:)— Copies toUIPasteboardand posts.onClipboardSetnotification.showNotification(_:)— Same pattern as AppHomeView's but simpler (no pending queue since no sub-sheets to block). Observes.onClipboardSetviaNotificationCenterininit.
- The clipboard fallback pattern: when
MFMailComposeViewController.canSendMail()is false (e.g., simulator or no mail account), tapping Email copies the address to clipboard and shows a toast instead of opening the mail composer. - Has its own
EmbeddedNotificationViewinstance (separate from AppHomeView's) — each screen that needs toast notifications manages its own. - Custom
NSNotification.Nameconstants are all defined inNetworkSyncManagerProtocol.swift:.networkStatusChange,.cloudSyncOperationComplete,.automaticFiltersUpdated,.onClipboardSet.
File: View Layer/Screens/ChooseAccentColorView.swift
Role: Lets the user pick the app tint. Presented as a sheet from the Home ••• menu only (not About, not Filter Tools).
Help/About pattern: NavigationView, inline title, X close (.tint(.primary)). Not a List.
- Color — Full
UIColorPickerViewControllerfilling the sheet (supportsAlphaoff). WritesaccentColorRGBas the user picks. VoiceOver: container labeledaccentColor_picker("Color") with children contained. - Reset to Default — Clears storage. Disabled when no custom color is stored. VoiceOver hint: restore the default color.
The sheet applies .optionalTint while a custom color is set. Home builds this ViewModel like Help (managers defaulted; optional onAccentChanged) so Home’s tint updates while the sheet is up. refresh() does not reload tint from defaults while this sheet is up.
init(appManager:defaultsManager:onAccentChanged:)— both managers default;defaultsManagerfalls back toappManager.defaultsManager. Home passes onlyonAccentChanged.pickerColor/hasCustomColor— picker binding and Reset enabled state.applyPickedColor(_:)— writesaccentColorRGB, thenonAccentChanged.reset()—accentColorRGB = kNoColorDict, thenonAccentChanged(nil).
Home uses requestSheet(.chooseAccentColor) and constructs the view in the sheet content (same as Help). Screen.chooseAccentColor.build() still returns this view with no callback.
File: View Layer/Screens/TestFiltersView.swift
Role: Lets the user type a sender and message and see how current filters would classify it. Presented as a sheet from AppHomeView's menu.
NavigationView + ScrollView (not Form — Form cannot interpolate row height). One grouped card, Add Filter style:
- Sender
TextField— optional, floating caption. - Message
TextEditor—minHeight/idealHeight80, focused 0.7s after appear. - Result row —
TestFilterResultRow(Home-style icon + heavy title + caption). Live as you type. The row owns the crossfade. VoiceOver: fields labeled, result combined, change announced (focus stays on the field). Height animation off when Reduce Motion is on; opacity crossfade stays. - Footer — how to test (
testFilters_footer).
Blank sender means “test the body only”: the ViewModel probes a numeric stand-in that is not itself filtered.
text,sender— bound to the fields;didSetre-evaluates.result: MessageEvaluationResult?— written byupdateResult()afterevaluateMessage. Card height animation is keyed offresult.action.
Fieldenum for@FocusState.- TestFilterResultRow (
Others/TestFilterResultRow.swift) — Draws the verdict. Crossfade identity lives on the row; the screen animates the card.
File: View Layer/Screens/LanguageListView.swift
Role: Dual-purpose screen controlled by a Mode enum. Used by Screen.addLanguageFilter (mode: .blockLanguage) and Screen.automaticBlocking (mode: .automaticBlocking).
.blockLanguage— Presented as a sheet. Wraps body in its ownNavigationView. Each language is aButtonthat adds a deny-language filter viaPersistanceManager.addFilter()and dismisses. Large title, toolbar X button..automaticBlocking— Pushed viaNavigationLinkfrom AppHomeView (already inside aNavigationView, so no wrapper). Each language is aToggleusingStatefulItem<NLLanguage>with getter/setter bridging toAutomaticFilterManager.languageAutomaticState/setLanguageAutmaticState. Inline title. Supports pull-to-refresh (.refreshable) when cache is stale (>0 days old).
A single List section with:
ForEachovermodel.languages: [StatefulItem<NLLanguage>]— renders either buttons or toggles per mode.- Empty state (automatic mode only) — if languages array is empty, shows either a loading
ProgressViewor an error message (distinguishing offline vs. fetch error). - Footer — In block-language mode: explanatory text. In automatic mode: last-updated timestamp (formatted via
DateFormatter) + help text second line.
mode: Mode— Set at init, determines all behavior branching.languages: [StatefulItem<NLLanguage>]— Language list fromAutomaticFilterManager.languages(for:), wrapped inStatefulItemfor toggle binding (automatic mode) or plain display (block mode).isLoading: Bool,isOnline: Bool— Loading/network state for empty-state UI.shouldAllowRefresh: Bool— Enables pull-to-refresh only when cache is >0 days old.footer: String,footerSecondLine: String?— Footer text, mode-dependent.refresh()— Reloads languages and recalculates footer/refresh state from managers.addFilter(language:)— Block-language mode only. Creates a.denyLanguagefilter with the language'sfilterText(format:$lang:english).forceUpdateFilters()—async @Sendable. Sleeps 1s (for pull-to-refresh animation), callsAutomaticFilterManager.forceUpdateAutomaticFilters(), then refreshes on main queue.
.networkStatusChange— When coming online with an empty language list, triggersupdateAutomaticFiltersIfNeeded()and shows loading..automaticFiltersUpdated— Refreshes the language list when filters finish updating.
- Conforms to
@unchecked Sendableto support the@Sendablerequirement of.refreshable. - The conditional
NavigationViewwrapper pattern:.blockLanguageadds its own,.automaticBlockingrelies on the parent's. Uses theView.if()extension for conditional toolbar/refreshable modifiers.
File: View Layer/Screens/AddFilterView.swift
Role: Form for creating a new deny or allow filter. Used by Screen.addDenyFilter (filterType: .deny) and Screen.addAllowFilter (filterType: .allow).
A NavigationView wrapping a ScrollView with a VStack:
- Filter text
TextField— auto-focused after 0.7s. Shows inline duplicate warning badge (red octagon + text) whenisDuplicateFilteris true. - Advanced options (collapsible via "More/Less" toggle button with rotating arrow):
- Deny folder picker (deny type only) — segmented
Pickerfor.junk,.transaction,.promotion. - Filter target picker — segmented: all / sender / body.
- Filter matching picker — segmented: contains / exact.
- Filter case picker — segmented: case insensitive / case sensitive.
- Deny folder picker (deny type only) — segmented
- Add button —
FilledButtonstyle. Disabled when text is shorter thankMinimumFilterLength(1) or is a duplicate. CallsaddFilter()and dismisses. - Toolbar X button to dismiss.
filterType: FilterType— Set at init (.denyor.allow). Controls title and whether deny-folder picker is shown.filterText: String— Two-way bound to text field.selectedDenyFolderType,selectedFilterTarget,selectedFilterMatching,selectedFilterCase— Two-way bound to segmented pickers.isExpanded: Bool— Controls advanced options visibility. Persisted toDefaultsManager.isExpandedAddFilterviadidSet.isDuplicateFilter: Bool— Computed property. ChecksPersistanceManager.isDuplicateFilter()in real time as user types. Guarded bydidAddFilterflag to avoid false positives after submission.addFilter()— Delegates toPersistanceManager.addFilter()with all selected options.
- The expanded/collapsed state of the advanced section is persisted across sessions via
DefaultsManager. - Duplicate detection is live — computed on every SwiftUI re-render by querying CoreData.
File: View Layer/Screens/FilterListView.swift
Role: Displays and manages the list of user-created filters for a given type. Used by Screen.denyFilterList, .allowFilterList, .denyLanguageFilterList — all build FilterListView with different FilterType.
Pushed via NavigationLink from AppHomeView (no own NavigationView). Uses @StateObject for the ViewModel. A List with multi-selection support (selection: $model.selectedFilters) inside a ScrollViewReader:
- Regular section —
ForEachovermodel.regularRowViewModels(non-regex). Header labels: "Text" (or "Language") + "Options" (or "Folder"). Supports.onDelete. - Regex section — Shown when any regex filters exist. Same row component; header is the regex match label. Footer (help text +
AddFilterButton) lives on this section when present, otherwise on the regular section. - Footer — Help text explaining the filter type + an
AddFilterButton(opens the appropriate add-filter sheet). The button is hidden for.denyLanguagewhen no more languages are available to block. - Identity — Row identity, scroll targets, new-item dots, and inline-edit focus all use
Filter.uuid(UUID), notNSManagedObjectID. - Focus — List-owned
@FocusState private var focusedFilterID: UUID?. Tap outside clears focus and callshideKeyboard(). - New filter highlight —
newlyAddedFilterdrivesdotFilterID+scrollToby UUID; row shows a fading accentCirclevia.onAppear/.onChange.
NavigationBarMenu — contextual trailing items:
- Normal mode: Ellipsis
Menuwith "Edit" (enters edit mode) and "Add Filter" options. - Edit mode: Shows
EditButton+ a red "Delete (N)" button when filters are selected. Bulk-deletes selected filters.
filterType: FilterType— Set at init, determines which filters to fetch and which add-filter screen to present.filters: [Filter]— Fetched fromPersistanceManager.fetchFilterRecords(for:), filtered by type.rowViewModels: [FilterListRowView.ViewModel]— Cached row ViewModels, rebuilt/updated inupdateRowViewModels()keyed byid(Filter.uuid).ForEachusesregularRowViewModels/regexRowViewModels— never construct row ViewModels insideForEach.selectedFilters: Set<Filter>— Multi-selection state for edit mode.editMode: EditMode— Controls List edit mode (.inactive/.active).canBlockAnotherLanguage: Bool— Whether the add-language button should be shown (checks if unblocked languages remain).footer: String— Help text, varies by filter type.addFilterViewModel/addLanguageViewModel— Owned sheet ViewModels withonAddedclosures (see sheet-callback convention in CLAUDE.md).refresh()on dismiss.newlyAddedFilter: Filter?— Set after add; view scrolls/highlights then clears by UUID identity.refresh()— Re-fetches filters and syncs the row ViewModel cache.deleteFilters(withOffsets:in:)— Swipe-to-delete. Delegates toPersistanceManager.deleteFilters().deleteFilters(_:)— Bulk delete from edit mode selection.
-
FilterListRowView (
Others/FilterListRowView.swift) — Individual filter row with inline editing. Has its ownViewModel(subclassesBaseViewModel) with stablelet id: UUIDfromfilter.uuid(assigns one if missing). Layout varies by filter type:- Deny/Allow:
EditableTextfor inline text editing (tap to edit, minimumkMinimumFilterLength) +Menu+Pickerchips for filter target, matching (hidden when regex), case, and deny folder. The open menu shows a system checkmark on the current value. Chip icon uses.tintwhen the option is non-default. - Deny Language: Read-only localized language name (resolved from
$lang:format viaNLLanguage(filterText:)). - Deny types with folder support: Additional
Menufor deny folder (junk/transaction/promotion). - All updates call through to
PersistanceManager.updateFilter()and triggeronUpdatecallback to parent (which callsrefresh()).
- Deny/Allow:
-
EditableText (
Others/EditableText.swift) — Tap-to-edit text component. Parent owns@FocusState<UUID?>; each instance takesfocusID: UUID. AsessionActiveflag gates a single finish path (EndReason: onCommit / onEditingChanged / focusChanged) so Return does not double-save. After ending the session, focus clear,onEditingChanged(false), and businessonCommitare deferred to the next main-run-loop turn — Return’sPlatformTextFieldCoordinator.triggerPrimaryActionstill writesTextFieldStateafter theonCommitcallback returns; rebuilding theList(or tearing down the field) inside that callback crashes withEXC_BAD_ACCESS. ZStack overlays displayText/AttributedStringwith aTextField; idle field stays opacity 0 but layout-stable (important for RTL). BelowminimumCharacters, the session ends without calling businessonCommit.
File: View Layer/Screens/ReportMessageView.swift
Role: Allows users to report a message as spam or not-spam to the backend. Presented as a sheet from AppHomeView's menu.
Structurally similar input card to TestFiltersView — a NavigationView wrapping a ZStack (for state overlays) containing a Form:
- Sender
TextField— floating label, auto-focused after 0.7s. - Message body
TextEditor— multiline, 80pt height. - Report type segmented
Picker— junk / not junk (ReportType.allCases). - Report button —
FilledButtonstyle. Disabled when both inputs empty. CallsreportMessage().
State overlays (unlike TestFiltersView, these are fully used):
- Loading:
.thinMaterialfull-screen overlay +ProgressView. - Result:
.thinMaterialoverlay + animatedCheckView(green checkmark drawn withPath+trimanimation) + thank-you text. Auto-dismisses after 1 second viaonChange(of: state).
Navigation title and toolbar X button are conditionally hidden during loading/result states via View.if().
text: String,sender: String— Two-way bound inputs.selectedReport: ReportType— Junk or not junk.state: ViewState—.userInput,.loading,.result(String)withisResultcomputed property.reportMessage()— Sets state to.loading, createsReportMessageRequestBody, callsReportMessageService.reportMessage()via asyncTask. On completion, sets state to.resulton main queue.
- Conforms to
@unchecked Sendablefor the asyncTaskinreportMessage(). ViewStatelives on this screen only (Test Filters evaluates live and has no loading/result overlay).CheckView(Others/CheckView.swift) — Animated checkmark usingPathwithtrimanimation. Purely cosmetic, no ViewModel.
File: View Layer/Screens/WhatsNewView.swift
Role: Shows new features added in the latest version. Presented as a sheet from AppHomeView on second+ launch when currentWhatsNewVersion exceeds the user's lastSeenWhatsNewVersion.
A NavigationView wrapping a ScrollView:
- Header — "What's New" title and subtitle.
- Entry cards —
ForEachoverWhatsNewEntry.allCasessorted byorder. Each card shows an SF Symbol in a tinted rounded square, title, and description. Actionable entries (e.g.,.tipJar) are tappable and triggeronActionableEntryTapped. Current lead cards: import/export (order0), then Appearance Touch-up (order1). - Dismiss button —
FilledButtonat bottom. SetslastSeenWhatsNewVersiontocurrentWhatsNewVersionand dismisses. - Toolbar X button to dismiss.
entries: [WhatsNewEntry]— All entries sorted byorder.onActionableEntryTapped: ((WhatsNewEntry) -> Void)?— Optional closure called when an actionable entry is tapped. Passed in from the presenting screen.markAsSeen()— SetslastSeenWhatsNewVersiontocurrentWhatsNewVersionso the sheet won't re-appear. Home also records this on user dismiss (including swipe). A file or deep link that replaces the sheet does not.
WhatsNewEntry has an isActionable computed property. When true, the entry row becomes a tappable Button that calls markAsSeen(), invokes onActionableEntryTapped, and dismisses the sheet. The presenting screen handles navigation — e.g., AppHomeView sets pendingScreenAfterDismiss = .tipJar so the Tip Jar sheet opens after WhatsNew dismisses.
This pattern is general-purpose: any future WhatsNewEntry case can become actionable by returning true from isActionable, and the presenting screen decides what to do in the onActionableEntryTapped closure.
WhatsNewEntryis aCaseIterableenum inConstants.swiftwith computed properties for title, description, SF Symbol (imageName), color, order, andisActionable.currentWhatsNewVersionmust be bumped inConstants.swiftwhen adding entries for a version that already shipped. This branch added.appearanceTouchup(second card, after import/export) without bumping — still6— because that What's New had not been released yet.- The What's New sheet only shows when: it's not the user's first session (
wasFirstRunOnInit == false),isAppFirstRunisfalse, andcurrentWhatsNewVersion > lastSeenWhatsNewVersion.
File: View Layer/Screens/FilterTransferPreviewView.swift
Role: Shared allow/deny/language picker for import and export. Home presents it as .filterImport or .filterExport. Uses @StateObject with init(model:).
The ViewModel reads pendingPreview and pendingKind from filterTransferManager (no associated value on Screen). Import confirm calls importFilters; Home toasts on dismiss from clearPendingImport(). Export confirm writes the file and presents the share sheet on this screen; the picker dismisses when share closes. Home clearPendingExport() on picker dismiss deletes the temp file.
VoiceOver: close is labeled, section title is a header, select-all is labeled from the existing select strings, candidate rows combine text with the same target/match/case/folder labels as Filter List. Option icons are hidden from VoiceOver. Icon frames use @ScaledMetric.
File: View Layer/Screens/TipJarView.swift
Role: Tip jar screen for voluntary in-app purchases. Presented as a sheet from AppHomeView's menu, AboutView, or via an actionable What's New entry.
A NavigationView wrapping a ZStack (for confetti overlay) containing a ScrollView:
- Header — Heart emoji, title ("tipJar_header"), and subtitle ("tipJar_subheader").
- Tip cards —
HStackof threeTipCardViewcomponents (small/medium/large, in separate file). Each shows the tier's emoji, display name, description, and price badge. When a specific card is being purchased, its price badge is replaced with aProgressViewspinner. Loading state shows aProgressView. Empty state shows "tipJar_unavailable" text. - Footer — Explanatory text ("tipJar_footer").
- Confetti overlay —
ConfettiView(CAEmitterLayer-based) shown after successful purchase. Intensity scales with tier (birthRate, lifetime, velocity). - Toolbar X button to dismiss.
Uses @Environment(\.verticalSizeClass) with an isCompact flag to reduce font sizes, spacing, and padding in landscape orientation.
Published state:
products: [Product]— StoreKit products fetched fromTipJarManager.isLoading: Bool— True while products are being fetched.purchaseState: PurchaseState— Enum:.idle,.purchasing(TipTier),.success(TipTier),.error.notification: NotificationView.ViewModel— Toast notification for thank-you message.shouldDismiss: Bool— Triggers dismiss after thank-you toast hides.
Key methods:
init— Reads products fromTipJarManager. If still loading, pollsisLoadingProductsevery 100ms on@MainActoruntil ready.purchase(_:)— Sets state to.purchasing(tier), callsTipJarManager.purchase(). On success: shows confetti + thank-you toast, auto-resets after confetti duration. On error: auto-resets after 3s.isPurchasing(tier:)— Returns true if the given tier is the one currently being purchased.
- TipCardView (
TipCardView.swift) — Button withTipCardButtonStyle(scale + opacity effect on press). Displays tier emoji, name, description, and price badge with accent color background. Shows aProgressViewspinner in place of the price when the card's tier is being purchased. - ConfettiView (
Others/ConfettiView.swift) —UIViewRepresentablewrappingCAEmitterLayer. Configurable birthRate, lifetime, and velocity. Emits from top of screen with various cell shapes and colors. Auto-stops emission after 0.3s (particles continue falling). - NotificationView
.tipSuccessfulcase — Toast notification withonHidecallback that triggers sheet dismissal.