From 88e4402d38cfa69f9a196fdde3c44a5b30ac7c8b Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 11 Feb 2026 12:13:20 -0600 Subject: [PATCH] fix: use tool-agnostic ~/.agents/ as default install path Copilot, Claude, Cursor, and others all read from ~/.agents/. The npx skills CLI handles fan-out to tool-specific directories. --- assets/README.md | 6 +- assets/setup.sh | 11 +- .../skills/swift-clean-architecture/SKILL.md | 143 +++++++ assets/skills/swift-localization/SKILL.md | 228 ++++++++++ assets/skills/swift-model-design/SKILL.md | 258 ++++++++++++ assets/skills/swift-modern/SKILL.md | 285 +++++++++++++ assets/skills/swift-pop/SKILL.md | 186 +++++++++ assets/skills/swiftui-accessibility/SKILL.md | 359 ++++++++++++++++ assets/skills/swiftui-modern/SKILL.md | 393 ++++++++++++++++++ assets/skills/swiftui-mvvm/SKILL.md | 228 ++++++++++ 10 files changed, 2091 insertions(+), 6 deletions(-) create mode 100644 assets/skills/swift-clean-architecture/SKILL.md create mode 100644 assets/skills/swift-localization/SKILL.md create mode 100644 assets/skills/swift-model-design/SKILL.md create mode 100644 assets/skills/swift-modern/SKILL.md create mode 100644 assets/skills/swift-pop/SKILL.md create mode 100644 assets/skills/swiftui-accessibility/SKILL.md create mode 100644 assets/skills/swiftui-modern/SKILL.md create mode 100644 assets/skills/swiftui-mvvm/SKILL.md diff --git a/assets/README.md b/assets/README.md index a6304fe..f22cb32 100644 --- a/assets/README.md +++ b/assets/README.md @@ -60,10 +60,12 @@ That's it. | Asset | Default Location | Override | |-------|-----------------|----------| | Registry skills | Managed by `npx skills` CLI | — | -| Custom skills | `~/.copilot/skills/` | `SKILLS_DIR` | -| Agents | `~/.copilot/agents/` | `AGENTS_DIR` | +| Custom skills | `~/.agents/skills/` | `SKILLS_DIR` | +| Agents | `~/.agents/agents/` | `AGENTS_DIR` | | Instructions | `./instructions/` | `INSTRUCTIONS_DIR` | +> The `~/.agents/` directory is tool-agnostic. Copilot, Claude, Cursor, and others all read from it. If you need a tool-specific path, override with the env var. + ## Adding New Assets - **Agents or instructions** — Drop the file into `assets/agents/` or `assets/instructions/` and push. Auto-discovered. diff --git a/assets/setup.sh b/assets/setup.sh index bb92c15..a7b30ef 100755 --- a/assets/setup.sh +++ b/assets/setup.sh @@ -16,9 +16,12 @@ set -euo pipefail VERSION="2.1.0" # ── Configuration (override with env vars) ─────────────────────────── +# Default paths use ~/.agents/ — the tool-agnostic directory. +# The npx skills CLI copies into tool-specific dirs (~/.copilot/, +# ~/.claude/, ~/.cursor/) automatically. ASSETS_BASE_URL="${ASSETS_BASE_URL:-}" -AGENTS_DIR="${AGENTS_DIR:-$HOME/.copilot/agents}" -SKILLS_DIR="${SKILLS_DIR:-$HOME/.copilot/skills}" +AGENTS_DIR="${AGENTS_DIR:-$HOME/.agents/agents}" +SKILLS_DIR="${SKILLS_DIR:-$HOME/.agents/skills}" INSTRUCTIONS_DIR="${INSTRUCTIONS_DIR:-./instructions}" REPO_TOKEN="${REPO_TOKEN:-}" @@ -334,8 +337,8 @@ ${BOLD}EXAMPLES${NC} ${BOLD}ENVIRONMENT VARIABLES${NC} ASSETS_BASE_URL Base URL for remote downloads (required without clone) - AGENTS_DIR Install location for agents (default: ~/.copilot/agents) - SKILLS_DIR Install location for custom skills (default: ~/.copilot/skills) + AGENTS_DIR Install location for agents (default: ~/.agents/agents) + SKILLS_DIR Install location for custom skills (default: ~/.agents/skills) INSTRUCTIONS_DIR Install location for instructions (default: ./instructions) REPO_TOKEN Auth token for private repos (optional) diff --git a/assets/skills/swift-clean-architecture/SKILL.md b/assets/skills/swift-clean-architecture/SKILL.md new file mode 100644 index 0000000..f677b64 --- /dev/null +++ b/assets/skills/swift-clean-architecture/SKILL.md @@ -0,0 +1,143 @@ +--- +name: Swift Clean Architecture +description: File organization, layer separation, folder structures, and proactive refactoring +globs: ["**/*.swift"] +--- + +# Clean Architecture for Swift + +**Separation of concerns is mandatory.** Code should be organized into distinct layers with clear responsibilities and dependencies flowing inward. + +## File Organization Rules + +### One Public Type Per File + +Each file should contain exactly one public struct, class, or enum. Private supporting types may be included only if they are small and used exclusively by the main type. + +### Keep Files Lean (300 Line Limit) + +Aim for files under 300 lines. If a file exceeds this: + +- Extract reusable sub-views into `Components/` folder +- Extract sheets/modals into `Sheets/` folder +- Extract complex logic into dedicated types +- Split private view structs into their own files + +### No Duplicate Code + +Before writing new code: + +1. Search for existing implementations +2. Extract common patterns into reusable components +3. Consider protocol extraction for shared behavior + +## Layer Responsibilities + +| Layer | Contains | Depends On | +|-------|----------|------------| +| **Views** | SwiftUI views, UI components | State, Models | +| **State** | `@Observable` stores, view models | Models, Services | +| **Services** | Business logic, networking, persistence | Models | +| **Models** | Data types, entities, DTOs | Nothing | +| **Protocols** | Interfaces for services and stores | Models | + +### Layer Rules + +1. **Views are dumb renderers** - No business logic. Read state and call methods. +2. **State holds business logic** - Computations, validations, data transformations. +3. **Services are stateless** - Pure functions where possible. Injected via protocols. +4. **Models are simple** - Plain data types. No dependencies on UI or services. + +## Folder Structures + +Choose based on project size and team structure: + +### Feature-First (Large Apps / Teams) + +Best for: Multiple developers, features that could become SPM packages, complex apps. + +``` +App/ +├── Shared/ +│ ├── Design/ # Colors, typography, constants +│ ├── Protocols/ # Shared protocol definitions +│ ├── Services/ # Shared services (networking, auth) +│ └── Components/ # Shared UI components +└── Features/ + ├── Home/ + │ ├── Views/ + │ │ ├── HomeView.swift + │ │ ├── Components/ + │ │ │ ├── HomeHeaderView.swift + │ │ │ └── HomeCardView.swift + │ │ └── Sheets/ + │ │ └── HomeFilterSheet.swift + │ ├── Models/ + │ │ └── HomeItem.swift + │ └── State/ + │ └── HomeStore.swift + └── Profile/ + ├── Views/ + ├── Models/ + └── State/ +``` + +### Layer-First (Small/Medium Apps / Solo) + +Best for: Solo developers, simpler apps, faster navigation. + +``` +App/ +├── Design/ # Colors, typography, constants +├── Models/ # All data models +├── Protocols/ # All protocol definitions +├── Services/ # All services +├── State/ # All observable stores +└── Views/ + ├── Components/ # Shared reusable components + ├── Sheets/ # Shared modal presentations + ├── Home/ # Home feature views + └── Profile/ # Profile feature views +``` + +## Proactive Refactoring + +**The agent will actively identify and suggest fixes for these violations:** + +### File Size Violations + +When a file exceeds 300 lines, suggest specific extractions: + +- "This file is 450 lines. Consider extracting `SomePrivateView` (lines 200-280) to `Components/SomePrivateView.swift`" + +### Duplicate Code Detection + +When similar code patterns appear: + +- "This filtering logic also exists in `OtherStore.swift`. Consider extracting to a shared protocol or utility." + +### View Struct Proliferation + +When a view file contains multiple private struct definitions: + +- "This view has 5 private structs. Extract `HeaderView`, `RowView`, and `FooterView` to the `Components/` folder." + +### Misplaced Business Logic + +When business logic appears in views: + +- "This validation logic belongs in the Store, not the View. Move `isValid` computed property to `FeatureStore`." + +### Protocol Extraction Opportunities + +When similar interfaces appear across types: + +- "Both `UserService` and `TeamService` have similar fetch/save patterns. Consider a `Persistable` protocol." + +## Naming Conventions + +- **Views**: `FeatureNameView.swift`, `FeatureNameRowView.swift` +- **Stores**: `FeatureNameStore.swift` +- **Models**: `FeatureName.swift` or `FeatureNameModel.swift` +- **Services**: `FeatureNameService.swift` +- **Protocols**: `FeatureNameProviding.swift` or `Persistable.swift` diff --git a/assets/skills/swift-localization/SKILL.md b/assets/skills/swift-localization/SKILL.md new file mode 100644 index 0000000..8d057a1 --- /dev/null +++ b/assets/skills/swift-localization/SKILL.md @@ -0,0 +1,228 @@ +--- +name: Swift Localization +description: Localization patterns using String Catalogs and modern APIs +globs: ["**/*.swift", "**/*.xcstrings"] +--- + +# Localization with String Catalogs + +Use **String Catalogs** (`.xcstrings` files) for localization in modern Swift projects. + +## Required Language Support + +At minimum, support these languages: + +- **English (en)** - Base language +- **Spanish - Mexico (es-MX)** +- **French - Canada (fr-CA)** + +## How String Catalogs Work + +### Automatic Extraction + +SwiftUI `Text` views with string literals are automatically extracted: + +```swift +// Automatically added to String Catalog +Text("Hello, World!") +Text("Welcome back, \(user.name)!") +``` + +### Manual Extraction for Non-Text Strings + +For strings outside of `Text` views, use `String(localized:)`: + +```swift +// Use String(localized:) for alerts, buttons, accessibility +let title = String(localized: "Delete Item") +let message = String(localized: "Are you sure you want to delete this item?") + +// With comments for translators +let greeting = String( + localized: "greeting_message", + defaultValue: "Hello!", + comment: "Greeting shown on the home screen" +) +``` + +## Never Use NSLocalizedString + +```swift +// BAD - Old API +let text = NSLocalizedString("Hello", comment: "Greeting") + +// GOOD - Modern API +let text = String(localized: "Hello") +``` + +## String Interpolation + +String Catalogs handle interpolation automatically: + +```swift +// In Swift +Text("You have \(count) items") + +// In String Catalog, translators see: +// "You have %lld items" +// They can reorder: "Items: %lld" for languages that need different order +``` + +## Pluralization + +Use automatic grammar agreement for plurals: + +```swift +// Automatic pluralization +Text("^[\(count) item](inflect: true)") + +// Result: +// count = 1: "1 item" +// count = 5: "5 items" +``` + +For complex pluralization rules, define in String Catalog with plural variants. + +## Formatting Numbers, Dates, Currency + +Always use formatters - they respect locale automatically: + +```swift +// Numbers +Text(price, format: .currency(code: "USD")) +Text(percentage, format: .percent) +Text(count, format: .number) + +// Dates +Text(date, format: .dateTime.month().day().year()) +Text(date, format: .relative(presentation: .named)) + +// Measurements +let distance = Measurement(value: 5, unit: UnitLength.miles) +Text(distance, format: .measurement(width: .abbreviated)) +``` + +## Localized String Keys + +Use meaningful keys for complex strings: + +```swift +// For simple UI text, use the text itself +Text("Settings") +Text("Cancel") + +// For complex or contextual strings, use keys +Text("home.welcome.title") // Key in String Catalog +Text("profile.empty.message") +``` + +## Accessibility Labels + +Localize all accessibility content: + +```swift +Image(systemName: "heart.fill") + .accessibilityLabel(String(localized: "Favorite")) + +Button { } label: { + Image(systemName: "trash") +} +.accessibilityLabel(String(localized: "Delete")) +.accessibilityHint(String(localized: "Removes this item permanently")) +``` + +## String Catalog Organization + +### File Structure + +``` +App/ +├── Localizable.xcstrings # Main strings +├── InfoPlist.xcstrings # Info.plist strings (app name, permissions) +└── Intents.xcstrings # Siri/Shortcuts strings (if applicable) +``` + +### Comments for Translators + +Add comments to help translators understand context: + +```swift +Text("Save", comment: "Button to save the current document") +Text("Save", comment: "Menu item to save all changes") + +// These become separate entries with context +``` + +## Common Patterns + +### Error Messages + +```swift +enum AppError: LocalizedError { + case networkUnavailable + case invalidData + + var errorDescription: String? { + switch self { + case .networkUnavailable: + return String(localized: "error.network.unavailable") + case .invalidData: + return String(localized: "error.data.invalid") + } + } +} +``` + +### Attributed Strings + +```swift +var attributedGreeting: AttributedString { + var string = AttributedString(localized: "Welcome to **MyApp**!") + // Markdown formatting is preserved + return string +} +``` + +### Dynamic Strings from Server + +For server-provided strings that need localization: + +```swift +// Use a mapping approach +let serverKey = response.messageKey // e.g., "subscription_expired" +let localizedMessage = String(localized: String.LocalizationValue(serverKey)) +``` + +## Testing Localization + +### Preview with Different Locales + +```swift +#Preview { + ContentView() + .environment(\.locale, Locale(identifier: "es-MX")) +} + +#Preview { + ContentView() + .environment(\.locale, Locale(identifier: "fr-CA")) +} +``` + +### Pseudo-Localization + +Enable in scheme to find truncation and layout issues: + +1. Edit Scheme → Run → Options +2. Set "Application Language" to a pseudo-language +3. Look for strings that don't expand properly + +## Export/Import for Translation + +```bash +# Export for translators +xcodebuild -exportLocalizations -project MyApp.xcodeproj -localizationPath ./Localizations + +# Import translations +xcodebuild -importLocalizations -project MyApp.xcodeproj -localizationPath ./Localizations/es-MX.xcloc +``` diff --git a/assets/skills/swift-model-design/SKILL.md b/assets/skills/swift-model-design/SKILL.md new file mode 100644 index 0000000..2a84d0f --- /dev/null +++ b/assets/skills/swift-model-design/SKILL.md @@ -0,0 +1,258 @@ +--- +name: Swift Model Design +description: Model design patterns including single source of truth and computed properties +globs: ["**/*.swift"] +--- + +# Model Design Patterns + +**Computed properties should be the single source of truth for derived data.** + +## Single Source of Truth Principle + +Never store data that can be computed from other stored data. This prevents sync bugs and simplifies maintenance. + +### Name Fields Pattern + +When a model has multiple name components, use a computed property for the display name: + +```swift +@Model +final class Person { + var prefix: String = "" // "Dr.", "Mr.", etc. + var firstName: String = "" + var middleName: String = "" + var lastName: String = "" + var suffix: String = "" // "Jr.", "III", etc. + var nickname: String = "" + + // GOOD - Computed from individual fields + var fullName: String { + var parts: [String] = [] + if !prefix.isEmpty { parts.append(prefix) } + if !firstName.isEmpty { parts.append(firstName) } + if !middleName.isEmpty { parts.append(middleName) } + if !lastName.isEmpty { parts.append(lastName) } + if !suffix.isEmpty { parts.append(suffix) } + return parts.joined(separator: " ") + } + + // For display with nickname + var displayName: String { + if !nickname.isEmpty { + return nickname + } + if !firstName.isEmpty { + return firstName + } + return fullName + } + + // Plain format for export (no special formatting) + var vCardName: String { + [firstName, middleName, lastName] + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + // BAD - Stored displayName that can get out of sync + // var storedDisplayName: String // Never add this +} +``` + +### Benefits of Computed Properties + +- **Always up to date**: Changes to individual fields are immediately reflected +- **No sync bugs**: No risk of stored value diverging from component fields +- **Simpler code**: No need to update derived values when editing source fields +- **Less storage**: No duplicate data in database + +## Derived State Patterns + +### Counts and Aggregates + +```swift +@Model +final class Project { + var name: String = "" + @Relationship(deleteRule: .cascade) + var tasks: [Task]? = [] + + // GOOD - Computed counts + var taskCount: Int { + tasks?.count ?? 0 + } + + var completedTaskCount: Int { + tasks?.filter(\.isCompleted).count ?? 0 + } + + var progress: Double { + guard taskCount > 0 else { return 0 } + return Double(completedTaskCount) / Double(taskCount) + } + + var isComplete: Bool { + taskCount > 0 && completedTaskCount == taskCount + } + + // BAD - Stored counts that need manual updates + // var storedTaskCount: Int = 0 + // var storedCompletedCount: Int = 0 +} +``` + +### Status and State + +```swift +@Model +final class Order { + var createdAt: Date = Date() + var paidAt: Date? + var shippedAt: Date? + var deliveredAt: Date? + var cancelledAt: Date? + + // GOOD - Computed status + var status: OrderStatus { + if cancelledAt != nil { return .cancelled } + if deliveredAt != nil { return .delivered } + if shippedAt != nil { return .shipped } + if paidAt != nil { return .paid } + return .pending + } + + var isActive: Bool { + cancelledAt == nil && deliveredAt == nil + } + + var canCancel: Bool { + shippedAt == nil && cancelledAt == nil + } +} + +enum OrderStatus: String, Codable { + case pending, paid, shipped, delivered, cancelled +} +``` + +### Validation State + +```swift +@Model +final class UserProfile { + var email: String = "" + var phone: String = "" + var firstName: String = "" + var lastName: String = "" + + // GOOD - Computed validation + var isEmailValid: Bool { + email.contains("@") && email.contains(".") + } + + var isPhoneValid: Bool { + let digits = phone.filter(\.isNumber) + return digits.count >= 10 + } + + var isComplete: Bool { + !firstName.isEmpty && !lastName.isEmpty && isEmailValid + } + + var validationErrors: [String] { + var errors: [String] = [] + if firstName.isEmpty { errors.append("First name is required") } + if lastName.isEmpty { errors.append("Last name is required") } + if !isEmailValid { errors.append("Valid email is required") } + return errors + } +} +``` + +## When to Store vs Compute + +### Store When: + +- Data comes from an external source (API, user input) +- Computation is expensive and value is accessed frequently +- Historical accuracy matters (price at time of purchase) +- You need to query/filter by the value in database + +### Compute When: + +- Value is derived from other stored properties +- Value can change when source properties change +- Keeping values in sync would be error-prone +- Computation is fast (string concatenation, simple math) + +## Caching Expensive Computations + +For expensive computed values accessed frequently: + +```swift +@Observable +@MainActor +final class AnalyticsStore { + private var items: [Item] = [] + + // Cache invalidation tracking + private var itemsVersion = 0 + private var cachedStatsVersion = -1 + private var cachedStats: Statistics? + + var statistics: Statistics { + // Return cached if valid + if cachedStatsVersion == itemsVersion, let cached = cachedStats { + return cached + } + + // Compute and cache + let stats = computeStatistics() + cachedStats = stats + cachedStatsVersion = itemsVersion + return stats + } + + func updateItems(_ newItems: [Item]) { + items = newItems + itemsVersion += 1 // Invalidate cache + } + + private func computeStatistics() -> Statistics { + // Expensive computation + } +} +``` + +## Identifiable and Hashable + +Implement `Identifiable` for use with SwiftUI lists: + +```swift +@Model +final class Item { + var id: UUID = UUID() // Or use @Attribute(.unique) if not using CloudKit + var name: String = "" +} + +// SwiftData models are automatically Identifiable if they have an 'id' property +``` + +For value types used in Sets or as Dictionary keys: + +```swift +struct Tag: Hashable, Codable { + let id: UUID + let name: String + + // Hashable based on id only + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Tag, rhs: Tag) -> Bool { + lhs.id == rhs.id + } +} +``` diff --git a/assets/skills/swift-modern/SKILL.md b/assets/skills/swift-modern/SKILL.md new file mode 100644 index 0000000..c4c998d --- /dev/null +++ b/assets/skills/swift-modern/SKILL.md @@ -0,0 +1,285 @@ +--- +name: Modern Swift +description: Modern Swift language patterns, concurrency, and API usage +globs: ["**/*.swift"] +--- + +# Modern Swift Patterns + +Use modern Swift language features and avoid deprecated patterns. + +## Concurrency + +### Always Mark Observable Classes with @MainActor + +```swift +@Observable +@MainActor +final class FeatureStore { + // All properties and methods run on main actor +} +``` + +### Use Modern Concurrency (No GCD) + +```swift +// BAD - Old GCD patterns +DispatchQueue.main.async { + self.updateUI() +} + +DispatchQueue.global().async { + let result = self.heavyWork() + DispatchQueue.main.async { + self.handle(result) + } +} + +// GOOD - Modern concurrency +await MainActor.run { + updateUI() +} + +Task.detached { + let result = await heavyWork() + await MainActor.run { + handle(result) + } +} +``` + +### Use Task.sleep(for:) Not nanoseconds + +```swift +// BAD +try await Task.sleep(nanoseconds: 1_000_000_000) + +// GOOD +try await Task.sleep(for: .seconds(1)) +try await Task.sleep(for: .milliseconds(500)) +``` + +### Strict Concurrency Compliance + +```swift +// Ensure data crossing actor boundaries is Sendable +struct Item: Sendable { + let id: UUID + let name: String +} + +// Use @unchecked Sendable only when you've manually verified thread safety +final class Cache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Any] = [:] +} +``` + +## Foundation APIs + +### Use Modern URL APIs + +```swift +// BAD - Deprecated patterns +let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] +let file = docs.appendingPathComponent("data.json") + +// GOOD - Modern APIs +let docs = URL.documentsDirectory +let file = docs.appending(path: "data.json") +``` + +### Use Modern Date Formatting + +```swift +// BAD - DateFormatter +let formatter = DateFormatter() +formatter.dateStyle = .medium +let string = formatter.string(from: date) + +// GOOD - format method +let string = date.formatted(.dateTime.month().day().year()) +let relative = date.formatted(.relative(presentation: .named)) +``` + +### Use Modern Number Formatting + +```swift +// BAD - C-style or NumberFormatter +let string = String(format: "%.2f", price) + +// GOOD - format method +let string = price.formatted(.currency(code: "USD")) +let percent = ratio.formatted(.percent.precision(.fractionLength(1))) +``` + +## String Handling + +### Use localizedStandardContains for User Search + +```swift +// BAD - Case-sensitive or manual lowercasing +items.filter { $0.name.lowercased().contains(query.lowercased()) } + +// GOOD - Locale-aware, case/diacritic insensitive +items.filter { $0.name.localizedStandardContains(query) } +``` + +### String Interpolation Over Concatenation + +```swift +// BAD +let message = "Hello, " + user.name + "!" + +// GOOD +let message = "Hello, \(user.name)!" +``` + +## Type Safety + +### Avoid Force Unwraps + +```swift +// BAD +let value = dictionary["key"]! +let url = URL(string: urlString)! + +// GOOD - Guard or optional binding +guard let value = dictionary["key"] else { + throw ValidationError.missingKey +} + +guard let url = URL(string: urlString) else { + throw NetworkError.invalidURL +} +``` + +### Avoid Force Casts + +```swift +// BAD +let view = cell as! CustomCell + +// GOOD +guard let view = cell as? CustomCell else { + assertionFailure("Expected CustomCell") + return +} +``` + +### Avoid Force Try + +```swift +// BAD +let data = try! encoder.encode(object) + +// GOOD - Handle the error +do { + let data = try encoder.encode(object) +} catch { + logger.error("Encoding failed: \(error)") +} +``` + +## Prefer Swift-Native Patterns + +### Static Member Lookup + +```swift +// BAD - Struct instances +.clipShape(RoundedRectangle(cornerRadius: 8)) + +// GOOD - Static member lookup +.clipShape(.rect(cornerRadius: 8)) +``` + +### Result Builders Over Imperative Construction + +```swift +// BAD - Imperative array building +var views: [AnyView] = [] +if showHeader { + views.append(AnyView(HeaderView())) +} +views.append(AnyView(ContentView())) + +// GOOD - ViewBuilder +@ViewBuilder +var content: some View { + if showHeader { + HeaderView() + } + ContentView() +} +``` + +### KeyPath Expressions + +```swift +// BAD +items.map { $0.name } +items.sorted { $0.date < $1.date } + +// GOOD +items.map(\.name) +items.sorted(using: KeyPathComparator(\.date)) +``` + +## Collections + +### Use First(where:) Over Filter().first + +```swift +// BAD - Creates intermediate array +let item = items.filter { $0.id == targetId }.first + +// GOOD - Short-circuits +let item = items.first { $0.id == targetId } +``` + +### Use Contains(where:) Over Filter().isEmpty + +```swift +// BAD +let hasActive = !items.filter { $0.isActive }.isEmpty + +// GOOD +let hasActive = items.contains { $0.isActive } +``` + +### Use Lazy for Chained Operations + +```swift +// Process large collections efficiently +let result = largeArray + .lazy + .filter { $0.isValid } + .map { $0.transformed } + .prefix(10) + .map(Array.init) // Materialize only when needed +``` + +## Error Handling + +### Prefer Typed Throws (Swift 6) + +```swift +// Swift 6 - Typed throws +enum NetworkError: Error { + case notFound + case unauthorized + case serverError(Int) +} + +func fetch() throws(NetworkError) -> Data { + // ... +} +``` + +### Use Result for Async Callbacks (When Not Using async/await) + +```swift +func fetch(completion: @escaping (Result) -> Void) { + // ... +} +``` diff --git a/assets/skills/swift-pop/SKILL.md b/assets/skills/swift-pop/SKILL.md new file mode 100644 index 0000000..ff614ed --- /dev/null +++ b/assets/skills/swift-pop/SKILL.md @@ -0,0 +1,186 @@ +--- +name: Swift Protocol-Oriented Programming +description: Protocol-first architecture patterns for reusability and testability +globs: ["**/*.swift"] +--- + +# Protocol-Oriented Programming (POP) + +**Protocol-first architecture is a priority.** When designing new features, always think about protocols and composition before concrete implementations. + +## When Architecting New Code + +1. **Start with the protocol** - Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol. + +2. **Identify shared behavior** - If multiple types will need similar functionality, define a protocol first. + +3. **Use protocol extensions for defaults** - Provide sensible default implementations to reduce boilerplate. + +4. **Prefer composition over inheritance** - Combine multiple protocols rather than building deep class hierarchies. + +## When Reviewing Existing Code + +1. **Look for duplicated patterns** - Similar logic across files is a candidate for protocol extraction. + +2. **Identify common interfaces** - Types that expose similar properties/methods should conform to a shared protocol. + +3. **Check before implementing** - Search for existing protocols that could be adopted or extended. + +4. **Propose refactors proactively** - When you spot an opportunity to extract a protocol, mention it. + +## Protocol Design Guidelines + +### Naming Conventions + +Use capability-based suffixes: + +- `-able`: `Persistable`, `Shareable`, `Validatable` +- `-ing`: `DataProviding`, `ErrorHandling`, `Loading` +- `-Provider`: `ContentProvider`, `DataProvider` +- `-Delegate`: `NavigationDelegate`, `FormDelegate` + +### Keep Protocols Focused + +Each protocol should represent one capability (Interface Segregation Principle): + +```swift +// GOOD - Focused protocols +protocol Identifiable { + var id: UUID { get } +} + +protocol Nameable { + var displayName: String { get } +} + +protocol Timestamped { + var createdAt: Date { get } + var updatedAt: Date { get } +} + +// Compose as needed +struct User: Identifiable, Nameable, Timestamped { ... } +``` + +```swift +// BAD - Kitchen sink protocol +protocol Entity { + var id: UUID { get } + var displayName: String { get } + var createdAt: Date { get } + var updatedAt: Date { get } + func save() async throws + func delete() async throws + func validate() -> Bool +} +``` + +### Associated Types + +Use sparingly. Prefer concrete types or generics at the call site when possible: + +```swift +// Prefer this for simple cases +protocol DataFetching { + func fetch(from url: URL) async throws -> T +} + +// Use associated types when the type is fundamental to the protocol +protocol Repository { + associatedtype Entity + func fetch(id: UUID) async throws -> Entity? + func save(_ entity: Entity) async throws +} +``` + +### Value vs Reference Semantics + +Constrain to `AnyObject` only when reference semantics are required: + +```swift +// Default - allows structs and classes +protocol Configurable { + mutating func configure(with options: Options) +} + +// When you need reference semantics (delegates, observers) +protocol NavigationDelegate: AnyObject { + func didNavigate(to destination: Destination) +} +``` + +## Protocol Extensions + +Provide default implementations for common behavior: + +```swift +protocol Validatable { + var validationErrors: [String] { get } + var isValid: Bool { get } +} + +extension Validatable { + var isValid: Bool { + validationErrors.isEmpty + } +} +``` + +## Dependency Injection with Protocols + +Define protocols for services to enable testing: + +```swift +protocol NetworkServiceProtocol { + func fetch(from url: URL) async throws -> T +} + +// Production implementation +final class NetworkService: NetworkServiceProtocol { ... } + +// Test mock +final class MockNetworkService: NetworkServiceProtocol { ... } +``` + +## Benefits + +- **Reusability** - Shared protocols work across features +- **Testability** - Mock types can conform to protocols for unit testing +- **Flexibility** - New features can adopt existing protocols immediately +- **Maintainability** - Fix a bug in a protocol extension, fix it everywhere +- **Discoverability** - Protocols document the expected interface clearly + +## Common Patterns + +### Repository Pattern + +```swift +protocol Repository { + associatedtype Entity: Identifiable + + func fetch(id: Entity.ID) async throws -> Entity? + func fetchAll() async throws -> [Entity] + func save(_ entity: Entity) async throws + func delete(_ entity: Entity) async throws +} +``` + +### Service Pattern + +```swift +protocol AuthServiceProtocol { + var isAuthenticated: Bool { get } + func signIn(email: String, password: String) async throws + func signOut() async throws +} +``` + +### Coordinator/Navigation Pattern + +```swift +protocol NavigationCoordinating: AnyObject { + func navigate(to destination: Destination) + func dismiss() + func presentSheet(_ sheet: SheetType) +} +``` diff --git a/assets/skills/swiftui-accessibility/SKILL.md b/assets/skills/swiftui-accessibility/SKILL.md new file mode 100644 index 0000000..09f0776 --- /dev/null +++ b/assets/skills/swiftui-accessibility/SKILL.md @@ -0,0 +1,359 @@ +--- +name: SwiftUI Accessibility +description: Dynamic Type support and VoiceOver accessibility implementation +globs: ["**/*.swift"] +--- + +# Accessibility: Dynamic Type and VoiceOver + +Accessibility is not optional. All apps must support Dynamic Type and VoiceOver. + +## Dynamic Type + +### Always Support Dynamic Type + +Use system text styles that scale automatically: + +```swift +// GOOD - Scales with Dynamic Type +Text("Title") + .font(.title) + +Text("Body text") + .font(.body) + +Text("Caption") + .font(.caption) +``` + +### Use @ScaledMetric for Custom Dimensions + +When you need custom sizes that should scale with Dynamic Type: + +```swift +struct CustomCard: View { + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24 + @ScaledMetric(relativeTo: .body) private var spacing: CGFloat = 12 + @ScaledMetric(relativeTo: .title) private var headerHeight: CGFloat = 44 + + var body: some View { + VStack(spacing: spacing) { + Image(systemName: "star") + .font(.system(size: iconSize)) + Text("Content") + } + } +} +``` + +### Choose Appropriate relativeTo Styles + +Match the scaling behavior to the content's purpose: + +| Content Type | relativeTo | +|-------------|-----------| +| Body content spacing | `.body` | +| Title decorations | `.title` | +| Caption elements | `.caption` | +| Large headers | `.largeTitle` | + +### Fixed Sizes (Use Sparingly) + +Only use fixed sizes when absolutely necessary, and document the reason: + +```swift +// Fixed size for app icon badge - must match system badge size +private let badgeSize: CGFloat = 24 // Fixed: matches system notification badge + +// Fixed for external API requirements +private let avatarUploadSize: CGFloat = 256 // Fixed: server requires exactly 256x256 +``` + +### Prefer System Text Styles + +```swift +// GOOD - System styles +.font(.body) +.font(.headline) +.font(.title) +.font(.caption) + +// AVOID - Custom sizes that don't scale +.font(.system(size: 14)) + +// IF you must use custom sizes, use ScaledMetric +@ScaledMetric private var customSize: CGFloat = 14 +.font(.system(size: customSize)) +``` + +## VoiceOver + +### Accessibility Labels + +All interactive elements must have meaningful labels: + +```swift +// GOOD - Descriptive label +Button { } label: { + Image(systemName: "trash") +} +.accessibilityLabel("Delete item") + +// GOOD - Context-aware label +Button { } label: { + Image(systemName: "heart.fill") +} +.accessibilityLabel(item.isFavorite ? "Remove from favorites" : "Add to favorites") +``` + +### Accessibility Values + +Use for dynamic state that changes: + +```swift +Slider(value: $volume) + .accessibilityLabel("Volume") + .accessibilityValue("\(Int(volume * 100)) percent") + +Toggle(isOn: $isEnabled) { + Text("Notifications") +} +.accessibilityValue(isEnabled ? "On" : "Off") +``` + +### Accessibility Hints + +Describe what happens when the user interacts: + +```swift +Button("Submit") { } + .accessibilityLabel("Submit order") + .accessibilityHint("Double-tap to place your order and proceed to payment") + +NavigationLink(value: item) { + ItemRow(item: item) +} +.accessibilityHint("Opens item details") +``` + +### Accessibility Traits + +Use traits to convey element type and behavior: + +```swift +// Button trait (usually automatic) +Text("Tap me") + .onTapGesture { } + .accessibilityAddTraits(.isButton) + +// Header trait for section headers +Text("Settings") + .font(.headline) + .accessibilityAddTraits(.isHeader) + +// Selected state +ItemRow(item: item) + .accessibilityAddTraits(isSelected ? .isSelected : []) + +// Image trait removal for decorative images +Image("decorative-background") + .accessibilityHidden(true) +``` + +### Hide Decorative Elements + +Hide elements that don't provide meaningful information: + +```swift +// Decorative separator +Divider() + .accessibilityHidden(true) + +// Background decoration +Image("pattern") + .accessibilityHidden(true) + +// Redundant icon next to text +HStack { + Image(systemName: "envelope") + .accessibilityHidden(true) // Label conveys the meaning + Text("Email") +} +``` + +### Group Related Elements + +Reduce navigation complexity by grouping related content: + +```swift +// GOOD - Single VoiceOver element +HStack { + Image(systemName: "person") + VStack(alignment: .leading) { + Text(user.name) + Text(user.email) + .font(.caption) + } +} +.accessibilityElement(children: .combine) + +// OR create a completely custom accessibility representation +.accessibilityElement(children: .ignore) +.accessibilityLabel("\(user.name), \(user.email)") +``` + +### Accessibility Actions + +Add custom actions for complex interactions: + +```swift +ItemRow(item: item) + .accessibilityAction(named: "Delete") { + deleteItem(item) + } + .accessibilityAction(named: "Edit") { + editItem(item) + } + .accessibilityAction(named: "Share") { + shareItem(item) + } +``` + +### Accessibility Announcements + +Announce important state changes: + +```swift +func completeTask() { + task.isCompleted = true + + // Announce the change + AccessibilityNotification.Announcement("Task completed") + .post() +} + +func showError(_ message: String) { + errorMessage = message + + // Announce errors immediately + AccessibilityNotification.Announcement(message) + .post() +} +``` + +### Accessibility Focus + +Control focus for important UI changes: + +```swift +struct ContentView: View { + @AccessibilityFocusState private var isSearchFocused: Bool + @State private var showingSearch = false + + var body: some View { + VStack { + if showingSearch { + TextField("Search", text: $searchText) + .accessibilityFocused($isSearchFocused) + } + + Button("Search") { + showingSearch = true + isSearchFocused = true // Move focus to search field + } + } + } +} +``` + +## Common Patterns + +### Cards and List Items + +```swift +struct ItemCard: View { + let item: Item + + var body: some View { + VStack(alignment: .leading) { + Text(item.title) + .font(.headline) + Text(item.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack { + Label("\(item.likes)", systemImage: "heart") + Label("\(item.comments)", systemImage: "bubble.right") + } + .font(.caption) + } + // Combine into single VoiceOver element + .accessibilityElement(children: .combine) + // Add meaningful summary + .accessibilityLabel("\(item.title), \(item.subtitle)") + .accessibilityValue("\(item.likes) likes, \(item.comments) comments") + } +} +``` + +### Interactive Charts + +```swift +Chart { + ForEach(data) { point in + LineMark(x: .value("Date", point.date), y: .value("Value", point.value)) + } +} +.accessibilityLabel("Sales chart") +.accessibilityValue("Showing data from \(startDate) to \(endDate)") +.accessibilityHint("Swipe up or down to hear individual data points") +.accessibilityChartDescriptor(self) +``` + +### Custom Controls + +```swift +struct RatingControl: View { + @Binding var rating: Int + + var body: some View { + HStack { + ForEach(1...5, id: \.self) { star in + Image(systemName: star <= rating ? "star.fill" : "star") + .onTapGesture { rating = star } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Rating") + .accessibilityValue("\(rating) of 5 stars") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + rating = min(5, rating + 1) + case .decrement: + rating = max(1, rating - 1) + @unknown default: + break + } + } + } +} +``` + +## Testing Accessibility + +### Enable VoiceOver in Simulator + +1. Settings → Accessibility → VoiceOver +2. Or use Accessibility Inspector (Xcode → Open Developer Tool) + +### Audit Checklist + +- [ ] All interactive elements have labels +- [ ] Dynamic content announces changes +- [ ] Decorative elements are hidden +- [ ] Text scales with Dynamic Type +- [ ] Touch targets are at least 44pt +- [ ] Color is not the only indicator of state +- [ ] Groups reduce navigation complexity diff --git a/assets/skills/swiftui-modern/SKILL.md b/assets/skills/swiftui-modern/SKILL.md new file mode 100644 index 0000000..7786a92 --- /dev/null +++ b/assets/skills/swiftui-modern/SKILL.md @@ -0,0 +1,393 @@ +--- +name: Modern SwiftUI +description: Modern SwiftUI API usage and best practices +globs: ["**/*.swift"] +--- + +# Modern SwiftUI Patterns + +Use modern SwiftUI APIs and avoid deprecated patterns. + +## Styling APIs + +### Use foregroundStyle() Not foregroundColor() + +```swift +// BAD - Deprecated +Text("Hello") + .foregroundColor(.blue) + +// GOOD +Text("Hello") + .foregroundStyle(.blue) + +// GOOD - With gradients +Text("Hello") + .foregroundStyle(.linearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing)) +``` + +### Use clipShape(.rect()) Not cornerRadius() + +```swift +// BAD - Deprecated +Image("photo") + .cornerRadius(12) + +// GOOD +Image("photo") + .clipShape(.rect(cornerRadius: 12)) + +// GOOD - With specific corners +Image("photo") + .clipShape(.rect(cornerRadii: .init(topLeading: 12, topTrailing: 12))) +``` + +### Use bold() Not fontWeight(.bold) + +```swift +// Less preferred +Text("Title") + .fontWeight(.bold) + +// Preferred +Text("Title") + .bold() +``` + +## Navigation + +### Use NavigationStack with navigationDestination + +```swift +// BAD - Old NavigationView with NavigationLink +NavigationView { + List(items) { item in + NavigationLink(destination: DetailView(item: item)) { + ItemRow(item: item) + } + } +} + +// GOOD - NavigationStack with typed destinations +NavigationStack { + List(items) { item in + NavigationLink(value: item) { + ItemRow(item: item) + } + } + .navigationDestination(for: Item.self) { item in + DetailView(item: item) + } +} +``` + +### Use NavigationPath for Programmatic Navigation + +```swift +@Observable +@MainActor +final class NavigationStore { + var path = NavigationPath() + + func navigate(to item: Item) { + path.append(item) + } + + func popToRoot() { + path.removeLast(path.count) + } +} +``` + +## Tab View + +### Use Tab API Not tabItem() + +```swift +// BAD - Old tabItem pattern +TabView { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } +} + +// GOOD - Tab API (iOS 18+) +TabView { + Tab("Home", systemImage: "house") { + HomeView() + } + Tab("Settings", systemImage: "gear") { + SettingsView() + } +} +``` + +## Observable Pattern + +### Use @Observable Not ObservableObject + +```swift +// BAD - Old Combine-based pattern +class FeatureStore: ObservableObject { + @Published var items: [Item] = [] +} + +struct FeatureView: View { + @StateObject var store = FeatureStore() + // or @ObservedObject +} + +// GOOD - Modern Observation +@Observable +@MainActor +final class FeatureStore { + var items: [Item] = [] +} + +struct FeatureView: View { + @State var store = FeatureStore() + // or for external injection: + @Bindable var store: FeatureStore +} +``` + +## Event Handling + +### Use Button Not onTapGesture() + +```swift +// BAD - No accessibility, no button styling +Text("Submit") + .onTapGesture { + submit() + } + +// GOOD - Proper button semantics +Button("Submit") { + submit() +} + +// When you need tap location/count, onTapGesture is acceptable +SomeView() + .onTapGesture(count: 2) { location in + handleDoubleTap(at: location) + } +``` + +### Use Two-Parameter onChange() + +```swift +// BAD - Deprecated single parameter +.onChange(of: searchText) { newValue in + search(for: newValue) +} + +// GOOD - Two parameter version +.onChange(of: searchText) { oldValue, newValue in + search(for: newValue) +} + +// GOOD - When you don't need old value +.onChange(of: searchText) { _, newValue in + search(for: newValue) +} +``` + +## Layout + +### Avoid UIScreen.main.bounds + +```swift +// BAD - Hardcoded screen size +let width = UIScreen.main.bounds.width + +// GOOD - GeometryReader when needed +GeometryReader { geometry in + SomeView() + .frame(width: geometry.size.width * 0.8) +} + +// BETTER - containerRelativeFrame (iOS 17+) +SomeView() + .containerRelativeFrame(.horizontal) { size, _ in + size * 0.8 + } +``` + +### Prefer containerRelativeFrame Over GeometryReader + +```swift +// Avoid GeometryReader when possible +ScrollView(.horizontal) { + LazyHStack { + ForEach(items) { item in + ItemCard(item: item) + .containerRelativeFrame(.horizontal, count: 3, spacing: 16) + } + } +} +``` + +## View Composition + +### Extract to View Structs Not Computed Properties + +```swift +// BAD - Computed properties for view composition +struct ContentView: View { + private var header: some View { + HStack { + Text("Title") + Spacer() + Button("Action") { } + } + } + + var body: some View { + VStack { + header + // ... + } + } +} + +// GOOD - Separate View struct +struct HeaderView: View { + let title: String + let action: () -> Void + + var body: some View { + HStack { + Text(title) + Spacer() + Button("Action", action: action) + } + } +} +``` + +### Avoid AnyView + +```swift +// BAD - Type erasure loses optimization +func makeView(for type: ViewType) -> AnyView { + switch type { + case .list: return AnyView(ListView()) + case .grid: return AnyView(GridView()) + } +} + +// GOOD - @ViewBuilder +@ViewBuilder +func makeView(for type: ViewType) -> some View { + switch type { + case .list: ListView() + case .grid: GridView() + } +} +``` + +## Lists and ForEach + +### Don't Convert to Array for Enumeration + +```swift +// BAD - Unnecessary Array conversion +ForEach(Array(items.enumerated()), id: \.offset) { index, item in + // ... +} + +// GOOD - Use indices or zip +ForEach(items.indices, id: \.self) { index in + let item = items[index] + // ... +} + +// GOOD - If you need both +ForEach(Array(zip(items.indices, items)), id: \.0) { index, item in + // ... +} +``` + +### Hide Scroll Indicators + +```swift +// Use scrollIndicators modifier +ScrollView { + // content +} +.scrollIndicators(.hidden) +``` + +## Button Labels with Images + +### Always Include Text with Image Buttons + +```swift +// BAD - No accessibility label +Button { + addItem() +} label: { + Image(systemName: "plus") +} + +// GOOD - Text alongside image +Button { + addItem() +} label: { + Label("Add Item", systemImage: "plus") +} + +// GOOD - If you only want to show the image +Button { + addItem() +} label: { + Label("Add Item", systemImage: "plus") + .labelStyle(.iconOnly) +} +``` + +## Design Constants + +### Never Use Raw Numeric Literals + +```swift +// BAD +.padding(16) +.clipShape(.rect(cornerRadius: 12)) +.opacity(0.7) + +// GOOD - Use design constants +.padding(Design.Spacing.medium) +.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) +.opacity(Design.Opacity.strong) +``` + +### Never Use Inline Colors + +```swift +// BAD +.foregroundStyle(Color(red: 0.2, green: 0.4, blue: 0.8)) +.background(Color(hex: "#3366CC")) + +// GOOD - Semantic color names +.foregroundStyle(Color.Theme.primary) +.background(Color.Background.secondary) +``` + +## Image Rendering + +### Prefer ImageRenderer Over UIGraphicsImageRenderer + +```swift +// For SwiftUI → Image conversion +let renderer = ImageRenderer(content: MyView()) +if let uiImage = renderer.uiImage { + // use image +} +``` diff --git a/assets/skills/swiftui-mvvm/SKILL.md b/assets/skills/swiftui-mvvm/SKILL.md new file mode 100644 index 0000000..8c87991 --- /dev/null +++ b/assets/skills/swiftui-mvvm/SKILL.md @@ -0,0 +1,228 @@ +--- +name: SwiftUI MVVM +description: View/State separation patterns for SwiftUI with Observable stores +globs: ["**/*.swift"] +--- + +# View/State Separation (MVVM-lite) + +**Views should be "dumb" renderers.** All business logic belongs in stores or dedicated view models. + +## What Belongs in State/Store + +- **Business logic**: Calculations, validations, rules +- **Computed properties based on data**: Hints, recommendations, derived values +- **State checks**: `canSubmit`, `isLoading`, `hasError` +- **Data transformations**: Filtering, sorting, aggregations +- **Side effects**: Network calls, persistence, analytics + +## What is Acceptable in Views + +- **Pure UI layout logic**: Adaptive layouts based on size class +- **Visual styling**: Color selection based on state +- **@ViewBuilder sub-views**: Breaking up complex layouts (keep in same file if small) +- **Accessibility labels**: Combining data into accessible descriptions +- **Simple conditionals for UI**: `if isExpanded { ... }` + +## Store Pattern + +```swift +@Observable +@MainActor +final class FeatureStore { + // MARK: - State + private(set) var items: [Item] = [] + private(set) var isLoading = false + private(set) var error: Error? + + // MARK: - Computed Properties (Business Logic) + var isEmpty: Bool { items.isEmpty } + var itemCount: Int { items.count } + var canSubmit: Bool { !selectedItems.isEmpty && !isLoading } + + var filteredItems: [Item] { + guard !searchText.isEmpty else { return items } + return items.filter { $0.name.localizedStandardContains(searchText) } + } + + // MARK: - User Input + var searchText = "" + var selectedItems: Set = [] + + // MARK: - Dependencies + private let service: FeatureServiceProtocol + + init(service: FeatureServiceProtocol) { + self.service = service + } + + // MARK: - Actions + func load() async { + isLoading = true + defer { isLoading = false } + + do { + items = try await service.fetchItems() + } catch { + self.error = error + } + } + + func submit() async { + guard canSubmit else { return } + // ... + } +} +``` + +## View Pattern + +```swift +struct FeatureView: View { + @Bindable var store: FeatureStore + + var body: some View { + List(store.filteredItems) { item in + ItemRow(item: item) + } + .searchable(text: $store.searchText) + .overlay { + if store.isEmpty { + ContentUnavailableView("No Items", systemImage: "tray") + } + } + .toolbar { + Button("Submit") { + Task { await store.submit() } + } + .disabled(!store.canSubmit) + } + .task { + await store.load() + } + } +} +``` + +## Bad vs Good Examples + +### Validation Logic + +```swift +// BAD - Business logic in view +struct MyView: View { + @Bindable var store: FeatureStore + + private var isValid: Bool { + !store.name.isEmpty && store.email.contains("@") + } + + var body: some View { + Button("Save") { } + .disabled(!isValid) + } +} + +// GOOD - Logic in Store, view just reads +// In FeatureStore: +var isValid: Bool { + !name.isEmpty && email.contains("@") +} + +// In View: +Button("Save") { store.save() } + .disabled(!store.isValid) +``` + +### Filtering Logic + +```swift +// BAD - Filtering in view +struct ListView: View { + @Bindable var store: ListStore + + var filteredItems: [Item] { + store.items.filter { $0.isActive && $0.category == selectedCategory } + } + + var body: some View { + List(filteredItems) { ... } + } +} + +// GOOD - Filtering in Store +// In ListStore: +var filteredItems: [Item] { + items.filter { $0.isActive && $0.category == selectedCategory } +} + +// In View: +List(store.filteredItems) { ... } +``` + +### Error Handling + +```swift +// BAD - Error formatting in view +struct ProfileView: View { + @Bindable var store: ProfileStore + + var errorMessage: String? { + guard let error = store.error else { return nil } + if let networkError = error as? NetworkError { + switch networkError { + case .notFound: return "Profile not found" + case .unauthorized: return "Please sign in" + default: return "Something went wrong" + } + } + return error.localizedDescription + } +} + +// GOOD - Error message in Store +// In ProfileStore: +var errorMessage: String? { + guard let error else { return nil } + return ErrorFormatter.message(for: error) +} + +// In View: +if let message = store.errorMessage { + Text(message) +} +``` + +## When to Create a Separate ViewModel + +Use a dedicated ViewModel (instead of a Store) when: + +1. The view has complex local state that doesn't need to persist +2. You need to transform data from multiple stores for a single view +3. The view has form validation with many fields +4. You're wrapping a UIKit component that needs state management + +```swift +@Observable +@MainActor +final class FormViewModel { + // Form-specific state + var firstName = "" + var lastName = "" + var email = "" + + // Validation + var isValid: Bool { + !firstName.isEmpty && !lastName.isEmpty && email.contains("@") + } + + var firstNameError: String? { + firstName.isEmpty ? "First name is required" : nil + } + + // Submit creates domain object + func createUser() -> User { + User(firstName: firstName, lastName: lastName, email: email) + } +} +```