skills/sagar-wants-to-make-ios-brainrot-apps/SKILL.md
Expert Swift & iOS development skill — SwiftUI architecture, design systems, networking, state management, and production patterns for building native iOS apps with Claude Code.
npx skillsauth add adisinghstudent/swift-ios sagar-wants-to-make-ios-brainrot-appsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Expert-level Swift and iOS development assistant. Covers SwiftUI, UIKit interop, architecture, design systems, networking, state management, testing, and production deployment patterns.
MyApp/
├── MyApp.xcodeproj/ # Or .xcworkspace if using CocoaPods
├── MyApp/
│ ├── App/
│ │ ├── MyAppApp.swift # @main entry point
│ │ ├── AppDelegate.swift # Only if UIKit lifecycle needed
│ │ └── Info.plist
│ ├── Features/ # Feature modules
│ │ ├── Chat/
│ │ │ ├── Views/
│ │ │ │ ├── ChatView.swift
│ │ │ │ └── MessageBubble.swift
│ │ │ ├── ViewModels/
│ │ │ │ └── ChatViewModel.swift
│ │ │ └── Models/
│ │ │ └── Message.swift
│ │ ├── Settings/
│ │ │ ├── Views/
│ │ │ └── ViewModels/
│ │ └── Auth/
│ │ ├── Views/
│ │ └── Services/
│ ├── Core/
│ │ ├── Design/ # Design system
│ │ │ ├── Theme.swift
│ │ │ ├── Colors.swift
│ │ │ ├── Typography.swift
│ │ │ └── Components/ # Reusable UI components
│ │ │ ├── PrimaryButton.swift
│ │ │ ├── CardView.swift
│ │ │ └── LoadingView.swift
│ │ ├── Networking/
│ │ │ ├── APIClient.swift
│ │ │ ├── Endpoint.swift
│ │ │ └── NetworkError.swift
│ │ ├── Storage/
│ │ │ ├── UserDefaults+Extensions.swift
│ │ │ └── KeychainService.swift
│ │ ├── Extensions/
│ │ │ ├── View+Extensions.swift
│ │ │ ├── Color+Hex.swift
│ │ │ └── Date+Formatting.swift
│ │ └── Utilities/
│ │ ├── Logger.swift
│ │ └── Constants.swift
│ ├── Resources/
│ │ ├── Assets.xcassets/
│ │ ├── Localizable.xcstrings # String catalogs (Xcode 15+)
│ │ └── Fonts/ # Custom fonts (.ttf/.otf)
│ └── Preview Content/
│ └── Preview Assets.xcassets/
├── MyAppTests/
├── MyAppUITests/
└── Package.swift # If using SPM for modularization
Group by feature, not by type. Each feature folder contains its own Views, ViewModels, and Models. Shared code lives in Core/.
View ←→ ViewModel ←→ Service/Repository ←→ API/Database
View — Pure SwiftUI. No business logic. Observes ViewModel.
ViewModel — @Observable class (iOS 17+) or ObservableObject. Holds state, handles user actions.
Service — Stateless. Networking, persistence, business rules.
Model — Plain structs. Codable, Identifiable, Hashable.
import SwiftUI
@Observable
final class ChatViewModel {
var messages: [Message] = []
var inputText = ""
var isLoading = false
var error: AppError?
private let chatService: ChatService
init(chatService: ChatService = .shared) {
self.chatService = chatService
}
func send() async {
guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
let text = inputText
inputText = ""
isLoading = true
defer { isLoading = false }
let userMessage = Message(role: .user, content: text)
messages.append(userMessage)
do {
let response = try await chatService.sendMessage(text, history: messages)
messages.append(response)
} catch {
self.error = AppError(error)
messages.removeLast() // Remove optimistic user message on failure
}
}
}
final class ChatViewModel: ObservableObject {
@Published var messages: [Message] = []
@Published var inputText = ""
@Published var isLoading = false
// Same logic, use @StateObject in view
}
struct ChatView: View {
@State private var viewModel = ChatViewModel()
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: viewModel.messages.count) {
withAnimation {
proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom)
}
}
}
InputBar(text: $viewModel.inputText, isLoading: viewModel.isLoading) {
Task { await viewModel.send() }
}
}
}
}
Since you can't use Tailwind in native iOS, build a design system with Swift extensions and view modifiers.
import SwiftUI
extension Color {
// MARK: - Brand
static let brand = Color("Brand", bundle: .main) // Define in Assets.xcassets
static let brandDark = Color("BrandDark", bundle: .main)
// MARK: - Semantic
static let surfacePrimary = Color(.systemBackground)
static let surfaceSecondary = Color(.secondarySystemBackground)
static let surfaceTertiary = Color(.tertiarySystemBackground)
static let textPrimary = Color(.label)
static let textSecondary = Color(.secondaryLabel)
static let textMuted = Color(.tertiaryLabel)
static let border = Color(.separator)
static let borderLight = Color(.opaqueSeparator)
// MARK: - Status
static let success = Color.green
static let warning = Color.orange
static let destructive = Color.red
// MARK: - Hex initializer
init(hex: String) {
let hex = hex.trimmingCharacters(in: .alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default: (a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
import SwiftUI
enum AppFont {
// System Dynamic Type (recommended — respects accessibility)
static let largeTitle = Font.largeTitle.weight(.bold)
static let title = Font.title2.weight(.semibold)
static let headline = Font.headline
static let body = Font.body
static let callout = Font.callout
static let caption = Font.caption
static let captionSecondary = Font.caption2
// Custom font (register in Info.plist under "Fonts provided by application")
static func custom(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .rounded)
}
}
// Usage: Text("Hello").font(AppFont.title)
enum Spacing {
static let xxs: CGFloat = 2
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let xxl: CGFloat = 32
static let xxxl: CGFloat = 48
}
enum CornerRadius {
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let full: CGFloat = 9999 // Capsule
}
// MARK: - Primary Button
struct PrimaryButton: View {
let title: String
let isLoading: Bool
let action: () -> Void
init(_ title: String, isLoading: Bool = false, action: @escaping () -> Void) {
self.title = title
self.isLoading = isLoading
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text(title)
.font(.body.weight(.semibold))
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.brand)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.lg))
}
.disabled(isLoading)
}
}
// MARK: - Card
struct CardView<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.padding(Spacing.lg)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.md))
}
}
// MARK: - Async Image with Placeholder
struct RemoteImage: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure:
Image(systemName: "photo")
.foregroundStyle(.tertiary)
default:
ProgressView()
}
}
}
}
extension View {
func cardStyle() -> some View {
self
.padding(Spacing.lg)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.md))
}
func inputStyle() -> some View {
self
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.background(Color.surfaceTertiary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.md))
.overlay(
RoundedRectangle(cornerRadius: CornerRadius.md)
.stroke(Color.border, lineWidth: 0.5)
)
}
func shimmer(_ isActive: Bool = true) -> some View {
self.redacted(reason: isActive ? .placeholder : [])
.shimmering(active: isActive)
}
}
// Custom shimmer modifier
struct ShimmerModifier: ViewModifier {
let active: Bool
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
if active {
content
.overlay(
LinearGradient(
colors: [.clear, .white.opacity(0.3), .clear],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: phase)
.mask(content)
)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 200
}
}
} else {
content
}
}
}
extension View {
func shimmering(active: Bool = true) -> some View {
modifier(ShimmerModifier(active: active))
}
}
@Observable
final class Router {
var path = NavigationPath()
func push<D: Hashable>(_ destination: D) {
path.append(destination)
}
func pop() {
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
}
// App entry
struct MyAppApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .chat(let id): ChatView(conversationId: id)
case .settings: SettingsView()
case .profile(let userId): ProfileView(userId: userId)
}
}
}
.environment(router)
}
}
}
enum Route: Hashable {
case chat(id: String)
case settings
case profile(userId: String)
}
struct ContentView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house.fill", value: 0) {
HomeView()
}
Tab("Chat", systemImage: "bubble.left.and.bubble.right.fill", value: 1) {
ChatView()
}
Tab("Settings", systemImage: "gearshape.fill", value: 2) {
SettingsView()
}
}
}
}
struct ParentView: View {
@State private var showLogin = false
@State private var selectedItem: Item?
var body: some View {
Button("Login") { showLogin = true }
.sheet(isPresented: $showLogin) {
LoginSheet()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.sheet(item: $selectedItem) { item in
ItemDetailSheet(item: item)
}
}
}
import Foundation
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let baseURL: URL
private let decoder: JSONDecoder
init(
baseURL: URL = URL(string: "https://api.example.com")!,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
}
// MARK: - Generic Request
func request<T: Decodable>(
_ endpoint: Endpoint,
as type: T.Type = T.self
) async throws -> T {
var request = URLRequest(url: baseURL.appending(path: endpoint.path))
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Auth
if let token = AuthManager.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Body
if let body = endpoint.body {
request.httpBody = try JSONEncoder().encode(body)
}
// Query parameters
if let params = endpoint.queryItems {
var components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
components.queryItems = params
request.url = components.url
}
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(statusCode: httpResponse.statusCode, data: data)
}
return try decoder.decode(T.self, from: data)
}
// MARK: - Streaming (SSE)
func stream(_ endpoint: Endpoint) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
var request = URLRequest(url: baseURL.appending(path: endpoint.path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
if let token = AuthManager.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = endpoint.body {
request.httpBody = try? JSONEncoder().encode(body)
}
do {
let (bytes, response) = try await session.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode else {
continuation.finish(throwing: NetworkError.invalidResponse)
return
}
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let data = String(line.dropFirst(6))
if data == "[DONE]" { break }
continuation.yield(data)
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
// MARK: - Endpoint
struct Endpoint {
let path: String
let method: HTTPMethod
let body: (any Encodable)?
let queryItems: [URLQueryItem]?
init(path: String, method: HTTPMethod = .get, body: (any Encodable)? = nil, queryItems: [URLQueryItem]? = nil) {
self.path = path
self.method = method
self.body = body
self.queryItems = queryItems
}
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
// MARK: - Typed Endpoints
extension Endpoint {
static func sendMessage(_ text: String, conversationId: String?) -> Endpoint {
Endpoint(path: "/chat/stream", method: .post, body: ChatRequest(
message: text,
conversationId: conversationId
))
}
static func getConversations(limit: Int = 50) -> Endpoint {
Endpoint(path: "/conversations", queryItems: [
URLQueryItem(name: "limit", value: "\(limit)")
])
}
static var me: Endpoint {
Endpoint(path: "/users/me")
}
}
// MARK: - Errors
enum NetworkError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int, data: Data)
case decodingError(Error)
case noConnection
var errorDescription: String? {
switch self {
case .invalidResponse: "Invalid server response"
case .httpError(let code, _): "Server error (\(code))"
case .decodingError: "Failed to parse response"
case .noConnection: "No internet connection"
}
}
}
@Observable
final class AppState {
var user: User?
var isAuthenticated: Bool { user != nil }
var conversations: [Conversation] = []
var selectedConversation: Conversation?
}
// Inject via environment
struct MyApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
}
}
// Consume
struct HomeView: View {
@Environment(AppState.self) private var appState
var body: some View {
if let user = appState.user {
Text("Hello, \(user.name)")
}
}
}
final class AuthManager: ObservableObject {
static let shared = AuthManager()
@Published var currentUser: User?
@Published var accessToken: String?
var isAuthenticated: Bool { currentUser != nil }
func signIn(email: String, password: String) async throws {
// Supabase auth, Firebase, custom backend, etc.
}
func signOut() {
currentUser = nil
accessToken = nil
}
}
import SwiftData
@Model
final class Conversation {
var id: String
var title: String
var createdAt: Date
var updatedAt: Date
@Relationship(deleteRule: .cascade) var messages: [ChatMessage]
init(id: String = UUID().uuidString, title: String) {
self.id = id
self.title = title
self.createdAt = .now
self.updatedAt = .now
self.messages = []
}
}
@Model
final class ChatMessage {
var id: String
var role: String // "user" | "assistant"
var content: String
var timestamp: Date
var conversation: Conversation?
init(role: String, content: String) {
self.id = UUID().uuidString
self.role = role
self.content = content
self.timestamp = .now
}
}
// Setup in App
@main
struct MyAppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Conversation.self, ChatMessage.self])
}
}
// Query in View
struct ConversationListView: View {
@Query(sort: \Conversation.updatedAt, order: .reverse) var conversations: [Conversation]
@Environment(\.modelContext) private var context
var body: some View {
List(conversations) { conversation in
Text(conversation.title)
}
.onDelete { indexSet in
for index in indexSet {
context.delete(conversations[index])
}
}
}
}
extension UserDefaults {
var hasCompletedOnboarding: Bool {
get { bool(forKey: "hasCompletedOnboarding") }
set { set(newValue, forKey: "hasCompletedOnboarding") }
}
var selectedModel: String {
get { string(forKey: "selectedModel") ?? "gpt-4" }
set { set(newValue, forKey: "selectedModel") }
}
}
// SwiftUI binding via @AppStorage
struct SettingsView: View {
@AppStorage("selectedModel") private var selectedModel = "gpt-4"
@AppStorage("hapticFeedback") private var hapticFeedback = true
var body: some View {
Form {
Picker("Model", selection: $selectedModel) {
Text("GPT-4").tag("gpt-4")
Text("Claude").tag("claude-sonnet")
}
Toggle("Haptic Feedback", isOn: $hapticFeedback)
}
}
}
import Security
enum KeychainService {
static func save(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
static func load(for key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound { return nil }
throw KeychainError.loadFailed(status)
}
return result as? Data
}
static func delete(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
}
}
import Supabase
final class AuthManager: ObservableObject {
static let shared = AuthManager()
let client = SupabaseClient(
supabaseURL: URL(string: ProcessInfo.processInfo.environment["SUPABASE_URL"] ?? "")!,
supabaseKey: ProcessInfo.processInfo.environment["SUPABASE_ANON_KEY"] ?? ""
)
@Published var session: Session?
var isAuthenticated: Bool { session != nil }
init() {
Task { await observeAuthChanges() }
}
func observeAuthChanges() async {
for await (event, session) in client.auth.authStateChanges {
await MainActor.run {
self.session = session
}
}
}
func signInWithGoogle() async throws {
try await client.auth.signInWithOAuth(.google) { url in
// Open URL in ASWebAuthenticationSession
await UIApplication.shared.open(url)
}
}
func signInWithEmail(_ email: String, password: String) async throws {
try await client.auth.signIn(email: email, password: password)
}
func signUp(email: String, password: String) async throws {
try await client.auth.signUp(email: email, password: password)
}
func signOut() async throws {
try await client.auth.signOut()
}
}
struct ChatView: View {
@State private var viewModel = ChatViewModel()
@FocusState private var isInputFocused: Bool
var body: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: Spacing.sm) {
ForEach(viewModel.messages) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
if viewModel.isLoading {
TypingIndicator()
.id("typing")
}
}
.padding()
}
.scrollDismissesKeyboard(.interactively)
.onChange(of: viewModel.messages.count) {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(viewModel.isLoading ? "typing" : viewModel.messages.last?.id, anchor: .bottom)
}
}
}
Divider()
// Input
HStack(alignment: .bottom, spacing: Spacing.sm) {
TextField("Message", text: $viewModel.inputText, axis: .vertical)
.lineLimit(1...5)
.textFieldStyle(.plain)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(Color.surfaceTertiary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.lg))
.focused($isInputFocused)
Button {
Task { await viewModel.send() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(viewModel.inputText.isEmpty ? .tertiary : Color.brand)
}
.disabled(viewModel.inputText.isEmpty || viewModel.isLoading)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
}
struct MessageBubble: View {
let message: Message
private var isUser: Bool { message.role == .user }
var body: some View {
HStack {
if isUser { Spacer(minLength: 60) }
Text(message.content)
.font(AppFont.body)
.foregroundStyle(isUser ? .white : Color.textPrimary)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.background(isUser ? Color.brand : Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: CornerRadius.lg))
if !isUser { Spacer(minLength: 60) }
}
}
}
struct TypingIndicator: View {
@State private var phase = 0.0
var body: some View {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.textMuted)
.frame(width: 8, height: 8)
.offset(y: sin(phase + Double(i) * .pi / 3) * 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.onAppear {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: false)) {
phase = .pi * 2
}
}
}
}
struct ConversationListView: View {
@State private var viewModel = ConversationListViewModel()
var body: some View {
List {
ForEach(viewModel.conversations) { conversation in
ConversationRow(conversation: conversation)
.task {
if conversation == viewModel.conversations.last {
await viewModel.loadMore()
}
}
}
if viewModel.isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
}
}
.refreshable {
await viewModel.refresh()
}
}
}
struct EmptyState: View {
let icon: String
let title: String
let message: String
var action: (() -> Void)?
var actionTitle: String?
var body: some View {
ContentUnavailableView {
Label(title, systemImage: icon)
} description: {
Text(message)
} actions: {
if let action, let actionTitle {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
}
}
}
}
struct SearchableListView: View {
@State private var searchText = ""
@State private var items: [Item] = []
var filteredItems: [Item] {
if searchText.isEmpty { return items }
return items.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
List(filteredItems) { item in
Text(item.title)
}
.searchable(text: $searchText, prompt: "Search items")
}
}
In Xcode: File > Add Package Dependencies, or add to Package.swift:
// Package.swift (for modular app or library)
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.iOS(.v17)],
dependencies: [
.package(url: "https://github.com/supabase/supabase-swift", from: "2.0.0"),
.package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: [
.product(name: "Supabase", package: "supabase-swift"),
.product(name: "Kingfisher", package: "Kingfisher"),
]),
]
)
| Package | Purpose | URL |
|---------|---------|-----|
| Supabase | BaaS (auth, DB, storage) | supabase/supabase-swift |
| Kingfisher | Image loading + cache | onevcat/Kingfisher |
| Alamofire | Networking (if URLSession isn't enough) | Alamofire/Alamofire |
| SwiftLint | Code style enforcement | realm/SwiftLint |
| Lottie | Animations | airbnb/lottie-ios |
| TCA | Composable Architecture | pointfreeco/swift-composable-architecture |
| KeychainAccess | Keychain wrapper | kishikawakatsumi/KeychainAccess |
| RevenueCat | In-app purchases | RevenueCat/purchases-ios-spm |
| PostHog | Analytics | PostHog/posthog-ios |
| Sentry | Crash reporting | getsentry/sentry-cocoa |
import XCTest
@testable import MyApp
final class ChatViewModelTests: XCTestCase {
var sut: ChatViewModel!
var mockService: MockChatService!
override func setUp() {
mockService = MockChatService()
sut = ChatViewModel(chatService: mockService)
}
func testSendMessage_appendsUserMessage() async {
sut.inputText = "Hello"
mockService.response = Message(role: .assistant, content: "Hi!")
await sut.send()
XCTAssertEqual(sut.messages.count, 2)
XCTAssertEqual(sut.messages[0].role, .user)
XCTAssertEqual(sut.messages[0].content, "Hello")
XCTAssertEqual(sut.messages[1].role, .assistant)
}
func testSendMessage_clearsInput() async {
sut.inputText = "Hello"
mockService.response = Message(role: .assistant, content: "Hi!")
await sut.send()
XCTAssertTrue(sut.inputText.isEmpty)
}
func testSendMessage_emptyInput_doesNothing() async {
sut.inputText = " "
await sut.send()
XCTAssertTrue(sut.messages.isEmpty)
}
}
#Preview {
ChatView()
.environment(AppState())
}
#Preview("Dark Mode") {
ChatView()
.environment(AppState())
.preferredColorScheme(.dark)
}
#Preview("Message Bubble") {
VStack {
MessageBubble(message: .init(role: .user, content: "Hello!"))
MessageBubble(message: .init(role: .assistant, content: "Hi there! How can I help?"))
}
.padding()
}
<!-- Camera access -->
<key>NSCameraUsageDescription</key>
<string>Take photos to share in chat</string>
<!-- Photo library -->
<key>NSPhotoLibraryUsageDescription</key>
<string>Select photos to share in chat</string>
<!-- Location -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Find nearby services</string>
<!-- Microphone -->
<key>NSMicrophoneUsageDescription</key>
<string>Record voice messages</string>
<!-- Face ID -->
<key>NSFaceIDUsageDescription</key>
<string>Unlock the app with Face ID</string>
Place in Assets.xcassets/AppIcon.appiconset/. Xcode 15+ only needs a single 1024x1024 image.
Use Info.plist storyboard or SwiftUI-based launch screen:
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
<key>UIImageName</key>
<string>LaunchLogo</string>
</dict>
When working with Xcode projects from the terminal/Claude Code:
# Build from CLI
xcodebuild -project MyApp.xcodeproj -scheme MyApp -sdk iphonesimulator build
# Run tests
xcodebuild test -project MyApp.xcodeproj -scheme MyApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16'
# Clean
xcodebuild clean -project MyApp.xcodeproj -scheme MyApp
# List schemes
xcodebuild -list -project MyApp.xcodeproj
# Open in Xcode
open MyApp.xcodeproj
# Generate xcodeproj from Package.swift (for SPM-based projects)
swift package generate-xcodeproj
# Resolve packages
swift package resolve
# Format Swift code (if SwiftFormat installed)
swift-format format --in-place --recursive Sources/
The .pbxproj file is Xcode's project file. When adding new Swift files from Claude Code:
.swift file in the correct directoryxcodegen / tuist for automated project generation)For CLI-friendly project management:
# Tuist (recommended)
brew install tuist
tuist init --platform ios
tuist generate # Generates .xcodeproj from Project.swift
# XcodeGen
brew install xcodegen
xcodegen generate # Generates .xcodeproj from project.yml
// Mark types as Sendable when crossing concurrency boundaries
struct Message: Codable, Identifiable, Sendable {
let id: String
let role: Role
let content: String
let timestamp: Date
enum Role: String, Codable, Sendable {
case user, assistant, system
}
}
// Use @MainActor for UI-bound classes
@MainActor
@Observable
final class ChatViewModel {
var messages: [Message] = []
// ...
}
// Structured concurrency
func loadData() async {
async let user = fetchUser()
async let conversations = fetchConversations()
let (u, c) = await (try? user, try? conversations)
self.user = u
self.conversations = c ?? []
}
| Web/Tailwind | SwiftUI Equivalent |
|---|---|
| flex | HStack / VStack / ZStack |
| flex-col | VStack |
| flex-row | HStack |
| grid | LazyVGrid / LazyHGrid |
| gap-4 | .spacing(16) on Stack or .padding() |
| p-4 | .padding(16) or .padding() |
| px-4 | .padding(.horizontal, 16) |
| rounded-xl | .clipShape(RoundedRectangle(cornerRadius: 12)) |
| rounded-full | .clipShape(Capsule()) or .clipShape(Circle()) |
| bg-gray-100 | .background(Color(.systemGray6)) |
| text-sm | .font(.caption) |
| text-lg | .font(.title3) |
| font-bold | .fontWeight(.bold) |
| text-center | .multilineTextAlignment(.center) |
| opacity-50 | .opacity(0.5) |
| shadow-md | .shadow(radius: 4) |
| hidden | .hidden() or conditional if |
| overflow-scroll | ScrollView |
| absolute / relative | ZStack with .offset() or GeometryReader |
| transition | .animation(.easeInOut, value: state) |
| hover: | .onHover { } (macOS) / no direct equivalent on iOS |
| dark: | @Environment(\.colorScheme) or adaptive Color(.label) |
| max-w-lg | .frame(maxWidth: 512) |
| w-full | .frame(maxWidth: .infinity) |
| border | .overlay(RoundedRectangle(...).stroke(...)) |
| divide-y | Use List with built-in dividers, or manual Divider() |
| truncate | .lineLimit(1) |
| sr-only | .accessibilityHidden(true) on visual, .accessibilityLabel() on interactive |
| Feature | Minimum iOS | |---------|------------| | SwiftUI | 13.0 | | Combine | 13.0 | | async/await | 15.0 | | NavigationStack | 16.0 | | Charts (Swift Charts) | 16.0 | | @Observable | 17.0 | | SwiftData | 17.0 | | #Preview macro | 17.0 | | String Catalogs | 17.0 | | TipKit | 17.0 | | Interactive Widgets | 17.0 | | Custom container views | 18.0 | | Liquid Glass | 26.0 |
Recommended minimum: iOS 17 — gives you @Observable, SwiftData, #Preview, and modern APIs while covering 90%+ of active devices.
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
development
Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.
development
End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.