feat: Thêm các kỹ năng Swift mới về kiến trúc doanh nghiệp, mạng, bảo mật, mẫu kiểm thử và thành phần UI.
This commit is contained in:
449
.agent/skills/swift-enterprise-architect/SKILL.md
Normal file
449
.agent/skills/swift-enterprise-architect/SKILL.md
Normal file
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: swift-enterprise-architect
|
||||
description: Kiến trúc và patterns cho ứng dụng SwiftUI Enterprise (MVVM, DI, Navigation, Project Structure). Use for iOS/macOS apps, SwiftUI development, hoặc khi cần structured Swift architecture.
|
||||
compatibility: "Swift 5.9+, iOS 17+, macOS 14+, SwiftUI"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
references: "Apple SwiftUI Documentation, Swift Concurrency"
|
||||
---
|
||||
|
||||
# Swift Enterprise Development Workflow
|
||||
|
||||
Quy trình 4 giai đoạn để phát triển ứng dụng SwiftUI theo chuẩn Enterprise.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Building iOS/macOS/visionOS apps / Xây dựng app Apple platforms
|
||||
- Creating enterprise SwiftUI applications / Tạo ứng dụng SwiftUI enterprise
|
||||
- Need MVVM + DI architecture / Cần kiến trúc MVVM + DI
|
||||
- Implementing navigation patterns / Triển khai điều hướng
|
||||
|
||||
**DO NOT use when:**
|
||||
- Simple single-screen apps / App đơn giản 1 màn hình
|
||||
- UIKit-only projects / Dự án chỉ UIKit
|
||||
- Backend Swift (use Vapor patterns) / Swift backend
|
||||
|
||||
## Overview / Tổng Quan
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ WORKFLOW 4 GIAI ĐOẠN (Swift Enterprise) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ PHASE 1 │────►│ PHASE 2 │ │
|
||||
│ │ PROJECT │ │ ARCHITECTURE │ │
|
||||
│ │ STRUCTURE │ │ │ │
|
||||
│ │ - Folders │ │ - MVVM Pattern │ │
|
||||
│ │ - Resources │ │ - DI Setup │ │
|
||||
│ │ - Config │ │ - Services │ │
|
||||
│ └─────────────┘ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ PHASE 4 │◄────│ PHASE 3 │ │
|
||||
│ │ PLATFORM │ │ UI & NAV │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Extensions│ │ - TabView │ │
|
||||
│ │ - Platform │ │ - NavStack │ │
|
||||
│ │ - Native │ │ - Sheets │ │
|
||||
│ └─────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Project Structure / Cấu Trúc Dự Án
|
||||
|
||||
**Goal**: Thiết lập cấu trúc thư mục chuẩn Enterprise
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
MyApp/
|
||||
├── MyAppApp.swift # App entry point
|
||||
├── ContentView.swift # Root view
|
||||
├── Core/ # Core utilities
|
||||
│ ├── Constants/
|
||||
│ │ └── Constants.swift # App-wide constants
|
||||
│ └── Extensions/
|
||||
│ ├── String+Extensions.swift
|
||||
│ └── View+Extensions.swift
|
||||
├── Models/ # Data models
|
||||
│ └── User.swift
|
||||
├── Services/ # Business services
|
||||
│ ├── APIService.swift
|
||||
│ └── AuthManager.swift
|
||||
├── ViewModels/ # MVVM ViewModels
|
||||
│ ├── AuthViewModel.swift
|
||||
│ └── HomeViewModel.swift
|
||||
├── Views/ # SwiftUI Views
|
||||
│ ├── Auth/
|
||||
│ │ ├── LoginView.swift
|
||||
│ │ └── RegisterView.swift
|
||||
│ ├── Home/
|
||||
│ │ └── HomeView.swift
|
||||
│ └── Screens/
|
||||
│ ├── ProfileView.swift
|
||||
│ └── SettingsView.swift
|
||||
└── Resources/
|
||||
├── Assets.xcassets/
|
||||
└── Localizable.strings
|
||||
```
|
||||
|
||||
### Constants Pattern
|
||||
|
||||
```swift
|
||||
// Core/Constants/Constants.swift
|
||||
|
||||
// MARK: - API Configuration
|
||||
enum APIConfig {
|
||||
static let baseURL = "https://api.example.com"
|
||||
static let apiVersion = "/api/v1"
|
||||
static let timeout: TimeInterval = 30.0
|
||||
}
|
||||
|
||||
// MARK: - Storage Keys
|
||||
enum StorageKeys {
|
||||
static let accessToken = "access_token"
|
||||
static let refreshToken = "refresh_token"
|
||||
static let userData = "user_data"
|
||||
}
|
||||
|
||||
// MARK: - Design System
|
||||
enum DesignSystem {
|
||||
// Spacing
|
||||
static let spacingXS: CGFloat = 4
|
||||
static let spacingSM: CGFloat = 8
|
||||
static let spacingMD: CGFloat = 16
|
||||
static let spacingLG: CGFloat = 24
|
||||
|
||||
// Corner Radius
|
||||
static let cornerRadiusSM: CGFloat = 8
|
||||
static let cornerRadiusMD: CGFloat = 12
|
||||
static let cornerRadiusLG: CGFloat = 16
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Architecture (MVVM + DI) / Kiến Trúc
|
||||
|
||||
**Goal**: Thiết lập MVVM pattern với Swift Concurrency
|
||||
|
||||
### ViewModel Pattern (BẮT BUỘC)
|
||||
|
||||
```swift
|
||||
// ViewModels/SomeViewModel.swift
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// ViewModel for SomeView
|
||||
/// ViewModel cho SomeView
|
||||
@MainActor
|
||||
final class SomeViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
/// Current items
|
||||
/// Các items hiện tại
|
||||
@Published private(set) var items: [Item] = []
|
||||
|
||||
/// Loading state
|
||||
/// Trạng thái đang tải
|
||||
@Published private(set) var isLoading = false
|
||||
|
||||
/// Error message
|
||||
/// Thông báo lỗi
|
||||
@Published var errorMessage: String?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let service: SomeServiceProtocol
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
/// Initialize with dependencies
|
||||
/// Khởi tạo với dependencies
|
||||
init(service: SomeServiceProtocol = SomeService.shared) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Load items from service
|
||||
/// Tải items từ service
|
||||
func loadItems() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
items = try await service.fetchItems()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Pattern
|
||||
|
||||
```swift
|
||||
// Services/SomeService.swift
|
||||
|
||||
/// Service protocol for DI
|
||||
/// Protocol service cho DI
|
||||
protocol SomeServiceProtocol {
|
||||
func fetchItems() async throws -> [Item]
|
||||
}
|
||||
|
||||
/// Main service implementation
|
||||
/// Implementation service chính
|
||||
final class SomeService: SomeServiceProtocol {
|
||||
|
||||
/// Shared singleton
|
||||
/// Singleton dùng chung
|
||||
static let shared = SomeService()
|
||||
|
||||
private init() {}
|
||||
|
||||
func fetchItems() async throws -> [Item] {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Injection via Init
|
||||
|
||||
```swift
|
||||
// ✅ GOOD: Protocol-based DI
|
||||
final class HomeViewModel: ObservableObject {
|
||||
private let authManager: AuthManagerProtocol
|
||||
private let apiService: APIServiceProtocol
|
||||
|
||||
init(
|
||||
authManager: AuthManagerProtocol = AuthManager.shared,
|
||||
apiService: APIServiceProtocol = APIService.shared
|
||||
) {
|
||||
self.authManager = authManager
|
||||
self.apiService = apiService
|
||||
}
|
||||
}
|
||||
|
||||
// Testing
|
||||
let mockAuth = MockAuthManager()
|
||||
let viewModel = HomeViewModel(authManager: mockAuth)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI & Navigation / Giao Diện & Điều Hướng
|
||||
|
||||
**Goal**: Xây dựng UI với TabView và NavigationStack
|
||||
|
||||
### TabView Navigation
|
||||
|
||||
```swift
|
||||
// ContentView.swift
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
/// Selected tab
|
||||
@State private var selectedTab: Tab = .home
|
||||
|
||||
/// Tab enumeration
|
||||
enum Tab: String, CaseIterable {
|
||||
case home, explore, profile
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: return "Home"
|
||||
case .explore: return "Explore"
|
||||
case .profile: return "Profile"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .home: return "house"
|
||||
case .explore: return "magnifyingglass"
|
||||
case .profile: return "person"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem { Label(Tab.home.title, systemImage: Tab.home.icon) }
|
||||
.tag(Tab.home)
|
||||
|
||||
ExploreView()
|
||||
.tabItem { Label(Tab.explore.title, systemImage: Tab.explore.icon) }
|
||||
.tag(Tab.explore)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label(Tab.profile.title, systemImage: Tab.profile.icon) }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### NavigationStack with Path
|
||||
|
||||
```swift
|
||||
// Views/Home/HomeView.swift
|
||||
|
||||
struct HomeView: View {
|
||||
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var navigationPath = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
List(viewModel.items) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetailView(item: item)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auth State Conditional UI
|
||||
|
||||
```swift
|
||||
// ContentView.swift with Auth State
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
@StateObject private var authManager = AuthManager.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch authManager.authState {
|
||||
case .unknown:
|
||||
ProgressView()
|
||||
case .unauthenticated:
|
||||
AuthContainerView()
|
||||
case .authenticated:
|
||||
MainTabView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await authManager.initialize()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Platform & Extensions / Nền Tảng & Extensions
|
||||
|
||||
### String Extensions
|
||||
|
||||
```swift
|
||||
// Core/Extensions/String+Extensions.swift
|
||||
|
||||
extension String {
|
||||
|
||||
/// Localized string
|
||||
/// Chuỗi đã bản địa hóa
|
||||
var localized: String {
|
||||
NSLocalizedString(self, comment: "")
|
||||
}
|
||||
|
||||
/// Email validation
|
||||
/// Kiểm tra email hợp lệ
|
||||
var isValidEmail: Bool {
|
||||
let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
|
||||
return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: self)
|
||||
}
|
||||
|
||||
/// Trimmed string
|
||||
/// Chuỗi đã trim
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### View Extensions
|
||||
|
||||
```swift
|
||||
// Core/Extensions/View+Extensions.swift
|
||||
|
||||
extension View {
|
||||
|
||||
/// Apply modifier conditionally
|
||||
/// Áp dụng modifier có điều kiện
|
||||
@ViewBuilder
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide keyboard
|
||||
/// Ẩn bàn phím
|
||||
func hideKeyboard() {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
| Mistake | Problem | Solution |
|
||||
|---------|---------|----------|
|
||||
| Logic in View | Hard to test | Move to ViewModel |
|
||||
| Missing `@MainActor` | Thread issues | Add to ViewModel class |
|
||||
| Force unwrap `!` | Crashes | Use `if let`, `guard let` |
|
||||
| Singleton abuse | Hard to test | Protocol-based DI |
|
||||
| No loading states | Bad UX | Add `isLoading` property |
|
||||
| Hardcoded strings | No i18n | Use `String.localized` |
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
| Category | Standard |
|
||||
|----------|----------|
|
||||
| Architecture | MVVM + Protocol DI |
|
||||
| ViewModel | `@MainActor final class` + `ObservableObject` |
|
||||
| State | `@Published`, `@State`, `@StateObject` |
|
||||
| Navigation | `NavigationStack` + `NavigationPath` |
|
||||
| Tabs | `TabView` with enum-based `Tab` |
|
||||
| Async | `async/await`, `.task {}` modifier |
|
||||
| Constants | Enum-based (no instance) |
|
||||
| Comments | Bilingual EN/VI |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Swift Networking](../swift-networking/SKILL.md) - HTTP client patterns
|
||||
- [Swift Security](../swift-security/SKILL.md) - Keychain & Auth
|
||||
- [Swift UI Components](../swift-ui-components/SKILL.md) - Reusable components
|
||||
- [Apple SwiftUI Docs](https://developer.apple.com/documentation/swiftui/)
|
||||
- [Swift Concurrency](https://developer.apple.com/documentation/swift/concurrency)
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo coding standards
|
||||
254
.agent/skills/swift-networking/SKILL.md
Normal file
254
.agent/skills/swift-networking/SKILL.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: swift-networking
|
||||
description: HTTP client, API handling, Error management cho Swift Enterprise. Use for REST APIs, URLSession, async networking, hoặc khi cần structured API layer.
|
||||
compatibility: "Swift 5.9+, iOS 17+, Foundation"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
references: "URLSession, Swift Concurrency"
|
||||
---
|
||||
|
||||
# Swift Networking Patterns
|
||||
|
||||
HTTP client và API handling patterns cho Swift Enterprise applications.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Building REST API client / Xây dựng REST API client
|
||||
- Need URLSession wrapper / Cần wrapper cho URLSession
|
||||
- Implementing token-based auth / Triển khai auth token-based
|
||||
- Error handling for APIs / Xử lý lỗi API
|
||||
|
||||
## Core Patterns / Mẫu Chính
|
||||
|
||||
### API Error Enum
|
||||
|
||||
```swift
|
||||
// Services/APIError.swift
|
||||
|
||||
/// API error types
|
||||
/// Các loại lỗi API
|
||||
enum APIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case noData
|
||||
case decodingError(Error)
|
||||
case networkError(Error)
|
||||
case serverError(statusCode: Int, message: String?)
|
||||
case unauthorized
|
||||
case forbidden
|
||||
case notFound
|
||||
case rateLimited
|
||||
case unknown
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Invalid URL / URL không hợp lệ"
|
||||
case .noData:
|
||||
return "No data received / Không nhận được dữ liệu"
|
||||
case .decodingError(let error):
|
||||
return "Decoding error: \(error.localizedDescription)"
|
||||
case .networkError(let error):
|
||||
return "Network error: \(error.localizedDescription)"
|
||||
case .serverError(let code, let message):
|
||||
return "Server error (\(code)): \(message ?? "Unknown")"
|
||||
case .unauthorized:
|
||||
return "Unauthorized / Chưa xác thực"
|
||||
case .forbidden:
|
||||
return "Access forbidden / Truy cập bị từ chối"
|
||||
case .notFound:
|
||||
return "Resource not found / Không tìm thấy"
|
||||
case .rateLimited:
|
||||
return "Rate limited / Giới hạn request"
|
||||
case .unknown:
|
||||
return "Unknown error / Lỗi không xác định"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Method Enum
|
||||
|
||||
```swift
|
||||
/// HTTP request methods
|
||||
/// Các phương thức HTTP
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case put = "PUT"
|
||||
case patch = "PATCH"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
```
|
||||
|
||||
### API Service Protocol
|
||||
|
||||
```swift
|
||||
/// API service protocol for DI
|
||||
/// Protocol API service cho DI
|
||||
protocol APIServiceProtocol {
|
||||
func request<T: Decodable>(
|
||||
endpoint: String,
|
||||
method: HTTPMethod,
|
||||
body: Encodable?,
|
||||
headers: [String: String]?
|
||||
) async throws -> T
|
||||
}
|
||||
```
|
||||
|
||||
### API Service Implementation
|
||||
|
||||
```swift
|
||||
// Services/APIService.swift
|
||||
|
||||
/// Main API service
|
||||
/// Dịch vụ API chính
|
||||
final class APIService: APIServiceProtocol {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = APIService()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let session: URLSession
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
|
||||
self.encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
// MARK: - Request
|
||||
|
||||
/// Perform network request
|
||||
/// Thực hiện request network
|
||||
func request<T: Decodable>(
|
||||
endpoint: String,
|
||||
method: HTTPMethod = .get,
|
||||
body: Encodable? = nil,
|
||||
headers: [String: String]? = nil
|
||||
) async throws -> T {
|
||||
|
||||
// Build URL
|
||||
guard let url = URL(string: APIConfig.baseURL + APIConfig.apiVersion + endpoint) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
// Create request
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.timeoutInterval = APIConfig.timeout
|
||||
|
||||
// Set headers
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
// Add auth token
|
||||
if let token = await AuthManager.shared.accessToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
headers?.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Set body
|
||||
if let body = body {
|
||||
request.httpBody = try encoder.encode(body)
|
||||
}
|
||||
|
||||
// Perform request
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
// Handle response
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.unknown
|
||||
}
|
||||
|
||||
// Check status code
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299:
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
case 401:
|
||||
await AuthManager.shared.handleUnauthorized()
|
||||
throw APIError.unauthorized
|
||||
case 403:
|
||||
throw APIError.forbidden
|
||||
case 404:
|
||||
throw APIError.notFound
|
||||
case 429:
|
||||
throw APIError.rateLimited
|
||||
default:
|
||||
let message = String(data: data, encoding: .utf8)
|
||||
throw APIError.serverError(statusCode: httpResponse.statusCode, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Methods
|
||||
|
||||
/// GET request
|
||||
func get<T: Decodable>(endpoint: String) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .get, body: nil as String?, headers: nil)
|
||||
}
|
||||
|
||||
/// POST request
|
||||
func post<T: Decodable, B: Encodable>(endpoint: String, body: B) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .post, body: body, headers: nil)
|
||||
}
|
||||
|
||||
/// PUT request
|
||||
func put<T: Decodable, B: Encodable>(endpoint: String, body: B) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .put, body: body, headers: nil)
|
||||
}
|
||||
|
||||
/// DELETE request
|
||||
func delete<T: Decodable>(endpoint: String) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .delete, body: nil as String?, headers: nil)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
| Mistake | Problem | Solution |
|
||||
|---------|---------|----------|
|
||||
| No error handling | Crashes | Use `do-catch` with custom errors |
|
||||
| Force unwrap URLs | Crashes | Guard with `APIError.invalidURL` |
|
||||
| Blocking main thread | Frozen UI | Use `async/await` |
|
||||
| No timeout | Hanging requests | Set `timeoutInterval` |
|
||||
| Hardcoded URLs | Hard to maintain | Use `APIConfig` enum |
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
| Category | Standard |
|
||||
|----------|----------|
|
||||
| HTTP Client | URLSession with async/await |
|
||||
| Errors | Custom `APIError` enum |
|
||||
| Encoding | snake_case JSON |
|
||||
| Auth | Bearer token injection |
|
||||
| Timeout | 30 seconds default |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Swift Enterprise Architect](../swift-enterprise-architect/SKILL.md) - Architecture
|
||||
- [Swift Security](../swift-security/SKILL.md) - Token management
|
||||
- [Apple URLSession](https://developer.apple.com/documentation/foundation/urlsession)
|
||||
294
.agent/skills/swift-security/SKILL.md
Normal file
294
.agent/skills/swift-security/SKILL.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: swift-security
|
||||
description: Security patterns cho Swift - Keychain, Token management, Auth State Machine, Biometric. Use for secure storage, authentication flows, hoặc khi cần security best practices.
|
||||
compatibility: "Swift 5.9+, iOS 17+, Security Framework"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
references: "Apple Security Framework, Keychain Services"
|
||||
---
|
||||
|
||||
# Swift Security Patterns
|
||||
|
||||
Keychain, Token management, và Auth patterns cho Swift Enterprise.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Storing sensitive data / Lưu trữ dữ liệu nhạy cảm
|
||||
- Implementing auth flows / Triển khai luồng xác thực
|
||||
- Token management / Quản lý tokens
|
||||
- Biometric authentication / Xác thực sinh trắc học
|
||||
|
||||
## Core Patterns / Mẫu Chính
|
||||
|
||||
### Keychain Helper
|
||||
|
||||
```swift
|
||||
// Services/KeychainHelper.swift
|
||||
|
||||
import Security
|
||||
|
||||
/// Keychain operations helper
|
||||
/// Helper cho các thao tác Keychain
|
||||
enum KeychainHelper {
|
||||
|
||||
/// Save value to Keychain
|
||||
/// Lưu giá trị vào Keychain
|
||||
static func save(key: String, value: String) {
|
||||
guard let data = value.data(using: .utf8) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: AppConstants.keychainService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
// Delete existing
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
// Add new
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
/// Read value from Keychain
|
||||
/// Đọc giá trị từ Keychain
|
||||
static func read(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: AppConstants.keychainService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var dataTypeRef: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = dataTypeRef as? Data,
|
||||
let value = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/// Delete value from Keychain
|
||||
/// Xóa giá trị khỏi Keychain
|
||||
static func delete(key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: AppConstants.keychainService,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auth State Machine
|
||||
|
||||
```swift
|
||||
// Services/AuthState.swift
|
||||
|
||||
/// Authentication state enumeration
|
||||
/// Enum trạng thái xác thực
|
||||
enum AuthState: Equatable {
|
||||
case unknown
|
||||
case unauthenticated
|
||||
case authenticated(User)
|
||||
|
||||
var isAuthenticated: Bool {
|
||||
if case .authenticated = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var user: User? {
|
||||
if case .authenticated(let user) = self {
|
||||
return user
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Manager
|
||||
|
||||
```swift
|
||||
// Services/AuthManager.swift
|
||||
|
||||
/// Authentication manager protocol
|
||||
protocol AuthManagerProtocol {
|
||||
var authState: AuthState { get }
|
||||
var accessToken: String? { get }
|
||||
func initialize() async
|
||||
func login(email: String, password: String) async throws
|
||||
func logout()
|
||||
}
|
||||
|
||||
/// Main authentication manager
|
||||
/// Quản lý xác thực chính
|
||||
@MainActor
|
||||
final class AuthManager: ObservableObject, AuthManagerProtocol {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AuthManager()
|
||||
|
||||
// MARK: - Published
|
||||
|
||||
@Published private(set) var authState: AuthState = .unknown
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var accessToken: String? {
|
||||
KeychainHelper.read(key: StorageKeys.accessToken)
|
||||
}
|
||||
|
||||
var refreshToken: String? {
|
||||
KeychainHelper.read(key: StorageKeys.refreshToken)
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Initialize auth on app launch
|
||||
/// Khởi tạo auth khi app khởi động
|
||||
func initialize() async {
|
||||
guard accessToken != nil else {
|
||||
authState = .unauthenticated
|
||||
return
|
||||
}
|
||||
|
||||
// Try cached user
|
||||
if let userData = UserDefaults.standard.data(forKey: StorageKeys.userData),
|
||||
let user = try? JSONDecoder().decode(User.self, from: userData) {
|
||||
authState = .authenticated(user)
|
||||
} else {
|
||||
await refreshCurrentUser()
|
||||
}
|
||||
}
|
||||
|
||||
/// Login with credentials
|
||||
/// Đăng nhập với credentials
|
||||
func login(email: String, password: String) async throws {
|
||||
struct LoginRequest: Encodable {
|
||||
let email: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let user: User
|
||||
}
|
||||
|
||||
let request = LoginRequest(email: email, password: password)
|
||||
let response: LoginResponse = try await APIService.shared.post(
|
||||
endpoint: "/auth/login",
|
||||
body: request
|
||||
)
|
||||
|
||||
// Save tokens
|
||||
KeychainHelper.save(key: StorageKeys.accessToken, value: response.accessToken)
|
||||
KeychainHelper.save(key: StorageKeys.refreshToken, value: response.refreshToken)
|
||||
|
||||
// Cache user
|
||||
if let userData = try? JSONEncoder().encode(response.user) {
|
||||
UserDefaults.standard.set(userData, forKey: StorageKeys.userData)
|
||||
}
|
||||
|
||||
authState = .authenticated(response.user)
|
||||
}
|
||||
|
||||
/// Logout current user
|
||||
/// Đăng xuất
|
||||
func logout() {
|
||||
KeychainHelper.delete(key: StorageKeys.accessToken)
|
||||
KeychainHelper.delete(key: StorageKeys.refreshToken)
|
||||
UserDefaults.standard.removeObject(forKey: StorageKeys.userData)
|
||||
|
||||
authState = .unauthenticated
|
||||
}
|
||||
|
||||
/// Handle 401 unauthorized
|
||||
/// Xử lý 401 unauthorized
|
||||
func handleUnauthorized() {
|
||||
Task {
|
||||
let success = await refreshTokens()
|
||||
if !success {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func refreshCurrentUser() async {
|
||||
do {
|
||||
let user: User = try await APIService.shared.get(endpoint: "/auth/me")
|
||||
if let userData = try? JSONEncoder().encode(user) {
|
||||
UserDefaults.standard.set(userData, forKey: StorageKeys.userData)
|
||||
}
|
||||
authState = .authenticated(user)
|
||||
} catch {
|
||||
authState = .unauthenticated
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshTokens() async -> Bool {
|
||||
guard let refreshToken = refreshToken else { return false }
|
||||
|
||||
struct RefreshRequest: Encodable { let refreshToken: String }
|
||||
struct RefreshResponse: Decodable { let accessToken: String; let refreshToken: String }
|
||||
|
||||
do {
|
||||
let response: RefreshResponse = try await APIService.shared.post(
|
||||
endpoint: "/auth/refresh",
|
||||
body: RefreshRequest(refreshToken: refreshToken)
|
||||
)
|
||||
KeychainHelper.save(key: StorageKeys.accessToken, value: response.accessToken)
|
||||
KeychainHelper.save(key: StorageKeys.refreshToken, value: response.refreshToken)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
| Mistake | Problem | Solution |
|
||||
|---------|---------|----------|
|
||||
| Store tokens in UserDefaults | Insecure | Use Keychain |
|
||||
| No token refresh | User logged out | Implement refresh flow |
|
||||
| Missing `@MainActor` | Thread issues | Add to AuthManager |
|
||||
| Hardcoded service name | Conflicts | Use `AppConstants` |
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
| Category | Standard |
|
||||
|----------|----------|
|
||||
| Token Storage | Keychain only |
|
||||
| Auth State | Enum-based state machine |
|
||||
| Manager | `@MainActor` + `ObservableObject` |
|
||||
| User Cache | UserDefaults (non-sensitive) |
|
||||
| 401 Handling | Refresh token, then logout |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Swift Enterprise Architect](../swift-enterprise-architect/SKILL.md) - Architecture
|
||||
- [Swift Networking](../swift-networking/SKILL.md) - API layer
|
||||
- [Apple Keychain Services](https://developer.apple.com/documentation/security/keychain_services)
|
||||
171
.agent/skills/swift-testing-patterns/SKILL.md
Normal file
171
.agent/skills/swift-testing-patterns/SKILL.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: swift-testing-patterns
|
||||
description: Unit testing, Mocking, UI testing patterns cho Swift Enterprise (XCTest, async testing). Use for ViewModels testing, mocking services, hoặc testing best practices.
|
||||
compatibility: "Swift 5.9+, XCTest, Swift Testing"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Swift Testing Patterns
|
||||
|
||||
Unit và Integration testing patterns cho Swift Enterprise.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Writing unit tests / Viết unit tests
|
||||
- Mocking services / Mock services
|
||||
- Testing ViewModels / Test ViewModels
|
||||
- Async code testing / Test async code
|
||||
|
||||
## Core Patterns / Mẫu Chính
|
||||
|
||||
### Mock Service
|
||||
|
||||
```swift
|
||||
// Tests/Mocks/MockAPIService.swift
|
||||
|
||||
final class MockAPIService: APIServiceProtocol {
|
||||
|
||||
var mockResult: Any?
|
||||
var mockError: Error?
|
||||
var requestCalled = false
|
||||
|
||||
func request<T: Decodable>(
|
||||
endpoint: String,
|
||||
method: HTTPMethod,
|
||||
body: Encodable?,
|
||||
headers: [String: String]?
|
||||
) async throws -> T {
|
||||
requestCalled = true
|
||||
|
||||
if let error = mockError {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let result = mockResult as? T else {
|
||||
throw APIError.unknown
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel Testing
|
||||
|
||||
```swift
|
||||
// Tests/ViewModelTests/HomeViewModelTests.swift
|
||||
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
@MainActor
|
||||
final class HomeViewModelTests: XCTestCase {
|
||||
|
||||
var sut: HomeViewModel!
|
||||
var mockService: MockAPIService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockService = MockAPIService()
|
||||
sut = HomeViewModel(apiService: mockService)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
mockService = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_loadItems_success() async {
|
||||
// Arrange
|
||||
let expectedItems = [Item(id: "1", name: "Test")]
|
||||
mockService.mockResult = expectedItems
|
||||
|
||||
// Act
|
||||
await sut.loadItems()
|
||||
|
||||
// Assert
|
||||
XCTAssertEqual(sut.items.count, 1)
|
||||
XCTAssertEqual(sut.items.first?.name, "Test")
|
||||
XCTAssertFalse(sut.isLoading)
|
||||
XCTAssertNil(sut.errorMessage)
|
||||
}
|
||||
|
||||
func test_loadItems_failure() async {
|
||||
// Arrange
|
||||
mockService.mockError = APIError.networkError(NSError(domain: "", code: -1))
|
||||
|
||||
// Act
|
||||
await sut.loadItems()
|
||||
|
||||
// Assert
|
||||
XCTAssertTrue(sut.items.isEmpty)
|
||||
XCTAssertNotNil(sut.errorMessage)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Testing
|
||||
|
||||
```swift
|
||||
func test_asyncOperation() async throws {
|
||||
// Use async/await directly
|
||||
let result = try await sut.performAsync()
|
||||
XCTAssertTrue(result)
|
||||
}
|
||||
|
||||
// With expectation (legacy)
|
||||
func test_asyncWithExpectation() {
|
||||
let expectation = expectation(description: "Async complete")
|
||||
|
||||
Task {
|
||||
await sut.loadData()
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 5.0)
|
||||
XCTAssertFalse(sut.items.isEmpty)
|
||||
}
|
||||
```
|
||||
|
||||
### Published Property Testing
|
||||
|
||||
```swift
|
||||
import Combine
|
||||
|
||||
func test_publishedProperty() {
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
let expectation = expectation(description: "Value changed")
|
||||
|
||||
sut.$items
|
||||
.dropFirst() // Skip initial value
|
||||
.sink { items in
|
||||
XCTAssertEqual(items.count, 1)
|
||||
expectation.fulfill()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task {
|
||||
await sut.loadItems()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 5.0)
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
| Pattern | Usage |
|
||||
|---------|-------|
|
||||
| Mock Service | Protocol + mock implementation |
|
||||
| `@MainActor` | Required for ViewModel tests |
|
||||
| Arrange-Act-Assert | Standard test structure |
|
||||
| `async throws` | Direct async testing |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Swift Enterprise Architect](../swift-enterprise-architect/SKILL.md)
|
||||
- [Apple XCTest](https://developer.apple.com/documentation/xctest)
|
||||
233
.agent/skills/swift-ui-components/SKILL.md
Normal file
233
.agent/skills/swift-ui-components/SKILL.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: swift-ui-components
|
||||
description: Reusable SwiftUI components, Extensions, Validation patterns. Use for String/View extensions, custom UI components, hoặc khi cần reusable code.
|
||||
compatibility: "Swift 5.9+, iOS 17+, SwiftUI"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Swift UI Components & Extensions
|
||||
|
||||
Reusable components và Extensions cho SwiftUI Enterprise.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Creating string validation / Tạo validation chuỗi
|
||||
- Building reusable views / Xây dựng views tái sử dụng
|
||||
- View modifiers / Thêm view modifiers
|
||||
- Localization patterns / Patterns đa ngôn ngữ
|
||||
|
||||
## String Extensions
|
||||
|
||||
```swift
|
||||
// Core/Extensions/String+Extensions.swift
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
|
||||
// MARK: - Localization
|
||||
|
||||
/// Localized string
|
||||
var localized: String {
|
||||
NSLocalizedString(self, comment: "")
|
||||
}
|
||||
|
||||
/// Localized with arguments
|
||||
func localized(with arguments: CVarArg...) -> String {
|
||||
String(format: localized, arguments: arguments)
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Valid email check
|
||||
var isValidEmail: Bool {
|
||||
let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
|
||||
return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: self)
|
||||
}
|
||||
|
||||
/// Valid Vietnamese phone
|
||||
var isValidVietnamesePhone: Bool {
|
||||
let regex = "^(0|\\+84)(3|5|7|8|9)[0-9]{8}$"
|
||||
return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: self)
|
||||
}
|
||||
|
||||
/// Valid password (8+ chars, upper, lower, digit)
|
||||
var isValidPassword: Bool {
|
||||
let regex = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]{8,}$"
|
||||
return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: self)
|
||||
}
|
||||
|
||||
/// Trimmed string
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// Is blank (empty or whitespace)
|
||||
var isBlank: Bool {
|
||||
trimmed.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
/// Masked email (j***@example.com)
|
||||
var maskedEmail: String {
|
||||
guard isValidEmail else { return self }
|
||||
let parts = split(separator: "@")
|
||||
guard parts.count == 2 else { return self }
|
||||
let local = String(parts[0])
|
||||
let domain = String(parts[1])
|
||||
if local.count <= 2 {
|
||||
return "\(local.prefix(1))***@\(domain)"
|
||||
}
|
||||
return "\(local.prefix(1))***\(local.suffix(1))@\(domain)"
|
||||
}
|
||||
|
||||
/// Format VND currency
|
||||
static func formatVND(_ amount: Double) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = "VND"
|
||||
formatter.currencySymbol = "₫"
|
||||
formatter.maximumFractionDigits = 0
|
||||
return formatter.string(from: NSNumber(value: amount)) ?? "\(Int(amount))₫"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## View Extensions
|
||||
|
||||
```swift
|
||||
// Core/Extensions/View+Extensions.swift
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
|
||||
/// Conditional modifier
|
||||
@ViewBuilder
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide keyboard
|
||||
func hideKeyboard() {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Corner radius with specific corners
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom rounded corner shape
|
||||
struct RoundedCorner: Shape {
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(
|
||||
roundedRect: rect,
|
||||
byRoundingCorners: corners,
|
||||
cornerRadii: CGSize(width: radius, height: radius)
|
||||
)
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Components
|
||||
|
||||
### Primary Button
|
||||
|
||||
```swift
|
||||
/// Primary action button
|
||||
struct PrimaryButton: View {
|
||||
let title: String
|
||||
let action: () -> Void
|
||||
var isLoading: Bool = false
|
||||
var isDisabled: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: DesignSystem.spacingSM) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, DesignSystem.spacingMD)
|
||||
.background(isDisabled ? Color.gray : Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(DesignSystem.cornerRadiusMD)
|
||||
}
|
||||
.disabled(isDisabled || isLoading)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Text Field with Validation
|
||||
|
||||
```swift
|
||||
/// Validated text field
|
||||
struct ValidatedTextField: View {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var isSecure: Bool = false
|
||||
var isValid: Bool = true
|
||||
var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DesignSystem.spacingXS) {
|
||||
Group {
|
||||
if isSecure {
|
||||
SecureField(placeholder, text: $text)
|
||||
} else {
|
||||
TextField(placeholder, text: $text)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(DesignSystem.cornerRadiusSM)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusSM)
|
||||
.stroke(isValid ? Color.clear : Color.red, lineWidth: 1)
|
||||
)
|
||||
|
||||
if let error = errorMessage, !isValid {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
| Pattern | Usage |
|
||||
|---------|-------|
|
||||
| `string.localized` | NSLocalizedString wrapper |
|
||||
| `string.isValidEmail` | Email regex validation |
|
||||
| `string.trimmed` | Remove whitespace |
|
||||
| `.if(condition) { }` | Conditional modifier |
|
||||
| `PrimaryButton` | Loading + disabled support |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Swift Enterprise Architect](../swift-enterprise-architect/SKILL.md)
|
||||
- [Swift Security](../swift-security/SKILL.md) - Validation patterns
|
||||
Reference in New Issue
Block a user