# IOS Rules

> 38 battle-tested iOS development rules covering accessibility, navigation, architecture, dark mode, localization, App Review guidelines, and more. Targets the mistakes LLMs actually make when generating Swift/SwiftUI code.

- Skill: `abdullah4ai/ios-rules` (Agent Skill)
- Install (CLI): `npx skillmds@latest add abdullah4ai/ios-rules`
- Raw SKILL.md: https://api.skillmd.com/api/skills/abdullah4ai/ios-rules/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: abdullah4ai (https://skillmd.com/u/abdullah4ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/abdullah4ai/ios-rules

---


# iOS Development Rules

38 rules for writing production-quality iOS apps. Each rule targets common LLM mistakes with concrete fixes.

# Accessibility

REDUCE MOTION:
- Check: @Environment(\.accessibilityReduceMotion) var reduceMotion
- When enabled:
  - Replace .spring() with .easeInOut(duration: 0.2)
  - Replace slide transitions with .opacity
  - Disable auto-playing animations
  - Keep functional animations (progress bars), remove decorative ones
```swift
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.3)) {
    // state change
}
.transition(reduceMotion ? .opacity : .slide)
```

REDUCE TRANSPARENCY:
- Check: @Environment(\.accessibilityReduceTransparency) var reduceTransparency
- When enabled: use opaque backgrounds instead of materials/blur.
```swift
.background(reduceTransparency ? Color(AppTheme.Colors.surface) : .ultraThinMaterial)
```

VOICEOVER LABELS:
- All interactive elements: .accessibilityLabel("descriptive text").
- Non-obvious actions: .accessibilityHint("Double tap to delete this item").
- Decorative images: .accessibilityHidden(true).
- Informative images: .accessibilityLabel("Profile photo of John").
- Icon-only buttons: MUST have .accessibilityLabel().
```swift
Button(action: addItem) {
    Image(systemName: "plus")
}
.accessibilityLabel("Add new item")
```

GROUPING & COMBINING:
- Related content (icon + label + value): .accessibilityElement(children: .combine).
- Custom read order: .accessibilityElement(children: .ignore) + manual .accessibilityLabel.
- Cards with multiple elements: combine into single accessible element.
```swift
HStack {
    Image(systemName: "heart.fill")
    Text("Favorites")
    Spacer()
    Text("12")
}
.accessibilityElement(children: .combine)
```

ACCESSIBILITY TRAITS:
- Section headers: .accessibilityAddTraits(.isHeader)
- Buttons that play media: .accessibilityAddTraits(.startsMediaSession)
- Summary/aggregate values: .accessibilityAddTraits(.isSummaryElement)
- Selected items: .accessibilityAddTraits(.isSelected)

FOCUS MANAGEMENT:
- Use @FocusState with field enum for form navigation.
- .submitLabel(.next) to show "Next" on keyboard, .submitLabel(.done) for last field.
- Chain fields with .onSubmit { focusedField = .nextField }.
```swift
enum Field: Hashable { case name, email, password }
@FocusState private var focusedField: Field?

TextField("Name", text: $name)
    .focused($focusedField, equals: .name)
    .submitLabel(.next)
    .onSubmit { focusedField = .email }
```

DYNAMIC TYPE:
- System text styles (.body, .headline, etc.) scale automatically.
- NEVER use .font(.system(size:)) — it opts out of Dynamic Type.
- If layout breaks at large sizes: .minimumScaleFactor(0.8) as last resort.
- Test with Xcode Environment Overrides at the largest accessibility size.
- ScrollView wraps content that may overflow at large type sizes.

COLOR & CONTRAST:
- Don't use color alone for status — always pair with icon + text.
- Minimum 4.5:1 contrast for normal text, 3:1 for large text.
- .foregroundStyle(.secondary) for de-emphasized text (maintains adaptive contrast).

ACCESSIBLE CUSTOM CONTROLS:
- Custom sliders/steppers: .accessibilityValue(), .accessibilityAdjustableAction().
- Custom toggles: .accessibilityAddTraits(.isToggle), .accessibilityValue(isOn ? "on" : "off").
- Progress indicators: .accessibilityValue("\(Int(progress * 100)) percent").

# App Clips

APP CLIPS:
SETUP: Requires separate App Clip target (kind: "app_clip" in plan extensions array).
App Clips are a lightweight version of your app for quick, focused tasks.

INFO.PLIST (auto-configured on App Clip target in project.yml):
NSAppClip dict with NSAppClipRequestEphemeralUserNotification and NSAppClipRequestLocationConfirmation is set automatically. No manual configuration needed.

ASSOCIATED DOMAINS (auto-configured in project.yml entitlements):
appclips:{bundleID} and parent-application-identifiers are set automatically.

APP CLIP EXPERIENCE URL: Configure in App Store Connect. Users launch App Clip via NFC, QR code, Maps, etc.

APP CLIP INVOCATION (receive URL):
struct AppClipApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    // Handle URL: extract parameters, show relevant content
                }
        }
    }
}

