Skip to content

Latest commit

 

History

History
448 lines (304 loc) · 30.8 KB

SCREENS.md

File metadata and controls

448 lines (304 loc) · 30.8 KB

Screens Architecture

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.


AppHomeView

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).

Layout

A NavigationView containing a List with three sections:

  1. Automatic Filtering — Single NavigationLink to .automaticBlocking (LanguageListView in automatic mode). Displays an ON/OFF badge. Entire section is disabled when "block all unknown" rule is active.

  2. Smart Filters — ForEach over model.rules: [StatefulItem<RuleType>]. Each rule renders as a Toggle. Toggling calls through StatefulItem.didSet -> ViewModel's setAutomaticRuleState -> AutomaticFilterManager. The .shortSender rule has an additional Menu picker for character threshold (3-6). The .countryAllowlist rule has a button that opens CountryListView (selected countries summary when on). All rules except .allUnknown are disabled when "block all unknown" is active.

  3. User Filters — Three NavigationLink rows (allow, deny, denyLanguage) each showing an active filter count badge. Links to FilterListView with the corresponding FilterType.

Navigation Bar

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.allCases is non-empty)

Overlays

  • EmbeddedFooterView — App version + copyright at bottom. Tap opens About sheet. Uses iOS 26 glassEffect with ultraThinMaterial fallback.
  • 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.

ViewModel

Published state:

  • filters: [Filter] — All user filter records (for count badges)
  • title: String — Navigation title
  • isAppFirstRun: Bool — Triggers onboarding sheet on first launch
  • isAutomaticFilteringOn: Bool — Whether any automatic filtering is active
  • isAllUnknownFilteringOn: Bool — Whether the "block all unknown" rule is on (disables other rules/filters)
  • shortSenderChoice: Int — Current threshold for short sender rule
  • subtitle: String — Summary of active automatic filters
  • rules: [StatefulItem<RuleType>] — Smart filter toggles with two-way binding
  • notification: NotificationView.ViewModel — Toast notification state
  • navigationScreen, sheetScreen — Navigation drivers. Sheets go through FlowManager.

