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