SKOverlay (promote full app from within App Clip):
import StoreKit
@Environment(\.requestAppStoreOverlay) var requestOverlay
Button("Get Full App") {
    requestOverlay(AppStoreOverlay.AppClipCompletion(appIdentifier: "YOUR_APP_ID"))
}

CONSTRAINTS:
- App Clip binary must be < 15 MB
- No access to HealthKit, CallKit, SiriKit (use App Intents in full app)
- Limited background modes
- Use @AppStorage for lightweight persistence (no SwiftData)

# App Review

APP REVIEW (StoreKit):
- import StoreKit; @Environment(\.requestReview) var requestReview
- Call requestReview() — Apple handles the dialog
- Trigger after meaningful engagement (launchCount >= 5, key task completed)
- Track with @AppStorage("launchCount"), increment in .onAppear of root view
- Apple limits to 3 prompts/year — never on first launch
- Check: never request immediately after error, crash, or purchase

# Apple Translation

APPLE ON-DEVICE TRANSLATION (Translation framework):
FRAMEWORK: import Translation (iOS 17.4+, on-device, NO internet required, NO API key)

KEY DISTINCTION: Translation is for translating USER CONTENT on demand (e.g. translating a message from French to English). It is NOT for app localization (.strings files). Do not confuse the two.

MODIFIER APPROACH (simplest — shows system translation sheet):
  @State private var showTranslation = false
  Text(userContent)
      .translationPresentation(isPresented: $showTranslation, text: userContent)
  Button("Translate") { showTranslation = true }

PROGRAMMATIC TRANSLATION (TranslationSession):
  @State private var translatedText = ""

  func translateText(_ input: String) async {
      let config = TranslationSession.Configuration(source: .init(identifier: "fr"), target: .init(identifier: "en"))
      let session = TranslationSession(configuration: config)
      do {
          let response = try await session.translate(input)
          translatedText = response.targetText
      } catch {
          // Handle: language pair not supported on device, model not downloaded
      }
  }

  // Call with .task or Button:
  .task { await translateText(originalText) }

SUPPORTED LANGUAGES: Check Translation.supportedLanguages for the device's available language pairs.
AVAILABILITY: Some language pairs require a model download on first use.
NO ENTITLEMENTS NEEDED: Translation framework requires no special entitlements or Info.plist keys.

# Biometrics

BIOMETRIC AUTHENTICATION (Face ID / Touch ID):
- import LocalAuthentication; LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)
- Requires NSFaceIDUsageDescription permission (add CONFIG_CHANGES)
- Check canEvaluatePolicy first; fall back to passcode if biometrics unavailable
- LAContext().biometryType to detect .faceID vs .touchID vs .none
- Always provide manual unlock alternative (PIN/password)

# Camera

CAMERA & PHOTOS:
- PhotosPicker (PhotosUI) for gallery selection — no permissions needed for limited access
- For camera capture: AVCaptureSession + AVCapturePhotoOutput + UIViewControllerRepresentable wrapper
- Camera requires NSCameraUsageDescription permission (add CONFIG_CHANGES)
- Full photo library requires NSPhotoLibraryUsageDescription
- Use @State private var selectedItem: PhotosPickerItem? with .onChange to load
- Load image: try await item.loadTransferable(type: Data.self)

# Charts

SWIFT CHARTS:
- import Charts; use Chart { } container
- BarMark, LineMark, AreaMark, PointMark, RuleMark for data visualization
- .foregroundStyle(by: .value("Category", item.category)) for color coding
- chartXAxis { AxisMarks() }, chartYAxis { AxisMarks() } for custom axis labels
- Extract Chart into a separate computed property to avoid body complexity
- Use .chartScrollableAxes(.horizontal) for large datasets

# Color & Contrast

CONTRAST RATIO REQUIREMENTS (WCAG 2.1 / Apple HIG):
- Normal text (<20pt): minimum 4.5:1 contrast ratio.
- Large text (20pt+ regular or 14pt+ bold): minimum 3:1 contrast ratio.
- Preferred: 7:1 for maximum readability.
- UI components (icons, borders, controls): minimum 3:1 against background.

SEMANTIC COLOR USAGE:
- Red (.red / destructive): delete, remove, error, critical alert.
- Green (.green): success, complete, enabled, positive.
- Orange (.orange): warning, caution, attention needed.
- Blue (.blue): informational, links, primary actions.
- NEVER use red for positive actions or green for destructive actions.

NEVER RELY ON COLOR ALONE:
- Status indicators: color + icon + text label (e.g. green circle + checkmark + "Complete").
- Error fields: red border + error icon + error message text.
- Charts/graphs: use patterns, shapes, or labels alongside color coding.
- Colorblindness: avoid red/green as the sole differentiator — use red/blue or add shapes.

SYSTEM ADAPTIVE COLORS:
- .primary: adapts to light/dark automatically — use for main text.
- .secondary: lighter text for subtitles, metadata.
- Color(.systemBackground): adapts to system appearance.
- These work well for structural elements; use AppTheme for brand colors.

