diff --git a/Mayday.xcodeproj/project.pbxproj b/Mayday.xcodeproj/project.pbxproj index 6a52731..219676f 100644 --- a/Mayday.xcodeproj/project.pbxproj +++ b/Mayday.xcodeproj/project.pbxproj @@ -32,19 +32,19 @@ 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 */; }; + AA000001000027 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000027 /* Localizable.xcstrings */; }; + AA000001000028 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000028 /* InfoPlist.xcstrings */; }; + 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 */; }; + AA000001000033 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000027 /* Localizable.xcstrings */; }; 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 */; }; - AA000001000033 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000027 /* Localizable.xcstrings */; }; AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA000008000001 /* MaydayLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -102,16 +102,16 @@ 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 = ""; }; + AA000002000033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; 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 = ""; }; + AA000002000040 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; 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; }; /* End PBXFileReference section */ @@ -237,6 +237,16 @@ path = Settings; sourceTree = ""; }; + AA000011000010 /* MaydayLiveActivity */ = { + isa = PBXGroup; + children = ( + AA000002000030 /* MaydayLiveActivityBundle.swift */, + AA000002000031 /* MaydayLiveActivityLiveActivity.swift */, + AA000002000033 /* Info.plist */, + ); + path = MaydayLiveActivity; + sourceTree = ""; + }; AA000011000011 /* UIKit */ = { isa = PBXGroup; children = ( @@ -250,16 +260,6 @@ path = UIKit; sourceTree = ""; }; - AA000011000010 /* MaydayLiveActivity */ = { - isa = PBXGroup; - children = ( - AA000002000030 /* MaydayLiveActivityBundle.swift */, - AA000002000031 /* MaydayLiveActivityLiveActivity.swift */, - AA000002000033 /* Info.plist */, - ); - path = MaydayLiveActivity; - sourceTree = ""; - }; AA000011000099 /* Products */ = { isa = PBXGroup; children = ( @@ -449,6 +449,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -477,6 +480,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/Mayday/Assets.xcassets/AccentColor.colorset/Contents.json b/Mayday/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..7cc1d38 100644 --- a/Mayday/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Mayday/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,33 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.188", + "green" : "0.231", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.227", + "green" : "0.271", + "red" : "1.000" + } + }, "idiom" : "universal" } ], diff --git a/Mayday/Assets.xcassets/Brand.colorset/Contents.json b/Mayday/Assets.xcassets/Brand.colorset/Contents.json new file mode 100644 index 0000000..7cc1d38 --- /dev/null +++ b/Mayday/Assets.xcassets/Brand.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.188", + "green" : "0.231", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.227", + "green" : "0.271", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mayday/Assets.xcassets/Info.colorset/Contents.json b/Mayday/Assets.xcassets/Info.colorset/Contents.json new file mode 100644 index 0000000..96b3471 --- /dev/null +++ b/Mayday/Assets.xcassets/Info.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.478", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.518", + "red" : "0.039" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mayday/Assets.xcassets/Success.colorset/Contents.json b/Mayday/Assets.xcassets/Success.colorset/Contents.json new file mode 100644 index 0000000..c3b3dc3 --- /dev/null +++ b/Mayday/Assets.xcassets/Success.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.349", + "green" : "0.780", + "red" : "0.204" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.345", + "green" : "0.820", + "red" : "0.188" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mayday/Assets.xcassets/Warning.colorset/Contents.json b/Mayday/Assets.xcassets/Warning.colorset/Contents.json new file mode 100644 index 0000000..9d05aeb --- /dev/null +++ b/Mayday/Assets.xcassets/Warning.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.584", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.039", + "green" : "0.624", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mayday/ContentView.swift b/Mayday/ContentView.swift index 454b7d5..79591a9 100644 --- a/Mayday/ContentView.swift +++ b/Mayday/ContentView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ContentView: View { - @EnvironmentObject var authViewModel: AuthViewModel + @Environment(AuthViewModel.self) private var authViewModel var body: some View { Group { @@ -19,3 +19,8 @@ struct ContentView: View { } } } + +#Preview { + ContentView() + .environment(AuthViewModel()) +} diff --git a/Mayday/Localizable.xcstrings b/Mayday/Localizable.xcstrings index 9ea6149..c7ef335 100644 --- a/Mayday/Localizable.xcstrings +++ b/Mayday/Localizable.xcstrings @@ -2,10 +2,23 @@ "sourceLanguage" : "ru", "strings" : { "(%lld)" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%lld)" + } + } + } }, "%@ alert: %@" : { "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ alert: %2$@" + } + }, "ru" : { "stringUnit" : { "state" : "new", @@ -31,7 +44,14 @@ } }, "Active" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active" + } + } + } }, "active_sessions" : { "localizations" : { @@ -65,40 +85,6 @@ } } }, - "alert_status_active" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "active" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "активен" - } - } - } - }, - "alert_status_resolved" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "resolved" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "завершён" - } - } - } - }, "cancel" : { "localizations" : { "en" : { @@ -292,7 +278,14 @@ } }, "Email" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Email" + } + } + } }, "error_invalid_credentials" : { "localizations" : { @@ -503,7 +496,14 @@ } }, "Mayday" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mayday" + } + } + } }, "new_password" : { "localizations" : { @@ -618,7 +618,14 @@ } }, "OK" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } }, "open_button" : { "localizations" : { @@ -797,7 +804,14 @@ } }, "Resolved" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolved" + } + } + } }, "save_button" : { "localizations" : { diff --git a/Mayday/MaydayApp.swift b/Mayday/MaydayApp.swift index f9e1437..f048cd8 100644 --- a/Mayday/MaydayApp.swift +++ b/Mayday/MaydayApp.swift @@ -2,13 +2,13 @@ import SwiftUI @main struct MaydayApp: App { - @StateObject private var authViewModel = AuthViewModel() + @State private var authViewModel = AuthViewModel() @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { ContentView() - .environmentObject(authViewModel) + .environment(authViewModel) } } } diff --git a/Mayday/ViewModels/AuthViewModel.swift b/Mayday/ViewModels/AuthViewModel.swift index 45d08e3..f9ff309 100644 --- a/Mayday/ViewModels/AuthViewModel.swift +++ b/Mayday/ViewModels/AuthViewModel.swift @@ -1,13 +1,14 @@ import Foundation import SwiftUI +@Observable @MainActor -class AuthViewModel: ObservableObject { - @Published var isAuthenticated = false - @Published var isCheckingAuth = true - @Published var currentUser: UserResponse? - @Published var isLoading = false - @Published var error: String? +final class AuthViewModel { + var isAuthenticated = false + var isCheckingAuth = true + var currentUser: UserResponse? + var isLoading = false + var error: String? private let auth = AuthService.shared private let keychain = KeychainService.shared diff --git a/Mayday/ViewModels/NotificationsViewModel.swift b/Mayday/ViewModels/NotificationsViewModel.swift index f6fe139..a1d2b15 100644 --- a/Mayday/ViewModels/NotificationsViewModel.swift +++ b/Mayday/ViewModels/NotificationsViewModel.swift @@ -2,16 +2,25 @@ import Foundation import SwiftUI import UIKit +@Observable @MainActor -class NotificationsViewModel: ObservableObject { - @Published var notifications: [AppNotification] = [] - @Published var unreadCount = 0 - @Published var isLoading = false - @Published var isLoadingMore = false - @Published var error: String? - @Published var hasMore = true +final class NotificationsViewModel { + var notifications: [AppNotification] = [] + var unreadCount = 0 + var isLoading = false + var isLoadingMore = false + var error: String? + var hasMore = true private var hasLoadedOnce = false + var unreadNotifications: [AppNotification] { + notifications.filter { !$0.isRead } + } + + var readNotifications: [AppNotification] { + notifications.filter { $0.isRead } + } + private let service = NotificationsAPIService.shared private let limit = 50 private var currentOffset = 0 diff --git a/Mayday/ViewModels/SettingsViewModel.swift b/Mayday/ViewModels/SettingsViewModel.swift index 0b5f307..f7f13de 100644 --- a/Mayday/ViewModels/SettingsViewModel.swift +++ b/Mayday/ViewModels/SettingsViewModel.swift @@ -1,12 +1,13 @@ import Foundation import SwiftUI +@Observable @MainActor -class SettingsViewModel: ObservableObject { - @Published var sessions: [SessionResponse] = [] - @Published var isLoading = false - @Published var error: String? - @Published var successMessage: String? +final class SettingsViewModel { + var sessions: [SessionResponse] = [] + var isLoading = false + var error: String? + var successMessage: String? private let service = NotificationsAPIService.shared diff --git a/Mayday/Views/Auth/LoginView.swift b/Mayday/Views/Auth/LoginView.swift index c38a1f0..5855db7 100644 --- a/Mayday/Views/Auth/LoginView.swift +++ b/Mayday/Views/Auth/LoginView.swift @@ -1,7 +1,7 @@ import SwiftUI struct LoginView: View { - @EnvironmentObject var authViewModel: AuthViewModel + @Environment(AuthViewModel.self) private var authViewModel @State private var email = "" @State private var password = "" @State private var showRegister = false @@ -23,7 +23,7 @@ struct LoginView: View { .scaledToFit() .frame(width: 84, height: 84) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .shadow(color: .red.opacity(0.25), radius: 12, y: 6) + .shadow(color: .brand.opacity(0.25), radius: 12, y: 6) Text("Mayday") .font(.largeTitle.bold()) @@ -56,7 +56,7 @@ struct LoginView: View { if let error = authViewModel.error { Text(error) - .foregroundStyle(.red) + .foregroundStyle(.brand) .font(.footnote) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .leading) @@ -69,7 +69,7 @@ struct LoginView: View { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill( LinearGradient( - colors: [Color.red, Color.red.opacity(0.82)], + colors: [Color.brand, Color.brand.opacity(0.82)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -107,3 +107,8 @@ struct LoginView: View { } } } + +#Preview { + LoginView() + .environment(AuthViewModel()) +} diff --git a/Mayday/Views/Auth/RegisterView.swift b/Mayday/Views/Auth/RegisterView.swift index e155e19..8786c39 100644 --- a/Mayday/Views/Auth/RegisterView.swift +++ b/Mayday/Views/Auth/RegisterView.swift @@ -1,8 +1,8 @@ import SwiftUI struct RegisterView: View { - @EnvironmentObject var authViewModel: AuthViewModel - @Environment(\.dismiss) var dismiss + @Environment(AuthViewModel.self) private var authViewModel + @Environment(\.dismiss) private var dismiss @State private var email = "" @State private var password = "" @@ -26,7 +26,7 @@ struct RegisterView: View { .scaledToFit() .frame(width: 76, height: 76) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .shadow(color: .red.opacity(0.22), radius: 12, y: 6) + .shadow(color: .brand.opacity(0.22), radius: 12, y: 6) Text("register_title") .font(.largeTitle.bold()) @@ -48,21 +48,21 @@ struct RegisterView: View { if password.count > 0 && password.count < 8 { Text("password_min_length") - .foregroundStyle(.red) + .foregroundStyle(.brand) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) } if confirmPassword.count > 0 && password != confirmPassword { Text("passwords_mismatch") - .foregroundStyle(.red) + .foregroundStyle(.brand) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) } if let error = authViewModel.error { Text(error) - .foregroundStyle(.red) + .foregroundStyle(.brand) .font(.footnote) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -81,7 +81,7 @@ struct RegisterView: View { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill( LinearGradient( - colors: [Color.red, Color.red.opacity(0.82)], + colors: [Color.brand, Color.brand.opacity(0.82)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -122,3 +122,10 @@ struct RegisterView: View { !email.isEmpty && password.count >= 8 && password == confirmPassword } } + +#Preview { + NavigationStack { + RegisterView() + } + .environment(AuthViewModel()) +} diff --git a/Mayday/Views/Auth/VerifyEmailView.swift b/Mayday/Views/Auth/VerifyEmailView.swift index 29ebdaa..39eebe7 100644 --- a/Mayday/Views/Auth/VerifyEmailView.swift +++ b/Mayday/Views/Auth/VerifyEmailView.swift @@ -4,7 +4,7 @@ struct VerifyEmailView: View { let email: String let password: String - @EnvironmentObject var authViewModel: AuthViewModel + @Environment(AuthViewModel.self) private var authViewModel @State private var codeDigits: [String] = Array(repeating: "", count: 6) @State private var resendCooldown = 0 @State private var focusedIndex: Int? @@ -40,7 +40,7 @@ struct VerifyEmailView: View { if let error = authViewModel.error { Text(error) - .foregroundStyle(.red) + .foregroundStyle(.brand) .font(.footnote) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -65,7 +65,7 @@ struct VerifyEmailView: View { .scaledToFit() .frame(width: 72, height: 72) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .shadow(color: .red.opacity(0.22), radius: 12, y: 6) + .shadow(color: .brand.opacity(0.22), radius: 12, y: 6) Text("verify_email_title") .font(.largeTitle.bold()) @@ -102,7 +102,7 @@ struct VerifyEmailView: View { } } .font(.footnote.weight(.semibold)) - .foregroundStyle(resendCooldown > 0 ? Color.secondary : Color.red) + .foregroundStyle(resendCooldown > 0 ? Color.secondary : Color.brand) .padding(.horizontal, 14) .padding(.vertical, 8) .background(Color(.tertiarySystemFill)) @@ -136,7 +136,7 @@ struct VerifyEmailView: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke( focusedIndex == index - ? Color.red.opacity(0.9) + ? Color.brand.opacity(0.9) : Color.primary.opacity(0.10), lineWidth: focusedIndex == index ? 2 : 1 ) @@ -188,3 +188,10 @@ struct VerifyEmailView: View { } } } + +#Preview { + NavigationStack { + VerifyEmailView(email: "user@example.com", password: "password123") + } + .environment(AuthViewModel()) +} diff --git a/Mayday/Views/Notifications/NotificationDetailView.swift b/Mayday/Views/Notifications/NotificationDetailView.swift index a0a779e..cca35c8 100644 --- a/Mayday/Views/Notifications/NotificationDetailView.swift +++ b/Mayday/Views/Notifications/NotificationDetailView.swift @@ -2,7 +2,7 @@ import SwiftUI struct NotificationDetailView: View { let notificationId: UUID - @ObservedObject var viewModel: NotificationsViewModel + var viewModel: NotificationsViewModel private var notification: AppNotification? { viewModel.notifications.first { $0.id == notificationId } @@ -56,12 +56,12 @@ struct NotificationDetailView: View { } label: { Text("mark_as_read") .font(.headline) - .foregroundStyle(.red) + .foregroundStyle(.brand) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( RoundedRectangle(cornerRadius: 16) - .fill(Color.red.opacity(0.1)) + .fill(Color.brand.opacity(0.1)) ) } .padding(.horizontal, 16) @@ -109,8 +109,8 @@ struct NotificationDetailView: 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) + ? (String(localized: "status_read"), .success) + : (String(localized: "status_new"), .brand) return Text(text) .font(.caption.bold()) .foregroundStyle(color) @@ -239,3 +239,18 @@ struct NotificationDetailView: View { } } } + +#Preview { + let notification = AppNotification( + id: UUID(), userId: UUID(), scopeId: nil, channel: .inApp, + contentType: .plain, templateId: nil, subject: "CPU Usage Critical", + body: "Server load has exceeded 95% for the last 5 minutes. Immediate action is required to prevent service degradation.", + source: "monitoring", metadata: ["severity": "critical", "host": "prod-01", "region": "eu-west-1"], + status: .sent, error: nil, attempts: 1, maxAttempts: 3, + nextRetryAt: nil, sentAt: Date(), readAt: nil, createdAt: Date() + ) + let vm = NotificationsViewModel() + NavigationStack { + NotificationDetailView(notification: notification, viewModel: vm) + } +} diff --git a/Mayday/Views/Notifications/NotificationsView.swift b/Mayday/Views/Notifications/NotificationsView.swift index 23c2567..4da39a7 100644 --- a/Mayday/Views/Notifications/NotificationsView.swift +++ b/Mayday/Views/Notifications/NotificationsView.swift @@ -1,18 +1,10 @@ import SwiftUI struct NotificationsView: View { - @EnvironmentObject var authViewModel: AuthViewModel - @StateObject private var viewModel = NotificationsViewModel() + @Environment(AuthViewModel.self) private var authViewModel + @State private var viewModel = NotificationsViewModel() @State private var showSettings = false - private var unreadNotifications: [AppNotification] { - viewModel.notifications.filter { !$0.isRead } - } - - private var readNotifications: [AppNotification] { - viewModel.notifications.filter { $0.isRead } - } - var body: some View { NavigationStack { Group { @@ -49,7 +41,7 @@ struct NotificationsView: View { } .sheet(isPresented: $showSettings) { SettingsView() - .environmentObject(authViewModel) + .environment(authViewModel) } .task { await viewModel.load() @@ -64,12 +56,12 @@ struct NotificationsView: View { } } - var notificationsList: some View { + private var notificationsList: some View { ScrollView { LazyVStack(spacing: 0) { - if !unreadNotifications.isEmpty { + if !viewModel.unreadNotifications.isEmpty { sectionHeader(String(localized: "notifications_active")) - ForEach(unreadNotifications) { notification in + ForEach(viewModel.unreadNotifications) { notification in NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) { ActiveNotificationCard(notification: notification) } @@ -85,9 +77,9 @@ struct NotificationsView: View { } } - if !readNotifications.isEmpty { + if !viewModel.readNotifications.isEmpty { sectionHeader(String(localized: "notifications_completed")) - ForEach(readNotifications) { notification in + ForEach(viewModel.readNotifications) { notification in NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) { ResolvedNotificationCard(notification: notification) } @@ -112,7 +104,7 @@ struct NotificationsView: View { } } - func sectionHeader(_ title: String) -> some View { + private func sectionHeader(_ title: String) -> some View { HStack { Text(title) .font(.subheadline) @@ -127,6 +119,43 @@ struct NotificationsView: View { } } +// MARK: - Previews + +private extension AppNotification { + static let previewUnread = AppNotification( + id: UUID(), userId: UUID(), scopeId: nil, channel: .inApp, + contentType: .plain, templateId: nil, subject: "CPU Usage Critical", + body: "Server load has exceeded 95% for the last 5 minutes.", + source: "monitoring", metadata: ["severity": "critical"], + status: .sent, error: nil, attempts: 1, maxAttempts: 3, + nextRetryAt: nil, sentAt: Date(), readAt: nil, createdAt: Date() + ) + + static let previewRead = AppNotification( + id: UUID(), userId: UUID(), scopeId: nil, channel: .inApp, + contentType: .plain, templateId: nil, subject: "Deployment Successful", + body: "Version 2.1.0 has been deployed to production.", + source: "ci/cd", metadata: ["severity": "success"], + status: .read, error: nil, attempts: 1, maxAttempts: 3, + nextRetryAt: nil, sentAt: Date(), readAt: Date(), createdAt: Date().addingTimeInterval(-3600) + ) +} + +#Preview("Active Card") { + ActiveNotificationCard(notification: .previewUnread) + .padding() +} + +#Preview("Resolved Card") { + ResolvedNotificationCard(notification: .previewRead) + .padding() +} + +#Preview("Notifications List") { + NotificationsView() + .environment(AuthViewModel()) +} + // MARK: - Active (Unread) Card struct ActiveNotificationCard: View { @@ -166,7 +195,7 @@ struct ActiveNotificationCard: View { Spacer() Text("open_button") .font(.subheadline.bold()) - .foregroundStyle(Color.red) + .foregroundStyle(Color.brand) .padding(.horizontal, 32) .padding(.vertical, 10) .background(Color(.systemBackground)) @@ -177,13 +206,13 @@ struct ActiveNotificationCard: View { .padding(16) .background( LinearGradient( - colors: [Color.red, Color.red.opacity(0.85)], + colors: [Color.brand, Color.brand.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .clipShape(RoundedRectangle(cornerRadius: 20)) - .shadow(color: .red.opacity(0.3), radius: 8, y: 4) + .shadow(color: .brand.opacity(0.3), radius: 8, y: 4) } } @@ -231,7 +260,7 @@ struct ResolvedNotificationCard: View { HStack(spacing: 4) { Image(systemName: "checkmark") .font(.caption2) - .foregroundStyle(.green) + .foregroundStyle(.success) Text("notification_read_at \(readAt.formatted(date: .abbreviated, time: .shortened))") .font(.caption) .foregroundStyle(.secondary) diff --git a/Mayday/Views/Settings/ChangePasswordView.swift b/Mayday/Views/Settings/ChangePasswordView.swift index 7cfd4b9..abab88c 100644 --- a/Mayday/Views/Settings/ChangePasswordView.swift +++ b/Mayday/Views/Settings/ChangePasswordView.swift @@ -1,46 +1,98 @@ import SwiftUI struct ChangePasswordView: View { - @StateObject private var viewModel = SettingsViewModel() - @Environment(\.dismiss) var dismiss + var viewModel: SettingsViewModel + @Environment(\.dismiss) private var dismiss @State private var currentPassword = "" @State private var newPassword = "" @State private var confirmPassword = "" + private var isFormInvalid: Bool { + !isFormValid || viewModel.isLoading + } + + private var isFormValid: Bool { + !currentPassword.isEmpty && newPassword.count >= 8 && newPassword == confirmPassword + } + var body: some View { NavigationStack { Form { Section { - SecureField("current_password", text: $currentPassword) + VStack(spacing: 12) { + AppSecureField( + title: "current_password", + icon: "lock.fill", + text: $currentPassword + ) .textContentType(.password) - SecureField("new_password", text: $newPassword) + + AppSecureField( + title: "new_password", + icon: "key.fill", + text: $newPassword + ) .textContentType(.newPassword) - SecureField("confirm_new_password", text: $confirmPassword) + + AppSecureField( + title: "confirm_new_password", + icon: "lock.rotation", + text: $confirmPassword + ) .textContentType(.newPassword) + } + .padding(.vertical, 4) + } + + if newPassword.count > 0 && newPassword.count < 8 { + Section { + Text("password_min_length") + .foregroundStyle(.brand) + .font(.footnote) + } + } + + if confirmPassword.count > 0 && newPassword != confirmPassword { + Section { + Text("passwords_mismatch") + .foregroundStyle(.brand) + .font(.footnote) + } } if let error = viewModel.error { Section { - Text(error).foregroundStyle(.red) + Text(error) + .foregroundStyle(.brand) + .font(.footnote) } } if let success = viewModel.successMessage { Section { - Text(success).foregroundStyle(.green) + Text(success) + .foregroundStyle(.success) + .font(.footnote) } } Section { - Button("save_button") { + Button { Task { let success = await viewModel.changePassword(current: currentPassword, new: newPassword) if success { dismiss() } } + } label: { + if viewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("save_button") + .frame(maxWidth: .infinity) + } } - .disabled(!isFormValid || viewModel.isLoading) - .frame(maxWidth: .infinity) + .disabled(isFormInvalid) } } .navigationTitle("change_password_title") @@ -52,8 +104,8 @@ struct ChangePasswordView: View { } } } - - var isFormValid: Bool { - !currentPassword.isEmpty && newPassword.count >= 8 && newPassword == confirmPassword - } +} + +#Preview { + ChangePasswordView(viewModel: SettingsViewModel()) } diff --git a/Mayday/Views/Settings/SessionsView.swift b/Mayday/Views/Settings/SessionsView.swift index ece179b..3a831fe 100644 --- a/Mayday/Views/Settings/SessionsView.swift +++ b/Mayday/Views/Settings/SessionsView.swift @@ -1,8 +1,8 @@ import SwiftUI struct SessionsView: View { - @EnvironmentObject var viewModel: SettingsViewModel - @Environment(\.dismiss) var dismiss + var viewModel: SettingsViewModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { @@ -18,8 +18,8 @@ struct SessionsView: View { .font(.caption) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Color.green.opacity(0.2)) - .foregroundStyle(.green) + .background(Color.success.opacity(0.2)) + .foregroundStyle(.success) .cornerRadius(4) } } @@ -51,3 +51,7 @@ struct SessionsView: View { } } } + +#Preview { + SessionsView(viewModel: SettingsViewModel()) +} diff --git a/Mayday/Views/Settings/SettingsView.swift b/Mayday/Views/Settings/SettingsView.swift index 086ba2e..32ca57a 100644 --- a/Mayday/Views/Settings/SettingsView.swift +++ b/Mayday/Views/Settings/SettingsView.swift @@ -1,13 +1,14 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject var authViewModel: AuthViewModel - @StateObject private var viewModel = SettingsViewModel() - @Environment(\.dismiss) var dismiss + @Environment(AuthViewModel.self) private var authViewModel + @State private var viewModel = SettingsViewModel() + @Environment(\.dismiss) private var dismiss @State private var showChangePassword = false @State private var showSessions = false @State private var showLogoutAllConfirm = false @State private var logoutAllError: String? + @State private var showLogoutAllError = false var body: some View { NavigationStack { @@ -19,9 +20,12 @@ struct SettingsView: View { } Section { - Button("change_password") { + Button { showChangePassword = true + } label: { + Text("change_password") } + .tint(.primary) Button { if let url = URL(string: UIApplication.openNotificationSettingsURLString) { @@ -29,8 +33,8 @@ struct SettingsView: View { } } label: { Label("push_notifications", systemImage: "bell.badge") - .foregroundStyle(.primary) } + .tint(.primary) } Section { @@ -48,7 +52,7 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } - .foregroundStyle(.primary) + .tint(.primary) } Section { @@ -71,6 +75,7 @@ struct SettingsView: View { await authViewModel.logout() } catch { logoutAllError = error.localizedDescription + showLogoutAllError = true } } } @@ -86,19 +91,12 @@ struct SettingsView: View { } } .sheet(isPresented: $showChangePassword) { - ChangePasswordView() + ChangePasswordView(viewModel: viewModel) } .sheet(isPresented: $showSessions) { - SessionsView() - .environmentObject(viewModel) + SessionsView(viewModel: viewModel) } - .alert( - "error_title", - isPresented: Binding( - get: { logoutAllError != nil }, - set: { if !$0 { logoutAllError = nil } } - ) - ) { + .alert("error_title", isPresented: $showLogoutAllError) { Button("OK") { logoutAllError = nil } } message: { Text(logoutAllError ?? "") @@ -109,3 +107,8 @@ struct SettingsView: View { } } } + +#Preview { + SettingsView() + .environment(AuthViewModel()) +} diff --git a/Mayday/Views/UIKit/AppBackground.swift b/Mayday/Views/UIKit/AppBackground.swift index c5a475c..f1455c7 100644 --- a/Mayday/Views/UIKit/AppBackground.swift +++ b/Mayday/Views/UIKit/AppBackground.swift @@ -4,7 +4,7 @@ struct AppBackgroundModifier: ViewModifier { func body(content: Content) -> some View { content.background( LinearGradient( - colors: [Color(.systemGroupedBackground), Color.red.opacity(0.08)], + colors: [Color(.systemGroupedBackground), Color.brand.opacity(0.08)], startPoint: .top, endPoint: .bottom ) diff --git a/Mayday/Views/UIKit/AppColors.swift b/Mayday/Views/UIKit/AppColors.swift new file mode 100644 index 0000000..729a3e5 --- /dev/null +++ b/Mayday/Views/UIKit/AppColors.swift @@ -0,0 +1,15 @@ +import SwiftUI + +extension Color { + static let brand = Color("Brand") + static let success = Color("Success") + static let warning = Color("Warning") + static let info = Color("Info") +} + +extension ShapeStyle where Self == Color { + static var brand: Color { .brand } + static var success: Color { .success } + static var warning: Color { .warning } + static var info: Color { .info } +} diff --git a/Mayday/Views/UIKit/AppSecureField.swift b/Mayday/Views/UIKit/AppSecureField.swift index f216ac6..7593a74 100644 --- a/Mayday/Views/UIKit/AppSecureField.swift +++ b/Mayday/Views/UIKit/AppSecureField.swift @@ -23,3 +23,8 @@ struct AppSecureField: View { ) } } + +#Preview { + AppSecureField(title: "Password", icon: "lock.fill", text: .constant("")) + .padding() +} diff --git a/Mayday/Views/UIKit/AppTextField.swift b/Mayday/Views/UIKit/AppTextField.swift index 6c017ae..4c55d3e 100644 --- a/Mayday/Views/UIKit/AppTextField.swift +++ b/Mayday/Views/UIKit/AppTextField.swift @@ -23,3 +23,8 @@ struct AppTextField: View { ) } } + +#Preview { + AppTextField(title: "Email", icon: "envelope.fill", text: .constant("user@example.com")) + .padding() +} diff --git a/Mayday/Views/UIKit/NotificationIconView.swift b/Mayday/Views/UIKit/NotificationIconView.swift index aba8cdf..d102b8e 100644 --- a/Mayday/Views/UIKit/NotificationIconView.swift +++ b/Mayday/Views/UIKit/NotificationIconView.swift @@ -17,10 +17,10 @@ enum NotificationSeverity: String { var color: Color { switch self { - case .critical: return .red - case .warning: return .orange - case .info: return .blue - case .success: return .green + case .critical: return .brand + case .warning: return .warning + case .info: return .info + case .success: return .success } } @@ -45,3 +45,13 @@ struct NotificationIconView: View { } } } + +#Preview { + HStack(spacing: 16) { + NotificationIconView(severity: .critical, isActive: true) + NotificationIconView(severity: .warning, isActive: false) + NotificationIconView(severity: .info, isActive: false) + NotificationIconView(severity: .success, isActive: false) + } + .padding() +}