Initial commit
This commit is contained in:
449
skills/expertise/iphone-apps/references/accessibility.md
Normal file
449
skills/expertise/iphone-apps/references/accessibility.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Accessibility
|
||||
|
||||
VoiceOver, Dynamic Type, and inclusive design for iOS apps.
|
||||
|
||||
## VoiceOver Support
|
||||
|
||||
### Basic Labels
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: item.icon)
|
||||
.accessibilityHidden(true) // Icon is decorative
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(item.name)
|
||||
Text(item.date, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isCompleted {
|
||||
Image(systemName: "checkmark")
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(item.name), \(item.isCompleted ? "completed" : "incomplete")")
|
||||
.accessibilityHint("Double tap to view details")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Actions
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
let onDelete: () -> Void
|
||||
let onToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(item.name)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(item.name)
|
||||
.accessibilityAction(named: "Toggle completion") {
|
||||
onToggle()
|
||||
}
|
||||
.accessibilityAction(named: "Delete") {
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Traits
|
||||
|
||||
```swift
|
||||
Text("Important Notice")
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
Button("Submit") { }
|
||||
.accessibilityAddTraits(.startsMediaSession)
|
||||
|
||||
Image("photo")
|
||||
.accessibilityAddTraits(.isImage)
|
||||
|
||||
Link("Learn more", destination: url)
|
||||
.accessibilityAddTraits(.isLink)
|
||||
|
||||
Toggle("Enable", isOn: $isEnabled)
|
||||
.accessibilityAddTraits(isEnabled ? .isSelected : [])
|
||||
```
|
||||
|
||||
### Announcements
|
||||
|
||||
```swift
|
||||
// Announce changes
|
||||
func saveCompleted() {
|
||||
AccessibilityNotification.Announcement("Item saved successfully").post()
|
||||
}
|
||||
|
||||
// Screen change
|
||||
func showNewScreen() {
|
||||
AccessibilityNotification.ScreenChanged(nil).post()
|
||||
}
|
||||
|
||||
// Layout change
|
||||
func expandSection() {
|
||||
isExpanded = true
|
||||
AccessibilityNotification.LayoutChanged(nil).post()
|
||||
}
|
||||
```
|
||||
|
||||
### Rotor Actions
|
||||
|
||||
```swift
|
||||
struct ArticleView: View {
|
||||
@State private var fontSize: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Text(article.content)
|
||||
.font(.system(size: fontSize))
|
||||
.accessibilityAdjustableAction { direction in
|
||||
switch direction {
|
||||
case .increment:
|
||||
fontSize = min(fontSize + 2, 32)
|
||||
case .decrement:
|
||||
fontSize = max(fontSize - 2, 12)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Type
|
||||
|
||||
### Scaled Fonts
|
||||
|
||||
```swift
|
||||
// System fonts scale automatically
|
||||
Text("Title")
|
||||
.font(.title)
|
||||
|
||||
Text("Body")
|
||||
.font(.body)
|
||||
|
||||
// Custom fonts with scaling
|
||||
Text("Custom")
|
||||
.font(.custom("Helvetica", size: 17, relativeTo: .body))
|
||||
|
||||
// Fixed size (use sparingly)
|
||||
Text("Fixed")
|
||||
.font(.system(size: 12).fixed())
|
||||
```
|
||||
|
||||
### Scaled Metrics
|
||||
|
||||
```swift
|
||||
struct IconButton: View {
|
||||
@ScaledMetric var iconSize: CGFloat = 24
|
||||
@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: spacing) {
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: iconSize))
|
||||
Text("Favorite")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Line Limits with Accessibility
|
||||
|
||||
```swift
|
||||
Text(item.description)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.tail)
|
||||
// But allow more for accessibility sizes
|
||||
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
|
||||
```
|
||||
|
||||
### Testing Dynamic Type
|
||||
|
||||
```swift
|
||||
#Preview("Default") {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
#Preview("Large") {
|
||||
ContentView()
|
||||
.environment(\.sizeCategory, .accessibilityLarge)
|
||||
}
|
||||
|
||||
#Preview("Extra Extra Large") {
|
||||
ContentView()
|
||||
.environment(\.sizeCategory, .accessibilityExtraExtraLarge)
|
||||
}
|
||||
```
|
||||
|
||||
## Reduce Motion
|
||||
|
||||
```swift
|
||||
struct AnimatedView: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// Content
|
||||
}
|
||||
.animation(reduceMotion ? .none : .spring(), value: isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative animations
|
||||
struct TransitionView: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var showDetail = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if showDetail {
|
||||
DetailView()
|
||||
.transition(reduceMotion ? .opacity : .slide)
|
||||
}
|
||||
}
|
||||
.animation(.default, value: showDetail)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Color and Contrast
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
```swift
|
||||
// Use semantic colors that adapt
|
||||
Text("Primary")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Secondary")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Tertiary")
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
// Error state
|
||||
Text("Error")
|
||||
.foregroundStyle(.red) // Use semantic red, not custom
|
||||
```
|
||||
|
||||
### Increase Contrast
|
||||
|
||||
```swift
|
||||
struct ContrastAwareView: View {
|
||||
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
|
||||
@Environment(\.accessibilityIncreaseContrast) private var increaseContrast
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(increaseContrast ? .primary : .secondary)
|
||||
|
||||
if differentiateWithoutColor {
|
||||
// Add non-color indicator
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Color Blind Support
|
||||
|
||||
```swift
|
||||
struct StatusIndicator: View {
|
||||
let status: Status
|
||||
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
if differentiateWithoutColor {
|
||||
Image(systemName: status.icon)
|
||||
}
|
||||
|
||||
Text(status.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Status {
|
||||
case success, warning, error
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .success: return .green
|
||||
case .warning: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .success: return "checkmark.circle"
|
||||
case .warning: return "exclamationmark.triangle"
|
||||
case .error: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .success: return "Success"
|
||||
case .warning: return "Warning"
|
||||
case .error: return "Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Focus State
|
||||
|
||||
```swift
|
||||
struct LoginView: View {
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field {
|
||||
case username, password
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Username", text: $username)
|
||||
.focused($focusedField, equals: .username)
|
||||
.submitLabel(.next)
|
||||
.onSubmit {
|
||||
focusedField = .password
|
||||
}
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.focused($focusedField, equals: .password)
|
||||
.submitLabel(.done)
|
||||
.onSubmit {
|
||||
login()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
focusedField = .username
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility Focus
|
||||
|
||||
```swift
|
||||
struct AlertView: View {
|
||||
@AccessibilityFocusState private var isAlertFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Important Alert")
|
||||
.accessibilityFocused($isAlertFocused)
|
||||
}
|
||||
.onAppear {
|
||||
isAlertFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Button Shapes
|
||||
|
||||
```swift
|
||||
struct AccessibleButton: View {
|
||||
@Environment(\.accessibilityShowButtonShapes) private var showButtonShapes
|
||||
|
||||
var body: some View {
|
||||
Button("Action") { }
|
||||
.padding()
|
||||
.background(showButtonShapes ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Smart Invert Colors
|
||||
|
||||
```swift
|
||||
Image("photo")
|
||||
.accessibilityIgnoresInvertColors() // Photos shouldn't invert
|
||||
```
|
||||
|
||||
## Audit Checklist
|
||||
|
||||
### VoiceOver
|
||||
- [ ] All interactive elements have labels
|
||||
- [ ] Decorative elements are hidden
|
||||
- [ ] Custom actions for swipe gestures
|
||||
- [ ] Headings marked correctly
|
||||
- [ ] Announcements for dynamic changes
|
||||
|
||||
### Dynamic Type
|
||||
- [ ] All text uses dynamic fonts
|
||||
- [ ] Layout adapts to large sizes
|
||||
- [ ] No text truncation at accessibility sizes
|
||||
- [ ] Touch targets remain accessible (44pt minimum)
|
||||
|
||||
### Color and Contrast
|
||||
- [ ] 4.5:1 contrast ratio for text
|
||||
- [ ] Information not conveyed by color alone
|
||||
- [ ] Works with Increase Contrast
|
||||
- [ ] Works with Smart Invert
|
||||
|
||||
### Motion
|
||||
- [ ] Animations respect Reduce Motion
|
||||
- [ ] No auto-playing animations
|
||||
- [ ] Alternative interactions for gesture-only features
|
||||
|
||||
### General
|
||||
- [ ] All functionality available via VoiceOver
|
||||
- [ ] Logical focus order
|
||||
- [ ] Error messages are accessible
|
||||
- [ ] Time limits are adjustable
|
||||
|
||||
## Testing Tools
|
||||
|
||||
### Accessibility Inspector
|
||||
1. Open Xcode > Open Developer Tool > Accessibility Inspector
|
||||
2. Point at elements to inspect labels, traits, hints
|
||||
3. Run audit for common issues
|
||||
|
||||
### VoiceOver Practice
|
||||
1. Settings > Accessibility > VoiceOver
|
||||
2. Use with your app
|
||||
3. Navigate by swiping, double-tap to activate
|
||||
|
||||
### Voice Control
|
||||
1. Settings > Accessibility > Voice Control
|
||||
2. Test all interactions with voice commands
|
||||
|
||||
### Xcode Previews
|
||||
|
||||
```swift
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||
.environment(\.accessibilityReduceMotion, true)
|
||||
.environment(\.accessibilityDifferentiateWithoutColor, true)
|
||||
}
|
||||
```
|
||||
497
skills/expertise/iphone-apps/references/app-architecture.md
Normal file
497
skills/expertise/iphone-apps/references/app-architecture.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# App Architecture
|
||||
|
||||
State management, dependency injection, and architectural patterns for iOS apps.
|
||||
|
||||
## State Management
|
||||
|
||||
### @Observable (iOS 17+)
|
||||
|
||||
The modern approach for shared state:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class AppState {
|
||||
var items: [Item] = []
|
||||
var selectedItemID: UUID?
|
||||
var isLoading = false
|
||||
var error: AppError?
|
||||
|
||||
// Computed properties work naturally
|
||||
var selectedItem: Item? {
|
||||
items.first { $0.id == selectedItemID }
|
||||
}
|
||||
|
||||
var hasItems: Bool { !items.isEmpty }
|
||||
}
|
||||
|
||||
// In views - only re-renders when used properties change
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
if appState.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
ItemList(items: appState.items)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Two-Way Bindings
|
||||
|
||||
For binding to @Observable properties:
|
||||
|
||||
```swift
|
||||
struct SettingsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
Form {
|
||||
TextField("Username", text: $appState.username)
|
||||
Toggle("Notifications", isOn: $appState.notificationsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### State Decision Tree
|
||||
|
||||
**@State** - View-local UI state
|
||||
- Toggle expanded/collapsed
|
||||
- Text field content
|
||||
- Sheet presentation
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**@Observable in Environment** - Shared app state
|
||||
- User session
|
||||
- Navigation state
|
||||
- Feature flags
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**@Query** - SwiftData persistence
|
||||
- Database entities
|
||||
- Filtered/sorted queries
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
@Query(sort: \Item.createdAt, order: .reverse)
|
||||
private var items: [Item]
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
### Environment Keys
|
||||
|
||||
Define environment keys for testable dependencies:
|
||||
|
||||
```swift
|
||||
// Protocol for testability
|
||||
protocol NetworkServiceProtocol {
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
|
||||
}
|
||||
|
||||
// Live implementation
|
||||
class LiveNetworkService: NetworkServiceProtocol {
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
// Real implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Mock for testing
|
||||
class MockNetworkService: NetworkServiceProtocol {
|
||||
var mockResult: Any?
|
||||
var mockError: Error?
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
if let error = mockError { throw error }
|
||||
return mockResult as! T
|
||||
}
|
||||
}
|
||||
|
||||
// Environment key
|
||||
struct NetworkServiceKey: EnvironmentKey {
|
||||
static let defaultValue: NetworkServiceProtocol = LiveNetworkService()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var networkService: NetworkServiceProtocol {
|
||||
get { self[NetworkServiceKey.self] }
|
||||
set { self[NetworkServiceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// Inject at app level
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.networkService, LiveNetworkService())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use in views
|
||||
struct ItemList: View {
|
||||
@Environment(\.networkService) private var networkService
|
||||
|
||||
var body: some View {
|
||||
// ...
|
||||
}
|
||||
|
||||
func loadItems() async {
|
||||
let items: [Item] = try await networkService.fetch(.items)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Container
|
||||
|
||||
For complex apps with many dependencies:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class AppDependencies {
|
||||
let network: NetworkServiceProtocol
|
||||
let storage: StorageServiceProtocol
|
||||
let purchases: PurchaseServiceProtocol
|
||||
let analytics: AnalyticsServiceProtocol
|
||||
|
||||
init(
|
||||
network: NetworkServiceProtocol = LiveNetworkService(),
|
||||
storage: StorageServiceProtocol = LiveStorageService(),
|
||||
purchases: PurchaseServiceProtocol = LivePurchaseService(),
|
||||
analytics: AnalyticsServiceProtocol = LiveAnalyticsService()
|
||||
) {
|
||||
self.network = network
|
||||
self.storage = storage
|
||||
self.purchases = purchases
|
||||
self.analytics = analytics
|
||||
}
|
||||
|
||||
// Convenience for testing
|
||||
static func mock() -> AppDependencies {
|
||||
AppDependencies(
|
||||
network: MockNetworkService(),
|
||||
storage: MockStorageService(),
|
||||
purchases: MockPurchaseService(),
|
||||
analytics: MockAnalyticsService()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject as single environment object
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var dependencies = AppDependencies()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(dependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## View Models (When Needed)
|
||||
|
||||
For views with significant logic, use a view-local model:
|
||||
|
||||
```swift
|
||||
struct ItemDetailScreen: View {
|
||||
let itemID: UUID
|
||||
@State private var viewModel: ItemDetailViewModel
|
||||
|
||||
init(itemID: UUID) {
|
||||
self.itemID = itemID
|
||||
self._viewModel = State(initialValue: ItemDetailViewModel(itemID: itemID))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if let item = viewModel.item {
|
||||
ItemContent(item: item)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class ItemDetailViewModel {
|
||||
let itemID: UUID
|
||||
var item: Item?
|
||||
var isLoading = false
|
||||
var error: Error?
|
||||
|
||||
init(itemID: UUID) {
|
||||
self.itemID = itemID
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
item = try await fetchItem(id: itemID)
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
func save() async {
|
||||
// Save logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Coordinator Pattern
|
||||
|
||||
For complex navigation flows:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class OnboardingCoordinator {
|
||||
var currentStep: OnboardingStep = .welcome
|
||||
var isComplete = false
|
||||
|
||||
enum OnboardingStep {
|
||||
case welcome
|
||||
case permissions
|
||||
case personalInfo
|
||||
case complete
|
||||
}
|
||||
|
||||
func next() {
|
||||
switch currentStep {
|
||||
case .welcome:
|
||||
currentStep = .permissions
|
||||
case .permissions:
|
||||
currentStep = .personalInfo
|
||||
case .personalInfo:
|
||||
currentStep = .complete
|
||||
isComplete = true
|
||||
case .complete:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func back() {
|
||||
switch currentStep {
|
||||
case .welcome:
|
||||
break
|
||||
case .permissions:
|
||||
currentStep = .welcome
|
||||
case .personalInfo:
|
||||
currentStep = .permissions
|
||||
case .complete:
|
||||
currentStep = .personalInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingFlow: View {
|
||||
@State private var coordinator = OnboardingCoordinator()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch coordinator.currentStep {
|
||||
case .welcome:
|
||||
WelcomeView(onContinue: coordinator.next)
|
||||
case .permissions:
|
||||
PermissionsView(onContinue: coordinator.next, onBack: coordinator.back)
|
||||
case .personalInfo:
|
||||
PersonalInfoView(onContinue: coordinator.next, onBack: coordinator.back)
|
||||
case .complete:
|
||||
CompletionView()
|
||||
}
|
||||
}
|
||||
.animation(.default, value: coordinator.currentStep)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Structured Error Types
|
||||
|
||||
```swift
|
||||
enum AppError: LocalizedError {
|
||||
case networkError(NetworkError)
|
||||
case storageError(StorageError)
|
||||
case validationError(String)
|
||||
case unauthorized
|
||||
case unknown(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return error.localizedDescription
|
||||
case .storageError(let error):
|
||||
return error.localizedDescription
|
||||
case .validationError(let message):
|
||||
return message
|
||||
case .unauthorized:
|
||||
return "Please sign in to continue"
|
||||
case .unknown(let error):
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .networkError:
|
||||
return "Check your internet connection and try again"
|
||||
case .unauthorized:
|
||||
return "Tap to sign in"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: LocalizedError {
|
||||
case noConnection
|
||||
case timeout
|
||||
case serverError(Int)
|
||||
case decodingError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noConnection:
|
||||
return "No internet connection"
|
||||
case .timeout:
|
||||
return "Request timed out"
|
||||
case .serverError(let code):
|
||||
return "Server error (\(code))"
|
||||
case .decodingError:
|
||||
return "Invalid response from server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Presentation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
// Content
|
||||
}
|
||||
.alert(
|
||||
"Error",
|
||||
isPresented: Binding(
|
||||
get: { appState.error != nil },
|
||||
set: { if !$0 { appState.error = nil } }
|
||||
),
|
||||
presenting: appState.error
|
||||
) { error in
|
||||
Button("OK") { }
|
||||
if error.recoverySuggestion != nil {
|
||||
Button("Retry") {
|
||||
Task { await retry() }
|
||||
}
|
||||
}
|
||||
} message: { error in
|
||||
VStack {
|
||||
Text(error.localizedDescription)
|
||||
if let suggestion = error.recoverySuggestion {
|
||||
Text(suggestion)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Architecture
|
||||
|
||||
### Unit Testing with Mocks
|
||||
|
||||
```swift
|
||||
@Test
|
||||
func testLoadItems() async throws {
|
||||
// Arrange
|
||||
let mockNetwork = MockNetworkService()
|
||||
mockNetwork.mockResult = [Item(name: "Test")]
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: mockNetwork)
|
||||
|
||||
// Act
|
||||
await viewModel.load()
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.items.count == 1)
|
||||
#expect(viewModel.items[0].name == "Test")
|
||||
#expect(viewModel.isLoading == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func testLoadItemsError() async throws {
|
||||
// Arrange
|
||||
let mockNetwork = MockNetworkService()
|
||||
mockNetwork.mockError = NetworkError.noConnection
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: mockNetwork)
|
||||
|
||||
// Act
|
||||
await viewModel.load()
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.items.isEmpty)
|
||||
#expect(viewModel.error != nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Preview with Dependencies
|
||||
|
||||
```swift
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(AppDependencies.mock())
|
||||
.environment(AppState())
|
||||
}
|
||||
```
|
||||
542
skills/expertise/iphone-apps/references/app-icons.md
Normal file
542
skills/expertise/iphone-apps/references/app-icons.md
Normal file
@@ -0,0 +1,542 @@
|
||||
# App Icons
|
||||
|
||||
Complete guide for generating, configuring, and managing iOS app icons from the CLI.
|
||||
|
||||
## Quick Start (Xcode 14+)
|
||||
|
||||
The simplest approach—provide a single 1024×1024 PNG and let Xcode auto-generate all sizes:
|
||||
|
||||
1. Create `Assets.xcassets/AppIcon.appiconset/`
|
||||
2. Add your 1024×1024 PNG
|
||||
3. Create `Contents.json` with single-size configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The system auto-generates all required device sizes from this single image.
|
||||
|
||||
## CLI Icon Generation
|
||||
|
||||
### Using sips (Built into macOS)
|
||||
|
||||
Generate all required sizes from a 1024×1024 source:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# generate-app-icons.sh
|
||||
# Usage: ./generate-app-icons.sh source.png output-dir
|
||||
|
||||
SOURCE="$1"
|
||||
OUTPUT="${2:-AppIcon.appiconset}"
|
||||
|
||||
mkdir -p "$OUTPUT"
|
||||
|
||||
# Generate all required sizes
|
||||
sips -z 1024 1024 "$SOURCE" --out "$OUTPUT/icon-1024.png"
|
||||
sips -z 180 180 "$SOURCE" --out "$OUTPUT/icon-180.png"
|
||||
sips -z 167 167 "$SOURCE" --out "$OUTPUT/icon-167.png"
|
||||
sips -z 152 152 "$SOURCE" --out "$OUTPUT/icon-152.png"
|
||||
sips -z 120 120 "$SOURCE" --out "$OUTPUT/icon-120.png"
|
||||
sips -z 87 87 "$SOURCE" --out "$OUTPUT/icon-87.png"
|
||||
sips -z 80 80 "$SOURCE" --out "$OUTPUT/icon-80.png"
|
||||
sips -z 76 76 "$SOURCE" --out "$OUTPUT/icon-76.png"
|
||||
sips -z 60 60 "$SOURCE" --out "$OUTPUT/icon-60.png"
|
||||
sips -z 58 58 "$SOURCE" --out "$OUTPUT/icon-58.png"
|
||||
sips -z 40 40 "$SOURCE" --out "$OUTPUT/icon-40.png"
|
||||
sips -z 29 29 "$SOURCE" --out "$OUTPUT/icon-29.png"
|
||||
sips -z 20 20 "$SOURCE" --out "$OUTPUT/icon-20.png"
|
||||
|
||||
echo "Generated icons in $OUTPUT"
|
||||
```
|
||||
|
||||
### Using ImageMagick
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Requires: brew install imagemagick
|
||||
|
||||
SOURCE="$1"
|
||||
OUTPUT="${2:-AppIcon.appiconset}"
|
||||
|
||||
mkdir -p "$OUTPUT"
|
||||
|
||||
for size in 1024 180 167 152 120 87 80 76 60 58 40 29 20; do
|
||||
convert "$SOURCE" -resize "${size}x${size}!" "$OUTPUT/icon-$size.png"
|
||||
done
|
||||
```
|
||||
|
||||
## Complete Contents.json (All Sizes)
|
||||
|
||||
For manual size control or when not using single-size mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "ios-marketing",
|
||||
"scale": "1x",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename": "icon-180.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "60x60"
|
||||
},
|
||||
{
|
||||
"filename": "icon-120.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "60x60"
|
||||
},
|
||||
{
|
||||
"filename": "icon-87.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "29x29"
|
||||
},
|
||||
{
|
||||
"filename": "icon-58.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "29x29"
|
||||
},
|
||||
{
|
||||
"filename": "icon-120.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "40x40"
|
||||
},
|
||||
{
|
||||
"filename": "icon-80.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "40x40"
|
||||
},
|
||||
{
|
||||
"filename": "icon-60.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "3x",
|
||||
"size": "20x20"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40.png",
|
||||
"idiom": "iphone",
|
||||
"scale": "2x",
|
||||
"size": "20x20"
|
||||
},
|
||||
{
|
||||
"filename": "icon-167.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename": "icon-152.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "76x76"
|
||||
},
|
||||
{
|
||||
"filename": "icon-76.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "76x76"
|
||||
},
|
||||
{
|
||||
"filename": "icon-80.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "40x40"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "40x40"
|
||||
},
|
||||
{
|
||||
"filename": "icon-58.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "29x29"
|
||||
},
|
||||
{
|
||||
"filename": "icon-29.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "29x29"
|
||||
},
|
||||
{
|
||||
"filename": "icon-40.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "2x",
|
||||
"size": "20x20"
|
||||
},
|
||||
{
|
||||
"filename": "icon-20.png",
|
||||
"idiom": "ipad",
|
||||
"scale": "1x",
|
||||
"size": "20x20"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required Sizes Reference
|
||||
|
||||
| Purpose | Size (pt) | Scale | Pixels | Device |
|
||||
|---------|-----------|-------|--------|--------|
|
||||
| App Store | 1024×1024 | 1x | 1024 | Marketing |
|
||||
| Home Screen | 60×60 | 3x | 180 | iPhone |
|
||||
| Home Screen | 60×60 | 2x | 120 | iPhone |
|
||||
| Home Screen | 83.5×83.5 | 2x | 167 | iPad Pro |
|
||||
| Home Screen | 76×76 | 2x | 152 | iPad |
|
||||
| Spotlight | 40×40 | 3x | 120 | iPhone |
|
||||
| Spotlight | 40×40 | 2x | 80 | iPhone/iPad |
|
||||
| Settings | 29×29 | 3x | 87 | iPhone |
|
||||
| Settings | 29×29 | 2x | 58 | iPhone/iPad |
|
||||
| Notification | 20×20 | 3x | 60 | iPhone |
|
||||
| Notification | 20×20 | 2x | 40 | iPhone/iPad |
|
||||
|
||||
## iOS 18 Dark Mode & Tinted Icons
|
||||
|
||||
iOS 18 adds appearance variants: Any (default), Dark, and Tinted.
|
||||
|
||||
### Asset Structure
|
||||
|
||||
Create three versions of each icon:
|
||||
- `icon-1024.png` - Standard (Any appearance)
|
||||
- `icon-1024-dark.png` - Dark mode variant
|
||||
- `icon-1024-tinted.png` - Tinted variant
|
||||
|
||||
### Dark Mode Design
|
||||
|
||||
- Use transparent background (system provides dark fill)
|
||||
- Keep foreground elements recognizable
|
||||
- Lighten foreground colors for contrast against dark background
|
||||
- Or provide full icon with dark-tinted background
|
||||
|
||||
### Tinted Design
|
||||
|
||||
- Must be grayscale, fully opaque
|
||||
- System applies user's tint color over the grayscale
|
||||
- Use gradient background: #313131 (top) to #141414 (bottom)
|
||||
|
||||
### Contents.json with Appearances
|
||||
|
||||
```json
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename": "icon-1024-dark.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "tinted"
|
||||
}
|
||||
],
|
||||
"filename": "icon-1024-tinted.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Alternate App Icons
|
||||
|
||||
Allow users to choose between different app icons.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Add alternate icon sets to asset catalog
|
||||
2. Configure build setting in project.pbxproj:
|
||||
|
||||
```
|
||||
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "DarkIcon ColorfulIcon";
|
||||
```
|
||||
|
||||
Or add icons loose in project with @2x/@3x naming and configure Info.plist:
|
||||
|
||||
```xml
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundleAlternateIcons</key>
|
||||
<dict>
|
||||
<key>DarkIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>DarkIcon</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>ColorfulIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>ColorfulIcon</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundlePrimaryIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
```
|
||||
|
||||
### SwiftUI Implementation
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
enum AppIcon: String, CaseIterable, Identifiable {
|
||||
case primary = "AppIcon"
|
||||
case dark = "DarkIcon"
|
||||
case colorful = "ColorfulIcon"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .primary: return "Default"
|
||||
case .dark: return "Dark"
|
||||
case .colorful: return "Colorful"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String? {
|
||||
self == .primary ? nil : rawValue
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class IconManager {
|
||||
var currentIcon: AppIcon = .primary
|
||||
|
||||
init() {
|
||||
if let iconName = UIApplication.shared.alternateIconName,
|
||||
let icon = AppIcon(rawValue: iconName) {
|
||||
currentIcon = icon
|
||||
}
|
||||
}
|
||||
|
||||
func setIcon(_ icon: AppIcon) async throws {
|
||||
guard UIApplication.shared.supportsAlternateIcons else {
|
||||
throw IconError.notSupported
|
||||
}
|
||||
|
||||
try await UIApplication.shared.setAlternateIconName(icon.iconName)
|
||||
currentIcon = icon
|
||||
}
|
||||
|
||||
enum IconError: LocalizedError {
|
||||
case notSupported
|
||||
|
||||
var errorDescription: String? {
|
||||
"This device doesn't support alternate icons"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IconPickerView: View {
|
||||
@Environment(IconManager.self) private var iconManager
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
List(AppIcon.allCases) { icon in
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
try await iconManager.setIcon(icon)
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
// Preview image (add to asset catalog)
|
||||
Image("\(icon.rawValue)-preview")
|
||||
.resizable()
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text(icon.displayName)
|
||||
|
||||
Spacer()
|
||||
|
||||
if iconManager.currentIcon == icon {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.navigationTitle("App Icon")
|
||||
.alert("Error", isPresented: .constant(error != nil)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
if let error {
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
- **Format**: PNG, non-interlaced
|
||||
- **Transparency**: Not allowed (fully opaque)
|
||||
- **Shape**: Square with 90° corners
|
||||
- **Color Space**: sRGB or Display P3
|
||||
- **Minimum**: 1024×1024 for App Store
|
||||
|
||||
### Design Constraints
|
||||
|
||||
1. **No rounded corners** - System applies mask automatically
|
||||
2. **No text** unless essential to brand identity
|
||||
3. **No photos or screenshots** - Too detailed at small sizes
|
||||
4. **No drop shadows or gloss** - System may add effects
|
||||
5. **No Apple hardware** - Copyright protected
|
||||
6. **No SF Symbols** - Prohibited in icons/logos
|
||||
|
||||
### Safe Zone
|
||||
|
||||
The system mask cuts corners using a superellipse shape. Keep critical elements away from edges.
|
||||
|
||||
Corner radius formula: `10/57 × icon_size`
|
||||
- 57px icon = 10px radius
|
||||
- 1024px icon ≈ 180px radius
|
||||
|
||||
### Test at Small Sizes
|
||||
|
||||
Your icon must be recognizable at 29×29 pixels (Settings icon size). If details are lost, simplify the design.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Missing Marketing Icon" Error
|
||||
|
||||
Ensure you have a 1024×1024 icon with idiom `ios-marketing` in Contents.json.
|
||||
|
||||
### Icon Has Transparency
|
||||
|
||||
App Store rejects icons with alpha channels. Check with:
|
||||
|
||||
```bash
|
||||
sips -g hasAlpha icon-1024.png
|
||||
```
|
||||
|
||||
Remove alpha channel:
|
||||
|
||||
```bash
|
||||
sips -s format png -s formatOptions 0 icon-1024.png --out icon-1024-opaque.png
|
||||
```
|
||||
|
||||
Or with ImageMagick:
|
||||
|
||||
```bash
|
||||
convert icon-1024.png -background white -alpha remove -alpha off icon-1024-opaque.png
|
||||
```
|
||||
|
||||
### Interlaced PNG Error
|
||||
|
||||
Convert to non-interlaced:
|
||||
|
||||
```bash
|
||||
convert icon-1024.png -interlace none icon-1024.png
|
||||
```
|
||||
|
||||
### Rounded Corners Look Wrong
|
||||
|
||||
Never pre-round your icon. Provide square corners and let iOS apply the mask. Pre-rounding causes visual artifacts where the mask doesn't align.
|
||||
|
||||
## Complete Generation Script
|
||||
|
||||
One-command generation for a new project:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# setup-app-icon.sh
|
||||
# Usage: ./setup-app-icon.sh source.png project-path
|
||||
|
||||
SOURCE="$1"
|
||||
PROJECT="${2:-.}"
|
||||
ICONSET="$PROJECT/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
mkdir -p "$ICONSET"
|
||||
|
||||
# Generate 1024x1024 (single-size mode)
|
||||
sips -z 1024 1024 "$SOURCE" --out "$ICONSET/icon-1024.png"
|
||||
|
||||
# Remove alpha channel if present
|
||||
sips -s format png -s formatOptions 0 "$ICONSET/icon-1024.png" --out "$ICONSET/icon-1024.png"
|
||||
|
||||
# Generate Contents.json for single-size mode
|
||||
cat > "$ICONSET/Contents.json" << 'EOF'
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "icon-1024.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "App icon configured at $ICONSET"
|
||||
```
|
||||
408
skills/expertise/iphone-apps/references/app-store.md
Normal file
408
skills/expertise/iphone-apps/references/app-store.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# App Store Submission
|
||||
|
||||
App Review guidelines, privacy requirements, and submission checklist.
|
||||
|
||||
## Pre-Submission Checklist
|
||||
|
||||
### App Completion
|
||||
- [ ] All features working
|
||||
- [ ] No crashes or major bugs
|
||||
- [ ] Performance optimized
|
||||
- [ ] Memory leaks resolved
|
||||
|
||||
### Content Requirements
|
||||
- [ ] App icon (1024x1024)
|
||||
- [ ] Screenshots for all device sizes
|
||||
- [ ] App preview videos (optional)
|
||||
- [ ] Description and keywords
|
||||
- [ ] Privacy policy URL
|
||||
- [ ] Support URL
|
||||
|
||||
### Technical Requirements
|
||||
- [ ] Minimum iOS version set correctly
|
||||
- [ ] Privacy manifest (`PrivacyInfo.xcprivacy`)
|
||||
- [ ] All permissions have usage descriptions
|
||||
- [ ] Export compliance answered
|
||||
- [ ] Content rights declared
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Required Sizes
|
||||
|
||||
```
|
||||
iPhone 6.9" (iPhone 16 Pro Max): 1320 x 2868
|
||||
iPhone 6.7" (iPhone 15 Plus): 1290 x 2796
|
||||
iPhone 6.5" (iPhone 11 Pro Max): 1284 x 2778
|
||||
iPhone 5.5" (iPhone 8 Plus): 1242 x 2208
|
||||
|
||||
iPad Pro 13" (6th gen): 2064 x 2752
|
||||
iPad Pro 12.9" (2nd gen): 2048 x 2732
|
||||
```
|
||||
|
||||
### Automating Screenshots
|
||||
|
||||
With fastlane:
|
||||
|
||||
```ruby
|
||||
# Fastfile
|
||||
lane :screenshots do
|
||||
capture_screenshots(
|
||||
scheme: "MyAppUITests",
|
||||
devices: [
|
||||
"iPhone 16 Pro Max",
|
||||
"iPhone 8 Plus",
|
||||
"iPad Pro (12.9-inch) (6th generation)"
|
||||
],
|
||||
languages: ["en-US", "es-ES"],
|
||||
output_directory: "./screenshots"
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
Snapfile:
|
||||
```ruby
|
||||
devices([
|
||||
"iPhone 16 Pro Max",
|
||||
"iPhone 8 Plus",
|
||||
"iPad Pro (12.9-inch) (6th generation)"
|
||||
])
|
||||
|
||||
languages(["en-US"])
|
||||
scheme("MyAppUITests")
|
||||
output_directory("./screenshots")
|
||||
clear_previous_screenshots(true)
|
||||
```
|
||||
|
||||
UI Test for screenshots:
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
class ScreenshotTests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testScreenshots() {
|
||||
snapshot("01-HomeScreen")
|
||||
|
||||
// Navigate to feature
|
||||
app.buttons["Feature"].tap()
|
||||
snapshot("02-FeatureScreen")
|
||||
|
||||
// Show detail
|
||||
app.cells.firstMatch.tap()
|
||||
snapshot("03-DetailScreen")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
### Required Elements
|
||||
|
||||
1. What data is collected
|
||||
2. How it's used
|
||||
3. Who it's shared with
|
||||
4. How long it's retained
|
||||
5. User rights (access, deletion)
|
||||
6. Contact information
|
||||
|
||||
### Template Structure
|
||||
|
||||
```markdown
|
||||
# Privacy Policy for [App Name]
|
||||
|
||||
Last updated: [Date]
|
||||
|
||||
## Information We Collect
|
||||
- Account information (email, name)
|
||||
- Usage data (features used, session duration)
|
||||
|
||||
## How We Use Information
|
||||
- Provide app functionality
|
||||
- Improve user experience
|
||||
- Send notifications (with permission)
|
||||
|
||||
## Data Sharing
|
||||
We do not sell your data. We share with:
|
||||
- Analytics providers (anonymized)
|
||||
- Cloud storage providers
|
||||
|
||||
## Data Retention
|
||||
We retain data while your account is active.
|
||||
Request deletion at [email].
|
||||
|
||||
## Your Rights
|
||||
- Access your data
|
||||
- Request deletion
|
||||
- Export your data
|
||||
|
||||
## Contact
|
||||
[email]
|
||||
```
|
||||
|
||||
## App Review Guidelines
|
||||
|
||||
### Common Rejections
|
||||
|
||||
**1. Incomplete Information**
|
||||
- Missing demo account credentials
|
||||
- Unclear functionality
|
||||
|
||||
**2. Bugs and Crashes**
|
||||
- App crashes on launch
|
||||
- Features don't work
|
||||
|
||||
**3. Placeholder Content**
|
||||
- Lorem ipsum text
|
||||
- Incomplete UI
|
||||
|
||||
**4. Privacy Issues**
|
||||
- Missing usage descriptions
|
||||
- Accessing data without permission
|
||||
|
||||
**5. Misleading Metadata**
|
||||
- Screenshots don't match app
|
||||
- Description claims unavailable features
|
||||
|
||||
### Demo Account
|
||||
|
||||
In App Store Connect notes:
|
||||
```
|
||||
Demo Account:
|
||||
Username: demo@example.com
|
||||
Password: Demo123!
|
||||
|
||||
Notes:
|
||||
- Subscription features are enabled
|
||||
- Push notifications require real device
|
||||
```
|
||||
|
||||
### Review Notes
|
||||
|
||||
```
|
||||
Notes for Review:
|
||||
|
||||
1. This app requires camera access for QR scanning (Settings tab > Scan QR).
|
||||
|
||||
2. Push notifications are used for:
|
||||
- Order status updates
|
||||
- New message alerts
|
||||
|
||||
3. Background location is used for:
|
||||
- Delivery tracking only when order is active
|
||||
|
||||
4. Demo account has pre-populated data for testing.
|
||||
|
||||
5. In-app purchases can be tested with sandbox account.
|
||||
```
|
||||
|
||||
## Export Compliance
|
||||
|
||||
### Quick Check
|
||||
|
||||
Answer YES to export compliance if your app:
|
||||
- Only uses HTTPS for network requests
|
||||
- Only uses Apple's standard encryption APIs
|
||||
- Only uses encryption for authentication/DRM
|
||||
|
||||
Most apps using HTTPS only can answer YES and select that encryption is exempt.
|
||||
|
||||
### Full Compliance
|
||||
|
||||
If using custom encryption, you need:
|
||||
- Encryption Registration Number (ERN) from BIS
|
||||
- Or exemption documentation
|
||||
|
||||
## App Privacy Labels
|
||||
|
||||
In App Store Connect, declare:
|
||||
|
||||
### Data Types
|
||||
|
||||
- Contact Info (name, email, phone)
|
||||
- Health & Fitness
|
||||
- Financial Info
|
||||
- Location
|
||||
- Browsing History
|
||||
- Search History
|
||||
- Identifiers (user ID, device ID)
|
||||
- Usage Data
|
||||
- Diagnostics
|
||||
|
||||
### Data Use
|
||||
|
||||
For each data type:
|
||||
- **Linked to User**: Can identify the user
|
||||
- **Used for Tracking**: Cross-app/web advertising
|
||||
|
||||
### Example Declaration
|
||||
|
||||
```
|
||||
Contact Info - Email Address:
|
||||
- Used for: App Functionality (account creation)
|
||||
- Linked to User: Yes
|
||||
- Used for Tracking: No
|
||||
|
||||
Usage Data:
|
||||
- Used for: Analytics
|
||||
- Linked to User: No
|
||||
- Used for Tracking: No
|
||||
```
|
||||
|
||||
## In-App Purchases
|
||||
|
||||
### Configuration
|
||||
|
||||
1. App Store Connect > Features > In-App Purchases
|
||||
2. Create products with:
|
||||
- Reference name
|
||||
- Product ID (com.app.product)
|
||||
- Price
|
||||
- Localized display name/description
|
||||
|
||||
### Review Screenshots
|
||||
|
||||
Provide screenshots showing:
|
||||
- Purchase screen
|
||||
- Content being purchased
|
||||
- Restore purchases option
|
||||
|
||||
### Subscription Guidelines
|
||||
|
||||
- Clear pricing shown before purchase
|
||||
- Easy cancellation instructions
|
||||
- Terms of service link
|
||||
- Restore purchases available
|
||||
|
||||
## TestFlight
|
||||
|
||||
### Internal Testing
|
||||
|
||||
- Up to 100 internal testers
|
||||
- No review required
|
||||
- Immediate availability
|
||||
|
||||
### External Testing
|
||||
|
||||
- Up to 10,000 testers
|
||||
- Beta App Review required
|
||||
- Public link option
|
||||
|
||||
### Test Notes
|
||||
|
||||
```
|
||||
What to Test:
|
||||
- New feature: Cloud sync
|
||||
- Bug fix: Login issues on iOS 18
|
||||
- Performance improvements
|
||||
|
||||
Known Issues:
|
||||
- Widget may not update immediately
|
||||
- Dark mode icon pending
|
||||
```
|
||||
|
||||
## Submission Process
|
||||
|
||||
### 1. Archive
|
||||
|
||||
```bash
|
||||
xcodebuild archive \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-archivePath build/MyApp.xcarchive
|
||||
```
|
||||
|
||||
### 2. Export
|
||||
|
||||
```bash
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath build/MyApp.xcarchive \
|
||||
-exportOptionsPlist ExportOptions.plist \
|
||||
-exportPath build/
|
||||
```
|
||||
|
||||
### 3. Upload
|
||||
|
||||
```bash
|
||||
xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file build/MyApp.ipa \
|
||||
--apiKey YOUR_KEY_ID \
|
||||
--apiIssuer YOUR_ISSUER_ID
|
||||
```
|
||||
|
||||
### 4. Submit
|
||||
|
||||
1. App Store Connect > Select build
|
||||
2. Complete all metadata
|
||||
3. Submit for Review
|
||||
|
||||
## Post-Submission
|
||||
|
||||
### Review Timeline
|
||||
|
||||
- Average: 24-48 hours
|
||||
- First submission: May take longer
|
||||
- Complex apps: May need more review
|
||||
|
||||
### Responding to Rejection
|
||||
|
||||
1. Read rejection carefully
|
||||
2. Address ALL issues
|
||||
3. Reply in Resolution Center
|
||||
4. Resubmit
|
||||
|
||||
### Expedited Review
|
||||
|
||||
Request for:
|
||||
- Critical bug fixes
|
||||
- Time-sensitive events
|
||||
- Security issues
|
||||
|
||||
Submit request at: https://developer.apple.com/contact/app-store/?topic=expedite
|
||||
|
||||
## Phased Release
|
||||
|
||||
After approval, choose:
|
||||
- **Immediate**: Available to everyone
|
||||
- **Phased**: 7 days gradual rollout
|
||||
- Day 1: 1%
|
||||
- Day 2: 2%
|
||||
- Day 3: 5%
|
||||
- Day 4: 10%
|
||||
- Day 5: 20%
|
||||
- Day 6: 50%
|
||||
- Day 7: 100%
|
||||
|
||||
Can pause or accelerate at any time.
|
||||
|
||||
## Version Updates
|
||||
|
||||
### What's New
|
||||
|
||||
```
|
||||
Version 2.1
|
||||
|
||||
New:
|
||||
• Cloud sync across devices
|
||||
• Dark mode support
|
||||
• Widget for home screen
|
||||
|
||||
Improved:
|
||||
• Faster app launch
|
||||
• Better search results
|
||||
|
||||
Fixed:
|
||||
• Login issues on iOS 18
|
||||
• Notification sound not playing
|
||||
```
|
||||
|
||||
### Maintaining Multiple Versions
|
||||
|
||||
- Keep previous version available during review
|
||||
- Test backward compatibility
|
||||
- Consider forced updates for critical fixes
|
||||
484
skills/expertise/iphone-apps/references/background-tasks.md
Normal file
484
skills/expertise/iphone-apps/references/background-tasks.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# Background Tasks
|
||||
|
||||
BGTaskScheduler, background fetch, and silent push for background processing.
|
||||
|
||||
## BGTaskScheduler
|
||||
|
||||
### Setup
|
||||
|
||||
1. Add capability: Background Modes
|
||||
2. Enable: Background fetch, Background processing
|
||||
3. Register identifiers in Info.plist:
|
||||
|
||||
```xml
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.app.refresh</string>
|
||||
<string>com.app.processing</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
```swift
|
||||
import BackgroundTasks
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
init() {
|
||||
registerBackgroundTasks()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
|
||||
private func registerBackgroundTasks() {
|
||||
// App Refresh - for frequent, short updates
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: "com.app.refresh",
|
||||
using: nil
|
||||
) { task in
|
||||
guard let task = task as? BGAppRefreshTask else { return }
|
||||
handleAppRefresh(task: task)
|
||||
}
|
||||
|
||||
// Processing - for longer, deferrable work
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: "com.app.processing",
|
||||
using: nil
|
||||
) { task in
|
||||
guard let task = task as? BGProcessingTask else { return }
|
||||
handleProcessing(task: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### App Refresh Task
|
||||
|
||||
Short tasks that need to run frequently:
|
||||
|
||||
```swift
|
||||
func handleAppRefresh(task: BGAppRefreshTask) {
|
||||
// Schedule next refresh
|
||||
scheduleAppRefresh()
|
||||
|
||||
// Create task
|
||||
let refreshTask = Task {
|
||||
do {
|
||||
try await syncLatestData()
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle expiration
|
||||
task.expirationHandler = {
|
||||
refreshTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleAppRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("Could not schedule app refresh: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func syncLatestData() async throws {
|
||||
// Fetch new data from server
|
||||
// Update local database
|
||||
// Badge update if needed
|
||||
}
|
||||
```
|
||||
|
||||
### Processing Task
|
||||
|
||||
Longer tasks that can be deferred:
|
||||
|
||||
```swift
|
||||
func handleProcessing(task: BGProcessingTask) {
|
||||
// Schedule next
|
||||
scheduleProcessing()
|
||||
|
||||
let processingTask = Task {
|
||||
do {
|
||||
try await performHeavyWork()
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
task.expirationHandler = {
|
||||
processingTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleProcessing() {
|
||||
let request = BGProcessingTaskRequest(identifier: "com.app.processing")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
|
||||
request.requiresNetworkConnectivity = true
|
||||
request.requiresExternalPower = false
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("Could not schedule processing: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func performHeavyWork() async throws {
|
||||
// Database maintenance
|
||||
// Large file uploads
|
||||
// ML model training
|
||||
// Cache cleanup
|
||||
}
|
||||
```
|
||||
|
||||
## Background URLSession
|
||||
|
||||
For large uploads/downloads that continue when app is suspended:
|
||||
|
||||
```swift
|
||||
class BackgroundDownloadService: NSObject {
|
||||
static let shared = BackgroundDownloadService()
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.background(
|
||||
withIdentifier: "com.app.background.download"
|
||||
)
|
||||
config.isDiscretionary = true // System chooses best time
|
||||
config.sessionSendsLaunchEvents = true // Wake app on completion
|
||||
|
||||
return URLSession(
|
||||
configuration: config,
|
||||
delegate: self,
|
||||
delegateQueue: nil
|
||||
)
|
||||
}()
|
||||
|
||||
private var completionHandler: (() -> Void)?
|
||||
|
||||
func download(from url: URL) {
|
||||
let task = session.downloadTask(with: url)
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func handleEventsForBackgroundURLSession(
|
||||
identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
self.completionHandler = completionHandler
|
||||
}
|
||||
}
|
||||
|
||||
extension BackgroundDownloadService: URLSessionDownloadDelegate {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
// Move file to permanent location
|
||||
let documentsURL = FileManager.default.urls(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask
|
||||
).first!
|
||||
let destinationURL = documentsURL.appendingPathComponent("downloaded.file")
|
||||
|
||||
try? FileManager.default.moveItem(at: location, to: destinationURL)
|
||||
}
|
||||
|
||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.completionHandler?()
|
||||
self.completionHandler = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In AppDelegate
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
BackgroundDownloadService.shared.handleEventsForBackgroundURLSession(
|
||||
identifier: identifier,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Silent Push Notifications
|
||||
|
||||
Trigger background work from server:
|
||||
|
||||
### Configuration
|
||||
|
||||
Entitlements:
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Handling
|
||||
|
||||
```swift
|
||||
// In AppDelegate
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
|
||||
) async -> UIBackgroundFetchResult {
|
||||
guard let action = userInfo["action"] as? String else {
|
||||
return .noData
|
||||
}
|
||||
|
||||
do {
|
||||
switch action {
|
||||
case "sync":
|
||||
try await syncData()
|
||||
return .newData
|
||||
case "refresh":
|
||||
try await refreshContent()
|
||||
return .newData
|
||||
default:
|
||||
return .noData
|
||||
}
|
||||
} catch {
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"content-available": 1
|
||||
},
|
||||
"action": "sync",
|
||||
"data": {
|
||||
"lastUpdate": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Location Updates
|
||||
|
||||
Background location monitoring:
|
||||
|
||||
```swift
|
||||
import CoreLocation
|
||||
|
||||
class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.allowsBackgroundLocationUpdates = true
|
||||
manager.pausesLocationUpdatesAutomatically = true
|
||||
}
|
||||
|
||||
// Significant location changes (battery efficient)
|
||||
func startMonitoringSignificantChanges() {
|
||||
manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
// Region monitoring
|
||||
func monitorRegion(_ region: CLCircularRegion) {
|
||||
manager.startMonitoring(for: region)
|
||||
}
|
||||
|
||||
// Continuous updates (high battery usage)
|
||||
func startContinuousUpdates() {
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
manager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
func locationManager(
|
||||
_ manager: CLLocationManager,
|
||||
didUpdateLocations locations: [CLLocation]
|
||||
) {
|
||||
guard let location = locations.last else { return }
|
||||
|
||||
// Process location update
|
||||
Task {
|
||||
try? await uploadLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(
|
||||
_ manager: CLLocationManager,
|
||||
didEnterRegion region: CLRegion
|
||||
) {
|
||||
// Handle region entry
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Background Audio
|
||||
|
||||
For audio playback while app is in background:
|
||||
|
||||
```swift
|
||||
import AVFoundation
|
||||
|
||||
class AudioService {
|
||||
private var player: AVAudioPlayer?
|
||||
|
||||
func configureAudioSession() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setActive(true)
|
||||
}
|
||||
|
||||
func play(url: URL) throws {
|
||||
player = try AVAudioPlayer(contentsOf: url)
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Background Tasks
|
||||
|
||||
### Simulate in Debugger
|
||||
|
||||
```swift
|
||||
// Pause in debugger, then:
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.app.refresh"]
|
||||
```
|
||||
|
||||
### Force Early Execution
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
func debugScheduleRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 1) // 1 second for testing
|
||||
|
||||
try? BGTaskScheduler.shared.submit(request)
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Battery Efficiency
|
||||
|
||||
```swift
|
||||
// Use discretionary for non-urgent work
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "com.app.upload")
|
||||
config.isDiscretionary = true // Wait for good network/power conditions
|
||||
|
||||
// Require power for heavy work
|
||||
let request = BGProcessingTaskRequest(identifier: "com.app.process")
|
||||
request.requiresExternalPower = true
|
||||
```
|
||||
|
||||
### Respect User Settings
|
||||
|
||||
```swift
|
||||
func scheduleRefreshIfAllowed() {
|
||||
// Check if user has Low Power Mode
|
||||
if ProcessInfo.processInfo.isLowPowerModeEnabled {
|
||||
// Reduce frequency or skip
|
||||
return
|
||||
}
|
||||
|
||||
// Check background refresh status
|
||||
switch UIApplication.shared.backgroundRefreshStatus {
|
||||
case .available:
|
||||
scheduleAppRefresh()
|
||||
case .denied, .restricted:
|
||||
// Inform user if needed
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Expiration
|
||||
|
||||
Always handle task expiration:
|
||||
|
||||
```swift
|
||||
func handleTask(_ task: BGTask) {
|
||||
let operation = Task {
|
||||
// Long running work
|
||||
}
|
||||
|
||||
// CRITICAL: Always set expiration handler
|
||||
task.expirationHandler = {
|
||||
operation.cancel()
|
||||
// Clean up
|
||||
// Save progress
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Persistence
|
||||
|
||||
Save progress so you can resume:
|
||||
|
||||
```swift
|
||||
func performIncrementalSync(task: BGTask) async {
|
||||
// Load progress
|
||||
let lastSyncDate = UserDefaults.standard.object(forKey: "lastSyncDate") as? Date ?? .distantPast
|
||||
|
||||
do {
|
||||
// Sync from last position
|
||||
let newDate = try await syncSince(lastSyncDate)
|
||||
|
||||
// Save progress
|
||||
UserDefaults.standard.set(newDate, forKey: "lastSyncDate")
|
||||
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Check Scheduled Tasks
|
||||
|
||||
```swift
|
||||
BGTaskScheduler.shared.getPendingTaskRequests { requests in
|
||||
for request in requests {
|
||||
print("Pending: \(request.identifier)")
|
||||
print("Earliest: \(request.earliestBeginDate ?? Date())")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cancel Tasks
|
||||
|
||||
```swift
|
||||
// Cancel specific
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: "com.app.refresh")
|
||||
|
||||
// Cancel all
|
||||
BGTaskScheduler.shared.cancelAllTaskRequests()
|
||||
```
|
||||
|
||||
### Console Logs
|
||||
|
||||
```bash
|
||||
# View background task logs
|
||||
log stream --predicate 'subsystem == "com.apple.BackgroundTasks"' --level debug
|
||||
```
|
||||
488
skills/expertise/iphone-apps/references/ci-cd.md
Normal file
488
skills/expertise/iphone-apps/references/ci-cd.md
Normal file
@@ -0,0 +1,488 @@
|
||||
# CI/CD
|
||||
|
||||
Xcode Cloud, fastlane, and automated testing and deployment.
|
||||
|
||||
## Xcode Cloud
|
||||
|
||||
### Setup
|
||||
|
||||
1. Enable in Xcode: Product > Xcode Cloud > Create Workflow
|
||||
2. Configure in App Store Connect
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```yaml
|
||||
# Configured in Xcode Cloud UI
|
||||
Workflow: Build and Test
|
||||
Start Conditions:
|
||||
- Push to main
|
||||
- Pull Request to main
|
||||
|
||||
Actions:
|
||||
- Build
|
||||
- Test (iOS Simulator)
|
||||
|
||||
Post-Actions:
|
||||
- Notify (Slack)
|
||||
```
|
||||
|
||||
### Custom Build Scripts
|
||||
|
||||
`.ci_scripts/ci_post_clone.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Install dependencies
|
||||
brew install swiftlint
|
||||
|
||||
# Generate files
|
||||
cd $CI_PRIMARY_REPOSITORY_PATH
|
||||
./scripts/generate-assets.sh
|
||||
```
|
||||
|
||||
`.ci_scripts/ci_pre_xcodebuild.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Run SwiftLint
|
||||
swiftlint lint --strict --reporter json > swiftlint-report.json || true
|
||||
|
||||
# Check for errors
|
||||
if grep -q '"severity": "error"' swiftlint-report.json; then
|
||||
echo "SwiftLint errors found"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set in Xcode Cloud:
|
||||
- `API_BASE_URL`
|
||||
- `SENTRY_DSN`
|
||||
- Secrets (automatically masked)
|
||||
|
||||
Access in build:
|
||||
```swift
|
||||
let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String
|
||||
```
|
||||
|
||||
## Fastlane
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew install fastlane
|
||||
|
||||
# Or via bundler
|
||||
bundle init
|
||||
echo 'gem "fastlane"' >> Gemfile
|
||||
bundle install
|
||||
```
|
||||
|
||||
### Fastfile
|
||||
|
||||
`fastlane/Fastfile`:
|
||||
```ruby
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Run tests"
|
||||
lane :test do
|
||||
run_tests(
|
||||
scheme: "MyApp",
|
||||
device: "iPhone 16",
|
||||
code_coverage: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
# Increment build number
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1
|
||||
)
|
||||
|
||||
# Build
|
||||
build_app(
|
||||
scheme: "MyApp",
|
||||
export_method: "app-store"
|
||||
)
|
||||
|
||||
# Upload
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
|
||||
# Notify
|
||||
slack(
|
||||
message: "New build uploaded to TestFlight!",
|
||||
slack_url: ENV["SLACK_URL"]
|
||||
)
|
||||
end
|
||||
|
||||
desc "Deploy to App Store"
|
||||
lane :release do
|
||||
# Ensure clean git
|
||||
ensure_git_status_clean
|
||||
|
||||
# Build
|
||||
build_app(
|
||||
scheme: "MyApp",
|
||||
export_method: "app-store"
|
||||
)
|
||||
|
||||
# Upload
|
||||
upload_to_app_store(
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
force: true,
|
||||
precheck_include_in_app_purchases: false
|
||||
)
|
||||
|
||||
# Tag
|
||||
add_git_tag(
|
||||
tag: "v#{get_version_number}"
|
||||
)
|
||||
push_git_tags
|
||||
end
|
||||
|
||||
desc "Sync certificates and profiles"
|
||||
lane :sync_signing do
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: true
|
||||
)
|
||||
match(
|
||||
type: "development",
|
||||
readonly: true
|
||||
)
|
||||
end
|
||||
|
||||
desc "Take screenshots"
|
||||
lane :screenshots do
|
||||
capture_screenshots(
|
||||
scheme: "MyAppUITests"
|
||||
)
|
||||
frame_screenshots(
|
||||
white: true
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Match (Code Signing)
|
||||
|
||||
`fastlane/Matchfile`:
|
||||
```ruby
|
||||
git_url("https://github.com/yourcompany/certificates")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["com.yourcompany.app"])
|
||||
username("developer@yourcompany.com")
|
||||
```
|
||||
|
||||
Setup:
|
||||
```bash
|
||||
# Initialize
|
||||
fastlane match init
|
||||
|
||||
# Generate certificates
|
||||
fastlane match appstore
|
||||
fastlane match development
|
||||
```
|
||||
|
||||
### Appfile
|
||||
|
||||
`fastlane/Appfile`:
|
||||
```ruby
|
||||
app_identifier("com.yourcompany.app")
|
||||
apple_id("developer@yourcompany.com")
|
||||
itc_team_id("123456")
|
||||
team_id("ABCDEF1234")
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
`.github/workflows/ci.yml`:
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-14
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
run: sudo xcode-select -s /Applications/Xcode_15.4.app
|
||||
|
||||
- name: Cache SPM
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
.build
|
||||
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-resultBundlePath TestResults.xcresult \
|
||||
CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
- name: Upload Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results
|
||||
path: TestResults.xcresult
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: macos-14
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Fastlane
|
||||
run: brew install fastlane
|
||||
|
||||
- name: Deploy to TestFlight
|
||||
env:
|
||||
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_KEY }}
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
|
||||
run: fastlane beta
|
||||
```
|
||||
|
||||
### Code Signing in CI
|
||||
|
||||
```yaml
|
||||
- name: Import Certificate
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Import certificate
|
||||
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
|
||||
security import certificate.p12 \
|
||||
-k build.keychain \
|
||||
-P "$CERTIFICATE_PASSWORD" \
|
||||
-T /usr/bin/codesign
|
||||
|
||||
# Allow codesign access
|
||||
security set-key-partition-list \
|
||||
-S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
- name: Install Provisioning Profile
|
||||
env:
|
||||
PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
echo "$PROVISIONING_PROFILE_BASE64" | base64 --decode > profile.mobileprovision
|
||||
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
```
|
||||
|
||||
## Version Management
|
||||
|
||||
### Automatic Versioning
|
||||
|
||||
```ruby
|
||||
# In Fastfile
|
||||
lane :bump_version do |options|
|
||||
# Get version from tag or parameter
|
||||
version = options[:version] || git_tag_last_match(pattern: "v*").gsub("v", "")
|
||||
|
||||
increment_version_number(
|
||||
version_number: version
|
||||
)
|
||||
|
||||
increment_build_number(
|
||||
build_number: number_of_commits
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
### Semantic Versioning Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/bump-version.sh
|
||||
|
||||
TYPE=$1 # major, minor, patch
|
||||
CURRENT=$(agvtool what-marketing-version -terse1)
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
case $TYPE in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
agvtool new-marketing-version $NEW_VERSION
|
||||
echo "Version bumped to $NEW_VERSION"
|
||||
```
|
||||
|
||||
## Test Reporting
|
||||
|
||||
### JUnit Format
|
||||
|
||||
```bash
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
|
||||
# Convert to JUnit
|
||||
xcrun xcresulttool get --format json --path TestResults.xcresult > results.json
|
||||
# Use xcresult-to-junit or similar tool
|
||||
```
|
||||
|
||||
### Code Coverage
|
||||
|
||||
```bash
|
||||
# Generate coverage
|
||||
xcodebuild test \
|
||||
-enableCodeCoverage YES \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
|
||||
# Export coverage report
|
||||
xcrun xccov view --report --json TestResults.xcresult > coverage.json
|
||||
```
|
||||
|
||||
### Slack Notifications
|
||||
|
||||
```ruby
|
||||
# In Fastfile
|
||||
after_all do |lane|
|
||||
slack(
|
||||
message: "Successfully deployed to TestFlight",
|
||||
success: true,
|
||||
default_payloads: [:git_branch, :git_author]
|
||||
)
|
||||
end
|
||||
|
||||
error do |lane, exception|
|
||||
slack(
|
||||
message: "Build failed: #{exception.message}",
|
||||
success: false
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
## App Store Connect API
|
||||
|
||||
### Key Setup
|
||||
|
||||
1. App Store Connect > Users and Access > Keys
|
||||
2. Generate Key with App Manager role
|
||||
3. Download `.p8` file
|
||||
|
||||
### Fastlane Configuration
|
||||
|
||||
`fastlane/Appfile`:
|
||||
```ruby
|
||||
# Use API Key instead of password
|
||||
app_store_connect_api_key(
|
||||
key_id: ENV["ASC_KEY_ID"],
|
||||
issuer_id: ENV["ASC_ISSUER_ID"],
|
||||
key_filepath: "./AuthKey.p8",
|
||||
in_house: false
|
||||
)
|
||||
```
|
||||
|
||||
### Upload with altool
|
||||
|
||||
```bash
|
||||
xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file build/MyApp.ipa \
|
||||
--apiKey $KEY_ID \
|
||||
--apiIssuer $ISSUER_ID
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Secrets Management
|
||||
|
||||
- Never commit secrets to git
|
||||
- Use environment variables or secret managers
|
||||
- Rotate keys regularly
|
||||
- Use match for certificate management
|
||||
|
||||
### Build Caching
|
||||
|
||||
```yaml
|
||||
# Cache derived data
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-build-${{ hashFiles('**/*.swift') }}
|
||||
```
|
||||
|
||||
### Parallel Testing
|
||||
|
||||
```ruby
|
||||
run_tests(
|
||||
devices: ["iPhone 16", "iPad Pro (12.9-inch)"],
|
||||
parallel_testing: true,
|
||||
concurrent_workers: 4
|
||||
)
|
||||
```
|
||||
|
||||
### Conditional Deploys
|
||||
|
||||
```yaml
|
||||
# Only deploy on version tags
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
```
|
||||
459
skills/expertise/iphone-apps/references/cli-observability.md
Normal file
459
skills/expertise/iphone-apps/references/cli-observability.md
Normal file
@@ -0,0 +1,459 @@
|
||||
# CLI Observability
|
||||
|
||||
Complete debugging and monitoring without opening Xcode. Claude has full visibility into build errors, runtime logs, crashes, memory issues, and network traffic.
|
||||
|
||||
<prerequisites>
|
||||
```bash
|
||||
# Install observability tools (one-time)
|
||||
brew tap ldomaradzki/xcsift && brew install xcsift
|
||||
brew install mitmproxy xcbeautify
|
||||
```
|
||||
</prerequisites>
|
||||
|
||||
<build_output>
|
||||
## Build Error Parsing
|
||||
|
||||
**xcsift** converts verbose xcodebuild output to token-efficient JSON for AI agents:
|
||||
|
||||
```bash
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
build 2>&1 | xcsift
|
||||
```
|
||||
|
||||
Output includes structured errors with file paths and line numbers:
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"errors": [
|
||||
{"file": "/path/File.swift", "line": 42, "message": "Type mismatch..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative** (human-readable):
|
||||
```bash
|
||||
xcodebuild build 2>&1 | xcbeautify
|
||||
```
|
||||
</build_output>
|
||||
|
||||
<runtime_logging>
|
||||
## Runtime Logs
|
||||
|
||||
### In-App Logging Pattern
|
||||
|
||||
Add to all apps:
|
||||
```swift
|
||||
import os
|
||||
|
||||
extension Logger {
|
||||
static let app = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App")
|
||||
static let network = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Network")
|
||||
static let data = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Data")
|
||||
}
|
||||
|
||||
// Usage
|
||||
Logger.network.debug("Request: \(url)")
|
||||
Logger.data.error("Save failed: \(error)")
|
||||
```
|
||||
|
||||
### Stream Logs from Simulator
|
||||
|
||||
```bash
|
||||
# All logs from your app
|
||||
xcrun simctl spawn booted log stream --level debug \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp"'
|
||||
|
||||
# Filter by category
|
||||
xcrun simctl spawn booted log stream --level debug \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp" AND category == "Network"'
|
||||
|
||||
# Errors only
|
||||
xcrun simctl spawn booted log stream \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp" AND messageType == error'
|
||||
|
||||
# JSON output for parsing
|
||||
xcrun simctl spawn booted log stream --level debug --style json \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp"'
|
||||
```
|
||||
|
||||
### Search Historical Logs
|
||||
|
||||
```bash
|
||||
# Collect logs from simulator
|
||||
xcrun simctl spawn booted log collect --output sim_logs.logarchive
|
||||
|
||||
# Search collected logs
|
||||
log show sim_logs.logarchive --predicate 'subsystem == "com.yourcompany.MyApp"'
|
||||
```
|
||||
</runtime_logging>
|
||||
|
||||
<crash_analysis>
|
||||
## Crash Logs
|
||||
|
||||
### Find Crashes (Simulator)
|
||||
|
||||
```bash
|
||||
# Simulator crash logs
|
||||
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
|
||||
|
||||
# View latest crash
|
||||
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
|
||||
```
|
||||
|
||||
### Symbolicate with atos
|
||||
|
||||
```bash
|
||||
# Get load address from "Binary Images:" section of crash report
|
||||
xcrun atos -arch arm64 \
|
||||
-o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
|
||||
-l 0x104600000 \
|
||||
0x104605ca4
|
||||
|
||||
# Verify dSYM matches
|
||||
xcrun dwarfdump --uuid MyApp.app.dSYM
|
||||
```
|
||||
|
||||
### Symbolicate with LLDB
|
||||
|
||||
```bash
|
||||
xcrun lldb
|
||||
(lldb) command script import lldb.macosx.crashlog
|
||||
(lldb) crashlog /path/to/crash.ips
|
||||
```
|
||||
</crash_analysis>
|
||||
|
||||
<debugger>
|
||||
## LLDB Debugging
|
||||
|
||||
### Launch with Console Output
|
||||
|
||||
```bash
|
||||
# Launch and see stdout/stderr
|
||||
xcrun simctl launch --console booted com.yourcompany.MyApp
|
||||
```
|
||||
|
||||
### Attach to Running App
|
||||
|
||||
```bash
|
||||
# By name
|
||||
lldb -n MyApp
|
||||
|
||||
# By PID
|
||||
lldb -p $(pgrep MyApp)
|
||||
|
||||
# Wait for app to launch
|
||||
lldb -n MyApp --wait-for
|
||||
```
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Breakpoints
|
||||
(lldb) breakpoint set --file ContentView.swift --line 42
|
||||
(lldb) breakpoint set --name "AppState.addItem"
|
||||
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
|
||||
|
||||
# Watchpoints (break when value changes)
|
||||
(lldb) watchpoint set variable self.items.count
|
||||
|
||||
# Execution
|
||||
(lldb) continue # or 'c'
|
||||
(lldb) next # step over
|
||||
(lldb) step # step into
|
||||
(lldb) finish # step out
|
||||
|
||||
# Inspection
|
||||
(lldb) p variable
|
||||
(lldb) po object
|
||||
(lldb) frame variable # all local vars
|
||||
(lldb) bt # backtrace
|
||||
(lldb) bt all # all threads
|
||||
|
||||
# Evaluate expressions
|
||||
(lldb) expr self.items.count
|
||||
(lldb) expr self.items.append(newItem)
|
||||
```
|
||||
</debugger>
|
||||
|
||||
<memory_debugging>
|
||||
## Memory Debugging
|
||||
|
||||
### Leak Detection (Simulator)
|
||||
|
||||
```bash
|
||||
# Check running process for leaks
|
||||
leaks MyApp
|
||||
```
|
||||
|
||||
### Profiling with xctrace
|
||||
|
||||
```bash
|
||||
# List templates
|
||||
xcrun xctrace list templates
|
||||
|
||||
# Time Profiler
|
||||
xcrun xctrace record \
|
||||
--template 'Time Profiler' \
|
||||
--time-limit 30s \
|
||||
--output profile.trace \
|
||||
--device booted \
|
||||
--launch -- com.yourcompany.MyApp
|
||||
|
||||
# Leaks
|
||||
xcrun xctrace record \
|
||||
--template 'Leaks' \
|
||||
--time-limit 5m \
|
||||
--device booted \
|
||||
--attach MyApp \
|
||||
--output leaks.trace
|
||||
|
||||
# Export data
|
||||
xcrun xctrace export --input profile.trace --toc
|
||||
```
|
||||
</memory_debugging>
|
||||
|
||||
<sanitizers>
|
||||
## Sanitizers
|
||||
|
||||
Enable via xcodebuild flags:
|
||||
|
||||
```bash
|
||||
# Address Sanitizer (memory errors, buffer overflows)
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-enableAddressSanitizer YES
|
||||
|
||||
# Thread Sanitizer (race conditions)
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-enableThreadSanitizer YES
|
||||
|
||||
# Undefined Behavior Sanitizer
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-enableUndefinedBehaviorSanitizer YES
|
||||
```
|
||||
|
||||
**Note:** ASAN and TSAN cannot run simultaneously.
|
||||
</sanitizers>
|
||||
|
||||
<network_inspection>
|
||||
## Network Traffic Inspection
|
||||
|
||||
### mitmproxy Setup
|
||||
|
||||
```bash
|
||||
# Run proxy (defaults to localhost:8080)
|
||||
mitmproxy # TUI
|
||||
mitmdump # CLI output only
|
||||
```
|
||||
|
||||
### Configure macOS Proxy (Simulator uses host network)
|
||||
|
||||
```bash
|
||||
# Enable
|
||||
networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8080
|
||||
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8080
|
||||
|
||||
# Disable when done
|
||||
networksetup -setwebproxystate "Wi-Fi" off
|
||||
networksetup -setsecurewebproxystate "Wi-Fi" off
|
||||
```
|
||||
|
||||
### Install Certificate on Simulator
|
||||
|
||||
```bash
|
||||
xcrun simctl keychain booted add-root-cert ~/.mitmproxy/mitmproxy-ca-cert.pem
|
||||
```
|
||||
|
||||
**Important:** Restart simulator after proxy/cert changes.
|
||||
|
||||
### Log Traffic
|
||||
|
||||
```bash
|
||||
# Log all requests
|
||||
mitmdump -w traffic.log
|
||||
|
||||
# Filter by domain
|
||||
mitmdump --filter "~d api.example.com"
|
||||
|
||||
# Verbose (show bodies)
|
||||
mitmdump -v
|
||||
```
|
||||
</network_inspection>
|
||||
|
||||
<test_results>
|
||||
## Test Result Parsing
|
||||
|
||||
```bash
|
||||
# Run tests with result bundle
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
|
||||
# Get summary
|
||||
xcrun xcresulttool get test-results summary --path TestResults.xcresult
|
||||
|
||||
# Export as JSON
|
||||
xcrun xcresulttool get --path TestResults.xcresult --format json > results.json
|
||||
|
||||
# Coverage report
|
||||
xcrun xccov view --report TestResults.xcresult
|
||||
|
||||
# Coverage as JSON
|
||||
xcrun xccov view --report --json TestResults.xcresult > coverage.json
|
||||
```
|
||||
|
||||
### Accessibility Audits (Xcode 15+)
|
||||
|
||||
Add to UI tests:
|
||||
```swift
|
||||
func testAccessibility() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
try app.performAccessibilityAudit()
|
||||
}
|
||||
```
|
||||
|
||||
Run via CLI:
|
||||
```bash
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyAppUITests \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-only-testing:MyAppUITests/AccessibilityTests
|
||||
```
|
||||
</test_results>
|
||||
|
||||
<swiftui_debugging>
|
||||
## SwiftUI Debugging
|
||||
|
||||
### Track View Re-evaluation
|
||||
|
||||
```swift
|
||||
var body: some View {
|
||||
let _ = Self._printChanges() // Logs what caused re-render
|
||||
VStack {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dump Objects
|
||||
|
||||
```swift
|
||||
let _ = dump(someObject) // Full object hierarchy to console
|
||||
```
|
||||
|
||||
**Note:** No CLI equivalent for Xcode's visual view hierarchy inspector. Use logging extensively.
|
||||
</swiftui_debugging>
|
||||
|
||||
<simulator_management>
|
||||
## Simulator Management
|
||||
|
||||
```bash
|
||||
# List simulators
|
||||
xcrun simctl list devices
|
||||
|
||||
# Boot simulator
|
||||
xcrun simctl boot "iPhone 16"
|
||||
open -a Simulator
|
||||
|
||||
# Install app
|
||||
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/MyApp.app
|
||||
|
||||
# Launch app
|
||||
xcrun simctl launch booted com.yourcompany.MyApp
|
||||
|
||||
# Launch with console output
|
||||
xcrun simctl launch --console booted com.yourcompany.MyApp
|
||||
|
||||
# Screenshot
|
||||
xcrun simctl io booted screenshot ~/Desktop/screenshot.png
|
||||
|
||||
# Video recording
|
||||
xcrun simctl io booted recordVideo ~/Desktop/recording.mov
|
||||
|
||||
# Set location
|
||||
xcrun simctl location booted set 37.7749,-122.4194
|
||||
|
||||
# Send push notification
|
||||
xcrun simctl push booted com.yourcompany.MyApp notification.apns
|
||||
|
||||
# Reset simulator
|
||||
xcrun simctl erase booted
|
||||
```
|
||||
</simulator_management>
|
||||
|
||||
<device_debugging>
|
||||
## Device Debugging (iOS 17+)
|
||||
|
||||
```bash
|
||||
# List devices
|
||||
xcrun devicectl list devices
|
||||
|
||||
# Install app
|
||||
xcrun devicectl device install app --device <udid> MyApp.app
|
||||
|
||||
# Launch app
|
||||
xcrun devicectl device process launch --device <udid> com.yourcompany.MyApp
|
||||
```
|
||||
</device_debugging>
|
||||
|
||||
<standard_debug_workflow>
|
||||
## Standard Debug Workflow
|
||||
|
||||
```bash
|
||||
# 1. Build with error parsing
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
build 2>&1 | xcsift
|
||||
|
||||
# 2. Boot simulator and start log streaming (background terminal)
|
||||
xcrun simctl boot "iPhone 16"
|
||||
open -a Simulator
|
||||
xcrun simctl spawn booted log stream --level debug \
|
||||
--predicate 'subsystem == "com.yourcompany.MyApp"' &
|
||||
|
||||
# 3. Install and launch
|
||||
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/MyApp.app
|
||||
xcrun simctl launch booted com.yourcompany.MyApp
|
||||
|
||||
# 4. If crash occurs
|
||||
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
|
||||
|
||||
# 5. Memory check
|
||||
leaks MyApp
|
||||
|
||||
# 6. Deep debugging
|
||||
lldb -n MyApp
|
||||
```
|
||||
</standard_debug_workflow>
|
||||
|
||||
<cli_vs_xcode>
|
||||
## What CLI Can and Cannot Do
|
||||
|
||||
| Task | CLI | Tool |
|
||||
|------|-----|------|
|
||||
| Build errors | ✓ | xcsift |
|
||||
| Runtime logs | ✓ | simctl log stream |
|
||||
| Crash symbolication | ✓ | atos, lldb |
|
||||
| Breakpoints/debugging | ✓ | lldb |
|
||||
| Memory leaks | ✓ | leaks, xctrace |
|
||||
| CPU profiling | ✓ | xctrace |
|
||||
| Network inspection | ✓ | mitmproxy |
|
||||
| Test results | ✓ | xcresulttool |
|
||||
| Accessibility audit | ✓ | UI tests |
|
||||
| Sanitizers | ✓ | xcodebuild flags |
|
||||
| View hierarchy | ⚠️ | _printChanges() only |
|
||||
| GPU debugging | ✗ | Requires Xcode |
|
||||
</cli_vs_xcode>
|
||||
407
skills/expertise/iphone-apps/references/cli-workflow.md
Normal file
407
skills/expertise/iphone-apps/references/cli-workflow.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# CLI Workflow
|
||||
|
||||
Build, run, test, and deploy iOS apps entirely from the terminal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
# Ensure Xcode is installed and selected
|
||||
xcode-select -p
|
||||
# Should show: /Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# If not, run:
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# Install XcodeGen for project creation
|
||||
brew install xcodegen
|
||||
|
||||
# Optional: prettier build output
|
||||
brew install xcbeautify
|
||||
|
||||
# Optional: device deployment
|
||||
brew install ios-deploy
|
||||
```
|
||||
|
||||
## Create Project (XcodeGen)
|
||||
|
||||
Create a new iOS project entirely from CLI:
|
||||
|
||||
```bash
|
||||
# Create directory structure
|
||||
mkdir MyApp && cd MyApp
|
||||
mkdir -p MyApp/{App,Models,Views,Services,Resources} MyAppTests MyAppUITests
|
||||
|
||||
# Create project.yml (Claude generates this - see project-scaffolding.md for full template)
|
||||
cat > project.yml << 'EOF'
|
||||
name: MyApp
|
||||
options:
|
||||
bundleIdPrefix: com.yourcompany
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
targets:
|
||||
MyApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources: [MyApp]
|
||||
settings:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
|
||||
DEVELOPMENT_TEAM: YOURTEAMID
|
||||
EOF
|
||||
|
||||
# Create app entry point
|
||||
cat > MyApp/App/MyApp.swift << 'EOF'
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Text("Hello, World!")
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Generate .xcodeproj
|
||||
xcodegen generate
|
||||
|
||||
# Verify
|
||||
xcodebuild -list -project MyApp.xcodeproj
|
||||
|
||||
# Build
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build
|
||||
```
|
||||
|
||||
See [project-scaffolding.md](project-scaffolding.md) for complete project.yml templates.
|
||||
|
||||
## Building
|
||||
|
||||
### Basic Build
|
||||
|
||||
```bash
|
||||
# Build for simulator
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
build
|
||||
|
||||
# Build for device
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'generic/platform=iOS' \
|
||||
build
|
||||
```
|
||||
|
||||
### Clean Build
|
||||
|
||||
```bash
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
clean build
|
||||
```
|
||||
|
||||
### Build with Specific SDK
|
||||
|
||||
```bash
|
||||
# List available SDKs
|
||||
xcodebuild -showsdks
|
||||
|
||||
# Build with specific SDK
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-sdk iphonesimulator17.0 \
|
||||
build
|
||||
```
|
||||
|
||||
## Running on Simulator
|
||||
|
||||
### Boot and Launch
|
||||
|
||||
```bash
|
||||
# List available simulators
|
||||
xcrun simctl list devices
|
||||
|
||||
# Boot simulator
|
||||
xcrun simctl boot "iPhone 16"
|
||||
|
||||
# Open Simulator app
|
||||
open -a Simulator
|
||||
|
||||
# Install app
|
||||
xcrun simctl install booted ~/Library/Developer/Xcode/DerivedData/MyApp-xxx/Build/Products/Debug-iphonesimulator/MyApp.app
|
||||
|
||||
# Launch app
|
||||
xcrun simctl launch booted com.yourcompany.MyApp
|
||||
|
||||
# Or install and launch in one step
|
||||
xcrun simctl install booted MyApp.app && xcrun simctl launch booted com.yourcompany.MyApp
|
||||
```
|
||||
|
||||
### Simulator Management
|
||||
|
||||
```bash
|
||||
# Create simulator
|
||||
xcrun simctl create "My iPhone 16" "iPhone 16" iOS17.0
|
||||
|
||||
# Delete simulator
|
||||
xcrun simctl delete "My iPhone 16"
|
||||
|
||||
# Reset simulator
|
||||
xcrun simctl erase booted
|
||||
|
||||
# Screenshot
|
||||
xcrun simctl io booted screenshot ~/Desktop/screenshot.png
|
||||
|
||||
# Record video
|
||||
xcrun simctl io booted recordVideo ~/Desktop/recording.mov
|
||||
```
|
||||
|
||||
### Simulate Conditions
|
||||
|
||||
```bash
|
||||
# Set location
|
||||
xcrun simctl location booted set 37.7749,-122.4194
|
||||
|
||||
# Send push notification
|
||||
xcrun simctl push booted com.yourcompany.MyApp notification.apns
|
||||
|
||||
# Set status bar (time, battery, etc.)
|
||||
xcrun simctl status_bar booted override --time "9:41" --batteryLevel 100
|
||||
```
|
||||
|
||||
## Running on Device
|
||||
|
||||
### List Connected Devices
|
||||
|
||||
```bash
|
||||
# List devices
|
||||
xcrun xctrace list devices
|
||||
|
||||
# Or using ios-deploy
|
||||
ios-deploy --detect
|
||||
```
|
||||
|
||||
### Deploy to Device
|
||||
|
||||
```bash
|
||||
# Install ios-deploy
|
||||
brew install ios-deploy
|
||||
|
||||
# Deploy and run
|
||||
ios-deploy --bundle MyApp.app --debug
|
||||
|
||||
# Just install without launching
|
||||
ios-deploy --bundle MyApp.app --no-wifi
|
||||
|
||||
# Deploy with app data
|
||||
ios-deploy --bundle MyApp.app --bundle_id com.yourcompany.MyApp
|
||||
```
|
||||
|
||||
### Wireless Debugging
|
||||
|
||||
1. Connect device via USB once
|
||||
2. In Xcode: Window > Devices and Simulators > Connect via network
|
||||
3. Deploy wirelessly:
|
||||
|
||||
```bash
|
||||
ios-deploy --bundle MyApp.app --wifi
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Unit Tests
|
||||
|
||||
```bash
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
```
|
||||
|
||||
### Run UI Tests
|
||||
|
||||
```bash
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyAppUITests \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-resultBundlePath UITestResults.xcresult
|
||||
```
|
||||
|
||||
### Run Specific Tests
|
||||
|
||||
```bash
|
||||
# Single test
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-only-testing:MyAppTests/NetworkServiceTests/testFetchItems
|
||||
|
||||
# Test class
|
||||
xcodebuild test \
|
||||
... \
|
||||
-only-testing:MyAppTests/NetworkServiceTests
|
||||
```
|
||||
|
||||
### View Test Results
|
||||
|
||||
```bash
|
||||
# Open results in Xcode
|
||||
open TestResults.xcresult
|
||||
|
||||
# Export to JSON (for CI)
|
||||
xcrun xcresulttool get --path TestResults.xcresult --format json
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Console Logs
|
||||
|
||||
```bash
|
||||
# Stream logs from simulator
|
||||
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.yourcompany.MyApp"'
|
||||
|
||||
# Stream logs from device
|
||||
idevicesyslog | grep MyApp
|
||||
```
|
||||
|
||||
### LLDB
|
||||
|
||||
```bash
|
||||
# Attach to running process
|
||||
lldb -n MyApp
|
||||
|
||||
# Debug app on launch
|
||||
ios-deploy --bundle MyApp.app --debug
|
||||
```
|
||||
|
||||
### Crash Logs
|
||||
|
||||
```bash
|
||||
# Simulator crash logs
|
||||
ls ~/Library/Logs/DiagnosticReports/
|
||||
|
||||
# Device crash logs (via Xcode)
|
||||
# Window > Devices and Simulators > View Device Logs
|
||||
```
|
||||
|
||||
## Archiving and Export
|
||||
|
||||
### Create Archive
|
||||
|
||||
```bash
|
||||
xcodebuild archive \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-archivePath build/MyApp.xcarchive \
|
||||
-destination 'generic/platform=iOS'
|
||||
```
|
||||
|
||||
### Export IPA
|
||||
|
||||
Create `ExportOptions.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>YOUR_TEAM_ID</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Export:
|
||||
|
||||
```bash
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath build/MyApp.xcarchive \
|
||||
-exportOptionsPlist ExportOptions.plist \
|
||||
-exportPath build/
|
||||
```
|
||||
|
||||
## App Store Connect
|
||||
|
||||
### Upload to TestFlight
|
||||
|
||||
```bash
|
||||
xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file build/MyApp.ipa \
|
||||
--apiKey YOUR_KEY_ID \
|
||||
--apiIssuer YOUR_ISSUER_ID
|
||||
```
|
||||
|
||||
Or use `xcrun notarytool` for newer workflows:
|
||||
|
||||
```bash
|
||||
xcrun notarytool submit build/MyApp.ipa \
|
||||
--key ~/.appstoreconnect/AuthKey_XXXXX.p8 \
|
||||
--key-id YOUR_KEY_ID \
|
||||
--issuer YOUR_ISSUER_ID \
|
||||
--wait
|
||||
```
|
||||
|
||||
### App Store Connect API Key
|
||||
|
||||
1. App Store Connect > Users and Access > Keys
|
||||
2. Generate API Key
|
||||
3. Download and store securely
|
||||
|
||||
## Useful Aliases
|
||||
|
||||
Add to `.zshrc`:
|
||||
|
||||
```bash
|
||||
# iOS development
|
||||
alias ios-build="xcodebuild -project *.xcodeproj -scheme \$(basename *.xcodeproj .xcodeproj) -destination 'platform=iOS Simulator,name=iPhone 16' build"
|
||||
alias ios-test="xcodebuild test -project *.xcodeproj -scheme \$(basename *.xcodeproj .xcodeproj) -destination 'platform=iOS Simulator,name=iPhone 16'"
|
||||
alias ios-run="xcrun simctl launch booted"
|
||||
alias ios-log="xcrun simctl spawn booted log stream --level debug"
|
||||
alias sim-boot="xcrun simctl boot 'iPhone 16' && open -a Simulator"
|
||||
alias sim-screenshot="xcrun simctl io booted screenshot ~/Desktop/sim-\$(date +%Y%m%d-%H%M%S).png"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
|
||||
```bash
|
||||
# Clear derived data
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData
|
||||
|
||||
# Reset package caches
|
||||
rm -rf ~/Library/Caches/org.swift.swiftpm
|
||||
|
||||
# Resolve packages
|
||||
xcodebuild -resolvePackageDependencies
|
||||
```
|
||||
|
||||
### Simulator Issues
|
||||
|
||||
```bash
|
||||
# Kill all simulators
|
||||
killall Simulator
|
||||
|
||||
# Reset all simulators
|
||||
xcrun simctl shutdown all && xcrun simctl erase all
|
||||
```
|
||||
|
||||
### Code Signing
|
||||
|
||||
```bash
|
||||
# List identities
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
# Check provisioning profiles
|
||||
ls ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
```
|
||||
527
skills/expertise/iphone-apps/references/data-persistence.md
Normal file
527
skills/expertise/iphone-apps/references/data-persistence.md
Normal file
@@ -0,0 +1,527 @@
|
||||
# Data Persistence
|
||||
|
||||
SwiftData, Core Data, and file-based storage for iOS apps.
|
||||
|
||||
## SwiftData (iOS 17+)
|
||||
|
||||
### Model Definition
|
||||
|
||||
```swift
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
class Item {
|
||||
var name: String
|
||||
var createdAt: Date
|
||||
var isCompleted: Bool
|
||||
var priority: Int
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var tasks: [Task]
|
||||
|
||||
@Relationship(inverse: \Category.items)
|
||||
var category: Category?
|
||||
|
||||
init(name: String, priority: Int = 0) {
|
||||
self.name = name
|
||||
self.createdAt = Date()
|
||||
self.isCompleted = false
|
||||
self.priority = priority
|
||||
self.tasks = []
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Task {
|
||||
var title: String
|
||||
var isCompleted: Bool
|
||||
|
||||
init(title: String) {
|
||||
self.title = title
|
||||
self.isCompleted = false
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Category {
|
||||
var name: String
|
||||
var items: [Item]
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.items = []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Container Setup
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(for: [Item.self, Category.self])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Querying Data
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
// Basic query
|
||||
@Query private var items: [Item]
|
||||
|
||||
// Sorted query
|
||||
@Query(sort: \Item.createdAt, order: .reverse)
|
||||
private var sortedItems: [Item]
|
||||
|
||||
// Filtered query
|
||||
@Query(filter: #Predicate<Item> { $0.isCompleted == false })
|
||||
private var incompleteItems: [Item]
|
||||
|
||||
// Complex query
|
||||
@Query(
|
||||
filter: #Predicate<Item> { !$0.isCompleted && $0.priority > 5 },
|
||||
sort: [
|
||||
SortDescriptor(\Item.priority, order: .reverse),
|
||||
SortDescriptor(\Item.createdAt)
|
||||
]
|
||||
)
|
||||
private var highPriorityItems: [Item]
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CRUD Operations
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
@Query private var items: [Item]
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.onDelete(perform: delete)
|
||||
}
|
||||
.toolbar {
|
||||
Button("Add", action: addItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
let item = Item(name: "New Item")
|
||||
context.insert(item)
|
||||
// Auto-saves
|
||||
}
|
||||
|
||||
private func delete(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
context.delete(items[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Container Configuration
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
let container: ModelContainer
|
||||
|
||||
init() {
|
||||
let schema = Schema([Item.self, Category.self])
|
||||
|
||||
let config = ModelConfiguration(
|
||||
schema: schema,
|
||||
isStoredInMemoryOnly: false,
|
||||
allowsSave: true,
|
||||
groupContainer: .identifier("group.com.yourcompany.app")
|
||||
)
|
||||
|
||||
do {
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
} catch {
|
||||
fatalError("Failed to configure SwiftData container: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### iCloud Sync
|
||||
|
||||
SwiftData syncs automatically with iCloud when:
|
||||
1. App has iCloud capability
|
||||
2. User is signed into iCloud
|
||||
3. Container uses CloudKit
|
||||
|
||||
```swift
|
||||
let config = ModelConfiguration(
|
||||
cloudKitDatabase: .automatic
|
||||
)
|
||||
```
|
||||
|
||||
## Core Data (All iOS Versions)
|
||||
|
||||
### Stack Setup
|
||||
|
||||
```swift
|
||||
class CoreDataStack {
|
||||
static let shared = CoreDataStack()
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container = NSPersistentContainer(name: "MyApp")
|
||||
|
||||
// Enable cloud sync
|
||||
guard let description = container.persistentStoreDescriptions.first else {
|
||||
fatalError("No persistent store description")
|
||||
}
|
||||
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||
containerIdentifier: "iCloud.com.yourcompany.app"
|
||||
)
|
||||
|
||||
container.loadPersistentStores { description, error in
|
||||
if let error = error {
|
||||
fatalError("Core Data failed to load: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
return container
|
||||
}()
|
||||
|
||||
var viewContext: NSManagedObjectContext {
|
||||
persistentContainer.viewContext
|
||||
}
|
||||
|
||||
func saveContext() {
|
||||
let context = viewContext
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With SwiftUI
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
let coreDataStack = CoreDataStack.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, coreDataStack.viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ItemList: View {
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)],
|
||||
predicate: NSPredicate(format: "isCompleted == NO")
|
||||
)
|
||||
private var items: FetchedResults<Item>
|
||||
|
||||
@Environment(\.managedObjectContext) private var context
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File-Based Storage
|
||||
|
||||
### Codable Models
|
||||
|
||||
```swift
|
||||
struct UserSettings: Codable {
|
||||
var theme: Theme
|
||||
var fontSize: Int
|
||||
var notificationsEnabled: Bool
|
||||
|
||||
enum Theme: String, Codable {
|
||||
case light, dark, system
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsStore {
|
||||
private let fileURL: URL
|
||||
|
||||
init() {
|
||||
let documentsDirectory = FileManager.default.urls(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask
|
||||
).first!
|
||||
fileURL = documentsDirectory.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
func load() -> UserSettings {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let settings = try? JSONDecoder().decode(UserSettings.self, from: data) else {
|
||||
return UserSettings(theme: .system, fontSize: 16, notificationsEnabled: true)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func save(_ settings: UserSettings) throws {
|
||||
let data = try JSONEncoder().encode(settings)
|
||||
try data.write(to: fileURL)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Document Directory Paths
|
||||
|
||||
```swift
|
||||
extension FileManager {
|
||||
var documentsDirectory: URL {
|
||||
urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
}
|
||||
|
||||
var cachesDirectory: URL {
|
||||
urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
}
|
||||
|
||||
var applicationSupportDirectory: URL {
|
||||
let url = urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
try? createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UserDefaults
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```swift
|
||||
// Save
|
||||
UserDefaults.standard.set("value", forKey: "key")
|
||||
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
|
||||
|
||||
// Load
|
||||
let value = UserDefaults.standard.string(forKey: "key")
|
||||
let hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")
|
||||
```
|
||||
|
||||
### @AppStorage
|
||||
|
||||
```swift
|
||||
struct SettingsView: View {
|
||||
@AppStorage("fontSize") private var fontSize = 16
|
||||
@AppStorage("isDarkMode") private var isDarkMode = false
|
||||
@AppStorage("username") private var username = ""
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Stepper("Font Size: \(fontSize)", value: $fontSize, in: 12...24)
|
||||
Toggle("Dark Mode", isOn: $isDarkMode)
|
||||
TextField("Username", text: $username)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Codable Storage
|
||||
|
||||
```swift
|
||||
extension UserDefaults {
|
||||
func set<T: Codable>(_ value: T, forKey key: String) {
|
||||
if let data = try? JSONEncoder().encode(value) {
|
||||
set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
||||
guard let data = data(forKey: key) else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
UserDefaults.standard.set(userProfile, forKey: "userProfile")
|
||||
let profile = UserDefaults.standard.get(UserProfile.self, forKey: "userProfile")
|
||||
```
|
||||
|
||||
## Keychain (Sensitive Data)
|
||||
|
||||
### Simple Wrapper
|
||||
|
||||
```swift
|
||||
import Security
|
||||
|
||||
class KeychainService {
|
||||
enum KeychainError: Error {
|
||||
case saveFailed(OSStatus)
|
||||
case loadFailed(OSStatus)
|
||||
case deleteFailed(OSStatus)
|
||||
case dataConversionError
|
||||
}
|
||||
|
||||
func save(_ data: Data, for key: String) throws {
|
||||
// Delete existing
|
||||
try? delete(key)
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
||||
]
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
func load(_ key: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.loadFailed(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw KeychainError.dataConversionError
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func delete(_ key: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.deleteFailed(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// String convenience
|
||||
extension KeychainService {
|
||||
func saveString(_ value: String, for key: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw KeychainError.dataConversionError
|
||||
}
|
||||
try save(data, for: key)
|
||||
}
|
||||
|
||||
func loadString(_ key: String) throws -> String {
|
||||
let data = try load(key)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw KeychainError.dataConversionError
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```swift
|
||||
let keychain = KeychainService()
|
||||
|
||||
// Save API token
|
||||
try keychain.saveString(token, for: "apiToken")
|
||||
|
||||
// Load API token
|
||||
let token = try keychain.loadString("apiToken")
|
||||
|
||||
// Delete on logout
|
||||
try keychain.delete("apiToken")
|
||||
```
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### SwiftData Migrations
|
||||
|
||||
```swift
|
||||
enum SchemaV1: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(1, 0, 0)
|
||||
static var models: [any PersistentModel.Type] {
|
||||
[Item.self]
|
||||
}
|
||||
|
||||
@Model
|
||||
class Item {
|
||||
var name: String
|
||||
init(name: String) { self.name = name }
|
||||
}
|
||||
}
|
||||
|
||||
enum SchemaV2: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(2, 0, 0)
|
||||
static var models: [any PersistentModel.Type] {
|
||||
[Item.self]
|
||||
}
|
||||
|
||||
@Model
|
||||
class Item {
|
||||
var name: String
|
||||
var createdAt: Date // New field
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.createdAt = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [any VersionedSchema.Type] {
|
||||
[SchemaV1.self, SchemaV2.self]
|
||||
}
|
||||
|
||||
static var stages: [MigrationStage] {
|
||||
[migrateV1toV2]
|
||||
}
|
||||
|
||||
static let migrateV1toV2 = MigrationStage.lightweight(
|
||||
fromVersion: SchemaV1.self,
|
||||
toVersion: SchemaV2.self
|
||||
)
|
||||
}
|
||||
```
|
||||
473
skills/expertise/iphone-apps/references/navigation-patterns.md
Normal file
473
skills/expertise/iphone-apps/references/navigation-patterns.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# Navigation Patterns
|
||||
|
||||
NavigationStack, deep linking, and programmatic navigation for iOS apps.
|
||||
|
||||
## NavigationStack Basics
|
||||
|
||||
### Value-Based Navigation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
List(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetail(item: item, path: $path)
|
||||
}
|
||||
.navigationDestination(for: Category.self) { category in
|
||||
CategoryView(category: category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
VStack {
|
||||
Button("Go to Settings") {
|
||||
path.append(Route.settings)
|
||||
}
|
||||
|
||||
Button("Go to Item") {
|
||||
path.append(items[0])
|
||||
}
|
||||
|
||||
Button("Deep Link") {
|
||||
// Push multiple screens
|
||||
path.append(Route.settings)
|
||||
path.append(SettingsSection.account)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
switch route {
|
||||
case .settings:
|
||||
SettingsView(path: $path)
|
||||
case .profile:
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetail(item: item)
|
||||
}
|
||||
.navigationDestination(for: SettingsSection.self) { section in
|
||||
SettingsSectionView(section: section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
path.removeLast(path.count)
|
||||
}
|
||||
|
||||
func popOne() {
|
||||
if !path.isEmpty {
|
||||
path.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case settings
|
||||
case profile
|
||||
}
|
||||
|
||||
enum SettingsSection: Hashable {
|
||||
case account
|
||||
case notifications
|
||||
case privacy
|
||||
}
|
||||
```
|
||||
|
||||
## Tab-Based Navigation
|
||||
|
||||
### TabView with NavigationStack per Tab
|
||||
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = Tab.home
|
||||
@State private var homePath = NavigationPath()
|
||||
@State private var searchPath = NavigationPath()
|
||||
@State private var profilePath = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack(path: $homePath) {
|
||||
HomeView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
.tag(Tab.home)
|
||||
|
||||
NavigationStack(path: $searchPath) {
|
||||
SearchView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.tag(Tab.search)
|
||||
|
||||
NavigationStack(path: $profilePath) {
|
||||
ProfileView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person")
|
||||
}
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
.onChange(of: selectedTab) { oldTab, newTab in
|
||||
// Pop to root when re-tapping current tab
|
||||
if oldTab == newTab {
|
||||
switch newTab {
|
||||
case .home: homePath.removeLast(homePath.count)
|
||||
case .search: searchPath.removeLast(searchPath.count)
|
||||
case .profile: profilePath.removeLast(profilePath.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Tab {
|
||||
case home, search, profile
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
### URL Scheme Handling
|
||||
|
||||
Configure in Info.plist:
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>myapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
Handle in App:
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
// myapp://item/123
|
||||
// myapp://settings/account
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
|
||||
|
||||
let pathComponents = components.path.split(separator: "/").map(String.init)
|
||||
|
||||
switch pathComponents.first {
|
||||
case "item":
|
||||
if let id = pathComponents.dropFirst().first {
|
||||
appState.navigateToItem(id: id)
|
||||
}
|
||||
case "settings":
|
||||
let section = pathComponents.dropFirst().first
|
||||
appState.navigateToSettings(section: section)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class AppState {
|
||||
var selectedTab: Tab = .home
|
||||
var homePath = NavigationPath()
|
||||
|
||||
func navigateToItem(id: String) {
|
||||
selectedTab = .home
|
||||
homePath.removeLast(homePath.count)
|
||||
if let item = findItem(id: id) {
|
||||
homePath.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
func navigateToSettings(section: String?) {
|
||||
selectedTab = .profile
|
||||
// Navigate to settings
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Universal Links
|
||||
|
||||
Configure in `apple-app-site-association` on your server:
|
||||
```json
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "TEAMID.com.yourcompany.app",
|
||||
"paths": ["/item/*", "/user/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add Associated Domains entitlement:
|
||||
```xml
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:example.com</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
Handle same as URL schemes with `onOpenURL`.
|
||||
|
||||
## Modal Presentation
|
||||
|
||||
### Sheet Navigation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var selectedItem: Item?
|
||||
@State private var showingNewItem = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(items) { item in
|
||||
Button(item.name) {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
Button {
|
||||
showingNewItem = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Item-based presentation
|
||||
.sheet(item: $selectedItem) { item in
|
||||
NavigationStack {
|
||||
ItemDetail(item: item)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
selectedItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Boolean-based presentation
|
||||
.sheet(isPresented: $showingNewItem) {
|
||||
NavigationStack {
|
||||
NewItemView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
showingNewItem = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Full Screen Cover
|
||||
|
||||
```swift
|
||||
.fullScreenCover(isPresented: $showingOnboarding) {
|
||||
OnboardingFlow()
|
||||
}
|
||||
```
|
||||
|
||||
### Detents (Sheet Sizes)
|
||||
|
||||
```swift
|
||||
.sheet(isPresented: $showingOptions) {
|
||||
OptionsView()
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation State Persistence
|
||||
|
||||
### Codable Navigation Path
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path: [Route] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
// Content
|
||||
}
|
||||
.onAppear {
|
||||
loadNavigationState()
|
||||
}
|
||||
.onChange(of: path) { _, newPath in
|
||||
saveNavigationState(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveNavigationState(_ path: [Route]) {
|
||||
if let data = try? JSONEncoder().encode(path) {
|
||||
UserDefaults.standard.set(data, forKey: "navigationPath")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNavigationState() {
|
||||
guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
|
||||
let savedPath = try? JSONDecoder().decode([Route].self, from: data) else {
|
||||
return
|
||||
}
|
||||
path = savedPath
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Codable, Hashable {
|
||||
case item(id: UUID)
|
||||
case settings
|
||||
case profile
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Coordinator
|
||||
|
||||
For complex apps, centralize navigation logic:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class NavigationCoordinator {
|
||||
var homePath = NavigationPath()
|
||||
var searchPath = NavigationPath()
|
||||
var selectedTab: Tab = .home
|
||||
|
||||
enum Tab {
|
||||
case home, search, profile
|
||||
}
|
||||
|
||||
func showItem(_ item: Item) {
|
||||
selectedTab = .home
|
||||
homePath.append(item)
|
||||
}
|
||||
|
||||
func showSearch(query: String) {
|
||||
selectedTab = .search
|
||||
searchPath.append(SearchQuery(text: query))
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
switch selectedTab {
|
||||
case .home:
|
||||
homePath.removeLast(homePath.count)
|
||||
case .search:
|
||||
searchPath.removeLast(searchPath.count)
|
||||
case .profile:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL) {
|
||||
// Parse and navigate
|
||||
}
|
||||
}
|
||||
|
||||
// Inject via environment
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var coordinator = NavigationCoordinator()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(coordinator)
|
||||
.onOpenURL { url in
|
||||
coordinator.handleDeepLink(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Search Integration
|
||||
|
||||
### Searchable Modifier
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchScope = SearchScope.all
|
||||
|
||||
var filteredItems: [Item] {
|
||||
items.filter { item in
|
||||
searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredItems) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.searchable(text: $searchText, prompt: "Search items")
|
||||
.searchScopes($searchScope) {
|
||||
Text("All").tag(SearchScope.all)
|
||||
Text("Recent").tag(SearchScope.recent)
|
||||
Text("Favorites").tag(SearchScope.favorites)
|
||||
}
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetail(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SearchScope {
|
||||
case all, recent, favorites
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Search Suggestions
|
||||
|
||||
```swift
|
||||
.searchable(text: $searchText) {
|
||||
ForEach(suggestions) { suggestion in
|
||||
Text(suggestion.text)
|
||||
.searchCompletion(suggestion.text)
|
||||
}
|
||||
}
|
||||
```
|
||||
527
skills/expertise/iphone-apps/references/networking.md
Normal file
527
skills/expertise/iphone-apps/references/networking.md
Normal file
@@ -0,0 +1,527 @@
|
||||
# Networking
|
||||
|
||||
URLSession patterns, caching, authentication, and offline support.
|
||||
|
||||
## Basic Networking Service
|
||||
|
||||
```swift
|
||||
actor NetworkService {
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
self.encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
}
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
let request = try endpoint.urlRequest()
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
guard 200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, data)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw NetworkError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func send<T: Encodable, R: Decodable>(_ body: T, to endpoint: Endpoint) async throws -> R {
|
||||
var request = try endpoint.urlRequest()
|
||||
request.httpBody = try encoder.encode(body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
guard 200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, data)
|
||||
}
|
||||
|
||||
return try decoder.decode(R.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
case httpError(Int, Data)
|
||||
case decodingError(Error)
|
||||
case noConnection
|
||||
case timeout
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL"
|
||||
case .invalidResponse:
|
||||
return "Invalid server response"
|
||||
case .httpError(let code, _):
|
||||
return "Server error (\(code))"
|
||||
case .decodingError:
|
||||
return "Failed to parse response"
|
||||
case .noConnection:
|
||||
return "No internet connection"
|
||||
case .timeout:
|
||||
return "Request timed out"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint Pattern
|
||||
|
||||
```swift
|
||||
enum Endpoint {
|
||||
case items
|
||||
case item(id: String)
|
||||
case createItem
|
||||
case updateItem(id: String)
|
||||
case deleteItem(id: String)
|
||||
case search(query: String, page: Int)
|
||||
|
||||
var baseURL: URL {
|
||||
URL(string: "https://api.example.com/v1")!
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .items, .createItem:
|
||||
return "/items"
|
||||
case .item(let id), .updateItem(let id), .deleteItem(let id):
|
||||
return "/items/\(id)"
|
||||
case .search:
|
||||
return "/search"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .items, .item, .search:
|
||||
return "GET"
|
||||
case .createItem:
|
||||
return "POST"
|
||||
case .updateItem:
|
||||
return "PUT"
|
||||
case .deleteItem:
|
||||
return "DELETE"
|
||||
}
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem]? {
|
||||
switch self {
|
||||
case .search(let query, let page):
|
||||
return [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "page", value: String(page))
|
||||
]
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func urlRequest() throws -> URLRequest {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)
|
||||
components?.queryItems = queryItems
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw NetworkError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Bearer Token
|
||||
|
||||
```swift
|
||||
actor AuthenticatedNetworkService {
|
||||
private let session: URLSession
|
||||
private let tokenProvider: TokenProvider
|
||||
|
||||
init(tokenProvider: TokenProvider) {
|
||||
self.session = .shared
|
||||
self.tokenProvider = tokenProvider
|
||||
}
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
var request = try endpoint.urlRequest()
|
||||
|
||||
// Add auth header
|
||||
let token = try await tokenProvider.validToken()
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
// Handle 401 - token expired
|
||||
if httpResponse.statusCode == 401 {
|
||||
// Refresh token and retry
|
||||
let newToken = try await tokenProvider.refreshToken()
|
||||
request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
|
||||
let (retryData, retryResponse) = try await session.data(for: request)
|
||||
|
||||
guard let retryHttpResponse = retryResponse as? HTTPURLResponse,
|
||||
200..<300 ~= retryHttpResponse.statusCode else {
|
||||
throw NetworkError.unauthorized
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(T.self, from: retryData)
|
||||
}
|
||||
|
||||
guard 200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.httpError(httpResponse.statusCode, data)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
protocol TokenProvider {
|
||||
func validToken() async throws -> String
|
||||
func refreshToken() async throws -> String
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth 2.0 Flow
|
||||
|
||||
```swift
|
||||
import AuthenticationServices
|
||||
|
||||
class OAuthService: NSObject {
|
||||
func signIn() async throws -> String {
|
||||
let authURL = URL(string: "https://auth.example.com/authorize?client_id=xxx&redirect_uri=myapp://callback&response_type=code")!
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: "myapp"
|
||||
) { callbackURL, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let callbackURL = callbackURL,
|
||||
let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
continuation.resume(throwing: OAuthError.invalidCallback)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(returning: code)
|
||||
}
|
||||
|
||||
session.presentationContextProvider = self
|
||||
session.prefersEphemeralWebBrowserSession = true
|
||||
session.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthService: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow }!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
### URLCache Configuration
|
||||
|
||||
```swift
|
||||
class CachedNetworkService {
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let cache = URLCache(
|
||||
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
|
||||
diskCapacity: 200 * 1024 * 1024 // 200 MB disk
|
||||
)
|
||||
|
||||
let config = URLSessionConfiguration.default
|
||||
config.urlCache = cache
|
||||
config.requestCachePolicy = .returnCacheDataElseLoad
|
||||
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> T {
|
||||
var request = try endpoint.urlRequest()
|
||||
request.cachePolicy = cachePolicy
|
||||
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
func fetchFresh<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
try await fetch(endpoint, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Caching
|
||||
|
||||
```swift
|
||||
actor DataCache {
|
||||
private var cache: [String: CachedItem] = [:]
|
||||
private let maxAge: TimeInterval
|
||||
|
||||
struct CachedItem {
|
||||
let data: Data
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
init(maxAge: TimeInterval = 300) {
|
||||
self.maxAge = maxAge
|
||||
}
|
||||
|
||||
func get(_ key: String) -> Data? {
|
||||
guard let item = cache[key] else { return nil }
|
||||
guard Date().timeIntervalSince(item.timestamp) < maxAge else {
|
||||
cache.removeValue(forKey: key)
|
||||
return nil
|
||||
}
|
||||
return item.data
|
||||
}
|
||||
|
||||
func set(_ data: Data, for key: String) {
|
||||
cache[key] = CachedItem(data: data, timestamp: Date())
|
||||
}
|
||||
|
||||
func invalidate(_ key: String) {
|
||||
cache.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
cache.removeAll()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Offline Support
|
||||
|
||||
### Network Monitor
|
||||
|
||||
```swift
|
||||
import Network
|
||||
|
||||
@Observable
|
||||
class NetworkMonitor {
|
||||
var isConnected = true
|
||||
var connectionType: ConnectionType = .wifi
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
|
||||
enum ConnectionType {
|
||||
case wifi, cellular, unknown
|
||||
}
|
||||
|
||||
init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
DispatchQueue.main.async {
|
||||
self?.isConnected = path.status == .satisfied
|
||||
self?.connectionType = self?.getConnectionType(path) ?? .unknown
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
private func getConnectionType(_ path: NWPath) -> ConnectionType {
|
||||
if path.usesInterfaceType(.wifi) {
|
||||
return .wifi
|
||||
} else if path.usesInterfaceType(.cellular) {
|
||||
return .cellular
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Offline-First Pattern
|
||||
|
||||
```swift
|
||||
actor OfflineFirstService {
|
||||
private let network: NetworkService
|
||||
private let storage: StorageService
|
||||
private let cache: DataCache
|
||||
|
||||
func fetchItems() async throws -> [Item] {
|
||||
// Try cache first
|
||||
if let cached = await cache.get("items"),
|
||||
let items = try? JSONDecoder().decode([Item].self, from: cached) {
|
||||
// Return cached, fetch fresh in background
|
||||
Task {
|
||||
try? await fetchAndCacheFresh()
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Try network
|
||||
do {
|
||||
let items: [Item] = try await network.fetch(.items)
|
||||
await cache.set(try JSONEncoder().encode(items), for: "items")
|
||||
return items
|
||||
} catch {
|
||||
// Fall back to storage
|
||||
return try await storage.loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAndCacheFresh() async throws {
|
||||
let items: [Item] = try await network.fetch(.items)
|
||||
await cache.set(try JSONEncoder().encode(items), for: "items")
|
||||
try await storage.saveItems(items)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pending Operations Queue
|
||||
|
||||
```swift
|
||||
actor PendingOperationsQueue {
|
||||
private var operations: [PendingOperation] = []
|
||||
private let storage: StorageService
|
||||
|
||||
struct PendingOperation: Codable {
|
||||
let id: UUID
|
||||
let endpoint: String
|
||||
let method: String
|
||||
let body: Data?
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
func add(_ operation: PendingOperation) async {
|
||||
operations.append(operation)
|
||||
try? await persist()
|
||||
}
|
||||
|
||||
func processAll() async {
|
||||
for operation in operations {
|
||||
do {
|
||||
try await execute(operation)
|
||||
operations.removeAll { $0.id == operation.id }
|
||||
} catch {
|
||||
// Keep in queue for retry
|
||||
continue
|
||||
}
|
||||
}
|
||||
try? await persist()
|
||||
}
|
||||
|
||||
private func execute(_ operation: PendingOperation) async throws {
|
||||
// Execute network request
|
||||
}
|
||||
|
||||
private func persist() async throws {
|
||||
try await storage.savePendingOperations(operations)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multipart Upload
|
||||
|
||||
```swift
|
||||
extension NetworkService {
|
||||
func upload(_ fileData: Data, filename: String, mimeType: String, to endpoint: Endpoint) async throws -> UploadResponse {
|
||||
let boundary = UUID().uuidString
|
||||
var request = try endpoint.urlRequest()
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(fileData)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
request.httpBody = body
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
200..<300 ~= httpResponse.statusCode else {
|
||||
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, data)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(UploadResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Download with Progress
|
||||
|
||||
```swift
|
||||
class DownloadService: NSObject, URLSessionDownloadDelegate {
|
||||
private lazy var session: URLSession = {
|
||||
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
private var progressHandler: ((Double) -> Void)?
|
||||
private var completionHandler: ((Result<URL, Error>) -> Void)?
|
||||
|
||||
func download(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.progressHandler = progress
|
||||
self.completionHandler = { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
session.downloadTask(with: url).resume()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
completionHandler?(.success(location))
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
DispatchQueue.main.async {
|
||||
self.progressHandler?(progress)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
completionHandler?(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
562
skills/expertise/iphone-apps/references/performance.md
Normal file
562
skills/expertise/iphone-apps/references/performance.md
Normal file
@@ -0,0 +1,562 @@
|
||||
# Performance
|
||||
|
||||
Instruments, memory management, launch optimization, and battery efficiency.
|
||||
|
||||
## Instruments Profiling
|
||||
|
||||
### Time Profiler
|
||||
|
||||
Find CPU-intensive code:
|
||||
|
||||
```bash
|
||||
# Profile from CLI
|
||||
xcrun xctrace record \
|
||||
--template 'Time Profiler' \
|
||||
--device-name 'iPhone 16' \
|
||||
--launch MyApp.app \
|
||||
--output profile.trace
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Main thread work during UI updates
|
||||
- Expensive computations in body
|
||||
- Synchronous I/O
|
||||
|
||||
### Allocations
|
||||
|
||||
Track memory usage:
|
||||
|
||||
```bash
|
||||
xcrun xctrace record \
|
||||
--template 'Allocations' \
|
||||
--device-name 'iPhone 16' \
|
||||
--launch MyApp.app \
|
||||
--output allocations.trace
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Memory growth over time
|
||||
- Abandoned memory
|
||||
- High transient allocations
|
||||
|
||||
### Leaks
|
||||
|
||||
Find retain cycles:
|
||||
|
||||
```bash
|
||||
xcrun xctrace record \
|
||||
--template 'Leaks' \
|
||||
--device-name 'iPhone 16' \
|
||||
--launch MyApp.app \
|
||||
--output leaks.trace
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Strong reference cycles in closures
|
||||
- Delegate patterns without weak references
|
||||
- Timer retain cycles
|
||||
|
||||
## Memory Management
|
||||
|
||||
### Weak References in Closures
|
||||
|
||||
```swift
|
||||
// Bad - creates retain cycle
|
||||
class ViewModel {
|
||||
var timer: Timer?
|
||||
|
||||
func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
self.update() // Strong capture
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Good - weak capture
|
||||
class ViewModel {
|
||||
var timer: Timer?
|
||||
|
||||
func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
self?.update()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Task Cancellation
|
||||
|
||||
```swift
|
||||
class ViewModel {
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
func load() {
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let items = try? await fetchItems()
|
||||
|
||||
// Check cancellation before updating
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.items = items ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Large Data Handling
|
||||
|
||||
```swift
|
||||
// Bad - loads all into memory
|
||||
let allPhotos = try await fetchAllPhotos()
|
||||
for photo in allPhotos {
|
||||
process(photo)
|
||||
}
|
||||
|
||||
// Good - stream processing
|
||||
for await photo in fetchPhotosStream() {
|
||||
process(photo)
|
||||
|
||||
// Allow UI updates
|
||||
if shouldYield {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SwiftUI Performance
|
||||
|
||||
### Avoid Expensive Body Computations
|
||||
|
||||
```swift
|
||||
// Bad - recomputes on every body call
|
||||
struct ItemList: View {
|
||||
let items: [Item]
|
||||
|
||||
var body: some View {
|
||||
let sortedItems = items.sorted { $0.date > $1.date } // Every render!
|
||||
List(sortedItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Good - compute once
|
||||
struct ItemList: View {
|
||||
let items: [Item]
|
||||
|
||||
var sortedItems: [Item] {
|
||||
items.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(sortedItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Better - use @State or computed in view model
|
||||
struct ItemList: View {
|
||||
@State private var sortedItems: [Item] = []
|
||||
let items: [Item]
|
||||
|
||||
var body: some View {
|
||||
List(sortedItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.onChange(of: items) { _, newItems in
|
||||
sortedItems = newItems.sorted { $0.date > $1.date }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optimize List Performance
|
||||
|
||||
```swift
|
||||
// Use stable identifiers
|
||||
struct Item: Identifiable {
|
||||
let id: UUID // Stable identifier
|
||||
var name: String
|
||||
}
|
||||
|
||||
// Explicit id for efficiency
|
||||
List(items, id: \.id) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
|
||||
// Lazy loading for large lists
|
||||
LazyVStack {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Equatable Conformance
|
||||
|
||||
```swift
|
||||
// Prevent unnecessary re-renders
|
||||
struct ItemRow: View, Equatable {
|
||||
let item: Item
|
||||
|
||||
static func == (lhs: ItemRow, rhs: ItemRow) -> Bool {
|
||||
lhs.item.id == rhs.item.id &&
|
||||
lhs.item.name == rhs.item.name
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(item.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Use in ForEach
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
.equatable()
|
||||
}
|
||||
```
|
||||
|
||||
### Task Modifier Optimization
|
||||
|
||||
```swift
|
||||
// Bad - recreates task on any state change
|
||||
struct ContentView: View {
|
||||
@State private var items: [Item] = []
|
||||
@State private var searchText = ""
|
||||
|
||||
var body: some View {
|
||||
List(filteredItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.task {
|
||||
items = await fetchItems() // Reruns when searchText changes!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Good - use task(id:)
|
||||
struct ContentView: View {
|
||||
@State private var items: [Item] = []
|
||||
@State private var searchText = ""
|
||||
@State private var needsLoad = true
|
||||
|
||||
var body: some View {
|
||||
List(filteredItems) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.task(id: needsLoad) {
|
||||
if needsLoad {
|
||||
items = await fetchItems()
|
||||
needsLoad = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Launch Time Optimization
|
||||
|
||||
### Measure Launch Time
|
||||
|
||||
```bash
|
||||
# Cold launch measurement
|
||||
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.os.signpost" && category == "PointsOfInterest"'
|
||||
```
|
||||
|
||||
In Instruments: App Launch template
|
||||
|
||||
### Defer Non-Critical Work
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
init() {
|
||||
// Critical only
|
||||
setupErrorReporting()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.task {
|
||||
// Defer non-critical
|
||||
await setupAnalytics()
|
||||
await preloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Synchronous Work
|
||||
|
||||
```swift
|
||||
// Bad - blocks launch
|
||||
@main
|
||||
struct MyApp: App {
|
||||
let database = Database.load() // Synchronous I/O
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Good - async initialization
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var database: Database?
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if let database {
|
||||
ContentView()
|
||||
.environment(database)
|
||||
} else {
|
||||
LaunchScreen()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
database = await Database.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reduce Dylib Loading
|
||||
|
||||
- Minimize third-party dependencies
|
||||
- Use static linking where possible
|
||||
- Merge frameworks
|
||||
|
||||
## Network Performance
|
||||
|
||||
### Request Batching
|
||||
|
||||
```swift
|
||||
// Bad - many small requests
|
||||
for id in itemIDs {
|
||||
let item = try await fetchItem(id)
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
// Good - batch request
|
||||
let items = try await fetchItems(ids: itemIDs)
|
||||
```
|
||||
|
||||
### Image Loading
|
||||
|
||||
```swift
|
||||
// Use AsyncImage with caching
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ProgressView()
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFit()
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// For better control, use custom caching
|
||||
actor ImageCache {
|
||||
private var cache: [URL: UIImage] = [:]
|
||||
|
||||
func image(for url: URL) async throws -> UIImage {
|
||||
if let cached = cache[url] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let image = UIImage(data: data)!
|
||||
cache[url] = image
|
||||
return image
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prefetching
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
let items: [Item]
|
||||
let prefetcher = ImagePrefetcher()
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
.onAppear {
|
||||
// Prefetch next items
|
||||
let index = items.firstIndex(of: item) ?? 0
|
||||
let nextItems = items.dropFirst(index + 1).prefix(5)
|
||||
prefetcher.prefetch(urls: nextItems.compactMap(\.imageURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Battery Optimization
|
||||
|
||||
### Location Updates
|
||||
|
||||
```swift
|
||||
import CoreLocation
|
||||
|
||||
class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
|
||||
func startUpdates() {
|
||||
// Use appropriate accuracy
|
||||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters // Not kCLLocationAccuracyBest
|
||||
|
||||
// Allow deferred updates
|
||||
manager.allowsBackgroundLocationUpdates = false
|
||||
manager.pausesLocationUpdatesAutomatically = true
|
||||
|
||||
// Use significant change for background
|
||||
manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
|
||||
```swift
|
||||
import BackgroundTasks
|
||||
|
||||
func scheduleAppRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("Could not schedule app refresh: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAppRefresh(task: BGAppRefreshTask) {
|
||||
// Schedule next refresh
|
||||
scheduleAppRefresh()
|
||||
|
||||
let refreshTask = Task {
|
||||
do {
|
||||
try await syncData()
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
task.expirationHandler = {
|
||||
refreshTask.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Network Efficiency
|
||||
|
||||
```swift
|
||||
// Use background URL session for large transfers
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "com.app.background")
|
||||
config.isDiscretionary = true // System chooses optimal time
|
||||
config.allowsCellularAccess = false // WiFi only for large downloads
|
||||
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
```
|
||||
|
||||
## Debugging Performance
|
||||
|
||||
### Signposts
|
||||
|
||||
```swift
|
||||
import os
|
||||
|
||||
let signposter = OSSignposter()
|
||||
|
||||
func processItems() async {
|
||||
let signpostID = signposter.makeSignpostID()
|
||||
let state = signposter.beginInterval("Process Items", id: signpostID)
|
||||
|
||||
for item in items {
|
||||
signposter.emitEvent("Processing", id: signpostID, "\(item.name)")
|
||||
await process(item)
|
||||
}
|
||||
|
||||
signposter.endInterval("Process Items", state)
|
||||
}
|
||||
```
|
||||
|
||||
### MetricKit
|
||||
|
||||
```swift
|
||||
import MetricKit
|
||||
|
||||
class MetricsManager: NSObject, MXMetricManagerSubscriber {
|
||||
override init() {
|
||||
super.init()
|
||||
MXMetricManager.shared.add(self)
|
||||
}
|
||||
|
||||
func didReceive(_ payloads: [MXMetricPayload]) {
|
||||
for payload in payloads {
|
||||
// Process CPU, memory, launch time metrics
|
||||
if let cpuMetrics = payload.cpuMetrics {
|
||||
print("CPU time: \(cpuMetrics.cumulativeCPUTime)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didReceive(_ payloads: [MXDiagnosticPayload]) {
|
||||
for payload in payloads {
|
||||
// Process crash and hang diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
### Launch
|
||||
- [ ] < 400ms to first frame
|
||||
- [ ] No synchronous I/O in init
|
||||
- [ ] Deferred non-critical setup
|
||||
|
||||
### Memory
|
||||
- [ ] No leaks
|
||||
- [ ] Stable memory usage
|
||||
- [ ] No abandoned memory
|
||||
|
||||
### UI
|
||||
- [ ] 60 fps scrolling
|
||||
- [ ] No main thread blocking
|
||||
- [ ] Efficient list rendering
|
||||
|
||||
### Network
|
||||
- [ ] Request batching
|
||||
- [ ] Image caching
|
||||
- [ ] Proper timeout handling
|
||||
|
||||
### Battery
|
||||
- [ ] Minimal background activity
|
||||
- [ ] Efficient location usage
|
||||
- [ ] Discretionary transfers
|
||||
594
skills/expertise/iphone-apps/references/polish-and-ux.md
Normal file
594
skills/expertise/iphone-apps/references/polish-and-ux.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# Polish and UX
|
||||
|
||||
Haptics, animations, gestures, and micro-interactions for premium iOS apps.
|
||||
|
||||
## Haptics
|
||||
|
||||
### Impact Feedback
|
||||
|
||||
```swift
|
||||
import UIKit
|
||||
|
||||
struct HapticEngine {
|
||||
// Impact - use for UI element hits
|
||||
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
// Notification - use for outcomes
|
||||
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(type)
|
||||
}
|
||||
|
||||
// Selection - use for picker/selection changes
|
||||
static func selection() {
|
||||
let generator = UISelectionFeedbackGenerator()
|
||||
generator.selectionChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
extension HapticEngine {
|
||||
static func light() { impact(.light) }
|
||||
static func medium() { impact(.medium) }
|
||||
static func heavy() { impact(.heavy) }
|
||||
static func rigid() { impact(.rigid) }
|
||||
static func soft() { impact(.soft) }
|
||||
|
||||
static func success() { notification(.success) }
|
||||
static func warning() { notification(.warning) }
|
||||
static func error() { notification(.error) }
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Guidelines
|
||||
|
||||
```swift
|
||||
// Button tap
|
||||
Button("Add Item") {
|
||||
HapticEngine.light()
|
||||
addItem()
|
||||
}
|
||||
|
||||
// Successful action
|
||||
func save() async {
|
||||
do {
|
||||
try await saveToDisk()
|
||||
HapticEngine.success()
|
||||
} catch {
|
||||
HapticEngine.error()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle
|
||||
Toggle("Enable", isOn: $isEnabled)
|
||||
.onChange(of: isEnabled) { _, _ in
|
||||
HapticEngine.selection()
|
||||
}
|
||||
|
||||
// Destructive action
|
||||
Button("Delete", role: .destructive) {
|
||||
HapticEngine.warning()
|
||||
delete()
|
||||
}
|
||||
|
||||
// Picker change
|
||||
Picker("Size", selection: $size) {
|
||||
ForEach(sizes, id: \.self) { size in
|
||||
Text(size).tag(size)
|
||||
}
|
||||
}
|
||||
.onChange(of: size) { _, _ in
|
||||
HapticEngine.selection()
|
||||
}
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
### Spring Animations
|
||||
|
||||
```swift
|
||||
// Standard spring (most natural)
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
|
||||
// Bouncy spring
|
||||
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
|
||||
showCard = true
|
||||
}
|
||||
|
||||
// Snappy spring
|
||||
withAnimation(.spring(duration: 0.2, bounce: 0.0)) {
|
||||
offset = .zero
|
||||
}
|
||||
|
||||
// Custom response and damping
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
||||
scale = 1.0
|
||||
}
|
||||
```
|
||||
|
||||
### Transitions
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var showDetail = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if showDetail {
|
||||
DetailView()
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.3), value: showDetail)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom transition
|
||||
extension AnyTransition {
|
||||
static var slideAndFade: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase Animations
|
||||
|
||||
```swift
|
||||
struct PulsingView: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.opacity(isAnimating ? 0.8 : 1.0)
|
||||
.animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: isAnimating)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keyframe Animations
|
||||
|
||||
```swift
|
||||
struct ShakeView: View {
|
||||
@State private var trigger = false
|
||||
|
||||
var body: some View {
|
||||
Text("Shake me")
|
||||
.keyframeAnimator(initialValue: 0.0, trigger: trigger) { content, value in
|
||||
content.offset(x: value)
|
||||
} keyframes: { _ in
|
||||
KeyframeTrack {
|
||||
SpringKeyframe(15, duration: 0.1)
|
||||
SpringKeyframe(-15, duration: 0.1)
|
||||
SpringKeyframe(10, duration: 0.1)
|
||||
SpringKeyframe(-10, duration: 0.1)
|
||||
SpringKeyframe(0, duration: 0.1)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
trigger.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Gestures
|
||||
|
||||
### Drag Gesture
|
||||
|
||||
```swift
|
||||
struct DraggableCard: View {
|
||||
@State private var offset = CGSize.zero
|
||||
@State private var isDragging = false
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.blue)
|
||||
.frame(width: 200, height: 300)
|
||||
.offset(offset)
|
||||
.scaleEffect(isDragging ? 1.05 : 1.0)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
withAnimation(.interactiveSpring()) {
|
||||
offset = value.translation
|
||||
isDragging = true
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
withAnimation(.spring(duration: 0.3)) {
|
||||
// Snap back or dismiss based on threshold
|
||||
if abs(value.translation.width) > 150 {
|
||||
// Dismiss
|
||||
offset = CGSize(width: value.translation.width > 0 ? 500 : -500, height: 0)
|
||||
} else {
|
||||
offset = .zero
|
||||
}
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Long Press with Preview
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Text(item.name)
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
.gesture(
|
||||
LongPressGesture(minimumDuration: 0.5)
|
||||
.onChanged { _ in
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
isPressed = true
|
||||
}
|
||||
HapticEngine.medium()
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(duration: 0.2)) {
|
||||
isPressed = false
|
||||
}
|
||||
showContextMenu()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gesture Priority
|
||||
|
||||
```swift
|
||||
struct ZoomableImage: View {
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offset = CGSize.zero
|
||||
|
||||
var body: some View {
|
||||
Image("photo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
// Magnification takes priority
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
scale = value
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation {
|
||||
scale = max(1, scale)
|
||||
}
|
||||
}
|
||||
.simultaneously(with:
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
offset = value.translation
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation {
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
### Skeleton Loading
|
||||
|
||||
```swift
|
||||
struct SkeletonView: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.gray.opacity(0.3), .gray.opacity(0.1), .gray.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(height: 20)
|
||||
.mask(
|
||||
Rectangle()
|
||||
.offset(x: isAnimating ? 300 : -300)
|
||||
)
|
||||
.animation(.linear(duration: 1.5).repeatForever(autoreverses: false), value: isAnimating)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadingListView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(0..<5) { _ in
|
||||
HStack {
|
||||
SkeletonView()
|
||||
.frame(width: 50, height: 50)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SkeletonView()
|
||||
.frame(width: 150)
|
||||
SkeletonView()
|
||||
.frame(width: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Indicators
|
||||
|
||||
```swift
|
||||
struct ContentLoadingView: View {
|
||||
let progress: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Circular progress
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.circular)
|
||||
|
||||
// Linear progress with percentage
|
||||
VStack {
|
||||
ProgressView(value: progress)
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Custom circular
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.gray.opacity(0.2), lineWidth: 8)
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(.blue, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut, value: progress)
|
||||
}
|
||||
.frame(width: 60, height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Micro-interactions
|
||||
|
||||
### Button Press Effect
|
||||
|
||||
```swift
|
||||
struct PressableButton: View {
|
||||
let title: String
|
||||
let action: () -> Void
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.padding()
|
||||
.background(.blue)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
.brightness(isPressed ? -0.1 : 0)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.easeInOut(duration: 0.1)) {
|
||||
isPressed = true
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(duration: 0.2)) {
|
||||
isPressed = false
|
||||
}
|
||||
action()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Success Checkmark
|
||||
|
||||
```swift
|
||||
struct SuccessCheckmark: View {
|
||||
@State private var isComplete = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 80, height: 80)
|
||||
.scaleEffect(isComplete ? 1 : 0)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 40, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.scaleEffect(isComplete ? 1 : 0)
|
||||
.rotationEffect(.degrees(isComplete ? 0 : -90))
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(duration: 0.5, bounce: 0.4).delay(0.1)) {
|
||||
isComplete = true
|
||||
}
|
||||
HapticEngine.success()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pull to Refresh Indicator
|
||||
|
||||
```swift
|
||||
struct CustomRefreshView: View {
|
||||
@Binding var isRefreshing: Bool
|
||||
|
||||
var body: some View {
|
||||
if isRefreshing {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Updating...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scroll Effects
|
||||
|
||||
### Parallax Header
|
||||
|
||||
```swift
|
||||
struct ParallaxHeader: View {
|
||||
let minHeight: CGFloat = 200
|
||||
let maxHeight: CGFloat = 350
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let offset = geometry.frame(in: .global).minY
|
||||
let height = max(minHeight, maxHeight + offset)
|
||||
|
||||
Image("header")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geometry.size.width, height: height)
|
||||
.clipped()
|
||||
.offset(y: offset > 0 ? -offset : 0)
|
||||
}
|
||||
.frame(height: maxHeight)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll Position Effects
|
||||
|
||||
```swift
|
||||
struct FadeOnScrollView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(0..<50) { index in
|
||||
Text("Item \(index)")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.background.secondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.scrollTransition { content, phase in
|
||||
content
|
||||
.opacity(phase.isIdentity ? 1 : 0.3)
|
||||
.scaleEffect(phase.isIdentity ? 1 : 0.9)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Empty States
|
||||
|
||||
```swift
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
let actionTitle: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.title2.bold())
|
||||
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
if items.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "tray",
|
||||
title: "No Items",
|
||||
message: "Add your first item to get started",
|
||||
actionTitle: "Add Item",
|
||||
action: { showNewItem = true }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Respect Reduce Motion
|
||||
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
Button("Action") { }
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
.animation(reduceMotion ? .none : .spring(), value: isPressed)
|
||||
}
|
||||
```
|
||||
|
||||
### Consistent Timing
|
||||
|
||||
Use consistent animation durations:
|
||||
- Quick feedback: 0.1-0.2s
|
||||
- Standard transitions: 0.3s
|
||||
- Prominent animations: 0.5s
|
||||
|
||||
### Haptic Pairing
|
||||
|
||||
Always pair animations with appropriate haptics:
|
||||
- Success animation → success haptic
|
||||
- Error shake → error haptic
|
||||
- Selection change → selection haptic
|
||||
468
skills/expertise/iphone-apps/references/project-scaffolding.md
Normal file
468
skills/expertise/iphone-apps/references/project-scaffolding.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# Project Scaffolding
|
||||
|
||||
Complete setup guide for new iOS projects with CLI-only development workflow.
|
||||
|
||||
## XcodeGen Setup (Recommended)
|
||||
|
||||
**Install XcodeGen** (one-time):
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
**Create a new iOS app**:
|
||||
```bash
|
||||
mkdir MyApp && cd MyApp
|
||||
mkdir -p MyApp/{App,Models,Views,Services,Resources} MyAppTests MyAppUITests
|
||||
# Create project.yml (see template below)
|
||||
# Create Swift files
|
||||
xcodegen generate
|
||||
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' build
|
||||
```
|
||||
|
||||
## project.yml Template
|
||||
|
||||
Complete iOS SwiftUI app with tests:
|
||||
|
||||
```yaml
|
||||
name: MyApp
|
||||
options:
|
||||
bundleIdPrefix: com.yourcompany
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
|
||||
targets:
|
||||
MyApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- MyApp
|
||||
resources:
|
||||
- path: MyApp/Resources
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
info:
|
||||
path: MyApp/Info.plist
|
||||
properties:
|
||||
UILaunchScreen: {}
|
||||
CFBundleName: $(PRODUCT_NAME)
|
||||
CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
UIRequiredDeviceCapabilities:
|
||||
- armv7
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
UISupportedInterfaceOrientations~ipad:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
entitlements:
|
||||
path: MyApp/MyApp.entitlements
|
||||
properties:
|
||||
aps-environment: development
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
|
||||
PRODUCT_NAME: MyApp
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: YOURTEAMID
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
configs:
|
||||
Debug:
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
SWIFT_OPTIMIZATION_LEVEL: -Onone
|
||||
Release:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -Osize
|
||||
|
||||
MyAppTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- MyAppTests
|
||||
dependencies:
|
||||
- target: MyApp
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.tests
|
||||
|
||||
MyAppUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
sources:
|
||||
- MyAppUITests
|
||||
dependencies:
|
||||
- target: MyApp
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp.uitests
|
||||
TEST_TARGET_NAME: MyApp
|
||||
|
||||
schemes:
|
||||
MyApp:
|
||||
build:
|
||||
targets:
|
||||
MyApp: all
|
||||
MyAppTests: [test]
|
||||
MyAppUITests: [test]
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
gatherCoverageData: true
|
||||
targets:
|
||||
- MyAppTests
|
||||
- MyAppUITests
|
||||
profile:
|
||||
config: Release
|
||||
archive:
|
||||
config: Release
|
||||
```
|
||||
|
||||
## project.yml with SwiftData
|
||||
|
||||
Add SwiftData support:
|
||||
|
||||
```yaml
|
||||
targets:
|
||||
MyApp:
|
||||
# ... existing config ...
|
||||
settings:
|
||||
base:
|
||||
# ... existing settings ...
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) SWIFT_DATA"
|
||||
dependencies:
|
||||
- sdk: SwiftData.framework
|
||||
```
|
||||
|
||||
## project.yml with Swift Packages
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
Alamofire:
|
||||
url: https://github.com/Alamofire/Alamofire
|
||||
from: 5.8.0
|
||||
KeychainAccess:
|
||||
url: https://github.com/kishikawakatsumi/KeychainAccess
|
||||
from: 4.2.0
|
||||
|
||||
targets:
|
||||
MyApp:
|
||||
# ... other config ...
|
||||
dependencies:
|
||||
- package: Alamofire
|
||||
- package: KeychainAccess
|
||||
```
|
||||
|
||||
## Alternative: Xcode GUI
|
||||
|
||||
For users who prefer Xcode:
|
||||
1. File > New > Project > iOS > App
|
||||
2. Settings: SwiftUI, Swift, SwiftData (optional)
|
||||
3. Save and close Xcode
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
MyApp/
|
||||
├── MyApp.xcodeproj/
|
||||
├── MyApp/
|
||||
│ ├── App/
|
||||
│ │ ├── MyApp.swift
|
||||
│ │ ├── AppState.swift
|
||||
│ │ └── AppDependencies.swift
|
||||
│ ├── Models/
|
||||
│ ├── Views/
|
||||
│ │ ├── ContentView.swift
|
||||
│ │ ├── Screens/
|
||||
│ │ └── Components/
|
||||
│ ├── Services/
|
||||
│ ├── Utilities/
|
||||
│ ├── Resources/
|
||||
│ │ ├── Assets.xcassets/
|
||||
│ │ ├── Localizable.xcstrings
|
||||
│ │ └── PrivacyInfo.xcprivacy
|
||||
│ ├── Info.plist
|
||||
│ └── MyApp.entitlements
|
||||
├── MyAppTests/
|
||||
└── MyAppUITests/
|
||||
```
|
||||
|
||||
## Starter Code
|
||||
|
||||
### MyApp.swift
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
init() {
|
||||
configureAppearance()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
.task {
|
||||
await appState.initialize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAppearance() {
|
||||
// Global appearance customization
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AppState.swift
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class AppState {
|
||||
// Navigation
|
||||
var navigationPath = NavigationPath()
|
||||
var selectedTab: Tab = .home
|
||||
|
||||
// App state
|
||||
var isLoading = false
|
||||
var error: AppError?
|
||||
var user: User?
|
||||
|
||||
// Feature flags
|
||||
var isPremium = false
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, search, profile
|
||||
}
|
||||
|
||||
func initialize() async {
|
||||
// Load initial data
|
||||
// Check purchase status
|
||||
// Request permissions if needed
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL) {
|
||||
// Parse URL and update navigation
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case networkError(Error)
|
||||
case dataError(String)
|
||||
case unauthorized
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return error.localizedDescription
|
||||
case .dataError(let message):
|
||||
return message
|
||||
case .unauthorized:
|
||||
return "Please sign in to continue"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ContentView.swift
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appState = appState
|
||||
|
||||
TabView(selection: $appState.selectedTab) {
|
||||
HomeScreen()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
.tag(AppState.Tab.home)
|
||||
|
||||
SearchScreen()
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.tag(AppState.Tab.search)
|
||||
|
||||
ProfileScreen()
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person")
|
||||
}
|
||||
.tag(AppState.Tab.profile)
|
||||
}
|
||||
.overlay {
|
||||
if appState.isLoading {
|
||||
LoadingOverlay()
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .constant(appState.error != nil)) {
|
||||
Button("OK") { appState.error = nil }
|
||||
} message: {
|
||||
if let error = appState.error {
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy Manifest
|
||||
|
||||
Required for App Store submission. Create `PrivacyInfo.xcprivacy`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<!-- Add collected data types here -->
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
## Entitlements Template
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Push Notifications -->
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
|
||||
<!-- App Groups (for shared data) -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.yourcompany.myapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
## Xcode Project Creation
|
||||
|
||||
Create via command line using `xcodegen` or `tuist`, or create in Xcode and immediately close:
|
||||
|
||||
```bash
|
||||
# Option 1: Using xcodegen
|
||||
brew install xcodegen
|
||||
# Create project.yml, then:
|
||||
xcodegen generate
|
||||
|
||||
# Option 2: Create in Xcode, configure, close
|
||||
# File > New > Project > iOS > App
|
||||
# Configure settings, then close Xcode
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
### Development vs Release
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-configuration Debug \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
build
|
||||
|
||||
# Release build
|
||||
xcodebuild -project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
build
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Use xcconfig files for different environments:
|
||||
|
||||
```
|
||||
// Debug.xcconfig
|
||||
API_BASE_URL = https://dev-api.example.com
|
||||
ENABLE_LOGGING = YES
|
||||
|
||||
// Release.xcconfig
|
||||
API_BASE_URL = https://api.example.com
|
||||
ENABLE_LOGGING = NO
|
||||
```
|
||||
|
||||
Access in code:
|
||||
```swift
|
||||
let apiURL = Bundle.main.infoDictionary?["API_BASE_URL"] as? String
|
||||
```
|
||||
|
||||
## Asset Catalog Setup
|
||||
|
||||
### App Icon
|
||||
- Provide 1024x1024 PNG
|
||||
- Xcode generates all sizes automatically
|
||||
|
||||
### Colors
|
||||
Define semantic colors in Assets.xcassets:
|
||||
- `AccentColor` - App tint color
|
||||
- `BackgroundPrimary` - Main background
|
||||
- `TextPrimary` - Primary text
|
||||
|
||||
### SF Symbols
|
||||
Prefer SF Symbols for icons. Use custom symbols only when necessary.
|
||||
|
||||
## Localization Setup
|
||||
|
||||
1. Enable localization in project settings
|
||||
2. Create `Localizable.xcstrings` (Xcode 15+)
|
||||
3. Use String Catalogs for automatic extraction
|
||||
|
||||
```swift
|
||||
// Strings are automatically extracted
|
||||
Text("Welcome")
|
||||
Text("Items: \(count)")
|
||||
```
|
||||
506
skills/expertise/iphone-apps/references/push-notifications.md
Normal file
506
skills/expertise/iphone-apps/references/push-notifications.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# Push Notifications
|
||||
|
||||
APNs setup, registration, rich notifications, and silent push.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
### Request Permission
|
||||
|
||||
```swift
|
||||
import UserNotifications
|
||||
|
||||
class PushService: NSObject {
|
||||
static let shared = PushService()
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.delegate = self
|
||||
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
if granted {
|
||||
await registerForRemoteNotifications()
|
||||
}
|
||||
return granted
|
||||
} catch {
|
||||
print("Permission request failed: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func registerForRemoteNotifications() {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
func checkPermissionStatus() async -> UNAuthorizationStatus {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
return settings.authorizationStatus
|
||||
}
|
||||
}
|
||||
|
||||
extension PushService: UNUserNotificationCenterDelegate {
|
||||
// Handle notification when app is in foreground
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
return [.banner, .sound, .badge]
|
||||
}
|
||||
|
||||
// Handle notification tap
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Handle action
|
||||
switch response.actionIdentifier {
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
// User tapped notification
|
||||
handleNotificationTap(userInfo)
|
||||
case "REPLY_ACTION":
|
||||
if let textResponse = response as? UNTextInputNotificationResponse {
|
||||
handleReply(textResponse.userText, userInfo: userInfo)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) {
|
||||
// Navigate to relevant screen
|
||||
if let itemID = userInfo["item_id"] as? String {
|
||||
// appState.navigateToItem(id: itemID)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReply(_ text: String, userInfo: [AnyHashable: Any]) {
|
||||
// Send reply
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Device Token
|
||||
|
||||
In your App or AppDelegate:
|
||||
|
||||
```swift
|
||||
// Using UIApplicationDelegateAdaptor
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
print("Device Token: \(token)")
|
||||
|
||||
// Send to your server
|
||||
Task {
|
||||
try? await sendTokenToServer(token)
|
||||
}
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
print("Failed to register: \(error)")
|
||||
}
|
||||
|
||||
private func sendTokenToServer(_ token: String) async throws {
|
||||
// POST to your server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rich Notifications
|
||||
|
||||
### Notification Content Extension
|
||||
|
||||
1. File > New > Target > Notification Content Extension
|
||||
2. Configure in `Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>UNNotificationExtensionCategory</key>
|
||||
<string>MEDIA_CATEGORY</string>
|
||||
<key>UNNotificationExtensionInitialContentSizeRatio</key>
|
||||
<real>0.5</real>
|
||||
</dict>
|
||||
<key>NSExtensionMainStoryboard</key>
|
||||
<string>MainInterface</string>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.content-extension</string>
|
||||
</dict>
|
||||
```
|
||||
|
||||
3. Implement `NotificationViewController`:
|
||||
|
||||
```swift
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import UserNotificationsUI
|
||||
|
||||
class NotificationViewController: UIViewController, UNNotificationContentExtension {
|
||||
@IBOutlet weak var imageView: UIImageView!
|
||||
@IBOutlet weak var titleLabel: UILabel!
|
||||
|
||||
func didReceive(_ notification: UNNotification) {
|
||||
let content = notification.request.content
|
||||
|
||||
titleLabel.text = content.title
|
||||
|
||||
// Load attachment
|
||||
if let attachment = content.attachments.first,
|
||||
attachment.url.startAccessingSecurityScopedResource() {
|
||||
defer { attachment.url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
if let data = try? Data(contentsOf: attachment.url),
|
||||
let image = UIImage(data: data) {
|
||||
imageView.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Notification Service Extension
|
||||
|
||||
Modify notification content before display:
|
||||
|
||||
1. File > New > Target > Notification Service Extension
|
||||
2. Implement:
|
||||
|
||||
```swift
|
||||
import UserNotifications
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
var bestAttemptContent: UNMutableNotificationContent?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
|
||||
|
||||
guard let bestAttemptContent = bestAttemptContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
// Download and attach media
|
||||
if let imageURLString = bestAttemptContent.userInfo["image_url"] as? String,
|
||||
let imageURL = URL(string: imageURLString) {
|
||||
downloadImage(from: imageURL) { attachment in
|
||||
if let attachment = attachment {
|
||||
bestAttemptContent.attachments = [attachment]
|
||||
}
|
||||
contentHandler(bestAttemptContent)
|
||||
}
|
||||
} else {
|
||||
contentHandler(bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
// Called just before extension is terminated
|
||||
if let contentHandler = contentHandler,
|
||||
let bestAttemptContent = bestAttemptContent {
|
||||
contentHandler(bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadImage(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
|
||||
let task = URLSession.shared.downloadTask(with: url) { location, _, error in
|
||||
guard let location = location, error == nil else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
let tempFile = tempDirectory.appendingPathComponent(UUID().uuidString + ".jpg")
|
||||
|
||||
do {
|
||||
try FileManager.default.moveItem(at: location, to: tempFile)
|
||||
let attachment = try UNNotificationAttachment(identifier: "image", url: tempFile)
|
||||
completion(attachment)
|
||||
} catch {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Actions and Categories
|
||||
|
||||
### Define Actions
|
||||
|
||||
```swift
|
||||
func registerNotificationCategories() {
|
||||
// Actions
|
||||
let replyAction = UNTextInputNotificationAction(
|
||||
identifier: "REPLY_ACTION",
|
||||
title: "Reply",
|
||||
options: [],
|
||||
textInputButtonTitle: "Send",
|
||||
textInputPlaceholder: "Type your reply..."
|
||||
)
|
||||
|
||||
let markReadAction = UNNotificationAction(
|
||||
identifier: "MARK_READ_ACTION",
|
||||
title: "Mark as Read",
|
||||
options: []
|
||||
)
|
||||
|
||||
let deleteAction = UNNotificationAction(
|
||||
identifier: "DELETE_ACTION",
|
||||
title: "Delete",
|
||||
options: [.destructive]
|
||||
)
|
||||
|
||||
// Category
|
||||
let messageCategory = UNNotificationCategory(
|
||||
identifier: "MESSAGE_CATEGORY",
|
||||
actions: [replyAction, markReadAction, deleteAction],
|
||||
intentIdentifiers: [],
|
||||
options: []
|
||||
)
|
||||
|
||||
// Register
|
||||
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
|
||||
}
|
||||
```
|
||||
|
||||
### Send with Category
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title": "New Message",
|
||||
"body": "You have a new message from John"
|
||||
},
|
||||
"category": "MESSAGE_CATEGORY",
|
||||
"mutable-content": 1
|
||||
},
|
||||
"image_url": "https://example.com/image.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
## Silent Push
|
||||
|
||||
For background data updates:
|
||||
|
||||
### Configuration
|
||||
|
||||
Add to entitlements:
|
||||
```xml
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Handle Silent Push
|
||||
|
||||
```swift
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any]
|
||||
) async -> UIBackgroundFetchResult {
|
||||
// Process in background
|
||||
do {
|
||||
try await syncData()
|
||||
return .newData
|
||||
} catch {
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
private func syncData() async throws {
|
||||
// Fetch new data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Send Silent Push
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"content-available": 1
|
||||
},
|
||||
"data": {
|
||||
"type": "sync",
|
||||
"timestamp": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Local Notifications
|
||||
|
||||
Schedule notifications without server:
|
||||
|
||||
```swift
|
||||
class LocalNotificationService {
|
||||
func scheduleReminder(title: String, body: String, at date: Date, id: String) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
|
||||
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func scheduleRepeating(title: String, body: String, hour: Int, minute: Int, id: String) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
var components = DateComponents()
|
||||
components.hour = hour
|
||||
components.minute = minute
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||
|
||||
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
|
||||
|
||||
try await UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func cancel(_ id: String) {
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id])
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Badge Management
|
||||
|
||||
```swift
|
||||
extension PushService {
|
||||
func updateBadge(count: Int) async {
|
||||
do {
|
||||
try await UNUserNotificationCenter.current().setBadgeCount(count)
|
||||
} catch {
|
||||
print("Failed to set badge: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func clearBadge() async {
|
||||
await updateBadge(count: 0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## APNs Server Setup
|
||||
|
||||
### Payload Format
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"body": "Body text"
|
||||
},
|
||||
"badge": 1,
|
||||
"sound": "default",
|
||||
"thread-id": "group-id",
|
||||
"category": "CATEGORY_ID"
|
||||
},
|
||||
"custom_key": "custom_value"
|
||||
}
|
||||
```
|
||||
|
||||
### Sending with JWT
|
||||
|
||||
```bash
|
||||
curl -v \
|
||||
--header "authorization: bearer $JWT" \
|
||||
--header "apns-topic: com.yourcompany.app" \
|
||||
--header "apns-push-type: alert" \
|
||||
--http2 \
|
||||
--data '{"aps":{"alert":"Hello"}}' \
|
||||
https://api.push.apple.com/3/device/$DEVICE_TOKEN
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Request Permission at Right Time
|
||||
|
||||
```swift
|
||||
// Don't request on launch
|
||||
// Instead, request after value is demonstrated
|
||||
func onFirstMessageReceived() {
|
||||
Task {
|
||||
let granted = await PushService.shared.requestPermission()
|
||||
if !granted {
|
||||
showPermissionBenefitsSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Permission Denied
|
||||
|
||||
```swift
|
||||
func showNotificationSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Group Notifications
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": "New message",
|
||||
"thread-id": "conversation-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Time Sensitive (iOS 15+)
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": "Your order arrived",
|
||||
"interruption-level": "time-sensitive"
|
||||
}
|
||||
}
|
||||
```
|
||||
532
skills/expertise/iphone-apps/references/security.md
Normal file
532
skills/expertise/iphone-apps/references/security.md
Normal file
@@ -0,0 +1,532 @@
|
||||
# Security
|
||||
|
||||
Keychain, secure storage, biometrics, and secure coding practices.
|
||||
|
||||
## Keychain
|
||||
|
||||
### KeychainService
|
||||
|
||||
```swift
|
||||
import Security
|
||||
|
||||
class KeychainService {
|
||||
enum KeychainError: Error {
|
||||
case saveFailed(OSStatus)
|
||||
case loadFailed(OSStatus)
|
||||
case deleteFailed(OSStatus)
|
||||
case dataConversionError
|
||||
case itemNotFound
|
||||
}
|
||||
|
||||
private let service: String
|
||||
|
||||
init(service: String = Bundle.main.bundleIdentifier ?? "app") {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
func save(_ data: Data, for key: String, accessibility: CFString = kSecAttrAccessibleWhenUnlocked) throws {
|
||||
// Delete existing
|
||||
try? delete(key)
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: accessibility
|
||||
]
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
func load(_ key: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status != errSecItemNotFound else {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.loadFailed(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else {
|
||||
throw KeychainError.dataConversionError
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func delete(_ key: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.deleteFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
func saveString(_ value: String, for key: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw KeychainError.dataConversionError
|
||||
}
|
||||
try save(data, for: key)
|
||||
}
|
||||
|
||||
func loadString(_ key: String) throws -> String {
|
||||
let data = try load(key)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw KeychainError.dataConversionError
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
func saveCodable<T: Codable>(_ value: T, for key: String) throws {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
try save(data, for: key)
|
||||
}
|
||||
|
||||
func loadCodable<T: Codable>(_ type: T.Type, for key: String) throws -> T {
|
||||
let data = try load(key)
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility Options
|
||||
|
||||
```swift
|
||||
// Available when unlocked
|
||||
kSecAttrAccessibleWhenUnlocked
|
||||
|
||||
// Available when unlocked, not backed up
|
||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
|
||||
// Available after first unlock (background access)
|
||||
kSecAttrAccessibleAfterFirstUnlock
|
||||
|
||||
// Always available (not recommended)
|
||||
kSecAttrAccessibleAlways
|
||||
```
|
||||
|
||||
## Biometric Authentication
|
||||
|
||||
### Local Authentication
|
||||
|
||||
```swift
|
||||
import LocalAuthentication
|
||||
|
||||
class BiometricService {
|
||||
enum BiometricType {
|
||||
case none, touchID, faceID
|
||||
}
|
||||
|
||||
var biometricType: BiometricType {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
switch context.biometryType {
|
||||
case .touchID:
|
||||
return .touchID
|
||||
case .faceID:
|
||||
return .faceID
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(reason: String) async -> Bool {
|
||||
let context = LAContext()
|
||||
context.localizedCancelTitle = "Cancel"
|
||||
|
||||
var error: NSError?
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
return try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: reason
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func authenticateWithFallback(reason: String) async -> Bool {
|
||||
let context = LAContext()
|
||||
|
||||
do {
|
||||
// Try biometrics first, fall back to passcode
|
||||
return try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthentication, // Includes passcode fallback
|
||||
localizedReason: reason
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Biometric-Protected Keychain
|
||||
|
||||
```swift
|
||||
extension KeychainService {
|
||||
func saveBiometricProtected(_ data: Data, for key: String) throws {
|
||||
try? delete(key)
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let access = SecAccessControlCreateWithFlags(
|
||||
nil,
|
||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
.biometryCurrentSet, // Invalidate if biometrics change
|
||||
&error
|
||||
) else {
|
||||
throw error!.takeRetainedValue()
|
||||
}
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessControl as String: access
|
||||
]
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
func loadBiometricProtected(_ key: String, prompt: String) throws -> Data {
|
||||
let context = LAContext()
|
||||
context.localizedReason = prompt
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecUseAuthenticationContext as String: context
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw KeychainError.loadFailed(status)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Secure Network Communication
|
||||
|
||||
### Certificate Pinning
|
||||
|
||||
```swift
|
||||
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
|
||||
private let pinnedCertificates: [SecCertificate]
|
||||
|
||||
init(certificates: [SecCertificate]) {
|
||||
self.pinnedCertificates = certificates
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge
|
||||
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let serverTrust = challenge.protectionSpace.serverTrust else {
|
||||
return (.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
|
||||
// Get server certificate
|
||||
guard let serverCertificate = SecTrustCopyCertificateChain(serverTrust)?
|
||||
.first else {
|
||||
return (.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
|
||||
// Compare with pinned certificates
|
||||
let serverCertData = SecCertificateCopyData(serverCertificate) as Data
|
||||
|
||||
for pinnedCert in pinnedCertificates {
|
||||
let pinnedCertData = SecCertificateCopyData(pinnedCert) as Data
|
||||
if serverCertData == pinnedCertData {
|
||||
let credential = URLCredential(trust: serverTrust)
|
||||
return (.useCredential, credential)
|
||||
}
|
||||
}
|
||||
|
||||
return (.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### App Transport Security
|
||||
|
||||
In Info.plist (avoid if possible):
|
||||
```xml
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>legacy-api.example.com</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSExceptionMinimumTLSVersion</key>
|
||||
<string>TLSv1.2</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
```
|
||||
|
||||
## Data Protection
|
||||
|
||||
### File Protection
|
||||
|
||||
```swift
|
||||
// Protect files on disk
|
||||
let fileURL = documentsDirectory.appendingPathComponent("sensitive.dat")
|
||||
try data.write(to: fileURL, options: .completeFileProtection)
|
||||
|
||||
// Check protection class
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
|
||||
let protection = attributes[.protectionKey] as? FileProtectionType
|
||||
```
|
||||
|
||||
### In-Memory Sensitive Data
|
||||
|
||||
```swift
|
||||
// Clear sensitive data when done
|
||||
var password = "secret"
|
||||
defer {
|
||||
password.removeAll() // Clear from memory
|
||||
}
|
||||
|
||||
// For arrays
|
||||
var sensitiveBytes = [UInt8](repeating: 0, count: 32)
|
||||
defer {
|
||||
sensitiveBytes.withUnsafeMutableBytes { ptr in
|
||||
memset_s(ptr.baseAddress, ptr.count, 0, ptr.count)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Secure Coding Practices
|
||||
|
||||
### Input Validation
|
||||
|
||||
```swift
|
||||
func processInput(_ input: String) throws -> String {
|
||||
// Validate length
|
||||
guard input.count <= 1000 else {
|
||||
throw ValidationError.tooLong
|
||||
}
|
||||
|
||||
// Sanitize HTML
|
||||
let sanitized = input
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
|
||||
// Validate format if needed
|
||||
guard isValidFormat(sanitized) else {
|
||||
throw ValidationError.invalidFormat
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
With SwiftData/Core Data, use predicates:
|
||||
```swift
|
||||
// Safe - parameterized
|
||||
let predicate = #Predicate<Item> { $0.name == searchTerm }
|
||||
|
||||
// Never do this
|
||||
// let sql = "SELECT * FROM items WHERE name = '\(searchTerm)'"
|
||||
```
|
||||
|
||||
### Avoid Logging Sensitive Data
|
||||
|
||||
```swift
|
||||
func authenticate(username: String, password: String) async throws {
|
||||
// Bad
|
||||
// print("Authenticating \(username) with password \(password)")
|
||||
|
||||
// Good
|
||||
print("Authenticating user: \(username)")
|
||||
|
||||
// Use OSLog with privacy
|
||||
import os
|
||||
let logger = Logger(subsystem: "com.app", category: "auth")
|
||||
logger.info("Authenticating user: \(username, privacy: .public)")
|
||||
logger.debug("Password length: \(password.count)") // Length only, never value
|
||||
}
|
||||
```
|
||||
|
||||
## Jailbreak Detection
|
||||
|
||||
```swift
|
||||
class SecurityChecker {
|
||||
func isDeviceCompromised() -> Bool {
|
||||
// Check for common jailbreak files
|
||||
let suspiciousPaths = [
|
||||
"/Applications/Cydia.app",
|
||||
"/Library/MobileSubstrate/MobileSubstrate.dylib",
|
||||
"/bin/bash",
|
||||
"/usr/sbin/sshd",
|
||||
"/etc/apt",
|
||||
"/private/var/lib/apt/"
|
||||
]
|
||||
|
||||
for path in suspiciousPaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if can write outside sandbox
|
||||
let testPath = "/private/jailbreak_test.txt"
|
||||
do {
|
||||
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.removeItem(atPath: testPath)
|
||||
return true
|
||||
} catch {
|
||||
// Expected - can't write outside sandbox
|
||||
}
|
||||
|
||||
// Check for fork
|
||||
let forkResult = fork()
|
||||
if forkResult >= 0 {
|
||||
// Fork succeeded - jailbroken
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## App Store Privacy
|
||||
|
||||
### Privacy Manifest
|
||||
|
||||
Create `PrivacyInfo.xcprivacy`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### App Tracking Transparency
|
||||
|
||||
```swift
|
||||
import AppTrackingTransparency
|
||||
|
||||
func requestTrackingPermission() async -> ATTrackingManager.AuthorizationStatus {
|
||||
await ATTrackingManager.requestTrackingAuthorization()
|
||||
}
|
||||
|
||||
// Check before tracking
|
||||
if ATTrackingManager.trackingAuthorizationStatus == .authorized {
|
||||
// Can use IDFA for tracking
|
||||
}
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
### Data Storage
|
||||
- [ ] Sensitive data in Keychain, not UserDefaults
|
||||
- [ ] Appropriate Keychain accessibility
|
||||
- [ ] File protection for sensitive files
|
||||
- [ ] Clear sensitive data from memory
|
||||
|
||||
### Network
|
||||
- [ ] HTTPS only (ATS)
|
||||
- [ ] Certificate pinning for sensitive APIs
|
||||
- [ ] Secure token storage
|
||||
- [ ] No hardcoded secrets
|
||||
|
||||
### Authentication
|
||||
- [ ] Biometric option available
|
||||
- [ ] Secure session management
|
||||
- [ ] Token refresh handling
|
||||
- [ ] Logout clears all data
|
||||
|
||||
### Code
|
||||
- [ ] Input validation
|
||||
- [ ] No sensitive data in logs
|
||||
- [ ] Parameterized queries
|
||||
- [ ] No hardcoded credentials
|
||||
|
||||
### Privacy
|
||||
- [ ] Privacy manifest complete
|
||||
- [ ] ATT compliance
|
||||
- [ ] Minimal data collection
|
||||
- [ ] Clear privacy policy
|
||||
553
skills/expertise/iphone-apps/references/storekit.md
Normal file
553
skills/expertise/iphone-apps/references/storekit.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# StoreKit 2
|
||||
|
||||
In-app purchases, subscriptions, and paywalls for iOS apps.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
### Product Configuration
|
||||
|
||||
Define products in App Store Connect, then load in app:
|
||||
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
@Observable
|
||||
class PurchaseService {
|
||||
private(set) var products: [Product] = []
|
||||
private(set) var purchasedProductIDs: Set<String> = []
|
||||
private(set) var subscriptionStatus: SubscriptionStatus = .unknown
|
||||
|
||||
private var transactionListener: Task<Void, Error>?
|
||||
|
||||
enum SubscriptionStatus {
|
||||
case unknown
|
||||
case subscribed
|
||||
case expired
|
||||
case inGracePeriod
|
||||
case notSubscribed
|
||||
}
|
||||
|
||||
init() {
|
||||
transactionListener = listenForTransactions()
|
||||
}
|
||||
|
||||
deinit {
|
||||
transactionListener?.cancel()
|
||||
}
|
||||
|
||||
func loadProducts() async throws {
|
||||
let productIDs = [
|
||||
"com.app.premium.monthly",
|
||||
"com.app.premium.yearly",
|
||||
"com.app.lifetime"
|
||||
]
|
||||
products = try await Product.products(for: productIDs)
|
||||
.sorted { $0.price < $1.price }
|
||||
}
|
||||
|
||||
func purchase(_ product: Product) async throws -> PurchaseResult {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
await updatePurchasedProducts()
|
||||
await transaction.finish()
|
||||
return .success
|
||||
|
||||
case .userCancelled:
|
||||
return .cancelled
|
||||
|
||||
case .pending:
|
||||
return .pending
|
||||
|
||||
@unknown default:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
func restorePurchases() async throws {
|
||||
try await AppStore.sync()
|
||||
await updatePurchasedProducts()
|
||||
}
|
||||
|
||||
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .unverified(_, let error):
|
||||
throw StoreError.verificationFailed(error)
|
||||
case .verified(let safe):
|
||||
return safe
|
||||
}
|
||||
}
|
||||
|
||||
func updatePurchasedProducts() async {
|
||||
var purchased: Set<String> = []
|
||||
|
||||
// Check non-consumables and subscriptions
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
purchased.insert(transaction.productID)
|
||||
}
|
||||
|
||||
purchasedProductIDs = purchased
|
||||
await updateSubscriptionStatus()
|
||||
}
|
||||
|
||||
private func updateSubscriptionStatus() async {
|
||||
// Check subscription group status
|
||||
guard let groupID = products.first?.subscription?.subscriptionGroupID else {
|
||||
subscriptionStatus = .notSubscribed
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
|
||||
guard let status = statuses.first else {
|
||||
subscriptionStatus = .notSubscribed
|
||||
return
|
||||
}
|
||||
|
||||
switch status.state {
|
||||
case .subscribed:
|
||||
subscriptionStatus = .subscribed
|
||||
case .expired:
|
||||
subscriptionStatus = .expired
|
||||
case .inGracePeriod:
|
||||
subscriptionStatus = .inGracePeriod
|
||||
case .revoked:
|
||||
subscriptionStatus = .notSubscribed
|
||||
default:
|
||||
subscriptionStatus = .unknown
|
||||
}
|
||||
} catch {
|
||||
subscriptionStatus = .unknown
|
||||
}
|
||||
}
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
Task.detached {
|
||||
for await result in Transaction.updates {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
await self.updatePurchasedProducts()
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PurchaseResult {
|
||||
case success
|
||||
case cancelled
|
||||
case pending
|
||||
case failed
|
||||
}
|
||||
|
||||
enum StoreError: LocalizedError {
|
||||
case verificationFailed(Error)
|
||||
case productNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .verificationFailed:
|
||||
return "Purchase verification failed"
|
||||
case .productNotFound:
|
||||
return "Product not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Paywall UI
|
||||
|
||||
```swift
|
||||
struct PaywallView: View {
|
||||
@Environment(PurchaseService.self) private var purchaseService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var isPurchasing = false
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerSection
|
||||
featuresSection
|
||||
productsSection
|
||||
termsSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Go Premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
try? await purchaseService.loadProducts()
|
||||
}
|
||||
.alert("Error", isPresented: .constant(error != nil)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error?.localizedDescription ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.yellow)
|
||||
|
||||
Text("Unlock Premium")
|
||||
.font(.title.bold())
|
||||
|
||||
Text("Get access to all features")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
private var featuresSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "Unlimited items")
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "Cloud sync")
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "Priority support")
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "No ads")
|
||||
}
|
||||
.padding()
|
||||
.background(.background.secondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var productsSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(purchaseService.products) { product in
|
||||
ProductButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct == product,
|
||||
action: { selectedProduct = product }
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await purchase()
|
||||
}
|
||||
} label: {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Subscribe")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(selectedProduct == nil || isPurchasing)
|
||||
|
||||
Button("Restore Purchases") {
|
||||
Task {
|
||||
try? await purchaseService.restorePurchases()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var termsSection: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text("Subscription automatically renews unless canceled.")
|
||||
HStack {
|
||||
Link("Terms", destination: URL(string: "https://example.com/terms")!)
|
||||
Text("•")
|
||||
Link("Privacy", destination: URL(string: "https://example.com/privacy")!)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func purchase() async {
|
||||
guard let product = selectedProduct else { return }
|
||||
|
||||
isPurchasing = true
|
||||
defer { isPurchasing = false }
|
||||
|
||||
do {
|
||||
let result = try await purchaseService.purchase(product)
|
||||
if result == .success {
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.green)
|
||||
Text(title)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProductButton: View {
|
||||
let product: Product
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
if let subscription = product.subscription {
|
||||
Text(subscription.subscriptionPeriod.debugDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(product.displayPrice)
|
||||
.font(.headline)
|
||||
}
|
||||
.padding()
|
||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subscription Management
|
||||
|
||||
### Check Subscription Status
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
var isSubscribed: Bool {
|
||||
subscriptionStatus == .subscribed || subscriptionStatus == .inGracePeriod
|
||||
}
|
||||
|
||||
func checkAccess(for feature: Feature) -> Bool {
|
||||
switch feature {
|
||||
case .basic:
|
||||
return true
|
||||
case .premium:
|
||||
return isSubscribed || purchasedProductIDs.contains("com.app.lifetime")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Feature {
|
||||
case basic
|
||||
case premium
|
||||
}
|
||||
```
|
||||
|
||||
### Show Manage Subscriptions
|
||||
|
||||
```swift
|
||||
Button("Manage Subscription") {
|
||||
Task {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
try? await AppStore.showManageSubscriptions(in: windowScene)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Subscription Renewal
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
func getSubscriptionRenewalInfo() async -> RenewalInfo? {
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productType == .autoRenewable else { continue }
|
||||
|
||||
guard let renewalInfo = try? await transaction.subscriptionStatus?.renewalInfo,
|
||||
case .verified(let info) = renewalInfo else { continue }
|
||||
|
||||
return RenewalInfo(
|
||||
willRenew: info.willAutoRenew,
|
||||
expirationDate: transaction.expirationDate,
|
||||
isInBillingRetry: info.isInBillingRetry
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct RenewalInfo {
|
||||
let willRenew: Bool
|
||||
let expirationDate: Date?
|
||||
let isInBillingRetry: Bool
|
||||
}
|
||||
```
|
||||
|
||||
## Consumables
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
func purchaseConsumable(_ product: Product, quantity: Int = 1) async throws {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
|
||||
// Grant content
|
||||
await grantConsumable(product.id, quantity: quantity)
|
||||
|
||||
// Must finish transaction for consumables
|
||||
await transaction.finish()
|
||||
|
||||
case .userCancelled, .pending:
|
||||
break
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func grantConsumable(_ productID: String, quantity: Int) async {
|
||||
// Add to user's balance (e.g., coins, credits)
|
||||
// This should be tracked in your own storage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Promotional Offers
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
func purchaseWithOffer(_ product: Product, offerID: String) async throws -> PurchaseResult {
|
||||
// Generate signature on your server
|
||||
guard let keyID = await fetchKeyID(),
|
||||
let nonce = UUID().uuidString.data(using: .utf8),
|
||||
let signature = await generateSignature(productID: product.id, offerID: offerID) else {
|
||||
throw StoreError.offerSigningFailed
|
||||
}
|
||||
|
||||
let result = try await product.purchase(options: [
|
||||
.promotionalOffer(
|
||||
offerID: offerID,
|
||||
keyID: keyID,
|
||||
nonce: UUID(),
|
||||
signature: signature,
|
||||
timestamp: Int(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
])
|
||||
|
||||
// Handle result same as regular purchase
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
await updatePurchasedProducts()
|
||||
await transaction.finish()
|
||||
return .success
|
||||
case .userCancelled:
|
||||
return .cancelled
|
||||
case .pending:
|
||||
return .pending
|
||||
@unknown default:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### StoreKit Configuration File
|
||||
|
||||
Create `Configuration.storekit` for local testing:
|
||||
|
||||
1. File > New > File > StoreKit Configuration File
|
||||
2. Add products matching your App Store Connect configuration
|
||||
3. Run with: Edit Scheme > Run > Options > StoreKit Configuration
|
||||
|
||||
### Test Purchase Scenarios
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
extension PurchaseService {
|
||||
func simulatePurchase() async {
|
||||
purchasedProductIDs.insert("com.app.premium.monthly")
|
||||
subscriptionStatus = .subscribed
|
||||
}
|
||||
|
||||
func clearPurchases() async {
|
||||
purchasedProductIDs.removeAll()
|
||||
subscriptionStatus = .notSubscribed
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### Transaction Manager (Testing)
|
||||
|
||||
Use Transaction Manager in Xcode to:
|
||||
- Clear purchase history
|
||||
- Simulate subscription expiration
|
||||
- Test renewal scenarios
|
||||
- Simulate billing issues
|
||||
|
||||
## App Store Server Notifications
|
||||
|
||||
Configure in App Store Connect to receive:
|
||||
- Subscription renewals
|
||||
- Cancellations
|
||||
- Refunds
|
||||
- Grace period events
|
||||
|
||||
Handle on your server to update user access accordingly.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Update UI After Purchase
|
||||
|
||||
```swift
|
||||
func purchase(_ product: Product) async throws -> PurchaseResult {
|
||||
let result = try await product.purchase()
|
||||
// ...
|
||||
await updatePurchasedProducts() // Always update
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Grace Period
|
||||
|
||||
```swift
|
||||
if purchaseService.subscriptionStatus == .inGracePeriod {
|
||||
// Show warning but allow access
|
||||
showGracePeriodBanner()
|
||||
}
|
||||
```
|
||||
|
||||
### Finish Transactions Promptly
|
||||
|
||||
```swift
|
||||
// Always finish after granting content
|
||||
await transaction.finish()
|
||||
```
|
||||
|
||||
### Test on Real Device
|
||||
|
||||
StoreKit Testing is great for development, but always test with sandbox accounts on real devices before release.
|
||||
549
skills/expertise/iphone-apps/references/swiftui-patterns.md
Normal file
549
skills/expertise/iphone-apps/references/swiftui-patterns.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# SwiftUI Patterns
|
||||
|
||||
Modern SwiftUI patterns for iOS 26 with iOS 18 compatibility.
|
||||
|
||||
## View Composition
|
||||
|
||||
### Small, Focused Views
|
||||
|
||||
```swift
|
||||
// Bad: Massive view
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
// 200 lines of UI code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Good: Composed from smaller views
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
HeaderView()
|
||||
ItemList()
|
||||
ActionBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HeaderView: View {
|
||||
var body: some View {
|
||||
// Focused implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extract Subviews
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
iconView
|
||||
contentView
|
||||
Spacer()
|
||||
chevronView
|
||||
}
|
||||
}
|
||||
|
||||
private var iconView: some View {
|
||||
Image(systemName: item.icon)
|
||||
.foregroundStyle(.accent)
|
||||
.frame(width: 30)
|
||||
}
|
||||
|
||||
private var contentView: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(item.name)
|
||||
.font(.headline)
|
||||
Text(item.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var chevronView: some View {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Data Loading
|
||||
|
||||
### Task Modifier
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
@State private var items: [Item] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else if let error {
|
||||
ErrorView(error: error, retry: load)
|
||||
} else {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
items = try await fetchItems()
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh Control
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
@State private var items: [Item] = []
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
.refreshable {
|
||||
items = try? await fetchItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task with ID
|
||||
|
||||
Reload when identifier changes:
|
||||
|
||||
```swift
|
||||
struct ItemDetail: View {
|
||||
let itemID: UUID
|
||||
@State private var item: Item?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let item {
|
||||
ItemContent(item: item)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task(id: itemID) {
|
||||
item = try? await fetchItem(id: itemID)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lists and Grids
|
||||
|
||||
### Swipe Actions
|
||||
|
||||
```swift
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
delete(item)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
archive(item)
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
toggleFavorite(item)
|
||||
} label: {
|
||||
Label("Favorite", systemImage: item.isFavorite ? "star.fill" : "star")
|
||||
}
|
||||
.tint(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Grids
|
||||
|
||||
```swift
|
||||
struct PhotoGrid: View {
|
||||
let photos: [Photo]
|
||||
let columns = [GridItem(.adaptive(minimum: 100), spacing: 2)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 2) {
|
||||
ForEach(photos) { photo in
|
||||
AsyncImage(url: photo.thumbnailURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.gray.opacity(0.3)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sections with Headers
|
||||
|
||||
```swift
|
||||
List {
|
||||
ForEach(groupedItems, id: \.key) { section in
|
||||
Section(section.key) {
|
||||
ForEach(section.items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
```
|
||||
|
||||
## Forms and Input
|
||||
|
||||
### Form with Validation
|
||||
|
||||
```swift
|
||||
struct ProfileForm: View {
|
||||
@State private var name = ""
|
||||
@State private var email = ""
|
||||
@State private var bio = ""
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.isEmpty && email.contains("@") && email.contains(".")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Personal Info") {
|
||||
TextField("Name", text: $name)
|
||||
.textContentType(.name)
|
||||
|
||||
TextField("Email", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
TextField("Bio", text: $bio, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save") {
|
||||
save()
|
||||
}
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pickers
|
||||
|
||||
```swift
|
||||
struct SettingsView: View {
|
||||
@State private var selectedTheme = Theme.system
|
||||
@State private var fontSize = 16.0
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker("Theme", selection: $selectedTheme) {
|
||||
ForEach(Theme.allCases) { theme in
|
||||
Text(theme.rawValue).tag(theme)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Text Size") {
|
||||
Slider(value: $fontSize, in: 12...24, step: 1) {
|
||||
Text("Font Size")
|
||||
} minimumValueLabel: {
|
||||
Text("A").font(.caption)
|
||||
} maximumValueLabel: {
|
||||
Text("A").font(.title)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sheets and Alerts
|
||||
|
||||
### Sheet Presentation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var showingSettings = false
|
||||
@State private var selectedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in
|
||||
Button(item.name) {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gear")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(item: $selectedItem) { item in
|
||||
ItemDetail(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmation Dialogs
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(item.name)
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete \(item.name)?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
delete(item)
|
||||
}
|
||||
} message: {
|
||||
Text("This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## iOS 26 Features
|
||||
|
||||
### Liquid Glass
|
||||
|
||||
```swift
|
||||
struct GlassCard: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Premium Content")
|
||||
.font(.headline)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
// iOS 26 glass effect
|
||||
.glassEffect()
|
||||
}
|
||||
}
|
||||
|
||||
// Availability check
|
||||
struct AdaptiveCard: View {
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
GlassCard()
|
||||
} else {
|
||||
StandardCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WebView
|
||||
|
||||
```swift
|
||||
import WebKit
|
||||
|
||||
// iOS 26+ native WebView
|
||||
struct WebContent: View {
|
||||
let url: URL
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
WebView(url: url)
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
WebViewRepresentable(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for iOS 18
|
||||
struct WebViewRepresentable: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
WKWebView()
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @Animatable Macro
|
||||
|
||||
```swift
|
||||
// iOS 26+
|
||||
@available(iOS 26, *)
|
||||
@Animatable
|
||||
struct PulsingCircle: View {
|
||||
var scale: Double
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.scaleEffect(scale)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Modifiers
|
||||
|
||||
### Reusable Styling
|
||||
|
||||
```swift
|
||||
struct CardModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding()
|
||||
.background(.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cardStyle() -> some View {
|
||||
modifier(CardModifier())
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
Text("Content")
|
||||
.cardStyle()
|
||||
```
|
||||
|
||||
### Conditional Modifiers
|
||||
|
||||
```swift
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
Text("Item")
|
||||
.if(isHighlighted) { view in
|
||||
view.foregroundStyle(.accent)
|
||||
}
|
||||
```
|
||||
|
||||
## Preview Techniques
|
||||
|
||||
### Multiple Configurations
|
||||
|
||||
```swift
|
||||
#Preview("Light Mode") {
|
||||
ItemRow(item: .sample)
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
|
||||
#Preview("Dark Mode") {
|
||||
ItemRow(item: .sample)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Large Text") {
|
||||
ItemRow(item: .sample)
|
||||
.environment(\.sizeCategory, .accessibilityExtraLarge)
|
||||
}
|
||||
```
|
||||
|
||||
### Interactive Previews
|
||||
|
||||
```swift
|
||||
#Preview {
|
||||
@Previewable @State var isOn = false
|
||||
|
||||
Toggle("Setting", isOn: $isOn)
|
||||
.padding()
|
||||
}
|
||||
```
|
||||
|
||||
### Preview with Mock Data
|
||||
|
||||
```swift
|
||||
extension Item {
|
||||
static let sample = Item(
|
||||
name: "Sample Item",
|
||||
subtitle: "Sample subtitle",
|
||||
icon: "star"
|
||||
)
|
||||
|
||||
static let samples: [Item] = [
|
||||
Item(name: "First", subtitle: "One", icon: "1.circle"),
|
||||
Item(name: "Second", subtitle: "Two", icon: "2.circle"),
|
||||
Item(name: "Third", subtitle: "Three", icon: "3.circle")
|
||||
]
|
||||
}
|
||||
|
||||
#Preview {
|
||||
List(Item.samples) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
```
|
||||
540
skills/expertise/iphone-apps/references/testing.md
Normal file
540
skills/expertise/iphone-apps/references/testing.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# Testing
|
||||
|
||||
Unit tests, UI tests, snapshot tests, and testing patterns for iOS apps.
|
||||
|
||||
## Swift Testing (Xcode 16+)
|
||||
|
||||
### Basic Tests
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
@testable import MyApp
|
||||
|
||||
@Suite("Item Tests")
|
||||
struct ItemTests {
|
||||
@Test("Create item with name")
|
||||
func createItem() {
|
||||
let item = Item(name: "Test")
|
||||
#expect(item.name == "Test")
|
||||
#expect(item.isCompleted == false)
|
||||
}
|
||||
|
||||
@Test("Toggle completion")
|
||||
func toggleCompletion() {
|
||||
var item = Item(name: "Test")
|
||||
item.isCompleted = true
|
||||
#expect(item.isCompleted == true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Tests
|
||||
|
||||
```swift
|
||||
@Test("Fetch items from network")
|
||||
func fetchItems() async throws {
|
||||
let service = MockNetworkService()
|
||||
service.mockResult = [Item(name: "Test")]
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: service)
|
||||
await viewModel.load()
|
||||
|
||||
#expect(viewModel.items.count == 1)
|
||||
#expect(viewModel.items[0].name == "Test")
|
||||
}
|
||||
|
||||
@Test("Handle network error")
|
||||
func handleNetworkError() async {
|
||||
let service = MockNetworkService()
|
||||
service.mockError = NetworkError.noConnection
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: service)
|
||||
await viewModel.load()
|
||||
|
||||
#expect(viewModel.items.isEmpty)
|
||||
#expect(viewModel.error != nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
```swift
|
||||
@Test("Validate email", arguments: [
|
||||
("test@example.com", true),
|
||||
("invalid", false),
|
||||
("@example.com", false),
|
||||
("test@", false)
|
||||
])
|
||||
func validateEmail(email: String, expected: Bool) {
|
||||
let isValid = EmailValidator.isValid(email)
|
||||
#expect(isValid == expected)
|
||||
}
|
||||
```
|
||||
|
||||
### Test Lifecycle
|
||||
|
||||
```swift
|
||||
@Suite("Database Tests")
|
||||
struct DatabaseTests {
|
||||
let database: TestDatabase
|
||||
|
||||
init() async throws {
|
||||
database = try await TestDatabase.create()
|
||||
}
|
||||
|
||||
@Test func insertItem() async throws {
|
||||
try await database.insert(Item(name: "Test"))
|
||||
let items = try await database.fetchAll()
|
||||
#expect(items.count == 1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## XCTest (Traditional)
|
||||
|
||||
### Basic XCTest
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
class ItemTests: XCTestCase {
|
||||
var sut: Item!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
sut = Item(name: "Test")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCreateItem() {
|
||||
XCTAssertEqual(sut.name, "Test")
|
||||
XCTAssertFalse(sut.isCompleted)
|
||||
}
|
||||
|
||||
func testToggleCompletion() {
|
||||
sut.isCompleted = true
|
||||
XCTAssertTrue(sut.isCompleted)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async XCTest
|
||||
|
||||
```swift
|
||||
func testFetchItems() async throws {
|
||||
let service = MockNetworkService()
|
||||
service.mockResult = [Item(name: "Test")]
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: service)
|
||||
await viewModel.load()
|
||||
|
||||
XCTAssertEqual(viewModel.items.count, 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
### Protocol-Based Mocks
|
||||
|
||||
```swift
|
||||
// Protocol
|
||||
protocol NetworkServiceProtocol {
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
|
||||
}
|
||||
|
||||
// Mock
|
||||
class MockNetworkService: NetworkServiceProtocol {
|
||||
var mockResult: Any?
|
||||
var mockError: Error?
|
||||
var fetchCallCount = 0
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
fetchCallCount += 1
|
||||
|
||||
if let error = mockError {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let result = mockResult as? T else {
|
||||
fatalError("Mock result type mismatch")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with Mocks
|
||||
|
||||
```swift
|
||||
@Test func loadItemsCallsNetwork() async {
|
||||
let mock = MockNetworkService()
|
||||
mock.mockResult = [Item]()
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: mock)
|
||||
await viewModel.load()
|
||||
|
||||
#expect(mock.fetchCallCount == 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing SwiftUI Views
|
||||
|
||||
### View Tests with ViewInspector
|
||||
|
||||
```swift
|
||||
import ViewInspector
|
||||
@testable import MyApp
|
||||
|
||||
@Test func itemRowDisplaysName() throws {
|
||||
let item = Item(name: "Test Item")
|
||||
let view = ItemRow(item: item)
|
||||
|
||||
let text = try view.inspect().hStack().text(0).string()
|
||||
#expect(text == "Test Item")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing View Models
|
||||
|
||||
```swift
|
||||
@Test func viewModelUpdatesOnSelection() async {
|
||||
let viewModel = ItemListViewModel()
|
||||
viewModel.items = [Item(name: "A"), Item(name: "B")]
|
||||
|
||||
viewModel.select(viewModel.items[0])
|
||||
|
||||
#expect(viewModel.selectedItem?.name == "A")
|
||||
}
|
||||
```
|
||||
|
||||
## UI Testing
|
||||
|
||||
### Basic UI Test
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
class MyAppUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app.launchArguments = ["--uitesting"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testAddItem() {
|
||||
// Tap add button
|
||||
app.buttons["Add"].tap()
|
||||
|
||||
// Enter name
|
||||
let textField = app.textFields["Item name"]
|
||||
textField.tap()
|
||||
textField.typeText("New Item")
|
||||
|
||||
// Save
|
||||
app.buttons["Save"].tap()
|
||||
|
||||
// Verify
|
||||
XCTAssertTrue(app.staticTexts["New Item"].exists)
|
||||
}
|
||||
|
||||
func testSwipeToDelete() {
|
||||
// Assume item exists
|
||||
let cell = app.cells["Item Row"].firstMatch
|
||||
|
||||
// Swipe and delete
|
||||
cell.swipeLeft()
|
||||
app.buttons["Delete"].tap()
|
||||
|
||||
// Verify
|
||||
XCTAssertFalse(cell.exists)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility Identifiers
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(item.name)
|
||||
}
|
||||
.accessibilityIdentifier("Item Row")
|
||||
}
|
||||
}
|
||||
|
||||
struct NewItemView: View {
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
TextField("Item name", text: $name)
|
||||
.accessibilityIdentifier("Item name")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Launch Arguments for Testing
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear {
|
||||
if CommandLine.arguments.contains("--uitesting") {
|
||||
// Use mock data
|
||||
// Skip onboarding
|
||||
// Clear state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot Testing
|
||||
|
||||
Using swift-snapshot-testing:
|
||||
|
||||
```swift
|
||||
import SnapshotTesting
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
class SnapshotTests: XCTestCase {
|
||||
func testItemRow() {
|
||||
let item = Item(name: "Test", subtitle: "Subtitle")
|
||||
let view = ItemRow(item: item)
|
||||
.frame(width: 375)
|
||||
|
||||
assertSnapshot(of: view, as: .image)
|
||||
}
|
||||
|
||||
func testItemRowDarkMode() {
|
||||
let item = Item(name: "Test", subtitle: "Subtitle")
|
||||
let view = ItemRow(item: item)
|
||||
.frame(width: 375)
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
assertSnapshot(of: view, as: .image, named: "dark")
|
||||
}
|
||||
|
||||
func testItemRowLargeText() {
|
||||
let item = Item(name: "Test", subtitle: "Subtitle")
|
||||
let view = ItemRow(item: item)
|
||||
.frame(width: 375)
|
||||
.environment(\.sizeCategory, .accessibilityExtraLarge)
|
||||
|
||||
assertSnapshot(of: view, as: .image, named: "large-text")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing SwiftData
|
||||
|
||||
```swift
|
||||
@Suite("SwiftData Tests")
|
||||
struct SwiftDataTests {
|
||||
@Test func insertAndFetch() async throws {
|
||||
// In-memory container for testing
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: Item.self, configurations: config)
|
||||
let context = container.mainContext
|
||||
|
||||
// Insert
|
||||
let item = Item(name: "Test")
|
||||
context.insert(item)
|
||||
try context.save()
|
||||
|
||||
// Fetch
|
||||
let descriptor = FetchDescriptor<Item>()
|
||||
let items = try context.fetch(descriptor)
|
||||
|
||||
#expect(items.count == 1)
|
||||
#expect(items[0].name == "Test")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Network Calls
|
||||
|
||||
### Using URLProtocol
|
||||
|
||||
```swift
|
||||
class MockURLProtocol: URLProtocol {
|
||||
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
return request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = MockURLProtocol.requestHandler else {
|
||||
fatalError("Handler not set")
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Test func fetchItemsReturnsData() async throws {
|
||||
// Configure mock
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [MockURLProtocol.self]
|
||||
let session = URLSession(configuration: config)
|
||||
|
||||
let mockItems = [Item(name: "Test")]
|
||||
let mockData = try JSONEncoder().encode(mockItems)
|
||||
|
||||
MockURLProtocol.requestHandler = { request in
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
return (response, mockData)
|
||||
}
|
||||
|
||||
// Test
|
||||
let service = NetworkService(session: session)
|
||||
let items: [Item] = try await service.fetch(.items)
|
||||
|
||||
#expect(items.count == 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Test Helpers
|
||||
|
||||
### Factory Methods
|
||||
|
||||
```swift
|
||||
extension Item {
|
||||
static func sample(
|
||||
name: String = "Sample",
|
||||
isCompleted: Bool = false,
|
||||
priority: Int = 0
|
||||
) -> Item {
|
||||
Item(name: name, isCompleted: isCompleted, priority: priority)
|
||||
}
|
||||
|
||||
static var samples: [Item] {
|
||||
[
|
||||
.sample(name: "First"),
|
||||
.sample(name: "Second", isCompleted: true),
|
||||
.sample(name: "Third", priority: 5)
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Test Utilities
|
||||
|
||||
```swift
|
||||
func waitForCondition(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
) async throws {
|
||||
let start = Date()
|
||||
while !condition() {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
throw TestError.timeout
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
}
|
||||
}
|
||||
|
||||
enum TestError: Error {
|
||||
case timeout
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tests from CLI
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16'
|
||||
|
||||
# Run specific test
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-only-testing:MyAppTests/ItemTests
|
||||
|
||||
# With code coverage
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-enableCodeCoverage YES \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Test Naming
|
||||
|
||||
```swift
|
||||
// Describe what is being tested and expected outcome
|
||||
@Test func itemListViewModel_load_setsItemsFromNetwork()
|
||||
@Test func purchaseService_purchaseProduct_updatesEntitlements()
|
||||
```
|
||||
|
||||
### Arrange-Act-Assert
|
||||
|
||||
```swift
|
||||
@Test func toggleCompletion() {
|
||||
// Arrange
|
||||
var item = Item(name: "Test")
|
||||
|
||||
// Act
|
||||
item.isCompleted.toggle()
|
||||
|
||||
// Assert
|
||||
#expect(item.isCompleted == true)
|
||||
}
|
||||
```
|
||||
|
||||
### One Assertion Per Test
|
||||
|
||||
Focus each test on a single behavior:
|
||||
|
||||
```swift
|
||||
// Good
|
||||
@Test func loadSetsItems() async { ... }
|
||||
@Test func loadSetsLoadingFalse() async { ... }
|
||||
@Test func loadClearsError() async { ... }
|
||||
|
||||
// Avoid
|
||||
@Test func loadWorks() async {
|
||||
// Too many assertions
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user