DARK MODE COLOR PAIRING:
- Every custom color must have both light and dark variants.
- Light mode: dark text on light backgrounds.
- Dark mode: light text on dark backgrounds.
- Both variants must independently meet contrast requirements.
- Test both modes — a color that works in light may fail in dark.

APPTHEME COLOR PATTERNS:
- Use Color(light:dark:) extension when app has appearance switching.
- Use plain Color(hex:) when no dark mode support.
- Define semantic tokens: AppTheme.Colors.primary, .surface, .error, .success.
- NEVER use raw hex strings in views — always reference AppTheme tokens.

TEXT ON IMAGES/GRADIENTS:
- Add a dark overlay (.black.opacity(0.4)) before placing white text on images.
- Or use .shadow(color: .black.opacity(0.3), radius: 2) on text.
- Never place light text on light images without contrast treatment.

OPACITY GUIDELINES:
- Avoid text below .opacity(0.6) — fails contrast requirements.
- Disabled state: use .disabled() modifier (auto-handles opacity correctly).
- Placeholder text: use .foregroundStyle(.secondary) instead of manual opacity.

# Component Patterns

BUTTON HIERARCHY (one primary per screen/section):

| Level      | Style                | Use Case                          | Code                                              |
|------------|----------------------|-----------------------------------|----------------------------------------------------|
| Primary    | .borderedProminent   | Main action (Save, Submit, Start) | .buttonStyle(.borderedProminent).controlSize(.large)|
| Secondary  | .bordered            | Alternative action (Cancel, Edit) | .buttonStyle(.bordered)                            |
| Tertiary   | .borderless          | Low-emphasis (Skip, Learn More)   | .buttonStyle(.borderless)                          |
| Destructive| .borderedProminent   | Delete, Remove                    | .buttonStyle(.borderedProminent).tint(.red)        |

- ONE .borderedProminent per screen/section — multiple primaries confuse the user.
- Full-width primary: .controlSize(.large).frame(maxWidth: .infinity).
- ALWAYS use Button() — never .onTapGesture for actions.
- Disabled buttons: .disabled(condition) — SwiftUI auto-handles opacity.

CARD DESIGN PATTERN:
```swift
VStack(alignment: .leading, spacing: AppTheme.Spacing.xSmall) {
    HStack {
        Image(systemName: "icon.name")
            .font(.title3)
            .foregroundStyle(AppTheme.Colors.primary)
        Spacer()
        Text("metadata")
            .font(.caption)
            .foregroundStyle(.secondary)
    }
    Text("Title")
        .font(.headline)
    Text("Description text goes here")
        .font(.subheadline)
        .foregroundStyle(.secondary)
}
.padding(AppTheme.Spacing.medium)
.background(AppTheme.Colors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.Style.cornerRadius))
.shadow(color: .black.opacity(0.06), radius: 8, y: 4)
```

INPUT FIELD STATES:
- Normal: TextField with .textFieldStyle(.roundedBorder).
- Focused: @FocusState with visual highlight (border color change or underline).
- Error: red border + error message below field.
```swift
TextField("Email", text: $email)
    .textFieldStyle(.roundedBorder)
    .overlay(
        RoundedRectangle(cornerRadius: 8)
            .stroke(emailError != nil ? .red : .clear, lineWidth: 1)
    )
if let error = emailError {
    Text(error)
        .font(.caption)
        .foregroundStyle(.red)
}
```
- Disabled: .disabled(true) — auto grays out.
- Form grouping: use Form or GroupBox for related fields.

LOADING STATES:

| Pattern           | Use Case                          | Code                                    |
|-------------------|-----------------------------------|-----------------------------------------|
| Inline spinner    | Button action, single item        | ProgressView().controlSize(.small)      |
| Full-screen       | Initial data load                 | ProgressView("Loading...")              |
| Pull-to-refresh   | List refresh                      | .refreshable { await refresh() }        |
| Skeleton          | Content placeholder               | .redacted(reason: .placeholder)         |
| Overlay           | Blocking operation                | .overlay { if loading { ProgressView() } } |

- Disable the triggering button while loading to prevent double-taps.
- Show loading for operations > 300ms. Instant operations need no indicator.

BADGE/CHIP PATTERN:
```swift
Text("Label")
    .font(.caption)
    .fontWeight(.medium)
    .padding(.horizontal, 8)
    .padding(.vertical, 4)
    .background(AppTheme.Colors.primary.opacity(0.15))
    .foregroundStyle(AppTheme.Colors.primary)
    .clipShape(Capsule())
```

TOGGLE/SWITCH:
- Use Toggle for binary settings with immediate effect.
- Label must clearly describe the ON state.
- Group related toggles in a Section with a header.

PICKER PATTERNS:
- 2-4 options: Picker with .segmentedStyle.
- 5+ options: Picker with default menu style or NavigationLink to selection list.
- Date selection: DatePicker with appropriate displayedComponents.

