feat: add complete Mayday iOS Xcode project
- Swift 6, SwiftUI, MVVM + async/await architecture - iOS 17.0 minimum deployment target - Two targets: Mayday app + MaydayLiveActivity widget extension - Models: UserResponse, TokenPair, AppNotification, SessionResponse, AlertAttributes - Services: HTTPClient (actor), AuthService, KeychainService, NotificationsAPIService, PushNotificationService - ViewModels: AuthViewModel, NotificationsViewModel, SettingsViewModel - Views: Login/Register/VerifyEmail, NotificationsList/Detail, Settings/ChangePassword/Sessions - APNs push notifications with UIApplicationDelegate - ActivityKit Live Activities for Dynamic Island + Lock Screen - Keychain (Security framework) token storage - 30-second polling with pagination for notifications - Xcode project file (project.pbxproj) with correct build phases for both targets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserResponse
|
||||
let tokens: TokenPair
|
||||
}
|
||||
|
||||
struct RegisterResponse: Decodable {
|
||||
let user: UserResponse
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, email, status, metadata, roles
|
||||
case emailVerifiedAt = "email_verified_at"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
user = try UserResponse(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
struct VerifyEmailResponse: Decodable {
|
||||
let user: UserResponse
|
||||
}
|
||||
|
||||
actor AuthService {
|
||||
static let shared = AuthService()
|
||||
private let client = HTTPClient.shared
|
||||
private let keychain = KeychainService.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
func login(email: String, password: String) async throws -> UserResponse {
|
||||
let response: LoginResponse = try await client.request(.login(email: email, password: password))
|
||||
try keychain.saveTokens(response.tokens)
|
||||
return response.user
|
||||
}
|
||||
|
||||
func register(email: String, password: String) async throws -> UserResponse {
|
||||
let response: UserResponse = try await client.request(.register(email: email, password: password))
|
||||
return response
|
||||
}
|
||||
|
||||
func verifyEmail(email: String, code: String) async throws -> UserResponse {
|
||||
let response: VerifyEmailResponse = try await client.request(.verifyEmail(email: email, code: code))
|
||||
return response.user
|
||||
}
|
||||
|
||||
func resendCode(email: String) async throws {
|
||||
let _: ResendCodeResponse = try await client.request(.resendCode(email: email))
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
guard let refreshToken = keychain.loadRefreshToken() else { return }
|
||||
let _: EmptyResponse = try await client.request(.logout(refreshToken: refreshToken))
|
||||
keychain.clearTokens()
|
||||
}
|
||||
|
||||
func getMe() async throws -> UserResponse {
|
||||
try await client.request(.getMe)
|
||||
}
|
||||
}
|
||||
|
||||
struct ResendCodeResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
@@ -0,0 +1,217 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case unauthorized
|
||||
case validationError([String: [String]])
|
||||
case serverError(String)
|
||||
case networkError(Error)
|
||||
case decodingError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .unauthorized: return "Неверный email или пароль"
|
||||
case .validationError(let errors):
|
||||
return errors.values.flatMap { $0 }.joined(separator: ", ")
|
||||
case .serverError(let message): return message
|
||||
case .networkError(let error): return error.localizedDescription
|
||||
case .decodingError(let error): return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct APIResponse<T: Decodable>: Decodable {
|
||||
let data: T
|
||||
}
|
||||
|
||||
struct APIErrorResponse: Decodable {
|
||||
let message: String
|
||||
let errors: [String: [String]]?
|
||||
}
|
||||
|
||||
enum Endpoint {
|
||||
// Auth
|
||||
case login(email: String, password: String)
|
||||
case register(email: String, password: String)
|
||||
case verifyEmail(email: String, code: String)
|
||||
case resendCode(email: String)
|
||||
case refresh(refreshToken: String)
|
||||
case logout(refreshToken: String)
|
||||
// Users
|
||||
case getMe
|
||||
case getSessions
|
||||
case deleteSession(id: UUID)
|
||||
case logoutAll
|
||||
case changePassword(current: String, new: String)
|
||||
// Notifications
|
||||
case getNotifications(page: Int, perPage: Int)
|
||||
case markAsRead(id: UUID)
|
||||
// Devices
|
||||
case registerDevice(token: String)
|
||||
case unregisterDevice(token: String)
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .login: return "/auth/login"
|
||||
case .register: return "/auth/register"
|
||||
case .verifyEmail: return "/auth/verify-email"
|
||||
case .resendCode: return "/auth/resend-code"
|
||||
case .refresh: return "/auth/refresh"
|
||||
case .logout: return "/auth/logout"
|
||||
case .getMe: return "/users/me"
|
||||
case .getSessions: return "/users/me/sessions"
|
||||
case .deleteSession(let id): return "/users/me/sessions/\(id.uuidString)"
|
||||
case .logoutAll: return "/users/me/logout-all"
|
||||
case .changePassword: return "/users/me/change-password"
|
||||
case .getNotifications: return "/notifications"
|
||||
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
||||
case .registerDevice: return "/devices/register"
|
||||
case .unregisterDevice: return "/devices/unregister"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .getMe, .getSessions, .getNotifications: return "GET"
|
||||
case .deleteSession: return "DELETE"
|
||||
case .markAsRead: return "PATCH"
|
||||
default: return "POST"
|
||||
}
|
||||
}
|
||||
|
||||
var requiresAuth: Bool {
|
||||
switch self {
|
||||
case .login, .register, .verifyEmail, .resendCode, .refresh, .logout:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var body: [String: Any]? {
|
||||
switch self {
|
||||
case .login(let email, let password):
|
||||
return ["email": email, "password": password]
|
||||
case .register(let email, let password):
|
||||
return ["email": email, "password": password]
|
||||
case .verifyEmail(let email, let code):
|
||||
return ["email": email, "code": code]
|
||||
case .resendCode(let email):
|
||||
return ["email": email]
|
||||
case .refresh(let token):
|
||||
return ["refresh_token": token]
|
||||
case .logout(let token):
|
||||
return ["refresh_token": token]
|
||||
case .changePassword(let current, let new):
|
||||
return ["current_password": current, "new_password": new]
|
||||
case .registerDevice(let token):
|
||||
return ["token": token, "platform": "ios"]
|
||||
case .unregisterDevice(let token):
|
||||
return ["token": token]
|
||||
case .getNotifications(let page, let perPage):
|
||||
return ["page": page, "per_page": perPage]
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor HTTPClient {
|
||||
static let shared = HTTPClient()
|
||||
|
||||
private let baseURL: String
|
||||
private let keychain = KeychainService.shared
|
||||
private var isRefreshing = false
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
baseURL = "http://localhost:8081"
|
||||
#else
|
||||
baseURL = "https://api.chemodan.example/sso"
|
||||
#endif
|
||||
}
|
||||
|
||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
let response: T = try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||
return response
|
||||
}
|
||||
|
||||
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
|
||||
guard let url = URL(string: baseURL + endpoint.path) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = endpoint.method
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if endpoint.requiresAuth, let token = keychain.loadAccessToken() {
|
||||
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let body = endpoint.body, endpoint.method != "GET" {
|
||||
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 && retryOnUnauthorized && !isRefreshing {
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
try await refreshTokens()
|
||||
return try await performRequest(endpoint, retryOnUnauthorized: false)
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
keychain.clearTokens()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 422 {
|
||||
if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data) {
|
||||
throw APIError.validationError(errorResponse.errors ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
if !(200..<300).contains(httpResponse.statusCode) {
|
||||
if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data) {
|
||||
throw APIError.serverError(errorResponse.message)
|
||||
}
|
||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
do {
|
||||
let wrapped = try decoder.decode(APIResponse<T>.self, from: data)
|
||||
return wrapped.data
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshTokens() async throws {
|
||||
guard let refreshToken = keychain.loadRefreshToken() else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
let response: TokenRefreshResponse = try await performRequest(
|
||||
.refresh(refreshToken: refreshToken),
|
||||
retryOnUnauthorized: false
|
||||
)
|
||||
try keychain.saveTokens(response.tokens)
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenRefreshResponse: Decodable {
|
||||
let tokens: TokenPair
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
final class KeychainService: Sendable {
|
||||
static let shared = KeychainService()
|
||||
|
||||
private let accessTokenKey = "mayday.access_token"
|
||||
private let refreshTokenKey = "mayday.refresh_token"
|
||||
private let expiresAtKey = "mayday.expires_at"
|
||||
|
||||
private init() {}
|
||||
|
||||
func saveTokens(_ tokens: TokenPair) throws {
|
||||
try save(tokens.accessToken, forKey: accessTokenKey)
|
||||
try save(tokens.refreshToken, forKey: refreshTokenKey)
|
||||
let expiresAtString = ISO8601DateFormatter().string(from: tokens.expiresAt)
|
||||
try save(expiresAtString, forKey: expiresAtKey)
|
||||
}
|
||||
|
||||
func loadAccessToken() -> String? {
|
||||
load(forKey: accessTokenKey)
|
||||
}
|
||||
|
||||
func loadRefreshToken() -> String? {
|
||||
load(forKey: refreshTokenKey)
|
||||
}
|
||||
|
||||
func clearTokens() {
|
||||
delete(forKey: accessTokenKey)
|
||||
delete(forKey: refreshTokenKey)
|
||||
delete(forKey: expiresAtKey)
|
||||
}
|
||||
|
||||
private func save(_ value: String, forKey key: String) throws {
|
||||
let data = Data(value.utf8)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private func load(forKey key: String) -> String? {
|
||||
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,
|
||||
let data = result as? Data,
|
||||
let string = String(data: data, encoding: .utf8) else { return nil }
|
||||
return string
|
||||
}
|
||||
|
||||
private func delete(forKey key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: Error {
|
||||
case saveFailed(OSStatus)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
actor NotificationsAPIService {
|
||||
static let shared = NotificationsAPIService()
|
||||
private let client = HTTPClient.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage {
|
||||
try await client.request(.getNotifications(page: page, perPage: perPage))
|
||||
}
|
||||
|
||||
func markAsRead(id: UUID) async throws {
|
||||
let _: AppNotification = try await client.request(.markAsRead(id: id))
|
||||
}
|
||||
|
||||
func getSessions() async throws -> [SessionResponse] {
|
||||
try await client.request(.getSessions)
|
||||
}
|
||||
|
||||
func deleteSession(id: UUID) async throws {
|
||||
let _: EmptyResponse = try await client.request(.deleteSession(id: id))
|
||||
}
|
||||
|
||||
func logoutAll() async throws -> Int {
|
||||
let response: LogoutAllResponse = try await client.request(.logoutAll)
|
||||
return response.revokedSessions
|
||||
}
|
||||
|
||||
func changePassword(current: String, new: String) async throws -> UserResponse {
|
||||
try await client.request(.changePassword(current: current, new: new))
|
||||
}
|
||||
|
||||
func updateAppBadge(_ count: Int) async {
|
||||
await UIApplication.shared.setApplicationIconBadgeNumber(count)
|
||||
}
|
||||
}
|
||||
|
||||
struct LogoutAllResponse: Decodable {
|
||||
let revokedSessions: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case revokedSessions = "revoked_sessions"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
import ActivityKit
|
||||
|
||||
@MainActor
|
||||
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = PushNotificationService()
|
||||
|
||||
@Published var deviceToken: String?
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
do {
|
||||
let granted = try await UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
return granted
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func registerForRemoteNotifications() {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
func handleDeviceToken(_ tokenData: Data) {
|
||||
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
deviceToken = token
|
||||
Task {
|
||||
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
|
||||
guard let aps = userInfo["aps"] as? [String: Any] else { return }
|
||||
|
||||
// Handle Live Activity push
|
||||
if let event = aps["event"] as? String {
|
||||
await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps)
|
||||
return
|
||||
}
|
||||
|
||||
// Update badge
|
||||
if let badge = aps["badge"] as? Int {
|
||||
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async {
|
||||
guard let contentStateData = aps["content-state"] as? [String: Any],
|
||||
let contentStateJSON = try? JSONSerialization.data(withJSONObject: contentStateData),
|
||||
let contentState = try? JSONDecoder.iso8601.decode(AlertAttributes.ContentState.self, from: contentStateJSON)
|
||||
else { return }
|
||||
|
||||
switch event {
|
||||
case "start":
|
||||
await startLiveActivity(userInfo: userInfo, contentState: contentState)
|
||||
case "update":
|
||||
await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
||||
case "end":
|
||||
await endLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState) async {
|
||||
guard let attributes = userInfo["attributes"] as? [String: Any],
|
||||
let topic = attributes["topic"] as? String,
|
||||
let alertId = attributes["alertId"] as? String,
|
||||
let severityStr = attributes["severity"] as? String,
|
||||
let severity = Severity(rawValue: severityStr) else { return }
|
||||
|
||||
guard severity != .info else { return }
|
||||
|
||||
// Limit to 3 concurrent activities
|
||||
let currentActivities = Activity<AlertAttributes>.activities
|
||||
if currentActivities.count >= 3 {
|
||||
// End the oldest
|
||||
if let oldest = currentActivities.min(by: {
|
||||
$0.contentState.startedAt < $1.contentState.startedAt
|
||||
}) {
|
||||
await oldest.end(ActivityContent(state: oldest.contentState, staleDate: nil), dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
let attrs = AlertAttributes(topic: topic, alertId: alertId, severity: severity)
|
||||
_ = try? Activity<AlertAttributes>.request(
|
||||
attributes: attrs,
|
||||
content: ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600))
|
||||
)
|
||||
}
|
||||
|
||||
private func updateLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
|
||||
guard let alertId else { return }
|
||||
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
||||
await activity.update(ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600)))
|
||||
}
|
||||
}
|
||||
|
||||
private func endLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
|
||||
guard let alertId else { return }
|
||||
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
||||
let dismissDate = Date().addingTimeInterval(5 * 60)
|
||||
await activity.end(
|
||||
ActivityContent(state: contentState, staleDate: dismissDate),
|
||||
dismissalPolicy: .after(dismissDate)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
completionHandler([.banner, .badge, .sound])
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
static let iso8601: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user