From f62464bc36e20e8ea6ef111e158d056acdad8f52 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 16 Jan 2026 10:46:44 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Th=C3=AAm=20c=C3=A1c=20k=E1=BB=B9=20n?= =?UTF-8?q?=C4=83ng=20Swift=20m=E1=BB=9Bi=20v=E1=BB=81=20ki=E1=BA=BFn=20tr?= =?UTF-8?q?=C3=BAc=20doanh=20nghi=E1=BB=87p,=20m=E1=BA=A1ng,=20b=E1=BA=A3o?= =?UTF-8?q?=20m=E1=BA=ADt,=20m=E1=BA=ABu=20ki=E1=BB=83m=20th=E1=BB=AD=20v?= =?UTF-8?q?=C3=A0=20th=C3=A0nh=20ph=E1=BA=A7n=20UI.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swift-enterprise-architect/SKILL.md | 449 ++++++++++++++++++ .agent/skills/swift-networking/SKILL.md | 254 ++++++++++ .agent/skills/swift-security/SKILL.md | 294 ++++++++++++ .agent/skills/swift-testing-patterns/SKILL.md | 171 +++++++ .agent/skills/swift-ui-components/SKILL.md | 233 +++++++++ .../UserInterfaceState.xcuserstate | Bin 50051 -> 50051 bytes 6 files changed, 1401 insertions(+) create mode 100644 .agent/skills/swift-enterprise-architect/SKILL.md create mode 100644 .agent/skills/swift-networking/SKILL.md create mode 100644 .agent/skills/swift-security/SKILL.md create mode 100644 .agent/skills/swift-testing-patterns/SKILL.md create mode 100644 .agent/skills/swift-ui-components/SKILL.md diff --git a/.agent/skills/swift-enterprise-architect/SKILL.md b/.agent/skills/swift-enterprise-architect/SKILL.md new file mode 100644 index 00000000..fdb6ed67 --- /dev/null +++ b/.agent/skills/swift-enterprise-architect/SKILL.md @@ -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`(_ 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 diff --git a/.agent/skills/swift-networking/SKILL.md b/.agent/skills/swift-networking/SKILL.md new file mode 100644 index 00000000..78077e68 --- /dev/null +++ b/.agent/skills/swift-networking/SKILL.md @@ -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( + 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( + 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(endpoint: String) async throws -> T { + try await request(endpoint: endpoint, method: .get, body: nil as String?, headers: nil) + } + + /// POST request + func post(endpoint: String, body: B) async throws -> T { + try await request(endpoint: endpoint, method: .post, body: body, headers: nil) + } + + /// PUT request + func put(endpoint: String, body: B) async throws -> T { + try await request(endpoint: endpoint, method: .put, body: body, headers: nil) + } + + /// DELETE request + func delete(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) diff --git a/.agent/skills/swift-security/SKILL.md b/.agent/skills/swift-security/SKILL.md new file mode 100644 index 00000000..cb364206 --- /dev/null +++ b/.agent/skills/swift-security/SKILL.md @@ -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) diff --git a/.agent/skills/swift-testing-patterns/SKILL.md b/.agent/skills/swift-testing-patterns/SKILL.md new file mode 100644 index 00000000..9e67a305 --- /dev/null +++ b/.agent/skills/swift-testing-patterns/SKILL.md @@ -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( + 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() + 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) diff --git a/.agent/skills/swift-ui-components/SKILL.md b/.agent/skills/swift-ui-components/SKILL.md new file mode 100644 index 00000000..7532ae1d --- /dev/null +++ b/.agent/skills/swift-ui-components/SKILL.md @@ -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`(_ 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 diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/UserInterfaceState.xcuserstate b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/UserInterfaceState.xcuserstate index 15da0371c7f1e5bc5d603ce20651cf75b648c159..4d5c84cf119f04476848452c068287d2b5cc7414 100644 GIT binary patch delta 588 zcmYk0Ur3Wt7{}C%BSx>I%I77TT4kojOfB*2 z4t-g72t`>bt8Ujz<@vxSc_Oe;&P2M6&1J3C%ABl=l+{5eJSEO{cmNOK5p+Q}JccKN z4hcFe=tn_E1pOrFXF*5Xi3ZPzM*PqVeMB=q(=pRy7c?vAtV#bn+%ggO$kT^hrgyjO zirZyFz+0Fm4pIPbm7XNL6Lefp{5uTDdUS6Xh7saSg6W({g6W);`Dke|jKdUhrXULw zFe&JipkD-?PN}D08gfDbL34u6$QjgRCRnZ%%Ld&g1KheM4c|=Hyu8LWlfRq%LvAnE z3Nyt1cg1g5CeAihhb5ud1kKAqUL=E}MtUk%8%z9pf#sepDd?claW@lBX|^#VI!Ww1YW?4cnPm!8{WVUyot|okr(rwd>;?; g5I@dOI(Z|H@HjumC2!%a{0dL<6u)asceYsm0GFTX$N&HU delta 643 zcmXYsO-NKx6oB8mFQz&=PkG=FD8!f}Ev!tkO%Md8Xlg-=cKI_+DK>9X$Gqnf&bulI z8AaVNDxyXg)*=cXw6ZeG(iT}`Dw^V7{{JU`g+Yf$PMl&4Ocs|`Lt z4}64A5Q1L#41F*NV=xUf5QbIw4r}leew&%Lz_u;bWx07~w|dl>pkzoc$)#jjlPx6~ zn}%wrT$Up%N}O-pB}A$wMrt$>cO_Bm4b+-(`|oBdqehA-tZ`@su`!kYBEKi2)a@_z z=|vSNj`RE+cn+PCqZMAjOK68z&;hUE4JTuqjB_%<$s{LVIho>Qx)t8SJLrluNJ)~5 zlNnCtI0;8G47;HUz0qkU92D)1cHz#~M0cwFe^S!+>WZQZxuv?NQutC6MJ(F1ez`LU z1Dwnn*Fusi#lVnwU!0)9I7~{8AXw`8AXw@J5e}s6hFMsY94#;h^RU3lA}33nEVn3L zScVlY11Bq-e6wDCU^tb6*kn;|#EWSollmX|4mklKr3f zPn0A%niT^!xg5>OYBT9l>bac2Wz|SIea7dm@_KZCu17!TFEKaRqZpeR9pq%#e0aG* zv3Nbg=~uIN%E!#W<3TmhUHye7(tY$WJwh{R7R{k~^aMRgi>Zf}&@$?!K3YyIX%)Re zpV2>{gUP3$&ncCaU`gLSeH8(>3hgiWw1Hp}MCrM4#9Um6()H2?qr