EMPTY STATES:
- Always use ContentUnavailableView for empty lists/collections.
- Include: icon (SF Symbol), title, description, and action button if applicable.
- Never show a blank screen — empty state guides the user to the first action.

DIVIDERS:
- Use sparingly — prefer spacing to create visual separation.
- In lists: SwiftUI List provides dividers automatically.
- Custom dividers: Divider() with .padding(.horizontal) for inset style.

# Dark Mode

DARK/LIGHT MODE:
- 3-way picker (system/light/dark) is the standard pattern:
  @AppStorage("appearance") private var appearance: String = "system"
  private var preferredColorScheme: ColorScheme? {
      switch appearance { case "light": return .light; case "dark": return .dark; default: return nil }
  }
  .preferredColorScheme(preferredColorScheme)    // on outermost container in @main app
- CRITICAL: .preferredColorScheme() MUST be in the root @main app, NOT just in the settings view.
- System option: .preferredColorScheme(nil) follows device setting.
- Settings screen: Picker with light/dark/system options writing to @AppStorage("appearance").

ADAPTIVE THEME COLORS (no color assets needed):
- Switch ALL AppTheme palette colors from plain Color(hex:) to Color(light:dark:) with TWO hex values:
  static let background = Color(light: Color(hex: "#F8F9FA"), dark: Color(hex: "#1C1C1E"))
  static let surface = Color(light: Color(hex: "#FFFFFF"), dark: Color(hex: "#2C2C2E"))