Key methods:

  • refresh() — Reloads all state from managers. Called on every navigation pop, sheet dismiss, and notification.
  • runLaunchFlowIfNeeded() — Called from Home ViewModel init (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() — Registers NotificationCenter observers (once) for .cloudSyncOperationComplete, .networkStatusChange, .filtersStateChanged, .automaticFiltersUpdated. Then compares store fingerprint to last UI fingerprint and refresh()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() — Prompts SKStoreReviewController after 7+ days and 5+ sessions. Triggered when user pops back from a navigation screen.
  • activeCount(for:) — Returns count of filters for a given FilterType.
  • exportFilters() — queueExport() then requestSheet(.filterExport). Disabled in the menu when there are no filters.

EnableExtensionView

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.

Layout

Presented as a sheet (.interactiveDismissDisabled()). Contains:

  • Description text
  • A ForEach over EnableExtensionStep.allCases rendering EnableExtensionStepView rows — one per step, animated sequentially in a looping cycle
  • CTA button (FilledButton) pinned via safeAreaInset(edge: .bottom) — deep-links to iOS Settings via UIApplication.openSettingsURLString
  • Toolbar X button to dismiss

Animation

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.

ViewModel

  • isAppFirstRun: Bool — @Published with didSet writing back to DefaultsManager. Both dismiss paths (X button and Settings button) set this to false.

Supporting Components

  • EnableExtensionStep (Others/EnableExtensionStep.swift) — CaseIterable, Hashable enum with 6 cases (settings, messages, unknownSenders, screenUnknownSenders, filterSpam, textMessageFilter). Provides stepNumber, title, description, symbolName, symbolColor, showsAppIcon, isToggle, and isLast computed 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 respect accessibilityReduceMotion. Toggle activation uses .task(id: isActive) to avoid race conditions.

HelpView

File: View Layer/Screens/HelpView.swift Role: FAQ and support screen. Presented as a sheet from AppHomeView's menu.

Layout

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 — DisclosureGroup per HelpFAQ. One open at a time. Footer is faq_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 @ScaledMetric icon 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.

ViewModel

  • 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.


AboutView

File: View Layer/Screens/AboutView.swift Role: App info, credits, and external links. Presented as a sheet from AppHomeView.

Layout

A NavigationView containing a VStack with:

  • Header — App logo image, app name as large bold title, version + build number.
  • List (.grouped style) with two sections:
    1. About — Markdown-rendered about text (AttributedString(markdown:) with inline-only parsing, fallback to plain text).
    2. Links — Five rows, each with an icon + title + subtitle:
      • GitHub -> external Link to appGithubURL
      • Email -> opens MailView sheet if mail is available, otherwise copies kSupportEmail to clipboard via setClipboard() and shows a toast notification. Envelope icon uses .foregroundStyle(.tint).
      • Twitter -> external Link to appTwitterURL
      • Icon designer credit -> external Link to iconDesignerURL (Instagram)
      • App Store review -> external Link to appReviewURL

Overlays

  • EmbeddedNotificationView — Toast banner for clipboard copy confirmation. Uses the same notification system as AppHomeView but scoped to this screen.
  • Toolbar X button to dismiss.

ViewModel

  • composeMailScreen: Bool — Drives MailView sheet.
  • result: Result<MFMailComposeResult, Error>? — Mail compose result.
  • notification: NotificationView.ViewModel — Owns its own notification model for clipboard toasts.
  • setClipboard(content:displayName:) — Copies to UIPasteboard and posts .onClipboardSet notification.
  • showNotification(_:) — Same pattern as AppHomeView's but simpler (no pending queue since no sub-sheets to block). Observes .onClipboardSet via NotificationCenter in init.

Notable

  • 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 EmbeddedNotificationView instance (separate from AppHomeView's) — each screen that needs toast notifications manages its own.
  • Custom NSNotification.Name constants are all defined in NetworkSyncManagerProtocol.swift: .networkStatusChange, .cloudSyncOperationComplete, .automaticFiltersUpdated, .onClipboardSet.

ChooseAccentColorView

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).

Layout

Help/About pattern: NavigationView, inline title, X close (.tint(.primary)). Not a List.

  • Color — Full UIColorPickerViewController filling the sheet (supportsAlpha off). Writes accentColorRGB as the user picks. VoiceOver: container labeled accentColor_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.

ViewModel

  • init(appManager:defaultsManager:onAccentChanged:) — both managers default; defaultsManager falls back to appManager.defaultsManager. Home passes only onAccentChanged.
  • pickerColor / hasCustomColor — picker binding and Reset enabled state.
  • applyPickedColor(_:) — writes accentColorRGB, then onAccentChanged.
  • reset() — accentColorRGB = kNoColorDict, then onAccentChanged(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.


TestFiltersView

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.

Layout

NavigationView + ScrollView (not Form — Form cannot interpolate row height). One grouped card, Add Filter style:

  • Sender TextField — optional, floating caption.
  • Message TextEditor — minHeight/idealHeight 80, 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.

ViewModel

  • text, sender — bound to the fields; didSet re-evaluates.
  • result: MessageEvaluationResult? — written by updateResult() after evaluateMessage. Card height animation is keyed off result.action.

Supporting

  • Field enum for @FocusState.
  • TestFilterResultRow (Others/TestFilterResultRow.swift) — Draws the verdict. Crossfade identity lives on the row; the screen animates the card.

LanguageListView

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).

Modes

  • .blockLanguage — Presented as a sheet. Wraps body in its own NavigationView. Each language is a Button that adds a deny-language filter via PersistanceManager.addFilter() and dismisses. Large title, toolbar X button.
  • .automaticBlocking — Pushed via NavigationLink from AppHomeView (already inside a NavigationView, so no wrapper). Each language is a Toggle using StatefulItem<NLLanguage> with getter/setter bridging to AutomaticFilterManager.languageAutomaticState / setLanguageAutmaticState. Inline title. Supports pull-to-refresh (.refreshable) when cache is stale (>0 days old).

Layout

A single List section with:

  • ForEach over model.languages: [StatefulItem<NLLanguage>] — renders either buttons or toggles per mode.
  • Empty state (automatic mode only) — if languages array is empty, shows either a loading ProgressView or 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.

ViewModel

  • mode: Mode — Set at init, determines all behavior branching.
  • languages: [StatefulItem<NLLanguage>] — Language list from AutomaticFilterManager.languages(for:), wrapped in StatefulItem for 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 .denyLanguage filter with the language's filterText (format: $lang:english).
  • forceUpdateFilters() — async @Sendable. Sleeps 1s (for pull-to-refresh animation), calls AutomaticFilterManager.forceUpdateAutomaticFilters(), then refreshes on main queue.

NotificationCenter Observers (automatic mode only)

  • .networkStatusChange — When coming online with an empty language list, triggers updateAutomaticFiltersIfNeeded() and shows loading.
  • .automaticFiltersUpdated — Refreshes the language list when filters finish updating.

Notable

  • Conforms to @unchecked Sendable to support the @Sendable requirement of .refreshable.
  • The conditional NavigationView wrapper pattern: .blockLanguage adds its own, .automaticBlocking relies on the parent's. Uses the View.if() extension for conditional toolbar/refreshable modifiers.

AddFilterView

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).

