diff --git a/Mayday.xcodeproj/project.pbxproj b/Mayday.xcodeproj/project.pbxproj index 6ac4ea4..6a52731 100644 --- a/Mayday.xcodeproj/project.pbxproj +++ b/Mayday.xcodeproj/project.pbxproj @@ -32,9 +32,15 @@ AA000001000023 /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000023 /* ChangePasswordView.swift */; }; AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; }; AA000001000025 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000025 /* Assets.xcassets */; }; - AA000001000026 /* PreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000026A /* PreviewData.swift */; }; + AA000001000034 /* AppTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000034 /* AppTextField.swift */; }; + AA000001000035 /* AppSecureField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000035 /* AppSecureField.swift */; }; + AA000001000036 /* AppBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000036 /* AppBackground.swift */; }; + AA000001000037 /* CardContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000037 /* CardContainer.swift */; }; + AA000001000038 /* NotificationIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000038 /* NotificationIconView.swift */; }; + AA000001000039 /* OTPDigitField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000039 /* OTPDigitField.swift */; }; AA000001000027 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000027 /* Localizable.xcstrings */; }; AA000001000028 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000028 /* InfoPlist.xcstrings */; }; + AA000001000040 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000040 /* LaunchScreen.storyboard */; }; AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000030 /* MaydayLiveActivityBundle.swift */; }; AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000031 /* MaydayLiveActivityLiveActivity.swift */; }; AA000001000032 /* AlertAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; }; @@ -93,11 +99,18 @@ AA000002000024 /* SessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; AA000002000025 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - AA000002000026A /* PreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewData.swift; sourceTree = ""; }; AA000002000027 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; AA000002000028 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + AA000002000029 /* Mayday.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mayday.entitlements; sourceTree = ""; }; + AA000002000040 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; AA000002000030 /* MaydayLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityBundle.swift; sourceTree = ""; }; AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = ""; }; + AA000002000034 /* AppTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTextField.swift; sourceTree = ""; }; + AA000002000035 /* AppSecureField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecureField.swift; sourceTree = ""; }; + AA000002000036 /* AppBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBackground.swift; sourceTree = ""; }; + AA000002000037 /* CardContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardContainer.swift; sourceTree = ""; }; + AA000002000038 /* NotificationIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIconView.swift; sourceTree = ""; }; + AA000002000039 /* OTPDigitField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDigitField.swift; sourceTree = ""; }; AA000002000033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AA000008000001 /* MaydayLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MaydayLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA000009000001 /* Mayday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mayday.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -140,6 +153,8 @@ AA000002000026 /* Info.plist */, AA000002000027 /* Localizable.xcstrings */, AA000002000028 /* InfoPlist.xcstrings */, + AA000002000029 /* Mayday.entitlements */, + AA000002000040 /* LaunchScreen.storyboard */, AA000011000003 /* Models */, AA000011000004 /* Services */, AA000011000005 /* ViewModels */, @@ -168,7 +183,6 @@ AA000002000011 /* AuthService.swift */, AA000002000012 /* NotificationsAPIService.swift */, AA000002000013 /* PushNotificationService.swift */, - AA000002000026A /* PreviewData.swift */, ); path = Services; sourceTree = ""; @@ -189,6 +203,7 @@ AA000011000007 /* Auth */, AA000011000008 /* Notifications */, AA000011000009 /* Settings */, + AA000011000011 /* UIKit */, ); path = Views; sourceTree = ""; @@ -222,6 +237,19 @@ path = Settings; sourceTree = ""; }; + AA000011000011 /* UIKit */ = { + isa = PBXGroup; + children = ( + AA000002000034 /* AppTextField.swift */, + AA000002000035 /* AppSecureField.swift */, + AA000002000036 /* AppBackground.swift */, + AA000002000037 /* CardContainer.swift */, + AA000002000038 /* NotificationIconView.swift */, + AA000002000039 /* OTPDigitField.swift */, + ); + path = UIKit; + sourceTree = ""; + }; AA000011000010 /* MaydayLiveActivity */ = { isa = PBXGroup; children = ( @@ -326,6 +354,7 @@ AA000001000025 /* Assets.xcassets in Resources */, AA000001000027 /* Localizable.xcstrings in Resources */, AA000001000028 /* InfoPlist.xcstrings in Resources */, + AA000001000040 /* LaunchScreen.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -368,7 +397,12 @@ AA000001000022 /* SettingsView.swift in Sources */, AA000001000023 /* ChangePasswordView.swift in Sources */, AA000001000024 /* SessionsView.swift in Sources */, - AA000001000026 /* PreviewData.swift in Sources */, + AA000001000034 /* AppTextField.swift in Sources */, + AA000001000035 /* AppSecureField.swift in Sources */, + AA000001000036 /* AppBackground.swift in Sources */, + AA000001000037 /* CardContainer.swift in Sources */, + AA000001000038 /* NotificationIconView.swift in Sources */, + AA000001000039 /* OTPDigitField.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -399,6 +433,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WA8SWY233K; @@ -426,6 +461,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WA8SWY233K; diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-1024@1x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-1024@1x.png new file mode 100644 index 0000000..4d5c843 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-1024@1x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@1x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@1x.png new file mode 100644 index 0000000..6a35ab6 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@1x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png new file mode 100644 index 0000000..e7f67e6 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png new file mode 100644 index 0000000..fe05b48 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@1x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@1x.png new file mode 100644 index 0000000..cd1aad7 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@1x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png new file mode 100644 index 0000000..dd4ab7b Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png new file mode 100644 index 0000000..aa1f634 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@1x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@1x.png new file mode 100644 index 0000000..e7f67e6 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@1x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png new file mode 100644 index 0000000..19da60f Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png new file mode 100644 index 0000000..7c2c738 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png new file mode 100644 index 0000000..7c2c738 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png new file mode 100644 index 0000000..4e07f91 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-76@1x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-76@1x.png new file mode 100644 index 0000000..fc84e34 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-76@1x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png new file mode 100644 index 0000000..a87a599 Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png new file mode 100644 index 0000000..396755a Binary files /dev/null and b/Mayday/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png differ diff --git a/Mayday/Assets.xcassets/AppIcon.appiconset/Contents.json b/Mayday/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..b444bd8 100644 --- a/Mayday/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Mayday/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,8 +1,111 @@ { "images" : [ { - "idiom" : "universal", - "platform" : "ios", + "filename" : "AppIcon-20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AppIcon-60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "AppIcon-20@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AppIcon-29@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-40@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-76@1x.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "AppIcon-1024@1x.png", + "idiom" : "ios-marketing", + "scale" : "1x", "size" : "1024x1024" } ], diff --git a/Mayday/Assets.xcassets/Logo.imageset/Contents.json b/Mayday/Assets.xcassets/Logo.imageset/Contents.json new file mode 100644 index 0000000..5f670ca --- /dev/null +++ b/Mayday/Assets.xcassets/Logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mayday/Assets.xcassets/Logo.imageset/logo.png b/Mayday/Assets.xcassets/Logo.imageset/logo.png new file mode 100644 index 0000000..4d5c843 Binary files /dev/null and b/Mayday/Assets.xcassets/Logo.imageset/logo.png differ diff --git a/Mayday/ContentView.swift b/Mayday/ContentView.swift index 5260fed..454b7d5 100644 --- a/Mayday/ContentView.swift +++ b/Mayday/ContentView.swift @@ -5,7 +5,10 @@ struct ContentView: View { var body: some View { Group { - if authViewModel.isAuthenticated { + if authViewModel.isCheckingAuth { + Color(.systemBackground) + .ignoresSafeArea() + } else if authViewModel.isAuthenticated { NotificationsView() } else { LoginView() diff --git a/Mayday/Info.plist b/Mayday/Info.plist index c96ba2f..52bbafd 100644 --- a/Mayday/Info.plist +++ b/Mayday/Info.plist @@ -33,8 +33,8 @@ remote-notification - UILaunchScreen - + UILaunchStoryboardName + LaunchScreen UIRequiredDeviceCapabilities armv7 diff --git a/Mayday/LaunchScreen.storyboard b/Mayday/LaunchScreen.storyboard new file mode 100644 index 0000000..b717203 --- /dev/null +++ b/Mayday/LaunchScreen.storyboard @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mayday/Localizable.xcstrings b/Mayday/Localizable.xcstrings index 7165986..9ea6149 100644 --- a/Mayday/Localizable.xcstrings +++ b/Mayday/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "ru", "strings" : { - "" : { - - }, "(%lld)" : { }, @@ -262,38 +259,6 @@ } } }, - "demo_badge" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "DEMO" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ДЕМО" - } - } - } - }, - "demo_mode" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Demo mode" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Демо-режим" - } - } - } - }, "details_section" : { "localizations" : { "en" : { diff --git a/Mayday/Mayday.entitlements b/Mayday/Mayday.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Mayday/Mayday.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/Mayday/Models/User.swift b/Mayday/Models/User.swift index a516268..ad530f8 100644 --- a/Mayday/Models/User.swift +++ b/Mayday/Models/User.swift @@ -16,6 +16,29 @@ struct UserResponse: Codable, Identifiable, Sendable { case createdAt = "created_at" case updatedAt = "updated_at" } + + init(id: UUID, email: String, status: UserStatus, metadata: [String: AnyCodable]? = nil, emailVerifiedAt: Date? = nil, roles: [String] = [], createdAt: Date, updatedAt: Date) { + self.id = id + self.email = email + self.status = status + self.metadata = metadata + self.emailVerifiedAt = emailVerifiedAt + self.roles = roles + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + email = try container.decode(String.self, forKey: .email) + status = try container.decode(UserStatus.self, forKey: .status) + metadata = try container.decodeIfPresent([String: AnyCodable].self, forKey: .metadata) + emailVerifiedAt = try container.decodeIfPresent(Date.self, forKey: .emailVerifiedAt) + roles = try container.decodeIfPresent([String].self, forKey: .roles) ?? [] + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } } enum UserStatus: String, Codable, Sendable { diff --git a/Mayday/Services/AuthService.swift b/Mayday/Services/AuthService.swift index cd696fa..5362a0a 100644 --- a/Mayday/Services/AuthService.swift +++ b/Mayday/Services/AuthService.swift @@ -5,23 +5,8 @@ struct LoginResponse: Decodable, Sendable { let tokens: TokenPair } -struct RegisterResponse: Decodable, Sendable { - 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, Sendable { - let user: UserResponse +struct MessageResponse: Decodable, Sendable { + let message: String } actor AuthService { @@ -42,13 +27,12 @@ actor AuthService { 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 verifyEmail(email: String, code: String) async throws { + let _: MessageResponse = try await client.request(.verifyEmail(email: email, code: code)) } func resendCode(email: String) async throws { - let _: ResendCodeResponse = try await client.request(.resendCode(email: email)) + let _: MessageResponse = try await client.request(.resendCode(email: email)) } func logout() async throws { @@ -64,8 +48,4 @@ actor AuthService { } } -struct ResendCodeResponse: Decodable, Sendable { - let message: String -} - struct EmptyResponse: Decodable, Sendable {} diff --git a/Mayday/Services/HTTPClient.swift b/Mayday/Services/HTTPClient.swift index 20ec2d6..293e3ba 100644 --- a/Mayday/Services/HTTPClient.swift +++ b/Mayday/Services/HTTPClient.swift @@ -166,8 +166,8 @@ actor HTTPClient { private init() { #if DEBUG - ssoBaseURL = "http://localhost:8081" - notificationBaseURL = "http://localhost:8092" + ssoBaseURL = "http://192.168.3.7:8081" + notificationBaseURL = "http://192.168.3.7:8092" #else ssoBaseURL = "https://id.robonen.ru" notificationBaseURL = "https://notify.robonen.ru" @@ -254,6 +254,13 @@ actor HTTPClient { throw APIError.serverError("HTTP \(httpResponse.statusCode)") } + // 204 No Content — return empty decodable if possible + if httpResponse.statusCode == 204 || data.isEmpty { + if let empty = EmptyResponse() as? T { + return empty + } + } + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 do { diff --git a/Mayday/Services/PreviewData.swift b/Mayday/Services/PreviewData.swift deleted file mode 100644 index eae0d32..0000000 --- a/Mayday/Services/PreviewData.swift +++ /dev/null @@ -1,206 +0,0 @@ -#if DEBUG -import Foundation -import ActivityKit - -enum PreviewData { - nonisolated(unsafe) static var isPreviewMode = false - - static let mockUser = UserResponse( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, - email: "demo@mayday.app", - status: .active, - metadata: nil, - emailVerifiedAt: Date(), - roles: ["user"], - createdAt: Date().addingTimeInterval(-90 * 86400), - updatedAt: Date() - ) - - static let mockNotifications: [AppNotification] = { - let now = Date() - let mockUserId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! - return [ - AppNotification( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, - userId: mockUserId, - scopeId: nil, - channel: .apns, - contentType: .plain, - templateId: nil, - subject: "Пожарная тревога", - body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.", - source: "Fire Alert", - metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"], - status: .sent, - error: nil, - attempts: 1, - maxAttempts: 3, - nextRetryAt: nil, - sentAt: now.addingTimeInterval(-120), - readAt: nil, - createdAt: now.addingTimeInterval(-120) - ), - AppNotification( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!, - userId: mockUserId, - scopeId: nil, - channel: .apns, - contentType: .plain, - templateId: nil, - subject: "Нарушение периметра", - body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.", - source: "Security Alert", - metadata: ["Зона": "B2", "Камера": "CAM-17"], - status: .sent, - error: nil, - attempts: 1, - maxAttempts: 3, - nextRetryAt: nil, - sentAt: now.addingTimeInterval(-300), - readAt: nil, - createdAt: now.addingTimeInterval(-300) - ), - AppNotification( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!, - userId: mockUserId, - scopeId: nil, - channel: .apns, - contentType: .plain, - templateId: nil, - subject: "Пожарная тревога", - body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.", - source: "Fire Alert", - metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"], - status: .read, - error: nil, - attempts: 1, - maxAttempts: 3, - nextRetryAt: nil, - sentAt: now.addingTimeInterval(-7200), - readAt: now.addingTimeInterval(-3600), - createdAt: now.addingTimeInterval(-7200) - ), - AppNotification( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!, - userId: mockUserId, - scopeId: nil, - channel: .apns, - contentType: .plain, - templateId: nil, - subject: "Медицинская помощь", - body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.", - source: "Medical Emergency", - metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"], - status: .read, - error: nil, - attempts: 1, - maxAttempts: 3, - nextRetryAt: nil, - sentAt: now.addingTimeInterval(-7200), - readAt: now.addingTimeInterval(-5400), - createdAt: now.addingTimeInterval(-7200) - ), - AppNotification( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!, - userId: mockUserId, - scopeId: nil, - channel: .inApp, - contentType: .plain, - templateId: nil, - subject: "Затопление", - body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.", - source: "Water Leak", - metadata: ["Здание": "Корпус 3", "Этаж": "B1"], - status: .read, - error: nil, - attempts: 1, - maxAttempts: 3, - nextRetryAt: nil, - sentAt: now.addingTimeInterval(-90000), - readAt: now.addingTimeInterval(-86400), - createdAt: now.addingTimeInterval(-90000) - ), - AppNotification( - id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!, - userId: mockUserId, - scopeId: nil, - channel: .inApp, - contentType: .plain, - templateId: nil, - subject: "Тестирование системы", - body: "Плановое тестирование системы оповещения. Действий не требуется.", - source: "Security Alert", - metadata: nil, - status: .read, - error: nil, - attempts: 1, - maxAttempts: 3, - nextRetryAt: nil, - sentAt: now.addingTimeInterval(-180000), - readAt: now.addingTimeInterval(-172800), - createdAt: now.addingTimeInterval(-180000) - ), - ] - }() - - static let mockSessions: [SessionResponse] = { - let now = Date() - return [ - SessionResponse( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!, - userAgent: "Mayday/1.0 (iPhone; iOS 18.3)", - ipAddress: "192.168.1.42", - isCurrent: true, - createdAt: now.addingTimeInterval(-3600), - expiresAt: now.addingTimeInterval(7 * 86400) - ), - SessionResponse( - id: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!, - userAgent: "Mayday/1.0 (iPad; iPadOS 18.3)", - ipAddress: "192.168.1.100", - isCurrent: false, - createdAt: now.addingTimeInterval(-86400), - expiresAt: now.addingTimeInterval(6 * 86400) - ), - ] - }() - - static func startMockLiveActivity() async { - // End any existing demo activities first - for activity in Activity.activities where activity.attributes.alertId == "demo-fire-alert" { - let state = activity.content.state - await activity.end(ActivityContent(state: state, staleDate: nil), dismissalPolicy: .immediate) - } - - let attributes = AlertAttributes( - topic: "Fire Alert", - alertId: "demo-fire-alert", - severity: .critical - ) - let state = AlertAttributes.ContentState( - title: "Пожарная тревога", - value: "Корпус 9, этаж 12", - status: .active, - startedAt: Date().addingTimeInterval(-120), - updatedAt: Date() - ) - _ = try? Activity.request( - attributes: attributes, - content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(3600)) - ) - } - - static func stopMockLiveActivity() async { - for activity in Activity.activities where activity.attributes.alertId == "demo-fire-alert" { - let resolvedState = AlertAttributes.ContentState( - title: "Пожарная тревога", - value: "Корпус 9, этаж 12", - status: .resolved, - startedAt: activity.content.state.startedAt, - updatedAt: Date() - ) - await activity.end(ActivityContent(state: resolvedState, staleDate: nil), dismissalPolicy: .immediate) - } - } -} -#endif diff --git a/Mayday/Services/PushNotificationService.swift b/Mayday/Services/PushNotificationService.swift index 848ca5c..58e77f4 100644 --- a/Mayday/Services/PushNotificationService.swift +++ b/Mayday/Services/PushNotificationService.swift @@ -39,7 +39,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async { guard let aps = userInfo["aps"] as? [String: Any] else { return } - // Handle Live Activity push + // Handle explicit Live Activity push (event inside aps) if let event = aps["event"] as? String { await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps) return @@ -49,6 +49,30 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen if let badge = aps["badge"] as? Int { try? await UNUserNotificationCenter.current().setBadgeCount(badge) } + + // Start a Live Activity from a regular push if metadata contains severity + if let metadata = userInfo["metadata"] as? [String: String], + let severityStr = metadata["severity"], + let severity = Severity(rawValue: severityStr), + let source = userInfo["source"] as? String, + let subject = userInfo["subject"] as? String { + + let alertId = (userInfo["notificationId"] as? String) ?? UUID().uuidString + let contentState = AlertAttributes.ContentState( + title: subject, + value: metadata["value"], + status: .active, + startedAt: Date(), + updatedAt: Date() + ) + await startLiveActivity( + userInfo: userInfo, + contentState: contentState, + topic: source, + alertId: alertId, + severity: severity + ) + } } private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async { @@ -59,7 +83,13 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen switch event { case "start": - await startLiveActivity(userInfo: userInfo, contentState: contentState) + if 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) { + await startLiveActivity(userInfo: userInfo, contentState: contentState, topic: topic, alertId: alertId, severity: severity) + } case "update": await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState) case "end": @@ -69,13 +99,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen } } - 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 } - + private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState, topic: String, alertId: String, severity: Severity) async { // Info-level alerts don't warrant a persistent Live Activity — they are low-priority // and should only appear as a standard banner notification. guard severity != .info else { return } diff --git a/Mayday/ViewModels/AuthViewModel.swift b/Mayday/ViewModels/AuthViewModel.swift index 11c6c76..45d08e3 100644 --- a/Mayday/ViewModels/AuthViewModel.swift +++ b/Mayday/ViewModels/AuthViewModel.swift @@ -4,6 +4,7 @@ import SwiftUI @MainActor class AuthViewModel: ObservableObject { @Published var isAuthenticated = false + @Published var isCheckingAuth = true @Published var currentUser: UserResponse? @Published var isLoading = false @Published var error: String? @@ -12,39 +13,28 @@ class AuthViewModel: ObservableObject { private let keychain = KeychainService.shared func checkAuthStatus() async { - #if DEBUG - if PreviewData.isPreviewMode { - currentUser = PreviewData.mockUser - isAuthenticated = true - return - } - #endif guard keychain.loadAccessToken() != nil else { isAuthenticated = false + isCheckingAuth = false return } - isLoading = true - defer { isLoading = false } do { currentUser = try await auth.getMe() isAuthenticated = true + isCheckingAuth = false await requestPushIfNeeded() } catch APIError.unauthorized { isAuthenticated = false + isCheckingAuth = false } catch { - isAuthenticated = false + // Network/transient errors — keep authenticated if we already were + if !isAuthenticated { + isAuthenticated = false + } + isCheckingAuth = false } } - #if DEBUG - func enterPreviewMode() async { - PreviewData.isPreviewMode = true - currentUser = PreviewData.mockUser - isAuthenticated = true - await PreviewData.startMockLiveActivity() - } - #endif - func login(email: String, password: String) async { isLoading = true error = nil @@ -84,15 +74,6 @@ class AuthViewModel: ObservableObject { } func logout() async { - #if DEBUG - if PreviewData.isPreviewMode { - await PreviewData.stopMockLiveActivity() - PreviewData.isPreviewMode = false - isAuthenticated = false - currentUser = nil - return - } - #endif isLoading = true defer { isLoading = false } do { diff --git a/Mayday/ViewModels/NotificationsViewModel.swift b/Mayday/ViewModels/NotificationsViewModel.swift index 6ab75c9..f6fe139 100644 --- a/Mayday/ViewModels/NotificationsViewModel.swift +++ b/Mayday/ViewModels/NotificationsViewModel.swift @@ -10,6 +10,7 @@ class NotificationsViewModel: ObservableObject { @Published var isLoadingMore = false @Published var error: String? @Published var hasMore = true + private var hasLoadedOnce = false private let service = NotificationsAPIService.shared private let limit = 50 @@ -17,17 +18,13 @@ class NotificationsViewModel: ObservableObject { private var pollingTask: Task? func load() async { - #if DEBUG - if PreviewData.isPreviewMode { - notifications = PreviewData.mockNotifications - hasMore = false - return - } - #endif - isLoading = true + isLoading = !hasLoadedOnce error = nil currentOffset = 0 - defer { isLoading = false } + defer { + isLoading = false + hasLoadedOnce = true + } do { let page = try await service.getNotifications(limit: limit, offset: 0) notifications = page.notifications @@ -40,9 +37,6 @@ class NotificationsViewModel: ObservableObject { } func loadMore() async { - #if DEBUG - if PreviewData.isPreviewMode { return } - #endif guard !isLoadingMore && hasMore else { return } isLoadingMore = true defer { isLoadingMore = false } @@ -60,29 +54,33 @@ class NotificationsViewModel: ObservableObject { func markAsRead(_ notification: AppNotification) async { guard !notification.isRead else { return } - #if DEBUG - if PreviewData.isPreviewMode { - if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - notifications[index] = notification.withReadAt(Date()) - } - return + + // Optimistic update — reflect read state immediately so the list + // shows the correct card style even if the user navigates back + // before the API call completes. + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index] = notification.withReadAt(Date()) + unreadCount = max(0, unreadCount - 1) + updateBadge() } - #endif + do { try await service.markAsRead(id: notification.id) - if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - notifications[index] = notification.withReadAt(Date()) - } - updateBadge() + } catch is CancellationError { + // View disappeared before the request finished — keep + // optimistic state; polling will reconcile if needed. } catch { + // Rollback on real failure + if let index = notifications.firstIndex(where: { $0.id == notification.id }) { + notifications[index] = notification + unreadCount += 1 + updateBadge() + } self.error = error.localizedDescription } } func markAllAsRead() async { - #if DEBUG - if PreviewData.isPreviewMode { return } - #endif do { try await service.markAllAsRead() await load() @@ -92,9 +90,6 @@ class NotificationsViewModel: ObservableObject { } func startPolling() { - #if DEBUG - if PreviewData.isPreviewMode { return } - #endif guard pollingTask == nil else { return } pollingTask = Task { while !Task.isCancelled { diff --git a/Mayday/ViewModels/SettingsViewModel.swift b/Mayday/ViewModels/SettingsViewModel.swift index d604382..0b5f307 100644 --- a/Mayday/ViewModels/SettingsViewModel.swift +++ b/Mayday/ViewModels/SettingsViewModel.swift @@ -11,12 +11,6 @@ class SettingsViewModel: ObservableObject { private let service = NotificationsAPIService.shared func loadSessions() async { - #if DEBUG - if PreviewData.isPreviewMode { - sessions = PreviewData.mockSessions - return - } - #endif isLoading = true defer { isLoading = false } do { @@ -27,12 +21,6 @@ class SettingsViewModel: ObservableObject { } func deleteSession(_ session: SessionResponse) async { - #if DEBUG - if PreviewData.isPreviewMode { - sessions.removeAll { $0.id == session.id } - return - } - #endif do { try await service.deleteSession(id: session.id) sessions.removeAll { $0.id == session.id } @@ -42,12 +30,6 @@ class SettingsViewModel: ObservableObject { } func changePassword(current: String, new: String) async -> Bool { - #if DEBUG - if PreviewData.isPreviewMode { - successMessage = String(localized: "password_changed_success") - return true - } - #endif isLoading = true error = nil defer { isLoading = false } diff --git a/Mayday/Views/Auth/LoginView.swift b/Mayday/Views/Auth/LoginView.swift index c8ca19a..c38a1f0 100644 --- a/Mayday/Views/Auth/LoginView.swift +++ b/Mayday/Views/Auth/LoginView.swift @@ -6,74 +6,101 @@ struct LoginView: View { @State private var password = "" @State private var showRegister = false + private var isFormInvalid: Bool { + email.isEmpty || password.isEmpty || authViewModel.isLoading + } + var body: some View { NavigationStack { - VStack(spacing: 24) { - Spacer() - VStack(spacing: 8) { - Image(systemName: "bell.badge.fill") - .font(.system(size: 60)) - .foregroundStyle(.red) - Text("Mayday") - .font(.largeTitle.bold()) - Text("login_subtitle") - .font(.subheadline) + ZStack { + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 24) + + VStack(spacing: 10) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 84, height: 84) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: .red.opacity(0.25), radius: 12, y: 6) + + Text("Mayday") + .font(.largeTitle.bold()) + + Text("login_subtitle") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 8) + + VStack(spacing: 14) { + AppTextField( + title: "Email", + icon: "envelope.fill", + text: $email + ) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + AppSecureField( + title: "password", + icon: "lock.fill", + text: $password + ) + .textContentType(.password) + } + + if let error = authViewModel.error { + Text(error) + .foregroundStyle(.red) + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button { + Task { await authViewModel.login(email: email, password: password) } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + LinearGradient( + colors: [Color.red, Color.red.opacity(0.82)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 52) + + if authViewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("login_button") + .font(.headline) + .foregroundStyle(.white) + } + } + } + .disabled(isFormInvalid) + .opacity(isFormInvalid ? 0.6 : 1) + + Button("login_no_account") { + showRegister = true + } + .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) - } - VStack(spacing: 16) { - TextField("Email", text: $email) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - - SecureField("password", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.password) - } - - if let error = authViewModel.error { - Text(error) - .foregroundStyle(.red) - .font(.footnote) - .multilineTextAlignment(.center) - } - - Button { - Task { await authViewModel.login(email: email, password: password) } - } label: { - if authViewModel.isLoading { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Text("login_button") - .frame(maxWidth: .infinity) + Spacer(minLength: 8) } + .cardContainer() } - .buttonStyle(.borderedProminent) - .disabled(email.isEmpty || password.isEmpty || authViewModel.isLoading) - - Button("login_no_account") { - showRegister = true - } - .font(.footnote) - - #if DEBUG - Button { - Task { await authViewModel.enterPreviewMode() } - } label: { - Label("demo_mode", systemImage: "play.circle.fill") - .font(.footnote) - .foregroundStyle(.secondary) - } - .padding(.top, 8) - #endif - - Spacer() } - .padding() + .appBackground() .navigationDestination(isPresented: $showRegister) { RegisterView() } diff --git a/Mayday/Views/Auth/RegisterView.swift b/Mayday/Views/Auth/RegisterView.swift index e4dffa0..e155e19 100644 --- a/Mayday/Views/Auth/RegisterView.swift +++ b/Mayday/Views/Auth/RegisterView.swift @@ -10,76 +10,110 @@ struct RegisterView: View { @State private var showVerify = false @State private var registeredEmail = "" + private var isFormInvalid: Bool { + !isFormValid || authViewModel.isLoading + } + var body: some View { - VStack(spacing: 24) { - Spacer() + ZStack { + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 24) - Text("register_title") - .font(.largeTitle.bold()) + VStack(spacing: 10) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 76, height: 76) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: .red.opacity(0.22), radius: 12, y: 6) - VStack(spacing: 16) { - TextField("Email", text: $email) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - - SecureField("password", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.newPassword) - - SecureField("confirm_password", text: $confirmPassword) - .textFieldStyle(.roundedBorder) - .textContentType(.newPassword) - } - - if password.count > 0 && password.count < 8 { - Text("password_min_length") - .foregroundStyle(.red) - .font(.footnote) - } - - if confirmPassword.count > 0 && password != confirmPassword { - Text("passwords_mismatch") - .foregroundStyle(.red) - .font(.footnote) - } - - if let error = authViewModel.error { - Text(error) - .foregroundStyle(.red) - .font(.footnote) - .multilineTextAlignment(.center) - } - - Button { - Task { - let success = await authViewModel.register(email: email, password: password) - if success { - registeredEmail = email - showVerify = true + Text("register_title") + .font(.largeTitle.bold()) } + + VStack(spacing: 14) { + AppTextField(title: "Email", icon: "envelope.fill", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + AppSecureField(title: "password", icon: "lock.fill", text: $password) + .textContentType(.newPassword) + + AppSecureField(title: "confirm_password", icon: "lock.rotation", text: $confirmPassword) + .textContentType(.newPassword) + } + + if password.count > 0 && password.count < 8 { + Text("password_min_length") + .foregroundStyle(.red) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if confirmPassword.count > 0 && password != confirmPassword { + Text("passwords_mismatch") + .foregroundStyle(.red) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let error = authViewModel.error { + Text(error) + .foregroundStyle(.red) + .font(.footnote) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button { + Task { + let success = await authViewModel.register(email: email, password: password) + if success { + registeredEmail = email + showVerify = true + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + LinearGradient( + colors: [Color.red, Color.red.opacity(0.82)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 52) + + if authViewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("register_button") + .font(.headline) + .foregroundStyle(.white) + } + } + } + .disabled(isFormInvalid) + .opacity(isFormInvalid ? 0.6 : 1) + + Button("register_has_account") { dismiss() } + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) } - } label: { - if authViewModel.isLoading { - ProgressView().frame(maxWidth: .infinity) - } else { - Text("register_button").frame(maxWidth: .infinity) - } + .cardContainer() + } + .navigationDestination(isPresented: $showVerify) { + VerifyEmailView(email: registeredEmail, password: password) } - .buttonStyle(.borderedProminent) - .disabled(!isFormValid || authViewModel.isLoading) - - Button("register_has_account") { dismiss() } - .font(.footnote) - - Spacer() - } - .padding() - .navigationDestination(isPresented: $showVerify) { - VerifyEmailView(email: registeredEmail) } + .appBackground() .navigationTitle("register_title") .navigationBarTitleDisplayMode(.inline) } diff --git a/Mayday/Views/Auth/VerifyEmailView.swift b/Mayday/Views/Auth/VerifyEmailView.swift index c3636cd..29ebdaa 100644 --- a/Mayday/Views/Auth/VerifyEmailView.swift +++ b/Mayday/Views/Auth/VerifyEmailView.swift @@ -2,98 +2,167 @@ import SwiftUI struct VerifyEmailView: View { let email: String + let password: String @EnvironmentObject var authViewModel: AuthViewModel @State private var codeDigits: [String] = Array(repeating: "", count: 6) @State private var resendCooldown = 0 - @FocusState private var focusedIndex: Int? + @State private var focusedIndex: Int? @State private var cooldownTask: Task? + private var code: String { + codeDigits.joined() + } + var body: some View { - VStack(spacing: 32) { - Spacer() - - VStack(spacing: 8) { - Text("verify_email_title") - .font(.largeTitle.bold()) - Text("verify_code_sent_to") - .foregroundStyle(.secondary) - Text(email) - .fontWeight(.semibold) + ScrollView { + contentCard + } + .appBackground() + .navigationTitle("verify_nav_title") + .navigationBarTitleDisplayMode(.inline) + .onAppear { focusedIndex = 0 } + .onDisappear { cooldownTask?.cancel() } + .onChange(of: code) { _, newValue in + if newValue.count == 6 { + Task { await submitCode(newValue) } } + } + } - HStack(spacing: 12) { - ForEach(0..<6, id: \.self) { index in - TextField("", text: $codeDigits[index]) - .frame(width: 44, height: 52) - .multilineTextAlignment(.center) - .font(.title2.bold()) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(focusedIndex == index ? Color.accentColor : Color.secondary, lineWidth: 2) - ) - .focused($focusedIndex, equals: index) - .onChange(of: codeDigits[index]) { _, newValue in - handleDigitChange(index: index, value: newValue) - } - } - } + + private var contentCard: some View { + VStack(spacing: 28) { + Spacer(minLength: 24) + + headerView + otpFieldsView if let error = authViewModel.error { Text(error) .foregroundStyle(.red) .font(.footnote) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) } - Button { - Task { await resendCode() } - } label: { + if code.count == 6 { + ProgressView() + .padding(.top, 2) + } + + resendButton + + Spacer(minLength: 8) + } + .cardContainer() + } + + private var headerView: some View { + VStack(spacing: 8) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: .red.opacity(0.22), radius: 12, y: 6) + + Text("verify_email_title") + .font(.largeTitle.bold()) + + Text("verify_code_sent_to") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(email) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + + private var otpFieldsView: some View { + HStack(spacing: 10) { + ForEach(0..<6, id: \.self) { index in + otpField(at: index) + } + } + } + + private var resendButton: some View { + Button { + Task { await resendCode() } + } label: { + Group { if resendCooldown > 0 { Text("verify_resend_cooldown \(resendCooldown)") } else { Text("verify_resend") } } - .disabled(resendCooldown > 0) - - Spacer() + .font(.footnote.weight(.semibold)) + .foregroundStyle(resendCooldown > 0 ? Color.secondary : Color.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color(.tertiarySystemFill)) + .clipShape(Capsule()) } - .padding() - .navigationTitle("verify_nav_title") - .navigationBarTitleDisplayMode(.inline) - .onAppear { focusedIndex = 0 } - .onDisappear { cooldownTask?.cancel() } + .disabled(resendCooldown > 0) } - private func handleDigitChange(index: Int, value: String) { - let filtered = value.filter { $0.isNumber } - if filtered.count > 1 { - // Paste handling - let digits = Array(filtered.prefix(6)) - for (i, d) in digits.enumerated() where i < 6 { - codeDigits[i] = String(d) + @ViewBuilder + private func otpField(at index: Int) -> some View { + OTPDigitField( + text: $codeDigits[index], + isFocused: focusedIndex == index, + onFocus: { focusedIndex = index }, + onInsert: { + if index < 5 { + focusedIndex = index + 1 + } + }, + onDeleteWhenEmpty: { + handleDeleteOnEmpty(at: index) + }, + onPaste: { digits in + handlePaste(digits, startingAt: index) } - focusedIndex = min(digits.count, 5) - } else { - codeDigits[index] = filtered.isEmpty ? "" : String(filtered.last!) - if !filtered.isEmpty && index < 5 { - focusedIndex = index + 1 - } - } + ) + .frame(width: 46, height: 56) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke( + focusedIndex == index + ? Color.red.opacity(0.9) + : Color.primary.opacity(0.10), + lineWidth: focusedIndex == index ? 2 : 1 + ) + ) + } - let code = codeDigits.joined() - if code.count == 6 { - Task { await submitCode(code) } + private func handleDeleteOnEmpty(at index: Int) { + guard index > 0 else { return } + codeDigits[index - 1] = "" + focusedIndex = index - 1 + } + + private func handlePaste(_ digits: [String], startingAt startIndex: Int) { + guard !digits.isEmpty else { return } + for (offset, digit) in digits.enumerated() { + let target = startIndex + offset + guard target < codeDigits.count else { break } + codeDigits[target] = String(digit.prefix(1)) } + focusedIndex = min(startIndex + digits.count, codeDigits.count - 1) } private func submitCode(_ code: String) async { await authViewModel.verifyEmail(email: email, code: code) if authViewModel.error == nil { - // Auto-login after verification - in a real flow we'd re-login here - // since verify doesn't return tokens + await authViewModel.login(email: email, password: password) } } diff --git a/Mayday/Views/Notifications/NotificationDetailView.swift b/Mayday/Views/Notifications/NotificationDetailView.swift index cc798b2..a0a779e 100644 --- a/Mayday/Views/Notifications/NotificationDetailView.swift +++ b/Mayday/Views/Notifications/NotificationDetailView.swift @@ -1,24 +1,49 @@ import SwiftUI struct NotificationDetailView: View { - let notification: AppNotification - let viewModel: NotificationsViewModel + let notificationId: UUID + @ObservedObject var viewModel: NotificationsViewModel + + private var notification: AppNotification? { + viewModel.notifications.first { $0.id == notificationId } + } + + init(notification: AppNotification, viewModel: NotificationsViewModel) { + self.notificationId = notification.id + self.viewModel = viewModel + } var body: some View { + Group { + if let notification { + scrollContent(notification) + } + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("details_section") + .navigationBarTitleDisplayMode(.inline) + .task { + if let notification, !notification.isRead { + await viewModel.markAsRead(notification) + } + } + } + + private func scrollContent(_ notification: AppNotification) -> some View { ScrollView { VStack(spacing: 0) { // Hero header - headerSection + headerSection(notification) // Info cards VStack(spacing: 16) { - detailsCard + detailsCard(notification) if let metadata = notification.metadata, !metadata.isEmpty { metadataCard(metadata) } - statusCard + statusCard(notification) } .padding(.horizontal, 16) .padding(.top, 24) @@ -44,29 +69,24 @@ struct NotificationDetailView: View { } } } - .background(Color(.systemGroupedBackground)) - .navigationTitle("details_section") - .navigationBarTitleDisplayMode(.inline) - .task { - await viewModel.markAsRead(notification) - } } // MARK: - Hero Header - private var headerSection: some View { - VStack(spacing: 16) { + private func headerSection(_ notification: AppNotification) -> some View { + let severity = NotificationSeverity(from: notification.metadata) + return VStack(spacing: 16) { ZStack { Circle() .fill(Color(.secondarySystemGroupedBackground)) .frame(width: 88, height: 88) - .shadow(color: topicColor.opacity(0.3), radius: 12, y: 4) + .shadow(color: severity.color.opacity(0.3), radius: 12, y: 4) Circle() - .fill(topicColor.opacity(0.15)) + .fill(severity.color.opacity(0.15)) .frame(width: 80, height: 80) - Image(systemName: topicIcon) + Image(systemName: severity.icon) .font(.system(size: 32)) - .foregroundStyle(topicColor) + .foregroundStyle(severity.color) } VStack(spacing: 6) { @@ -79,7 +99,7 @@ struct NotificationDetailView: View { .foregroundStyle(.secondary) } - statusBadge + statusBadge(for: notification) } .padding(.vertical, 28) .frame(maxWidth: .infinity) @@ -87,7 +107,7 @@ struct NotificationDetailView: View { // MARK: - Status Badge - private var statusBadge: some View { + private func statusBadge(for notification: AppNotification) -> some View { let (text, color): (String, Color) = notification.isRead ? (String(localized: "status_read"), .green) : (String(localized: "status_new"), .red) @@ -102,7 +122,7 @@ struct NotificationDetailView: View { // MARK: - Details Card - private var detailsCard: some View { + private func detailsCard(_ notification: AppNotification) -> some View { VStack(alignment: .leading, spacing: 12) { Label("details_section", systemImage: "doc.text.fill") .font(.subheadline.bold()) @@ -168,14 +188,14 @@ struct NotificationDetailView: View { // MARK: - Status Card - private var statusCard: some View { + private func statusCard(_ notification: AppNotification) -> some View { VStack(alignment: .leading, spacing: 12) { Label("status_section", systemImage: "clock.fill") .font(.subheadline.bold()) .foregroundStyle(.primary) VStack(spacing: 8) { - infoRow(icon: "paperplane.fill", label: String(localized: "channel_label"), value: channelLabel) + infoRow(icon: "paperplane.fill", label: String(localized: "channel_label"), value: channelLabel(for: notification)) Divider() infoRow(icon: "clock", label: String(localized: "received_label"), value: notification.createdAt.formatted(date: .abbreviated, time: .shortened)) if let readAt = notification.readAt { @@ -209,7 +229,7 @@ struct NotificationDetailView: View { // MARK: - Helpers - private var channelLabel: String { + private func channelLabel(for notification: AppNotification) -> String { switch notification.channel { case .inApp: return String(localized: "channel_in_app") case .apns: return "Push" @@ -218,34 +238,4 @@ struct NotificationDetailView: View { case .webhook: return "Webhook" } } - - private var topicIcon: String { - let lowered = (notification.source ?? "").lowercased() - if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { - return "flame.fill" - } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { - return "heart.fill" - } else if lowered.contains("security") || lowered.contains("безопас") { - return "shield.fill" - } else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") { - return "drop.fill" - } else { - return "exclamationmark.triangle.fill" - } - } - - private var topicColor: Color { - let lowered = (notification.source ?? "").lowercased() - if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { - return .red - } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { - return .green - } else if lowered.contains("security") || lowered.contains("безопас") { - return .blue - } else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") { - return .cyan - } else { - return .orange - } - } } diff --git a/Mayday/Views/Notifications/NotificationsView.swift b/Mayday/Views/Notifications/NotificationsView.swift index d128370..23c2567 100644 --- a/Mayday/Views/Notifications/NotificationsView.swift +++ b/Mayday/Views/Notifications/NotificationsView.swift @@ -36,21 +36,8 @@ struct NotificationsView: View { } .background(Color(.systemGroupedBackground)) .navigationTitle("notifications_title") + .navigationBarTitleDisplayMode(.inline) .toolbar { - #if DEBUG - if PreviewData.isPreviewMode { - ToolbarItem(placement: .topBarLeading) { - Button(action: {}) { - Text("demo_badge") - .font(.caption2.bold()) - } - .buttonStyle(.borderedProminent) - .tint(.orange) - .controlSize(.mini) - .allowsHitTesting(false) - } - } - #endif ToolbarItem(placement: .topBarTrailing) { Button { showSettings = true @@ -86,6 +73,7 @@ struct NotificationsView: View { NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) { ActiveNotificationCard(notification: notification) } + .id("\(notification.id)-\(notification.isRead)") .buttonStyle(.plain) .padding(.horizontal, 16) .padding(.bottom, 12) @@ -103,6 +91,7 @@ struct NotificationsView: View { NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) { ResolvedNotificationCard(notification: notification) } + .id("\(notification.id)-\(notification.isRead)") .buttonStyle(.plain) .padding(.horizontal, 16) .padding(.bottom, 12) @@ -146,7 +135,7 @@ struct ActiveNotificationCard: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { - NotificationIconView(source: notification.source, isActive: true) + NotificationIconView(severity: NotificationSeverity(from: notification.metadata), isActive: true) VStack(alignment: .leading, spacing: 2) { Text(notification.subject ?? "") @@ -206,7 +195,7 @@ struct ResolvedNotificationCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { - NotificationIconView(source: notification.source, isActive: false) + NotificationIconView(severity: NotificationSeverity(from: notification.metadata), isActive: false) VStack(alignment: .leading, spacing: 2) { Text(notification.subject ?? "") @@ -255,51 +244,3 @@ struct ResolvedNotificationCard: View { .shadow(color: .black.opacity(0.06), radius: 8, y: 2) } } - -// MARK: - Notification Icon - -struct NotificationIconView: View { - let source: String? - let isActive: Bool - - private var iconName: String { - let lowered = (source ?? "").lowercased() - if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { - return "flame.fill" - } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { - return "heart.fill" - } else if lowered.contains("security") || lowered.contains("безопас") { - return "shield.fill" - } else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") { - return "drop.fill" - } else { - return "exclamationmark.triangle.fill" - } - } - - private var iconColor: Color { - let lowered = (source ?? "").lowercased() - if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { - return .red - } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { - return .green - } else if lowered.contains("security") || lowered.contains("безопас") { - return .blue - } else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") { - return .cyan - } else { - return .orange - } - } - - var body: some View { - ZStack { - Circle() - .fill(isActive ? .white.opacity(0.25) : iconColor.opacity(0.12)) - .frame(width: 40, height: 40) - Image(systemName: iconName) - .font(.body) - .foregroundStyle(isActive ? .white : iconColor) - } - } -} diff --git a/Mayday/Views/Settings/SettingsView.swift b/Mayday/Views/Settings/SettingsView.swift index 0237473..086ba2e 100644 --- a/Mayday/Views/Settings/SettingsView.swift +++ b/Mayday/Views/Settings/SettingsView.swift @@ -59,6 +59,23 @@ struct SettingsView: View { Button("logout_all_button", role: .destructive) { showLogoutAllConfirm = true } + .confirmationDialog( + "logout_all_confirm", + isPresented: $showLogoutAllConfirm, + titleVisibility: .visible + ) { + Button("logout_all_action", role: .destructive) { + Task { + do { + _ = try await NotificationsAPIService.shared.logoutAll() + await authViewModel.logout() + } catch { + logoutAllError = error.localizedDescription + } + } + } + Button("cancel", role: .cancel) {} + } } } .navigationTitle("settings_title") @@ -75,23 +92,6 @@ struct SettingsView: View { SessionsView() .environmentObject(viewModel) } - .confirmationDialog( - "logout_all_confirm", - isPresented: $showLogoutAllConfirm, - titleVisibility: .visible - ) { - Button("logout_all_action", role: .destructive) { - Task { - do { - _ = try await NotificationsAPIService.shared.logoutAll() - await authViewModel.logout() - } catch { - logoutAllError = error.localizedDescription - } - } - } - Button("cancel", role: .cancel) {} - } .alert( "error_title", isPresented: Binding( diff --git a/Mayday/Views/UIKit/AppBackground.swift b/Mayday/Views/UIKit/AppBackground.swift new file mode 100644 index 0000000..c5a475c --- /dev/null +++ b/Mayday/Views/UIKit/AppBackground.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct AppBackgroundModifier: ViewModifier { + func body(content: Content) -> some View { + content.background( + LinearGradient( + colors: [Color(.systemGroupedBackground), Color.red.opacity(0.08)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + ) + } +} + +extension View { + func appBackground() -> some View { + modifier(AppBackgroundModifier()) + } +} diff --git a/Mayday/Views/UIKit/AppSecureField.swift b/Mayday/Views/UIKit/AppSecureField.swift new file mode 100644 index 0000000..f216ac6 --- /dev/null +++ b/Mayday/Views/UIKit/AppSecureField.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct AppSecureField: View { + let title: LocalizedStringKey + let icon: String + @Binding var text: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 18) + + SecureField(title, text: $text) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } +} diff --git a/Mayday/Views/UIKit/AppTextField.swift b/Mayday/Views/UIKit/AppTextField.swift new file mode 100644 index 0000000..6c017ae --- /dev/null +++ b/Mayday/Views/UIKit/AppTextField.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct AppTextField: View { + let title: LocalizedStringKey + let icon: String + @Binding var text: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 18) + + TextField(title, text: $text) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } +} diff --git a/Mayday/Views/UIKit/CardContainer.swift b/Mayday/Views/UIKit/CardContainer.swift new file mode 100644 index 0000000..b99b09a --- /dev/null +++ b/Mayday/Views/UIKit/CardContainer.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct CardContainerModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.horizontal, 20) + .padding(.vertical, 24) + .background(Color(.systemBackground).opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.primary.opacity(0.06), lineWidth: 1) + ) + .padding(16) + } +} + +extension View { + func cardContainer() -> some View { + modifier(CardContainerModifier()) + } +} diff --git a/Mayday/Views/UIKit/NotificationIconView.swift b/Mayday/Views/UIKit/NotificationIconView.swift new file mode 100644 index 0000000..aba8cdf --- /dev/null +++ b/Mayday/Views/UIKit/NotificationIconView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +enum NotificationSeverity: String { + case critical + case warning + case info + case success + + var icon: String { + switch self { + case .critical: return "exclamationmark.triangle.fill" + case .warning: return "exclamationmark.circle.fill" + case .info: return "info.circle.fill" + case .success: return "checkmark.seal.fill" + } + } + + var color: Color { + switch self { + case .critical: return .red + case .warning: return .orange + case .info: return .blue + case .success: return .green + } + } + + init(from metadata: [String: String]?) { + let raw = metadata?["severity"]?.lowercased() ?? "" + self = NotificationSeverity(rawValue: raw) ?? .info + } +} + +struct NotificationIconView: View { + let severity: NotificationSeverity + let isActive: Bool + + var body: some View { + ZStack { + Circle() + .fill(isActive ? .white.opacity(0.25) : severity.color.opacity(0.12)) + .frame(width: 40, height: 40) + Image(systemName: severity.icon) + .font(.body) + .foregroundStyle(isActive ? .white : severity.color) + } + } +} diff --git a/Mayday/Views/UIKit/OTPDigitField.swift b/Mayday/Views/UIKit/OTPDigitField.swift new file mode 100644 index 0000000..47f67b9 --- /dev/null +++ b/Mayday/Views/UIKit/OTPDigitField.swift @@ -0,0 +1,100 @@ +import SwiftUI +import UIKit + +struct OTPDigitField: UIViewRepresentable { + @Binding var text: String + let isFocused: Bool + let onFocus: () -> Void + let onInsert: () -> Void + let onDeleteWhenEmpty: () -> Void + let onPaste: ([String]) -> Void + + func makeUIView(context: Context) -> BackspaceAwareTextField { + let textField = BackspaceAwareTextField() + textField.delegate = context.coordinator + textField.keyboardType = .numberPad + textField.textAlignment = .center + textField.font = UIFont.systemFont(ofSize: 24, weight: .bold) + textField.textContentType = .oneTimeCode + textField.onDeleteWhenEmpty = { + onDeleteWhenEmpty() + } + textField.addTarget(context.coordinator, action: #selector(Coordinator.editingChanged(_:)), for: .editingChanged) + return textField + } + + func updateUIView(_ uiView: BackspaceAwareTextField, context: Context) { + if uiView.text != text { + uiView.text = text + } + + if isFocused && !uiView.isFirstResponder { + DispatchQueue.main.async { + uiView.becomeFirstResponder() + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: OTPDigitField + + init(parent: OTPDigitField) { + self.parent = parent + } + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + parent.onFocus() + return true + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + parent.onFocus() + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if string.isEmpty { + return true + } + + let digits = string.filter { $0.isNumber } + guard !digits.isEmpty else { + return false + } + + if digits.count > 1 { + parent.onPaste(digits.map(String.init)) + return false + } + + parent.text = String(digits.prefix(1)) + parent.onInsert() + return false + } + + @objc + func editingChanged(_ textField: UITextField) { + let digitsOnly = (textField.text ?? "").filter { $0.isNumber } + let single = String(digitsOnly.prefix(1)) + if textField.text != single { + textField.text = single + } + parent.text = single + } + } +} + +final class BackspaceAwareTextField: UITextField { + var onDeleteWhenEmpty: (() -> Void)? + + override func deleteBackward() { + let wasEmpty = (text ?? "").isEmpty + super.deleteBackward() + if wasEmpty { + onDeleteWhenEmpty?() + } + } +} diff --git a/README.md b/README.md index 210a1e8..3d36267 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ -# mayday -The app you hope you never get a notification from +

+ Mayday logo +

+ +

Mayday

+ +

+ The app you hope you never get a notification from. +

+ +

+ iOS app for emergency and critical alerts with clear severity states, + quick triage, and secure session-aware access. +

+ +## Highlights + +- Fast notification feed with unread/read sections +- Auto-mark as read on open with consistent UI state +- Severity-driven icon and color mapping from `metadata.severity` +- Auth flow with login, register, and email verification +- Account settings: sessions and password management + +## Tech + +- SwiftUI +- MVVM +- Async/await networking +- Keychain-backed auth token storage + +## Run + +1. Open `Mayday.xcodeproj` in Xcode +2. Select the `Mayday` scheme +3. Build and run on simulator or device + +## Project + +- App source: `Mayday/` +- Live Activity target: `MaydayLiveActivity/` + +## Icon Attribution + +Icon from [lucide.dev](https://lucide.dev), released under the ISC License. + +Copyright (c) 2026 Lucide Contributors