- Color(light:dark:) uses UIColor(dynamicProvider:) — reacts to .preferredColorScheme() automatically.
- YOU decide the dark palette based on app mood — user does not specify dark colors.
- Dark palette guidelines: darken backgrounds (#1C1C1E, #2C2C2E), lighten/brighten accents slightly, use Color.primary/Color.secondary for text.
- AppTheme MUST include the Color(light:dark:) extension (see shared constraints).

# Design System Rules

## AppTheme Pattern
Every app **MUST** use a centralized theme with **nested enums** for `Colors`, `Fonts`, and `Spacing`. Do NOT use a flat enum with top-level static properties.

```swift
// REQUIRED — always use nested enums
import SwiftUI

enum AppTheme {
    enum Colors {
        static let accent = Color.blue       // one accent per app
        static let textPrimary = Color.primary
        static let textSecondary = Color.secondary
        static let background = Color(.systemBackground)
        static let surface = Color(.secondarySystemBackground)
        static let cardBackground = Color(.secondarySystemGroupedBackground)
    }

    enum Fonts {
        static let largeTitle = Font.largeTitle
        static let title = Font.title
        static let headline = Font.headline
        static let body = Font.body
        static let caption = Font.caption
    }

    enum Spacing {
        static let small: CGFloat = 8
        static let medium: CGFloat = 16
        static let large: CGFloat = 24
        static let cornerRadius: CGFloat = 12
    }
}
```

```swift
// FORBIDDEN — never use flat structure
enum AppTheme {
    static let accentColor = Color.blue   // ❌ wrong
    static let spacing: CGFloat = 8       // ❌ wrong
}
```

Reference as: `AppTheme.Colors.accent`, `AppTheme.Fonts.headline`, `AppTheme.Spacing.medium`

## Typography
- **System fonts only** — use SwiftUI font styles: `.largeTitle`, `.title`, `.headline`, `.body`, `.caption`
- No custom fonts, no downloaded fonts
- Use `AppTheme.Fonts` for consistent sizing

## Icons (SF Symbols)
- **SF Symbols only** for all icons — required for every list row, button, empty state, and tab
- Reference via `Image(systemName: "symbol.name")`
- Pick domain-appropriate symbols (e.g. "checkmark.circle.fill" for todos, "note.text" for notes, "heart.fill" for favorites)
- Use `.symbolRenderingMode(.hierarchical)` or `.symbolRenderingMode(.palette)` for visual depth
- No custom icon assets unless the app concept specifically requires them

## Colors
- **One accent color** that fits the app's purpose
- Use semantic colors: `.primary`, `.secondary`, `Color(.systemBackground)`
- Do NOT add dark mode support, colorScheme checks, or custom dark/light color handling unless the user explicitly requests it

## Spacing Standards
- **16pt** standard padding (outer margins, section spacing)
- **8pt** compact spacing (between related elements)
- **24pt** large spacing (between major sections)
- Use `AppTheme.Spacing` constants throughout

## Empty States
Every list or collection MUST have an empty state. Use `ContentUnavailableView` (iOS 17+) for a polished look:

```swift
// Required — show when collection is empty
if items.isEmpty {
    ContentUnavailableView(
        "No Notes Yet",
        systemImage: "note.text",
        description: Text("Tap + to create your first note")
    )
} else {
    // Show the list
}
```

For custom empty states, use a styled VStack with SF Symbol + descriptive text:

```swift
VStack(spacing: 16) {
    Image(systemName: "tray")
        .font(.system(size: 48))
        .foregroundStyle(.secondary)
    Text("Nothing here yet")
        .font(.title3)
    Text("Add your first item to get started")
        .font(.subheadline)
        .foregroundStyle(.secondary)
}
```

## Animations
Use subtle, purposeful animations for state changes and list mutations:

```swift
// Toggle/complete actions — spring animation
withAnimation(.spring) {
    item.isComplete.toggle()
}

// List insertions/removals — combine opacity + scale
.transition(.opacity.combined(with: .scale))

// Numeric text changes
.contentTransition(.numericText())

// Filter/tab changes
.animation(.default, value: selectedFilter)
```

Rules:
- **Always** use `withAnimation(.spring)` for toggle/complete state changes
- **Always** add `.transition(.opacity.combined(with: .scale))` for list add/remove
- **Never** add gratuitous motion that slows down interaction
- Keep animations subtle — `.spring` and `.default` curves only

# Feedback States

LOADING PATTERNS:

1. Inline button spinner (action on single element):
```swift
Button {
    Task { await save() }
} label: {
    if isSaving {
        ProgressView()
            .controlSize(.small)
    } else {
        Text("Save")
    }
}
.disabled(isSaving)
```

2. Full-screen loading (initial data load):
```swift
if isLoading {
    ProgressView("Loading...")
} else {
    ContentView()
}
```

3. Skeleton loading (content placeholders):
```swift
ForEach(Item.sampleData) { item in
    ItemRow(item: item)
}
.redacted(reason: .placeholder)
```

4. Pull-to-refresh (list content):
```swift
List { ... }
    .refreshable { await viewModel.refresh() }
```

5. Overlay loading (blocking operation):
```swift
.overlay {
    if isProcessing {
        ZStack {
            Color.black.opacity(0.3)
            ProgressView()
                .controlSize(.large)
                .tint(.white)
        }
        .ignoresSafeArea()
    }
}
```

LOADING RULES:
- Show indicator for operations > 300ms.
- ALWAYS disable the triggering button while loading (prevents double-taps).
- Never block the entire UI for a partial operation — use inline spinner.
- Match loading style to scope: button-level → inline, screen-level → full-screen.

ERROR HANDLING UI:

1. Inline validation (below form fields):
```swift
if let error = emailError {
    HStack(spacing: 4) {
        Image(systemName: "exclamationmark.circle.fill")
        Text(error)
    }
    .font(.caption)
    .foregroundStyle(.red)
}
```

2. Alert for blocking errors (require user acknowledgment):
```swift
.alert("Error", isPresented: $showError) {
    Button("Retry") { Task { await retry() } }
    Button("Cancel", role: .cancel) { }
} message: {
    Text(errorMessage)
}
```

3. Banner for non-blocking errors (dismissible):
```swift
if let error = bannerError {
    HStack {
        Image(systemName: "exclamationmark.triangle.fill")
            .foregroundStyle(.orange)
        Text(error)
            .font(.subheadline)
        Spacer()
        Button("Dismiss") { bannerError = nil }
            .font(.caption)
    }
    .padding(AppTheme.Spacing.small)
    .background(.orange.opacity(0.1))
    .clipShape(RoundedRectangle(cornerRadius: 8))
    .padding(.horizontal, AppTheme.Spacing.medium)
}
```

ERROR HANDLING RULES:
- Inline validation: show immediately as user types or on field blur.
- Alert: use for errors that block progress (network failure, permission denied).
- Banner: use for non-critical errors (sync failed, partial data).
- ALWAYS provide a retry path — never leave users stuck.
- Error messages: describe what happened + what the user can do.

SUCCESS FEEDBACK:
- Haptic: UINotificationFeedbackGenerator().notificationOccurred(.success).
- Visual: brief animation (checkmark, scale bounce, color flash).
- NEVER use modal alert for success — too disruptive.
- Subtle confirmation: toast, inline checkmark, or haptic alone.
```swift
// Brief success animation
withAnimation(.spring(response: 0.3)) {
    showSuccess = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
    withAnimation { showSuccess = false }
}
```

DISABLED STATE:
- .disabled(condition) — SwiftUI auto-handles opacity reduction.
- Always explain WHY something is disabled (tooltip, caption text, or label).
- Example: "Fill in all required fields to continue" below a disabled button.
- Don't hide actions — show them disabled with explanation.

NETWORK/SYSTEM ERROR PATTERN (ViewModel):
```swift
@MainActor @Observable
class ItemViewModel {
    var items: [Item] = []
    var isLoading = false
    var error: String?

    func loadItems() async {
        isLoading = true
        error = nil
        do {
            items = try await fetchItems()
        } catch {
            self.error = "Couldn't load items. Pull to refresh to try again."
        }
        isLoading = false
    }
}
```

EMPTY VS ERROR VS LOADING:
- Loading: ProgressView or .redacted skeleton.
- Empty (no data yet): ContentUnavailableView with action to create first item.
- Error (load failed): error message + retry button.
- These are three distinct states — never conflate them.

# File Structure Rules

## Line Limit
- **Target**: 150 lines per file — aim for this
- **Hard limit**: 200 lines — files exceeding 200 lines MUST be split using extensions (`View+Sections.swift`)
- Files between 150-200 lines are acceptable if splitting would hurt readability

## Body as Table of Contents
The `body` property should read like a table of contents — only referencing computed properties, not containing implementation:

```swift
// Good
var body: some View {
    VStack {
        headerSection
        contentSection
        footerSection
    }
}

// Bad — implementation directly in body
var body: some View {
    VStack {
        HStack {
            Image(systemName: "person")
            Text(user.name)
                .font(.headline)
            Spacer()
            Button("Edit") { showEdit = true }
        }
        // ... 80+ more lines
    }
}
```

## Extension Splitting Pattern
When a view grows beyond 150 lines, split into extensions by section:

```swift
// ProfileView.swift — main file
struct ProfileView: View {
    @State var viewModel: ProfileViewModel

    var body: some View {
        ScrollView {
            headerSection
            statsSection
            settingsSection
        }
    }
}

// ProfileView+Sections.swift — extracted sections
extension ProfileView {
    var headerSection: some View { ... }
    var statsSection: some View { ... }
    var settingsSection: some View { ... }
}
```

## Directory Structure
```
Models/              → Data model structs (with static sampleData)
Theme/               → AppTheme.swift only
Features/<Name>/     → Co-locate View + ViewModel (e.g. Features/TodoList/TodoListView.swift)
Features/Common/     → Shared reusable views used by multiple features
App/                 → @main app entry point only
```

- NEVER use flat `Views/`, `ViewModels/`, or `Components/` top-level directories
- Every View and its ViewModel MUST live under `Features/<FeatureName>/`
- Shared components go under `Features/Common/`

## One Type Per File
- Each file contains exactly one primary type (struct, class, or enum)
- Extensions of the same type may live in separate files
- File name matches the type name: `ProfileView.swift` for `struct ProfileView`

## Naming Conventions
- Views: `{Feature}View.swift` (e.g., `NotesListView.swift`)
- ViewModels: `{Feature}ViewModel.swift` (e.g., `NotesListViewModel.swift`)
- Models: `{ModelName}.swift` (e.g., `Note.swift`)
- Theme: `AppTheme.swift`
- App entry: `{AppName}App.swift`

# Forbidden Patterns

## Networking — BANNED
- No `URLSession`, no `Alamofire`, no REST clients
- No API calls of any kind
- No `async let` URL fetches
- The app is fully on-device

## UIKit — AVOID BY DEFAULT
- Prefer SwiftUI-first architecture
- `UIKit` imports are allowed only when a required feature has no viable SwiftUI equivalent
- `UIViewRepresentable` / `UIViewControllerRepresentable` are allowed only as minimal bridges for those required UIKit features
- No Storyboards, no XIBs, no Interface Builder

## Third-Party Packages — BANNED
- No SPM (Swift Package Manager) dependencies
- No CocoaPods
- No Carthage
- Use only Apple-native frameworks

## CoreData — BANNED
- Use **SwiftData** instead of CoreData
- No `NSManagedObject`, no `NSPersistentContainer`
- No `.xcdatamodeld` files

## Authentication & Cloud — BANNED
- No authentication screens, login flows, or token management
- No CloudKit, no iCloud sync
- No push notifications
- No Firebase, no Supabase, no backend services

## Type Re-declarations — BANNED
- **NEVER** re-declare types that exist in other project files
- **NEVER** re-declare types from SwiftUI/Foundation (`Color`, `CGPoint`, `Font`, etc.)
- Import the module or file that defines the type
- Each type must be defined in **exactly one file**

```swift
// BANNED — re-declaring Color enum that SwiftUI already provides
enum Color {
    case red, blue, green
}

// BANNED — re-declaring a model that exists in Models/Note.swift
struct Note {
    var title: String
}

// CORRECT — import and use the existing type
import SwiftUI  // provides Color
// Note is already defined in Models/Note.swift, just reference it
```

# Foundation Models

APPLE ON-DEVICE AI (FoundationModels — iOS 26+):
FRAMEWORK: import FoundationModels

AVAILABILITY CHECK (MANDATORY — model may not be available on all devices):
guard case .available = SystemLanguageModel.default.availability else {
    // Show "This feature requires Apple Intelligence" message
    return
}

BASIC TEXT GENERATION:
let session = LanguageModelSession()
let response = try await session.respond(to: "Summarize this text: \(userText)")
print(response.content)

STREAMING GENERATION:
let stream = session.streamResponse(to: prompt)
for try await partial in stream {
    displayText += partial.text
}

STRUCTURED OUTPUT with @Generable:
@Generable
struct RecipeSuggestion {
    @Guide(description: "Name of the dish") var name: String
    @Guide(description: "Estimated prep time in minutes") var prepTime: Int
    @Guide(description: "Main ingredients") var ingredients: [String]
}

let session = LanguageModelSession()
let recipe: RecipeSuggestion = try await session.respond(
    to: "Suggest a quick pasta dish",
    generating: RecipeSuggestion.self
)

SESSION INSTRUCTIONS (system prompt):
let session = LanguageModelSession(instructions: "You are a helpful cooking assistant. Keep responses concise.")

GUARDRAILS:
- Model output is filtered by Apple's safety system
- No internet required — fully on-device
- Context window is limited (~4K tokens typical) — keep prompts concise
- Use session.respond() for single turns, keep session for multi-turn conversations

# Gestures

STANDARD GESTURE TABLE:

| Gesture    | SwiftUI API           | Use Case                         |
|------------|-----------------------|----------------------------------|
| Tap        | Button()              | Primary actions, navigation      |
| Long press | .contextMenu          | Secondary actions, previews      |
| Swipe      | .swipeActions         | List row actions (delete, edit)  |
| Drag       | .draggable/.dropDest  | Reorder, drag-and-drop           |
| Pull       | .refreshable          | Refresh content                  |
| Pinch      | MagnifyGesture        | Zoom images/maps                 |
| Rotate     | RotateGesture         | Rotate content                   |

BUTTON VS ONTAPGESTURE:
- ALWAYS use Button() for tappable UI elements. Never .onTapGesture on Text/Image/VStack.
- Button provides: accessibility labels, hit testing, highlight states, VoiceOver support.
- .onTapGesture only for non-button interactions (dismiss, background tap).

SWIPE ACTIONS (list rows):
- Trailing side: destructive actions (delete, archive).
- Leading side: positive/toggle actions (pin, favorite, mark read).
- Use .tint() to color-code actions.
- Destructive actions: .role(.destructive) for red styling.
```swift
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
    Button(role: .destructive) {
        deleteItem(item)
    } label: {
        Label("Delete", systemImage: "trash")
    }
}
.swipeActions(edge: .leading) {
    Button {
        toggleFavorite(item)
    } label: {
        Label(item.isFavorite ? "Unfavorite" : "Favorite",
              systemImage: item.isFavorite ? "star.slash" : "star.fill")
    }
    .tint(.yellow)
}
```

CONTEXT MENUS:
- Use for secondary actions on any element (not just list rows).
- Group related actions, use Divider() between groups.
- Destructive actions go LAST with .role(.destructive).
```swift
.contextMenu {
    Button("Edit", systemImage: "pencil") { edit(item) }
    Button("Share", systemImage: "square.and.arrow.up") { share(item) }
    Divider()
    Button("Delete", systemImage: "trash", role: .destructive) { delete(item) }
}
```

GESTURE PRIORITY:
- Default: child gestures take priority over parent.
- .highPriorityGesture(): parent gesture overrides child.
- .simultaneousGesture(): both recognize at the same time.
- Use .simultaneousGesture for scroll + pinch zoom combinations.

HAPTIC FEEDBACK PAIRING:
- Tap actions: UIImpactFeedbackGenerator(style: .light) for subtle confirmation.
- Toggle/switch: UIImpactFeedbackGenerator(style: .medium).
- Destructive action: UINotificationFeedbackGenerator().notificationOccurred(.warning).
- Success completion: UINotificationFeedbackGenerator().notificationOccurred(.success).
- Selection change (picker/list): UISelectionFeedbackGenerator().selectionChanged().
- Drag start/end: UIImpactFeedbackGenerator(style: .medium).

CONFIRMATION FOR DESTRUCTIVE ACTIONS:
- Always confirm before destructive actions (delete, remove, clear all).
- Use .confirmationDialog for choices, .alert for single confirmation.
```swift
.confirmationDialog("Delete Item?", isPresented: $showDelete) {
    Button("Delete", role: .destructive) { delete(item) }
    Button("Cancel", role: .cancel) { }
}
```

PULL-TO-REFRESH:
- Use .refreshable {} on List or ScrollView for refresh.
- SwiftUI handles the indicator automatically.
- The closure should be async — SwiftUI shows/hides spinner based on task completion.

SCROLL GESTURES:
- ScrollView handles scroll automatically.
- .scrollDismissesKeyboard(.interactively) for forms with keyboard.
- .scrollIndicators(.hidden) only when indicators are visually distracting.

# Haptics

HAPTIC FEEDBACK:
- UIImpactFeedbackGenerator(style: .medium).impactOccurred() for simple taps
- UINotificationFeedbackGenerator().notificationOccurred(.success/.warning/.error) for outcomes
- UISelectionFeedbackGenerator().selectionChanged() for selection changes
- CoreHaptics for custom patterns: CHHapticEngine + CHHapticEvent
- Always check CHHapticEngine.capabilitiesForHardware().supportsHaptics
- Prepare generator before use: generator.prepare() for lower latency

# Healthkit

HEALTHKIT:
- import HealthKit; HKHealthStore()
- Check availability: HKHealthStore.isHealthDataAvailable()
- Request auth: healthStore.requestAuthorization(toShare:read:)
- Requires NSHealthShareUsageDescription + NSHealthUpdateUsageDescription (add CONFIG_CHANGES)
- Requires com.apple.developer.healthkit entitlement (add CONFIG_CHANGES)
- Query: HKSampleQuery, HKStatisticsQuery for aggregated data
- Common types: HKQuantityType(.stepCount), .heartRate, .activeEnergyBurned

# Live Activities

LIVE ACTIVITIES (ActivityKit + Dynamic Island):
FRAMEWORK: import ActivityKit

SETUP:
- Requires separate extension target (kind: "live_activity" in plan extensions array)
- NSSupportsLiveActivities: YES is auto-configured on the main app target in project.yml
- AppGroup entitlements are auto-configured for data sharing between app and extension

ATTRIBUTES (define in Shared/ directory so both app and extension compile it):
struct DeliveryAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var status: String       // Mutable: changes during the activity
        var progress: Double
    }
    var orderID: String          // Static: set at start, cannot change
}