Layout

A NavigationView wrapping a ScrollView with a VStack:

  • Filter text TextField — auto-focused after 0.7s. Shows inline duplicate warning badge (red octagon + text) when isDuplicateFilter is true.
  • Advanced options (collapsible via "More/Less" toggle button with rotating arrow):
    • Deny folder picker (deny type only) — segmented Picker for .junk, .transaction, .promotion.
    • Filter target picker — segmented: all / sender / body.
    • Filter matching picker — segmented: contains / exact.
    • Filter case picker — segmented: case insensitive / case sensitive.
  • Add button — FilledButton style. Disabled when text is shorter than kMinimumFilterLength (1) or is a duplicate. Calls addFilter() and dismisses.
  • Toolbar X button to dismiss.

ViewModel

  • filterType: FilterType — Set at init (.deny or .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 to DefaultsManager.isExpandedAddFilter via didSet.
  • isDuplicateFilter: Bool — Computed property. Checks PersistanceManager.isDuplicateFilter() in real time as user types. Guarded by didAddFilter flag to avoid false positives after submission.
  • addFilter() — Delegates to PersistanceManager.addFilter() with all selected options.

Notable

  • 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.

FilterListView

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.

Layout

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 — ForEach over model.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 .denyLanguage when no more languages are available to block.
  • Identity — Row identity, scroll targets, new-item dots, and inline-edit focus all use Filter.uuid (UUID), not NSManagedObjectID.
  • Focus — List-owned @FocusState private var focusedFilterID: UUID?. Tap outside clears focus and calls hideKeyboard().
  • New filter highlight — newlyAddedFilter drives dotFilterID + scrollTo by UUID; row shows a fading accent Circle via .onAppear / .onChange.

Navigation Bar

NavigationBarMenu — contextual trailing items:

  • Normal mode: Ellipsis Menu with "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.

ViewModel

  • filterType: FilterType — Set at init, determines which filters to fetch and which add-filter screen to present.
  • filters: [Filter] — Fetched from PersistanceManager.fetchFilterRecords(for:), filtered by type.
  • rowViewModels: [FilterListRowView.ViewModel] — Cached row ViewModels, rebuilt/updated in updateRowViewModels() keyed by id (Filter.uuid). ForEach uses regularRowViewModels / regexRowViewModels — never construct row ViewModels inside ForEach.
  • 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 with onAdded closures (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 to PersistanceManager.deleteFilters().
  • deleteFilters(_:) — Bulk delete from edit mode selection.

Supporting Components

  • FilterListRowView (Others/FilterListRowView.swift) — Individual filter row with inline editing. Has its own ViewModel (subclasses BaseViewModel) with stable let id: UUID from filter.uuid (assigns one if missing). Layout varies by filter type:

    • Deny/Allow: EditableText for inline text editing (tap to edit, minimum kMinimumFilterLength) + Menu + Picker chips 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 .tint when the option is non-default.
    • Deny Language: Read-only localized language name (resolved from $lang: format via NLLanguage(filterText:)).
    • Deny types with folder support: Additional Menu for deny folder (junk/transaction/promotion).
    • All updates call through to PersistanceManager.updateFilter() and trigger onUpdate callback to parent (which calls refresh()).
  • EditableText (Others/EditableText.swift) — Tap-to-edit text component. Parent owns @FocusState<UUID?>; each instance takes focusID: UUID. A sessionActive flag gates a single finish path (EndReason: onCommit / onEditingChanged / focusChanged) so Return does not double-save. After ending the session, focus clear, onEditingChanged(false), and business onCommit are deferred to the next main-run-loop turn — Return’s PlatformTextFieldCoordinator.triggerPrimaryAction still writes TextFieldState after the onCommit callback returns; rebuilding the List (or tearing down the field) inside that callback crashes with EXC_BAD_ACCESS. ZStack overlays display Text/AttributedString with a TextField; idle field stays opacity 0 but layout-stable (important for RTL). Below minimumCharacters, the session ends without calling business onCommit.


ReportMessageView

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.

Layout

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 — FilledButton style. Disabled when both inputs empty. Calls reportMessage().

State overlays (unlike TestFiltersView, these are fully used):

  • Loading: .thinMaterial full-screen overlay + ProgressView.
  • Result: .thinMaterial overlay + animated CheckView (green checkmark drawn with Path + trim animation) + thank-you text. Auto-dismisses after 1 second via onChange(of: state).

Navigation title and toolbar X button are conditionally hidden during loading/result states via View.if().

ViewModel

  • text: String, sender: String — Two-way bound inputs.
  • selectedReport: ReportType — Junk or not junk.
  • state: ViewState — .userInput, .loading, .result(String) with isResult computed property.
  • reportMessage() — Sets state to .loading, creates ReportMessageRequestBody, calls ReportMessageService.reportMessage() via async Task. On completion, sets state to .result on main queue.

Notable

  • Conforms to @unchecked Sendable for the async Task in reportMessage().
  • ViewState lives on this screen only (Test Filters evaluates live and has no loading/result overlay).
  • CheckView (Others/CheckView.swift) — Animated checkmark using Path with trim animation. Purely cosmetic, no ViewModel.

WhatsNewView

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.

Layout

A NavigationView wrapping a ScrollView:

  • Header — "What's New" title and subtitle.
  • Entry cards — ForEach over WhatsNewEntry.allCases sorted by order. Each card shows an SF Symbol in a tinted rounded square, title, and description. Actionable entries (e.g., .tipJar) are tappable and trigger onActionableEntryTapped. Current lead cards: import/export (order 0), then Appearance Touch-up (order 1).
  • Dismiss button — FilledButton at bottom. Sets lastSeenWhatsNewVersion to currentWhatsNewVersion and dismisses.
  • Toolbar X button to dismiss.

ViewModel

  • entries: [WhatsNewEntry] — All entries sorted by order.
  • onActionableEntryTapped: ((WhatsNewEntry) -> Void)? — Optional closure called when an actionable entry is tapped. Passed in from the presenting screen.
  • markAsSeen() — Sets lastSeenWhatsNewVersion to currentWhatsNewVersion so 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.

Actionable Entries

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.

Notable

  • WhatsNewEntry is a CaseIterable enum in Constants.swift with computed properties for title, description, SF Symbol (imageName), color, order, and isActionable.
  • currentWhatsNewVersion must be bumped in Constants.swift when adding entries for a version that already shipped. This branch added .appearanceTouchup (second card, after import/export) without bumping — still 6 — 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), isAppFirstRun is false, and currentWhatsNewVersion > lastSeenWhatsNewVersion.

FilterTransferPreviewView

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.


TipJarView

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.

Layout

A NavigationView wrapping a ZStack (for confetti overlay) containing a ScrollView:

  • Header — Heart emoji, title ("tipJar_header"), and subtitle ("tipJar_subheader").
  • Tip cards — HStack of three TipCardView components (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 a ProgressView spinner. Loading state shows a ProgressView. 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.

Landscape Support

Uses @Environment(\.verticalSizeClass) with an isCompact flag to reduce font sizes, spacing, and padding in landscape orientation.

ViewModel

Published state:

  • products: [Product] — StoreKit products fetched from TipJarManager.
  • 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 from TipJarManager. If still loading, polls isLoadingProducts every 100ms on @MainActor until ready.
  • purchase(_:) — Sets state to .purchasing(tier), calls TipJarManager.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.

Supporting Components

  • TipCardView (TipCardView.swift) — Button with TipCardButtonStyle (scale + opacity effect on press). Displays tier emoji, name, description, and price badge with accent color background. Shows a ProgressView spinner in place of the price when the card's tier is being purchased.
  • ConfettiView (Others/ConfettiView.swift) — UIViewRepresentable wrapping CAEmitterLayer. 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 .tipSuccessful case — Toast notification with onHide callback that triggers sheet dismissal.