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.
This commit is contained in:
parent
20446c5224
commit
88e4402d38
@ -60,10 +60,12 @@ That's it.
|
|||||||
| Asset | Default Location | Override |
|
| Asset | Default Location | Override |
|
||||||
|-------|-----------------|----------|
|
|-------|-----------------|----------|
|
||||||
| Registry skills | Managed by `npx skills` CLI | — |
|
| Registry skills | Managed by `npx skills` CLI | — |
|
||||||
| Custom skills | `~/.copilot/skills/` | `SKILLS_DIR` |
|
| Custom skills | `~/.agents/skills/` | `SKILLS_DIR` |
|
||||||
| Agents | `~/.copilot/agents/` | `AGENTS_DIR` |
|
| Agents | `~/.agents/agents/` | `AGENTS_DIR` |
|
||||||
| Instructions | `./instructions/` | `INSTRUCTIONS_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
|
## Adding New Assets
|
||||||
|
|
||||||
- **Agents or instructions** — Drop the file into `assets/agents/` or `assets/instructions/` and push. Auto-discovered.
|
- **Agents or instructions** — Drop the file into `assets/agents/` or `assets/instructions/` and push. Auto-discovered.
|
||||||
|
|||||||
@ -16,9 +16,12 @@ set -euo pipefail
|
|||||||
VERSION="2.1.0"
|
VERSION="2.1.0"
|
||||||
|
|
||||||
# ── Configuration (override with env vars) ───────────────────────────
|
# ── 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:-}"
|
ASSETS_BASE_URL="${ASSETS_BASE_URL:-}"
|
||||||
AGENTS_DIR="${AGENTS_DIR:-$HOME/.copilot/agents}"
|
AGENTS_DIR="${AGENTS_DIR:-$HOME/.agents/agents}"
|
||||||
SKILLS_DIR="${SKILLS_DIR:-$HOME/.copilot/skills}"
|
SKILLS_DIR="${SKILLS_DIR:-$HOME/.agents/skills}"
|
||||||
INSTRUCTIONS_DIR="${INSTRUCTIONS_DIR:-./instructions}"
|
INSTRUCTIONS_DIR="${INSTRUCTIONS_DIR:-./instructions}"
|
||||||
REPO_TOKEN="${REPO_TOKEN:-}"
|
REPO_TOKEN="${REPO_TOKEN:-}"
|
||||||
|
|
||||||
@ -334,8 +337,8 @@ ${BOLD}EXAMPLES${NC}
|
|||||||
|
|
||||||
${BOLD}ENVIRONMENT VARIABLES${NC}
|
${BOLD}ENVIRONMENT VARIABLES${NC}
|
||||||
ASSETS_BASE_URL Base URL for remote downloads (required without clone)
|
ASSETS_BASE_URL Base URL for remote downloads (required without clone)
|
||||||
AGENTS_DIR Install location for agents (default: ~/.copilot/agents)
|
AGENTS_DIR Install location for agents (default: ~/.agents/agents)
|
||||||
SKILLS_DIR Install location for custom skills (default: ~/.copilot/skills)
|
SKILLS_DIR Install location for custom skills (default: ~/.agents/skills)
|
||||||
INSTRUCTIONS_DIR Install location for instructions (default: ./instructions)
|
INSTRUCTIONS_DIR Install location for instructions (default: ./instructions)
|
||||||
REPO_TOKEN Auth token for private repos (optional)
|
REPO_TOKEN Auth token for private repos (optional)
|
||||||
|
|
||||||
|
|||||||
143
assets/skills/swift-clean-architecture/SKILL.md
Normal file
143
assets/skills/swift-clean-architecture/SKILL.md
Normal file
@ -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`
|
||||||
228
assets/skills/swift-localization/SKILL.md
Normal file
228
assets/skills/swift-localization/SKILL.md
Normal file
@ -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
|
||||||
|
```
|
||||||
258
assets/skills/swift-model-design/SKILL.md
Normal file
258
assets/skills/swift-model-design/SKILL.md
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
285
assets/skills/swift-modern/SKILL.md
Normal file
285
assets/skills/swift-modern/SKILL.md
Normal file
@ -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<Data, NetworkError>) -> Void) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
186
assets/skills/swift-pop/SKILL.md
Normal file
186
assets/skills/swift-pop/SKILL.md
Normal file
@ -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<T: Decodable>(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<T: Decodable>(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)
|
||||||
|
}
|
||||||
|
```
|
||||||
359
assets/skills/swiftui-accessibility/SKILL.md
Normal file
359
assets/skills/swiftui-accessibility/SKILL.md
Normal file
@ -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
|
||||||
393
assets/skills/swiftui-modern/SKILL.md
Normal file
393
assets/skills/swiftui-modern/SKILL.md
Normal file
@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
228
assets/skills/swiftui-mvvm/SKILL.md
Normal file
228
assets/skills/swiftui-mvvm/SKILL.md
Normal file
@ -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<Item.ID> = []
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
Loading…
Reference in New Issue
Block a user