STARTING AN ACTIVITY (in app):
let attributes = DeliveryAttributes(orderID: "123")
let initialState = DeliveryAttributes.ContentState(status: "Preparing", progress: 0.0)
let content = ActivityContent(state: initialState, staleDate: nil)
let activity = try Activity.request(attributes: attributes, content: content)

UPDATING AN ACTIVITY:
let updatedState = DeliveryAttributes.ContentState(status: "On the way", progress: 0.6)
let updatedContent = ActivityContent(state: updatedState, staleDate: nil)
await activity.update(updatedContent)

ENDING AN ACTIVITY:
await activity.end(nil, dismissalPolicy: .immediate)

LOCK SCREEN / DYNAMIC ISLAND UI (in extension):
struct DeliveryLiveActivityView: View {
    let context: ActivityViewContext<DeliveryAttributes>
    var body: some View {
        HStack { Text(context.state.status); ProgressView(value: context.state.progress) }
    }
}

struct DeliveryWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            DeliveryLiveActivityView(context: context)                  // Lock Screen
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) { Text(context.state.status) }
                DynamicIslandExpandedRegion(.trailing) { ProgressView(value: context.state.progress) }
            } compactLeading: {
                Image(systemName: "bicycle")
            } compactTrailing: {
                Text("\(Int(context.state.progress * 100))%")
            } minimal: {
                ProgressView(value: context.state.progress)
            }
        }
    }
}

