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.
File: View Layer/Screens/AppHomeView.swift
Role: Main screen and app entry point. Hosts all primary navigation.
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). 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 icon) with items:
- Test Filters ->
.testFilterssheet - Report Message ->
.reportMessagesheet - Help ->
.helpsheet - About ->
.aboutsheet - Tip Jar ->
.tipJarsheet - What's New ->
.whatsNewsheet (only ifWhatsNewEntry.allCasesis non-empty) - Load Debug Data (DEBUG builds only)
EmbeddedFooterView— App version + copyright at bottom. Tap opens About sheet. Uses iOS 26glassEffectwithultraThinMaterialfallback.EmbeddedNotificationView— Toast banner at top with spring animation. Shows offline status, sync completion, 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,modalFullScreen— Navigation drivers
Key methods:
refresh()— Reloads all state from managers. Called on every navigation pop, sheet dismiss, and notification.startMonitoring()— RegistersNotificationCenterobservers (once) for.cloudSyncOperationComplete,.networkStatusChange,.automaticFiltersUpdated.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.
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 wrapping a ScrollView with:
- Subtitle text
- Two contact buttons side by side:
- Email (conditionally shown via
MFMailComposeViewController.canSendMail()) — opensMailViewsheet, aUIViewControllerRepresentablewrapper aroundMFMailComposeViewControllerpre-filled withkSupportEmail. - GitHub — external
Linkto the repo URL.
- Email (conditionally shown via
- FAQ list —
ForEachovermodel.questions: [QuestionView.ViewModel]. EachQuestionViewis an expandable accordion: tap the question to toggleisExpanded, which reveals the answer with anopacitySlowInFastOuttransition. The first question has an.activateFiltersaction that opens the EnableExtension sheet when tapped. - Toolbar X button to dismiss
EmbeddedFooterViewoverlay (tapping opens About sheet viasheetScreen)
questions: [QuestionView.ViewModel]— Loaded fromAppManager.getFrequentlyAskedQuestions()which returns hardcoded localized FAQ entries.title: String— Navigation title.sheetScreen: Screen?— For presenting sub-sheets (About, EnableExtension).composeMailScreen: Bool— Drives theMailViewsheet presentation.result: Result<MFMailComposeResult, Error>?— Mail compose result (unused beyond storage).
- QuestionView (
Others/QuestionView.swift) — Self-contained accordion. Has its ownViewModel(not aBaseViewModelsubclass — justObservableObject). Supports an optionalQuestionActionenum (.none,.activateFilters) with anonActionclosure. RTL-aware chevron icon. - MailView (
Others/MailView.swift) —UIViewControllerRepresentablewrappingMFMailComposeViewController. Uses Coordinator pattern for delegate callbacks. Pre-fills recipient withkSupportEmail.
File: View Layer/Screens/AboutView.swift
Role: App info, credits, and external links. Presented as a sheet from AppHomeView or HelpView.
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 - 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/TestFiltersView.swift
Role: Debug/testing tool for users to test their filters against sample input. Presented as a sheet from AppHomeView's menu.
A NavigationView wrapping a ZStack (for loading overlay) containing a Form with a single section:
- Sender
TextField— with floating label above (manually positioned via ZStack + negative padding). - Message body
TextEditor— multiline input (fixed 80pt height), auto-focused 0.7s after appear. - Result display —
FadingTextViewshowing the filter evaluation result with a fade-in/fade-out animation on text change. - Test button —
FilledButtonstyle. Disabled when both inputs are empty. CallsevaluateMessage()and dismisses the keyboard. - Loading overlay — Semi-transparent background +
ProgressViewshown whenstate == .loading(currentlystateis declared but never set to.loading).
text: String,sender: String— Two-way bound to the input fields.fadeTextModel: FadingTextView.ViewModel— Drives the result text display.state: ViewState— Enum with.userInput,.loading,.result(String)cases. Has custom==conformance.evaluateMessage()— CallsMessageEvaluationManager.evaluateMessage(body:sender:)directly. If sender is empty, defaults to"1234567". Displays both the action result (junk/allow/promotion/transaction) and the reason (which filter matched).
- FadingTextView (
Others/FadingTextView.swift) — Animates text transitions: fades out old text, swaps, fades in new text. Has its own lightweightViewModel(plainObservableObject, notBaseViewModel). UsesonReceiveto react to text changes. - Field enum — Defined inside the
TestFiltersViewextension. Used with@FocusStatefor keyboard focus management. - ViewState enum — Also defined inside the extension. Supports associated value for result text.
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). A List with multi-selection support (selection: $model.selectedFilters) containing a single section:
- Header — Column labels: "Text" (or "Language") + "Options" (or "Folder").
- Rows —
ForEachovermodel.filtersrenderingFilterListRowViewcomponents. Supports.onDeletefor swipe-to-delete. - Footer — Help text explaining the filter type + an
AddFilterButtonat the bottom (opens the appropriate add-filter sheet). The button is hidden for.denyLanguagewhen no more languages are available to block.
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.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.sheetScreen: Screen?— For presenting add-filter sheets. Triggersrefresh()on dismiss.refresh()— Re-fetches filters from persistence.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). Layout varies by filter type:- Deny/Allow:
EditableTextfor inline text editing (tap to edit, minimum 3 chars) + threeMenubuttons for filter target, matching mode, and case sensitivity — each with tap-to-toggle and long-press for full menu. Color-coded: green when non-default option is active. - 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.
- Deny/Allow:
-
EditableText (
Others/EditableText.swift) — Tap-to-edit text component. Uses a ZStack with overlappingText(display) andTextField(edit) toggled byeditProcessGoingstate. Enforces minimum character count. CallsonCommitwhen editing ends.
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 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— Same enum pattern as TestFiltersView (.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(). - The
ViewStateenum is nearly identical to TestFiltersView's — both defined independently inside their respective extensions. - Unlike TestFiltersView, this screen fully uses all three states (userInput -> loading -> result -> auto-dismiss).
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.allCases. Each card shows an emoji icon, title, and description. Actionable entries (e.g.,.tipJar) are tappable and triggeronActionableEntryTapped. - 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.
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, emoji, order, andisActionable.currentWhatsNewVersionmust be bumped inConstants.swiftwhen adding new entries.- 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/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.