MANDATORY FILES (every live activity extension MUST have ALL of these):
1. {Name}Bundle.swift — @main WidgetBundle entry point. Without this, the extension has no entry point → linker error "undefined symbol: _main" → CodeSign failure.
2. LiveActivityWidget.swift — Widget struct with ActivityConfiguration(for: Attributes.self) for Lock Screen and Dynamic Island UI.
3. (Optional) Intents.swift — AppIntents for interactive buttons (complete, skip, etc.).

SHARED TYPES (CRITICAL):
- ActivityAttributes struct MUST be defined in Shared/ directory (NOT in main app's Models/). Both the main app and the live activity extension compile Shared/, so the type is visible to both. Defining it only in the main app causes "Cannot find type in scope" in the extension.

SWIFT 6 CONCURRENCY:
- AppIntent static properties MUST use "static let" (not "static var"). Mutable global state violates Swift 6 concurrency rules.
- LiveActivityService (if @MainActor) → all ViewModels calling it must also be @MainActor.

PUSH-TO-START: Use APNs with activity-update payload to start activities remotely (advanced, requires server).

# Localization

LOCALIZATION (.strings files + RTL/LTR + Language Switching):

FORBIDDEN PATTERNS (CRITICAL — violation = broken app):
- NEVER hardcode translations with if/else or switch on language code. Example of FORBIDDEN code:
  if appLanguage == "ar" { Text("الإعدادات") } else { Text("Settings") }
  switch language { case "ar": return "بحث" default: return "Search" }
- NEVER build a manual translation dictionary/map in code (e.g., let translations = ["en": "Settings", "ar": "الإعدادات"]).
- NEVER use ternary operators to pick translated strings: Text(isArabic ? "الإعدادات" : "Settings").
- These patterns bypass Apple's localization system, break when new languages are added, and ignore the environment locale.
- The ONLY correct approach: use string literals in views — Text("Settings"), Button("Save"), .navigationTitle("Dashboard") — and let Localizable.strings handle translations.
- The deployment pipeline generates .strings files automatically. Your code must ONLY contain English string literals.

.strings FILE GENERATION:
- When localization is requested, generate Resources/{lang}.lproj/Localizable.strings for EACH language.
- File format: standard Apple .strings — one "key" = "translation"; per line.
- KEYS MUST BE THE ENGLISH TEXT ITSELF. Example: "Settings" = "Settings"; (en), "Settings" = "الإعدادات"; (ar). NOT snake_case like "settings_title".
- This means Text("Settings") in code auto-localizes because the key IS the English text.
- English .strings: identity mapping (key = value). Other languages: key = translated value.
- ALL user-facing strings MUST have a key: Text(), Button(), La

…(truncated)
