From a45d792f1b673c85405beb7c3c2c3d761fcf454b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurent=20Tr=C3=A9guier?= Date: Fri, 16 Aug 2024 15:18:40 +0200 Subject: [PATCH] Allow logging in and out --- Fyreplace.xcodeproj/project.pbxproj | 44 +++-- Fyreplace/Config/Config.swift | 2 + Fyreplace/Config/Info.plist | 2 + Fyreplace/Data/Keychain.swift | 103 +++++++++++ Fyreplace/Fakes/FakeClient.swift | 4 +- Fyreplace/Resources/Localizable.xcstrings | 70 ++++++++ Fyreplace/States/LoadingViewState.swift | 6 - Fyreplace/Views/Forms/EnvironmentPicker.swift | 4 +- Fyreplace/Views/Forms/SubmitButton.swift | 52 +++--- Fyreplace/Views/MainView.swift | 74 ++------ Fyreplace/Views/MainViewProtocol.swift | 43 +++++ .../Views/Navigation/CompactNavigation.swift | 37 ++-- Fyreplace/Views/Navigation/Destination.swift | 23 +++ .../Views/Navigation/RegularNavigation.swift | 14 +- Fyreplace/Views/Screens/ArchiveScreen.swift | 1 - .../Views/Screens/AuthenticatingScreen.swift | 31 ++++ Fyreplace/Views/Screens/DraftsScreen.swift | 1 - Fyreplace/Views/Screens/FeedScreen.swift | 1 - Fyreplace/Views/Screens/LoginScreen.swift | 161 ++++++++++-------- .../Views/Screens/LoginScreenProtocol.swift | 67 ++++++++ .../Views/Screens/MultiChoiceScreen.swift | 4 +- .../Views/Screens/NotificationsScreen.swift | 1 - Fyreplace/Views/Screens/PublishedScreen.swift | 1 - Fyreplace/Views/Screens/RegisterScreen.swift | 104 +++++------ .../Screens/RegisterScreenProtocol.swift | 10 ++ Fyreplace/Views/Screens/Screen.swift | 10 +- Fyreplace/Views/Screens/SettingsScreen.swift | 34 +++- .../ViewProtocol.swift} | 14 +- FyreplaceTests/Screens/LoginScreenTests.swift | 78 +++++++-- .../Screens/RegisterScreenTests.swift | 56 +++--- 30 files changed, 744 insertions(+), 308 deletions(-) create mode 100644 Fyreplace/Data/Keychain.swift delete mode 100644 Fyreplace/States/LoadingViewState.swift create mode 100644 Fyreplace/Views/MainViewProtocol.swift create mode 100644 Fyreplace/Views/Screens/AuthenticatingScreen.swift create mode 100644 Fyreplace/Views/Screens/LoginScreenProtocol.swift create mode 100644 Fyreplace/Views/Screens/RegisterScreenProtocol.swift rename Fyreplace/{States/StatefulProtocol.swift => Views/ViewProtocol.swift} (80%) diff --git a/Fyreplace.xcodeproj/project.pbxproj b/Fyreplace.xcodeproj/project.pbxproj index f50bb73..712b8bc 100644 --- a/Fyreplace.xcodeproj/project.pbxproj +++ b/Fyreplace.xcodeproj/project.pbxproj @@ -18,7 +18,7 @@ 4D13AF812C4E907200845FDB /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D13AF802C4E907200845FDB /* Environment.swift */; }; 4D39A4C82BF516B7003FA52E /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 4D39A4C72BF516B7003FA52E /* Localizable.xcstrings */; }; 4D4D394A2C086DA2007196D2 /* PublishedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */; }; - 4D51F2802C621ADB0018E76E /* StatefulProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D51F27F2C621ADB0018E76E /* StatefulProtocol.swift */; }; + 4D51F2802C621ADB0018E76E /* ViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D51F27F2C621ADB0018E76E /* ViewProtocol.swift */; }; 4D51F2842C621B8D0018E76E /* EventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D51F2832C621B8D0018E76E /* EventBus.swift */; }; 4D51F2862C6232A30018E76E /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D51F2852C6232A30018E76E /* Event.swift */; }; 4D5251EC2C1097A600018CD2 /* Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5251EB2C1097A600018CD2 /* Destination.swift */; }; @@ -26,7 +26,6 @@ 4D5251F12C109D0D00018CD2 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5251F02C109D0D00018CD2 /* Screen.swift */; }; 4D5251F32C109FAC00018CD2 /* Label+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5251F22C109FAC00018CD2 /* Label+Destination.swift */; }; 4D5251F52C10A9F100018CD2 /* DynamicNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5251F42C10A9F100018CD2 /* DynamicNavigation.swift */; }; - 4D53485C2C66423C0001EFDE /* LoadingViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D53485B2C66423C0001EFDE /* LoadingViewState.swift */; }; 4D5348612C6646F80001EFDE /* StoringEventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5348602C6646F80001EFDE /* StoringEventBus.swift */; }; 4D54C92C2BF2608A001DE071 /* FyreplaceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C92B2BF2608A001DE071 /* FyreplaceApp.swift */; }; 4D54C92E2BF2608A001DE071 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C92D2BF2608A001DE071 /* MainView.swift */; }; @@ -50,6 +49,12 @@ 4DB10B502C4FEBFC00634BF6 /* HelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */; }; 4DB2E36D2C416611007F958D /* SubmitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB2E36C2C416611007F958D /* SubmitButton.swift */; }; 4DB2E36F2C418F5C007F958D /* DynamicForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB2E36E2C418F5C007F958D /* DynamicForm.swift */; }; + 4DC5B1CA2C6FA23000B75A07 /* LoginScreenProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1C92C6FA23000B75A07 /* LoginScreenProtocol.swift */; }; + 4DC5B1CD2C6FA28E00B75A07 /* RegisterScreenProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1CC2C6FA28E00B75A07 /* RegisterScreenProtocol.swift */; }; + 4DC5B1CF2C6FA2BE00B75A07 /* MainViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1CE2C6FA2BE00B75A07 /* MainViewProtocol.swift */; }; + 4DC5B1D62C6FEA2100B75A07 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1D52C6FEA2100B75A07 /* Keychain.swift */; }; + 4DC5B1D92C720B9800B75A07 /* AuthenticationMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1D82C720B9800B75A07 /* AuthenticationMiddleware.swift */; }; + 4DC5B1E02C7277B300B75A07 /* AuthenticatingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1DF2C7277B300B75A07 /* AuthenticatingScreen.swift */; }; 4DCE062B2C08E5E200F69AF1 /* CompactNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DCE062A2C08E5E200F69AF1 /* CompactNavigation.swift */; }; 4DCE062D2C08E65300F69AF1 /* RegularNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DCE062C2C08E65300F69AF1 /* RegularNavigation.swift */; }; 4DE140CD2C52688000A699AE /* DestinationCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE140CC2C52687F00A699AE /* DestinationCommands.swift */; }; @@ -87,7 +92,7 @@ 4D13AF802C4E907200845FDB /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 4D39A4C72BF516B7003FA52E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedScreen.swift; sourceTree = ""; }; - 4D51F27F2C621ADB0018E76E /* StatefulProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulProtocol.swift; sourceTree = ""; }; + 4D51F27F2C621ADB0018E76E /* ViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewProtocol.swift; sourceTree = ""; }; 4D51F2832C621B8D0018E76E /* EventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBus.swift; sourceTree = ""; }; 4D51F2852C6232A30018E76E /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; 4D5251A22C0A78F800018CD2 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; @@ -96,7 +101,6 @@ 4D5251F02C109D0D00018CD2 /* Screen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; 4D5251F22C109FAC00018CD2 /* Label+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Label+Destination.swift"; sourceTree = ""; }; 4D5251F42C10A9F100018CD2 /* DynamicNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicNavigation.swift; sourceTree = ""; }; - 4D53485B2C66423C0001EFDE /* LoadingViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewState.swift; sourceTree = ""; }; 4D5348602C6646F80001EFDE /* StoringEventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoringEventBus.swift; sourceTree = ""; }; 4D54C9282BF2608A001DE071 /* Fyreplace.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Fyreplace.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4D54C92B2BF2608A001DE071 /* FyreplaceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FyreplaceApp.swift; sourceTree = ""; }; @@ -132,6 +136,12 @@ 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpCommands.swift; sourceTree = ""; }; 4DB2E36C2C416611007F958D /* SubmitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmitButton.swift; sourceTree = ""; }; 4DB2E36E2C418F5C007F958D /* DynamicForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicForm.swift; sourceTree = ""; }; + 4DC5B1C92C6FA23000B75A07 /* LoginScreenProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenProtocol.swift; sourceTree = ""; }; + 4DC5B1CC2C6FA28E00B75A07 /* RegisterScreenProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterScreenProtocol.swift; sourceTree = ""; }; + 4DC5B1CE2C6FA2BE00B75A07 /* MainViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewProtocol.swift; sourceTree = ""; }; + 4DC5B1D52C6FEA2100B75A07 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + 4DC5B1D82C720B9800B75A07 /* AuthenticationMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationMiddleware.swift; sourceTree = ""; }; + 4DC5B1DF2C7277B300B75A07 /* AuthenticatingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatingScreen.swift; sourceTree = ""; }; 4DCE062A2C08E5E200F69AF1 /* CompactNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactNavigation.swift; sourceTree = ""; }; 4DCE062C2C08E65300F69AF1 /* RegularNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularNavigation.swift; sourceTree = ""; }; 4DCE06312C09E19400F69AF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -175,6 +185,8 @@ isa = PBXGroup; children = ( 4D13AF802C4E907200845FDB /* Environment.swift */, + 4DC5B1D52C6FEA2100B75A07 /* Keychain.swift */, + 4DC5B1D82C720B9800B75A07 /* AuthenticationMiddleware.swift */, ); path = Data; sourceTree = ""; @@ -195,13 +207,16 @@ children = ( 4D5251F02C109D0D00018CD2 /* Screen.swift */, 4D5251EE2C1098E900018CD2 /* MultiChoiceScreen.swift */, + 4DC5B1DF2C7277B300B75A07 /* AuthenticatingScreen.swift */, 4D54C9682BF4E8F4001DE071 /* FeedScreen.swift */, 4D54C96A2BF4E97E001DE071 /* NotificationsScreen.swift */, 4D54C96C2BF4E9BE001DE071 /* ArchiveScreen.swift */, 4D54C96E2BF4E9DF001DE071 /* DraftsScreen.swift */, 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */, 4D54C9702BF4EA15001DE071 /* SettingsScreen.swift */, + 4DC5B1C92C6FA23000B75A07 /* LoginScreenProtocol.swift */, 4D9B3B372C334B3A00A8F7AD /* LoginScreen.swift */, + 4DC5B1CC2C6FA28E00B75A07 /* RegisterScreenProtocol.swift */, 4D9B3B392C334B6300A8F7AD /* RegisterScreen.swift */, ); path = Screens; @@ -268,7 +283,6 @@ 4D13AF7C2C4E904E00845FDB /* Data */, 4D51F2822C621B7D0018E76E /* Events */, 4D54C95E2BF27C78001DE071 /* Views */, - 4DFB906A2C58FC4400D4DABF /* States */, 4DB10B4E2C4FEBCE00634BF6 /* Commands */, 4DA7BFB92C5FDEA1005CC4FF /* Fakes */, 4D54C92B2BF2608A001DE071 /* FyreplaceApp.swift */, @@ -325,6 +339,8 @@ 4D9B3B3B2C34B11D00A8F7AD /* Forms */, 4D5251E82C10532100018CD2 /* Navigation */, 4D4D394B2C086DD3007196D2 /* Screens */, + 4D51F27F2C621ADB0018E76E /* ViewProtocol.swift */, + 4DC5B1CE2C6FA2BE00B75A07 /* MainViewProtocol.swift */, 4D54C92D2BF2608A001DE071 /* MainView.swift */, ); path = Views; @@ -393,15 +409,6 @@ path = Config; sourceTree = ""; }; - 4DFB906A2C58FC4400D4DABF /* States */ = { - isa = PBXGroup; - children = ( - 4D51F27F2C621ADB0018E76E /* StatefulProtocol.swift */, - 4D53485B2C66423C0001EFDE /* LoadingViewState.swift */, - ); - path = States; - sourceTree = ""; - }; 4DFB906E2C5908BF00D4DABF /* Screens */ = { isa = PBXGroup; children = ( @@ -587,6 +594,7 @@ 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */, 4D54C9712BF4EA15001DE071 /* SettingsScreen.swift in Sources */, 4D13AF7B2C4E8F4200845FDB /* EnvironmentPicker.swift in Sources */, + 4DC5B1CF2C6FA2BE00B75A07 /* MainViewProtocol.swift in Sources */, 4D9B3B422C36E23A00A8F7AD /* Array+RawRepresentable.swift in Sources */, 4D4D394A2C086DA2007196D2 /* PublishedScreen.swift in Sources */, 4D5251EC2C1097A600018CD2 /* Destination.swift in Sources */, @@ -594,9 +602,11 @@ 4D5251F12C109D0D00018CD2 /* Screen.swift in Sources */, 4DE140CD2C52688000A699AE /* DestinationCommands.swift in Sources */, 4D54C96B2BF4E97E001DE071 /* NotificationsScreen.swift in Sources */, - 4D51F2802C621ADB0018E76E /* StatefulProtocol.swift in Sources */, + 4D51F2802C621ADB0018E76E /* ViewProtocol.swift in Sources */, + 4DC5B1D92C720B9800B75A07 /* AuthenticationMiddleware.swift in Sources */, 4D9B3B472C36F50300A8F7AD /* UITextContentType.swift in Sources */, 4D9B3B382C334B3A00A8F7AD /* LoginScreen.swift in Sources */, + 4DC5B1E02C7277B300B75A07 /* AuthenticatingScreen.swift in Sources */, 4D5251EF2C1098E900018CD2 /* MultiChoiceScreen.swift in Sources */, 4DB10B502C4FEBFC00634BF6 /* HelpCommands.swift in Sources */, 4D9B3B452C36F46F00A8F7AD /* NSTextContentType.swift in Sources */, @@ -606,11 +616,13 @@ 4DCE062B2C08E5E200F69AF1 /* CompactNavigation.swift in Sources */, 4DCE062D2C08E65300F69AF1 /* RegularNavigation.swift in Sources */, 4D54C9692BF4E8F4001DE071 /* FeedScreen.swift in Sources */, - 4D53485C2C66423C0001EFDE /* LoadingViewState.swift in Sources */, + 4DC5B1D62C6FEA2100B75A07 /* Keychain.swift in Sources */, 4D9B3B3A2C334B6300A8F7AD /* RegisterScreen.swift in Sources */, 4DB2E36D2C416611007F958D /* SubmitButton.swift in Sources */, 4D54C96D2BF4E9BE001DE071 /* ArchiveScreen.swift in Sources */, + 4DC5B1CD2C6FA28E00B75A07 /* RegisterScreenProtocol.swift in Sources */, 4D13AF812C4E907200845FDB /* Environment.swift in Sources */, + 4DC5B1CA2C6FA23000B75A07 /* LoginScreenProtocol.swift in Sources */, 4D51F2842C621B8D0018E76E /* EventBus.swift in Sources */, 4D5251F52C10A9F100018CD2 /* DynamicNavigation.swift in Sources */, 4D9DC5032C11BF2500BA0507 /* Config.swift in Sources */, diff --git a/Fyreplace/Config/Config.swift b/Fyreplace/Config/Config.swift index 8548d09..b5691fb 100644 --- a/Fyreplace/Config/Config.swift +++ b/Fyreplace/Config/Config.swift @@ -32,10 +32,12 @@ struct Config { } struct App { + let name: String let info: Info let api: Api init(_ data: [String: Any]) { + name = data.string("Name")! info = .init(data.dictionary("Info")!) api = .init(data.dictionary("Api")!) } diff --git a/Fyreplace/Config/Info.plist b/Fyreplace/Config/Info.plist index ef9fe50..4a3f1ef 100644 --- a/Fyreplace/Config/Info.plist +++ b/Fyreplace/Config/Info.plist @@ -4,6 +4,8 @@ App + Name + $(PRODUCT_NAME) Api Dev diff --git a/Fyreplace/Data/Keychain.swift b/Fyreplace/Data/Keychain.swift new file mode 100644 index 0000000..6ca2f5b --- /dev/null +++ b/Fyreplace/Data/Keychain.swift @@ -0,0 +1,103 @@ +import Foundation +import Security +import SwiftUI + +struct Keychain { + let service: String + + private var query: [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecMatchLimit: kSecMatchLimitOne, + kSecAttrLabel: Config.default.app.name, + kSecAttrService: service, + ] + } + + func get() -> String { + var info = query + info[kSecReturnData] = true + var itemReference: CFTypeRef? + guard SecItemCopyMatching(info as CFDictionary, &itemReference) == errSecSuccess, + let data = itemReference as? Data, + let value = String(data: data, encoding: .utf8) + else { return "" } + return value + } + + @KeychainActor + func set(_ value: String) -> Bool { + guard !value.isEmpty else { return delete() } + let data = value.data(using: .utf8) + + if SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) == errSecSuccess { + return true + } else { + var info = query + info[kSecValueData] = data + return SecItemAdd(info as CFDictionary, nil) == errSecSuccess + } + } + + @KeychainActor + func delete() -> Bool { + return SecItemDelete(query as CFDictionary) == errSecSuccess + } +} + +@globalActor +actor KeychainActor: GlobalActor { + static let shared = KeychainActor() +} + +@propertyWrapper +struct KeychainStorage: DynamicProperty { + @ObservedObject + private var cache: KeychainCache + + private let keychain: Keychain + + var wrappedValue: String { + get { + cache.value + } + + nonmutating set(value) { + cache.value = value + + Task { + await keychain.set(value) + } + } + } + + init(_ key: String) { + let cleanKey = key.replacing(".", with: ":") + keychain = .init(service: cleanKey) + cache = .shared(for: cleanKey, defaultValue: keychain.get()) + } +} + +class KeychainCache: ObservableObject { + private static var instances: [String: KeychainCache] = [:] + + private let key: String + + @Published + var value: String + + init(key: String, defaultValue: String) { + self.key = key + value = defaultValue + } + + static func shared(for key: String, defaultValue: String) -> KeychainCache { + if let instance = instances[key] { + return instance + } + + let instance = KeychainCache(key: key, defaultValue: defaultValue) + instances[key] = instance + return instance + } +} diff --git a/Fyreplace/Fakes/FakeClient.swift b/Fyreplace/Fakes/FakeClient.swift index d16da46..1890d95 100644 --- a/Fyreplace/Fakes/FakeClient.swift +++ b/Fyreplace/Fakes/FakeClient.swift @@ -159,8 +159,8 @@ extension FakeClient { extension FakeClient { static let badIdentifer = "bad-identifier" static let goodIdentifer = "good-identifier" - static let badSecret = "bad-secret" - static let goodSecret = "good-secret" + static let badSecret = "000000" + static let goodSecret = "123456" static let badToken = "bad-token" static let goodToken = "good-token" diff --git a/Fyreplace/Resources/Localizable.xcstrings b/Fyreplace/Resources/Localizable.xcstrings index 95c1d5b..a1b13ac 100644 --- a/Fyreplace/Resources/Localizable.xcstrings +++ b/Fyreplace/Resources/Localizable.xcstrings @@ -34,6 +34,16 @@ } } }, + "Cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, "Environment.Default" : { "localizations" : { "en" : { @@ -134,6 +144,26 @@ } } }, + "Login.Error.BadRequest.Message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check if the code you provided is correct." + } + } + } + }, + "Login.Error.BadRequest.Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid code" + } + } + } + }, "Login.Error.NotFound.Message" : { "localizations" : { "en" : { @@ -164,6 +194,16 @@ } } }, + "Login.Help.RandomCode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A one-time use code has been sent to your email address." + } + } + } + }, "Login.Identifier" : { "localizations" : { "en" : { @@ -184,6 +224,26 @@ } } }, + "Login.RandomCode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One-time code" + } + } + } + }, + "Login.RandomCode.Prompt" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "123456" + } + } + } + }, "Login.Submit" : { "localizations" : { "en" : { @@ -373,6 +433,16 @@ } } } + }, + "Settings.Logout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logout" + } + } + } } }, "version" : "1.0" diff --git a/Fyreplace/States/LoadingViewState.swift b/Fyreplace/States/LoadingViewState.swift deleted file mode 100644 index c471506..0000000 --- a/Fyreplace/States/LoadingViewState.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -class LoadingViewState: ObservableObject { - @Published - var isLoading = false -} diff --git a/Fyreplace/Views/Forms/EnvironmentPicker.swift b/Fyreplace/Views/Forms/EnvironmentPicker.swift index 25bf997..5f259ae 100644 --- a/Fyreplace/Views/Forms/EnvironmentPicker.swift +++ b/Fyreplace/Views/Forms/EnvironmentPicker.swift @@ -4,10 +4,10 @@ struct EnvironmentPicker: View { let namespace: Namespace.ID @AppStorage("connection.environment") - private var selection = ServerEnvironment.default + private var selectedEnvironment = ServerEnvironment.default var body: some View { - Picker("Environment.Title", selection: $selection) { + Picker("Environment.Title", selection: $selectedEnvironment) { ForEach(ServerEnvironment.allCases) { environment in let suffix = environment == .default ? " " + .init(localized: "Environment.Default") diff --git a/Fyreplace/Views/Forms/SubmitButton.swift b/Fyreplace/Views/Forms/SubmitButton.swift index bcaf0d5..41af7fa 100644 --- a/Fyreplace/Views/Forms/SubmitButton.swift +++ b/Fyreplace/Views/Forms/SubmitButton.swift @@ -2,37 +2,43 @@ import SwiftUI struct SubmitButton: View { let text: LocalizedStringKey - let canSubmit: Bool let isLoading: Bool let submit: () -> Void - var body: some View { - HStack { - Spacer() + @Namespace + private var namespace - let button = Button(action: submit) { - Text(text).padding(.horizontal) - } - .disabled(!canSubmit) - .animation(.default, value: canSubmit) - .controlSize(.large) - .accessibilityIdentifier("submit") + @Environment(\.isEnabled) + private var isEnabled + var body: some View { + let button = Button(action: submit) { + Text(text) #if os(macOS) - if canSubmit { - button.buttonStyle(.borderedProminent) - } else { - button - } - #else - if isLoading { - ProgressView() - } else { - button + .padding(.horizontal) + .opacity(isLoading ? 0 : 1) + .overlay { + if isLoading { + ProgressView().controlSize(.small) + } } #endif - - Spacer() } + .animation(.default, value: isEnabled) + .matchedGeometryEffect(id: "button", in: namespace) + + #if os(macOS) + if isEnabled { + button.buttonStyle(.borderedProminent) + } else { + button + } + #else + if isLoading { + ProgressView().controlSize(.regular) + } else { + button + } + #endif } } diff --git a/Fyreplace/Views/MainView.swift b/Fyreplace/Views/MainView.swift index 5a401a7..4c4f7a0 100644 --- a/Fyreplace/Views/MainView.swift +++ b/Fyreplace/Views/MainView.swift @@ -1,46 +1,19 @@ import SwiftUI -protocol MainViewProtocol: StatefulProtocol where State == MainView.State {} - -@MainActor -extension MainViewProtocol { - func addError(_ error: UnexpectedError) { - state.errors.append(error) - tryShowSomething() - } - - func removeError() async { - state.errors.removeFirst() - await wait() - tryShowSomething() - } - - func addFailure(_ failure: FailureEvent) { - state.failures.append(failure) - tryShowSomething() - } +struct MainView: View, MainViewProtocol { + let eventBus: EventBus - func removeFailure() async { - state.failures.removeFirst() - await wait() - tryShowSomething() - } + @State + var showError = false - private func wait() async { - try? await Task.sleep(for: .milliseconds(100)) - } + @State + var showFailure = false - private func tryShowSomething() { - if !state.errors.isEmpty { - state.showError = true - } else if !state.failures.isEmpty { - state.showFailure = true - } - } -} + @State + var errors: [UnexpectedError] = [] -struct MainView: View, MainViewProtocol { - let eventBus: EventBus + @State + var failures: [FailureEvent] = [] @Environment(\.config) private var config @@ -56,9 +29,6 @@ struct MainView: View, MainViewProtocol { @AppStorage("connection.environment") private var environment = ServerEnvironment.default - @StateObject - var state = State() - var body: some View { #if os(macOS) let navigation = RegularNavigation() @@ -71,8 +41,8 @@ struct MainView: View, MainViewProtocol { .environment(\.api, config.app.api.client(for: environment)) .environmentObject(eventBus) .alert( - isPresented: $state.showError, - error: state.errors.first + isPresented: $showError, + error: errors.first ) { Button("Ok", role: .cancel) { Task { @@ -81,9 +51,9 @@ struct MainView: View, MainViewProtocol { } } .alert( - state.failures.first?.title ?? "", - isPresented: $state.showFailure, - presenting: state.failures.first, + failures.first?.title ?? "", + isPresented: $showFailure, + presenting: failures.first, actions: { _ in Button("Ok", role: .cancel) { Task { @@ -97,20 +67,6 @@ struct MainView: View, MainViewProtocol { .onReceive(eventBus.events.compactMap { ($0 as? ErrorEvent)?.error }, perform: addError(_:)) .onReceive(eventBus.events.compactMap { ($0 as? FailureEvent) }, perform: addFailure(_:)) } - - final class State: ObservableObject { - @Published - var showError = false - - @Published - var showFailure = false - - @Published - var errors: [UnexpectedError] = [] - - @Published - var failures: [FailureEvent] = [] - } } #Preview { diff --git a/Fyreplace/Views/MainViewProtocol.swift b/Fyreplace/Views/MainViewProtocol.swift new file mode 100644 index 0000000..67c2a7b --- /dev/null +++ b/Fyreplace/Views/MainViewProtocol.swift @@ -0,0 +1,43 @@ +protocol MainViewProtocol: ViewProtocol { + var showError: Bool { get nonmutating set } + var showFailure: Bool { get nonmutating set } + var errors: [UnexpectedError] { get nonmutating set } + var failures: [FailureEvent] { get nonmutating set } +} + +@MainActor +extension MainViewProtocol { + func addError(_ error: UnexpectedError) { + errors.append(error) + tryShowSomething() + } + + func removeError() async { + errors.removeFirst() + await wait() + tryShowSomething() + } + + func addFailure(_ failure: FailureEvent) { + failures.append(failure) + tryShowSomething() + } + + func removeFailure() async { + failures.removeFirst() + await wait() + tryShowSomething() + } + + private func wait() async { + try? await Task.sleep(for: .milliseconds(100)) + } + + private func tryShowSomething() { + if !errors.isEmpty { + showError = true + } else if !failures.isEmpty { + showFailure = true + } + } +} diff --git a/Fyreplace/Views/Navigation/CompactNavigation.swift b/Fyreplace/Views/Navigation/CompactNavigation.swift index 30a5a82..502a6d5 100644 --- a/Fyreplace/Views/Navigation/CompactNavigation.swift +++ b/Fyreplace/Views/Navigation/CompactNavigation.swift @@ -7,21 +7,19 @@ struct CompactNavigation: View { @SceneStorage("CompactNavigation.selectedChoices") private var selectedChoices = Destination.essentials + @KeychainStorage("connection.token") + private var token + var body: some View { TabView(selection: $selectedTab) { - let destinations = Destination.essentials - - ForEach(Array(destinations.enumerated()), id: \.element.id) { i, destination in + ForEach(Array(Destination.essentials.enumerated()), id: \.element.id) { i, destination in NavigationStack { - let children = Destination.all.filter { $0.parent == destination } + let content = CompactNavigationDestination(destination: destination, multiScreenChoice: $selectedChoices[i]) - if children.isEmpty { - Screen(destination: destination) + if destination.canOfferAuthentication { + AuthenticatingScreen { content } } else { - MultiChoiceScreen( - choices: [destination] + children, - choice: $selectedChoices[i] - ) + content } } .tabItem { Label(destination) } @@ -34,3 +32,22 @@ struct CompactNavigation: View { #Preview { CompactNavigation() } + +private struct CompactNavigationDestination: View { + let destination: Destination + + let multiScreenChoice: Binding + + var body: some View { + let children = Destination.all.filter { $0.parent == destination } + + if children.isEmpty { + Screen(destination: destination) + } else { + MultiChoiceScreen( + choices: [destination] + children, + choice: multiScreenChoice + ) + } + } +} diff --git a/Fyreplace/Views/Navigation/Destination.swift b/Fyreplace/Views/Navigation/Destination.swift index 249d438..c53caca 100644 --- a/Fyreplace/Views/Navigation/Destination.swift +++ b/Fyreplace/Views/Navigation/Destination.swift @@ -72,6 +72,29 @@ public enum Destination: String, CaseIterable, Identifiable, Codable { } } + var canOfferAuthentication: Bool { + switch self { + case .feed, + .login, + .register: + false + default: + true + } + } + + var requiresAuthentication: Bool { + switch self { + case .feed, + .settings, + .login, + .register: + false + default: + true + } + } + var keyboardShortcut: KeyboardShortcut? { switch self { case .feed: diff --git a/Fyreplace/Views/Navigation/RegularNavigation.swift b/Fyreplace/Views/Navigation/RegularNavigation.swift index df1e9c6..14d1600 100644 --- a/Fyreplace/Views/Navigation/RegularNavigation.swift +++ b/Fyreplace/Views/Navigation/RegularNavigation.swift @@ -15,7 +15,10 @@ struct RegularNavigation: View { private var selectedDestination: Destination? #endif - private var finalDestination: Destination { + @KeychainStorage("connection.token") + private var token + + private var undeniableDestination: Destination { #if os(macOS) selectedDestination #else @@ -29,13 +32,20 @@ struct RegularNavigation: View { NavigationLink(value: destination) { Label(destination) } + .disabled(destination.requiresAuthentication && token.isEmpty) } #if os(macOS) .navigationSplitViewColumnWidth(min: 180, ideal: 180) #endif } detail: { NavigationStack { - Screen(destination: finalDestination) + if undeniableDestination.canOfferAuthentication { + AuthenticatingScreen { + Screen(destination: undeniableDestination) + } + } else { + Screen(destination: undeniableDestination) + } } } .onReceive( diff --git a/Fyreplace/Views/Screens/ArchiveScreen.swift b/Fyreplace/Views/Screens/ArchiveScreen.swift index cf4c5d0..be5fe41 100644 --- a/Fyreplace/Views/Screens/ArchiveScreen.swift +++ b/Fyreplace/Views/Screens/ArchiveScreen.swift @@ -5,7 +5,6 @@ struct ArchiveScreen: View { Text(Destination.archive.titleKey) .padding() .navigationTitle(Destination.archive.titleKey) - .accessibilityIdentifier(Destination.archive.id) } } diff --git a/Fyreplace/Views/Screens/AuthenticatingScreen.swift b/Fyreplace/Views/Screens/AuthenticatingScreen.swift new file mode 100644 index 0000000..3adb093 --- /dev/null +++ b/Fyreplace/Views/Screens/AuthenticatingScreen.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct AuthenticatingScreen: View where Content: View { + @ViewBuilder + let content: () -> Content + + @SceneStorage("RstrictedScreen.choice") + private var choice = Destination.login + + @AppStorage("account.isWaitingForRandomCode") + private var isWaitingForRandomCode = false + + @KeychainStorage("connection.token") + private var token + + var body: some View { + if token.isEmpty { + MultiChoiceScreen( + choices: [.login, .register], + choice: $choice, + canChoose: !isWaitingForRandomCode + ) + .navigationTitle("") + #if os(macOS) + .animation(.snappy, value: choice) + #endif + } else { + content() + } + } +} diff --git a/Fyreplace/Views/Screens/DraftsScreen.swift b/Fyreplace/Views/Screens/DraftsScreen.swift index edc30d3..1371f7c 100644 --- a/Fyreplace/Views/Screens/DraftsScreen.swift +++ b/Fyreplace/Views/Screens/DraftsScreen.swift @@ -5,7 +5,6 @@ struct DraftsScreen: View { Text(Destination.drafts.titleKey) .padding() .navigationTitle(Destination.drafts.titleKey) - .accessibilityIdentifier(Destination.drafts.id) } } diff --git a/Fyreplace/Views/Screens/FeedScreen.swift b/Fyreplace/Views/Screens/FeedScreen.swift index 8823f3a..7c08bb0 100644 --- a/Fyreplace/Views/Screens/FeedScreen.swift +++ b/Fyreplace/Views/Screens/FeedScreen.swift @@ -5,7 +5,6 @@ struct FeedScreen: View { Text(Destination.feed.titleKey) .padding() .navigationTitle(Destination.feed.titleKey) - .accessibilityIdentifier(Destination.feed.id) } } diff --git a/Fyreplace/Views/Screens/LoginScreen.swift b/Fyreplace/Views/Screens/LoginScreen.swift index c09f800..f2f0572 100644 --- a/Fyreplace/Views/Screens/LoginScreen.swift +++ b/Fyreplace/Views/Screens/LoginScreen.swift @@ -1,101 +1,128 @@ import SwiftUI -protocol LoginScreenProtocol: StatefulProtocol where State == LoginScreen.State { - var eventBus: EventBus { get } - - var client: APIProtocol { get } -} - -@MainActor -extension LoginScreenProtocol { - func sendEmail() async { - await callWhileLoading(failOn: eventBus) { - let response = try await client.createNewToken(.init(body: .json(.init(identifier: state.identifier)))) - - switch response { - case .ok: - break - case .notFound: - eventBus.send(.failure(title: "Login.Error.NotFound.Title", text: "Login.Error.NotFound.Message")) - case .badRequest: - eventBus.send(.failure(title: "Error.BadRequest.Title", text: "Error.BadRequest.Message")) - case .default: - eventBus.send(.error(UnknownError())) - } - } - } -} - struct LoginScreen: View, LoginScreenProtocol { let namespace: Namespace.ID - @ObservedObject - var state: State - @EnvironmentObject var eventBus: EventBus @Environment(\.api) var client + @State + var isLoading = false + + @AppStorage("account.identifier") + var identifier = "" + + @AppStorage("account.randomCode") + var randomCode = "" + + @AppStorage("account.isWaitingForRandomCode") + var isWaitingForRandomCode = false + + @KeychainStorage("connection.token") + var token + @FocusState - private var focused: Bool + private var focused: FocusedField? var body: some View { - DynamicForm { - let submitButton = SubmitButton( - text: "Login.Submit", - canSubmit: state.canSubmit, - isLoading: state.isLoading, - submit: submit - ) - .matchedGeometryEffect(id: "submit", in: namespace) - - #if os(macOS) - let footer = submitButton.padding(.top) - #else - let footer: Spacer? = nil - #endif + let submitButton = SubmitButton( + text: "Login.Submit", + isLoading: isLoading, + submit: submit + ) + .disabled(!canSubmit) + .matchedGeometryEffect(id: "submit", in: namespace) + + let cancelButton = Button(role: .cancel) { + withAnimation { + isWaitingForRandomCode = false + randomCode = "" + } + } label: { + Text("Cancel").padding(.horizontal) + } + .disabled(!isWaitingForRandomCode) + DynamicForm { Section( header: LogoHeader(text: "Login.Header", namespace: namespace), - footer: footer + footer: VStack { + if isWaitingForRandomCode { + Text("Login.Help.RandomCode") + } + } ) { EnvironmentPicker(namespace: namespace) + .disabled(isWaitingForRandomCode) - TextField("Login.Identifier", text: $state.identifier, prompt: Text("Login.Identifier.Prompt")) + TextField("Login.Identifier", text: $identifier, prompt: Text("Login.Identifier.Prompt")) .autocorrectionDisabled() - .focused($focused) + .focused($focused, equals: .identifier) + .disabled(isWaitingForRandomCode) .onSubmit(submit) - .accessibilityIdentifier("identifier") .matchedGeometryEffect(id: "first-field", in: namespace) - #if !os(macOS) - .labelsHidden() - #endif + + if isWaitingForRandomCode { + TextField("Login.RandomCode", text: $randomCode, prompt: Text("Login.RandomCode.Prompt")) + .textContentType(.oneTimeCode) + .autocorrectionDisabled() + .focused($focused, equals: .randomCode) + .onSubmit(submit) + .onAppear { + if randomCode.isEmpty { + focused = .randomCode + } + } + #if !os(macOS) + .keyboardType(.numberPad) + #endif + } + } + .onAppear { + if identifier.isEmpty { + focused = .identifier + } } - .onAppear { focused = state.identifier.isEmpty } - #if !os(macOS) - submitButton - #endif + Section { + HStack { + #if os(macOS) + cancelButton.controlSize(.large) + Spacer() + submitButton.controlSize(.large) + #else + Spacer() + submitButton + .toolbar { + if isWaitingForRandomCode { + ToolbarItem { + cancelButton + } + } + } + Spacer() + #endif + } + } } - .accessibilityIdentifier(Destination.login.id) + .disabled(isLoading) } private func submit() { - guard state.canSubmit else { return } - focused = false + guard canSubmit else { return } + focused = nil Task { - await sendEmail() + await submit() } } - final class State: LoadingViewState { - @Published - var identifier = "" - - var canSubmit: Bool { 3 ... 254 ~= identifier.count && !isLoading } + enum FocusedField { + case identifier + case randomCode } } @@ -104,9 +131,7 @@ struct LoginScreen: View, LoginScreenProtocol { @Namespace var namespace - @State - var state = LoginScreen.State() - - LoginScreen(namespace: namespace, state: state) + LoginScreen(namespace: namespace) } + .environmentObject(EventBus()) } diff --git a/Fyreplace/Views/Screens/LoginScreenProtocol.swift b/Fyreplace/Views/Screens/LoginScreenProtocol.swift new file mode 100644 index 0000000..2c54632 --- /dev/null +++ b/Fyreplace/Views/Screens/LoginScreenProtocol.swift @@ -0,0 +1,67 @@ +import SwiftUI + +protocol LoginScreenProtocol: LoadingViewProtocol { + var eventBus: EventBus { get } + var client: APIProtocol { get } + + var identifier: String { get nonmutating set } + var randomCode: String { get nonmutating set } + var isWaitingForRandomCode: Bool { get nonmutating set } + var token: String { get nonmutating set } +} + +@MainActor +extension LoginScreenProtocol { + var canSubmit: Bool { + !isLoading && (isWaitingForRandomCode ? randomCode.count == 6 : 3 ... 254 ~= identifier.count) + } + + func submit() async { + await callWhileLoading(failOn: eventBus) { + if isWaitingForRandomCode { + try await createToken() + } else if try await sendEmail() { + withAnimation { + isWaitingForRandomCode = true + } + } + } + } + + func sendEmail() async throws -> Bool { + let response = try await client.createNewToken(body: .json(.init(identifier: identifier))) + + switch response { + case .ok: + return true + case .badRequest: + eventBus.send(.failure(title: "Error.BadRequest.Title", text: "Error.BadRequest.Message")) + case .notFound: + eventBus.send(.failure(title: "Login.Error.NotFound.Title", text: "Login.Error.NotFound.Message")) + case .default: + eventBus.send(.error(UnknownError())) + } + + return false + } + + func createToken() async throws { + let response = try await client.createToken(body: .json(.init(identifier: identifier, secret: randomCode))) + + switch response { + case let .created(response): + switch response.body { + case let .plainText(body): + token = try await .init(collecting: body, upTo: 1024) + randomCode = "" + isWaitingForRandomCode = false + } + case .badRequest: + eventBus.send(.failure(title: "Login.Error.BadRequest.Title", text: "Login.Error.BadRequest.Message")) + case .notFound: + eventBus.send(.failure(title: "Login.Error.NotFound.Title", text: "Login.Error.NotFound.Message")) + case .default: + eventBus.send(.error(UnknownError())) + } + } +} diff --git a/Fyreplace/Views/Screens/MultiChoiceScreen.swift b/Fyreplace/Views/Screens/MultiChoiceScreen.swift index 139fee1..3778421 100644 --- a/Fyreplace/Views/Screens/MultiChoiceScreen.swift +++ b/Fyreplace/Views/Screens/MultiChoiceScreen.swift @@ -6,6 +6,8 @@ struct MultiChoiceScreen: View { @Binding var choice: Destination + var canChoose = true + var body: some View { ZStack { Screen(destination: choice) @@ -17,9 +19,9 @@ struct MultiChoiceScreen: View { Text(choice.titleKey).tag(choice) } } + .disabled(!canChoose) .pickerStyle(.segmented) .fixedSize() - .accessibilityIdentifier("tabs") } } #if !os(macOS) diff --git a/Fyreplace/Views/Screens/NotificationsScreen.swift b/Fyreplace/Views/Screens/NotificationsScreen.swift index f13c469..5a00a1e 100644 --- a/Fyreplace/Views/Screens/NotificationsScreen.swift +++ b/Fyreplace/Views/Screens/NotificationsScreen.swift @@ -5,7 +5,6 @@ struct NotificationsScreen: View { Text(Destination.notifications.titleKey) .padding() .navigationTitle(Destination.notifications.titleKey) - .accessibilityIdentifier(Destination.notifications.id) } } diff --git a/Fyreplace/Views/Screens/PublishedScreen.swift b/Fyreplace/Views/Screens/PublishedScreen.swift index 99fc15a..ceee6ed 100644 --- a/Fyreplace/Views/Screens/PublishedScreen.swift +++ b/Fyreplace/Views/Screens/PublishedScreen.swift @@ -5,7 +5,6 @@ struct PublishedScreen: View { Text(Destination.published.titleKey) .padding() .navigationTitle(Destination.published.titleKey) - .accessibilityIdentifier(Destination.published.id) } } diff --git a/Fyreplace/Views/Screens/RegisterScreen.swift b/Fyreplace/Views/Screens/RegisterScreen.swift index b9b90df..8bd938e 100644 --- a/Fyreplace/Views/Screens/RegisterScreen.swift +++ b/Fyreplace/Views/Screens/RegisterScreen.swift @@ -1,105 +1,105 @@ import SwiftUI -protocol RegisterScreenProtocol: StatefulProtocol where State == RegisterScreen.State {} - struct RegisterScreen: View, RegisterScreenProtocol { let namespace: Namespace.ID - @ObservedObject - var state: State + @State + var isLoading = false + + @AppStorage("account.username") + var username = "" + + @AppStorage("account.email") + var email = "" @FocusState private var focused: FocusedField? var body: some View { - DynamicForm { - let submitButton = SubmitButton( - text: "Register.Submit", - canSubmit: state.canSubmit, - isLoading: state.isLoading, - submit: submit - ) - .matchedGeometryEffect(id: "submit", in: namespace) - - #if os(macOS) - let footer = submitButton.padding(.top) - let usernamePrompt = Text("Register.Username.Prompt") - let emailPrompt = Text("Register.Email.Prompt") - #else - let footer = Text("Register.Help") - let usernamePrompt: Text? = nil - let emailPrompt: Text? = nil - #endif + let submitButton = SubmitButton( + text: "Register.Submit", + isLoading: isLoading, + submit: submit + ) + .disabled(!canSubmit) + .matchedGeometryEffect(id: "submit", in: namespace) + + #if os(macOS) + let footer: Spacer? = nil + let usernamePrompt = Text("Register.Username.Prompt") + let emailPrompt = Text("Register.Email.Prompt") + #else + let footer = Text("Register.Help") + let usernamePrompt: Text? = nil + let emailPrompt: Text? = nil + #endif + DynamicForm { Section( header: LogoHeader(text: "Register.Header", namespace: namespace), footer: footer ) { EnvironmentPicker(namespace: namespace) - TextField("Register.Username", text: $state.username, prompt: usernamePrompt) + TextField("Register.Username", text: $username, prompt: usernamePrompt) .textContentType(.username) .autocorrectionDisabled() .focused($focused, equals: .username) .submitLabel(.next) .onSubmit { focused = .email } - .accessibilityIdentifier("username") .matchedGeometryEffect(id: "first-field", in: namespace) - TextField("Register.Email", text: $state.email, prompt: emailPrompt) + TextField("Register.Email", text: $email, prompt: emailPrompt) .textContentType(.email) .autocorrectionDisabled() .focused($focused, equals: .email) .submitLabel(.done) .onSubmit(submit) - .accessibilityIdentifier("email") + #if !os(macOS) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + #endif } .onAppear { - if state.username.isEmpty { + if username.isEmpty { focused = .username - } else if state.email.isEmpty { + } else if email.isEmpty { focused = .email } } - #if !os(macOS) - submitButton - #endif + Section { + HStack { + #if os(macOS) + Spacer() + submitButton.controlSize(.large) + #else + Spacer() + submitButton + Spacer() + #endif + } + } } - .accessibilityIdentifier(Destination.register.id) } private func submit() { - guard state.canSubmit else { return } + guard canSubmit else { return } focused = nil } - final class State: LoadingViewState { - @Published - var username = "" - - @Published - var email = "" - - var isUsernameValid: Bool { 3 ... 50 ~= username.count } - var isEmailValid: Bool { 3 ... 254 ~= email.count && email.contains("@") } - var canSubmit: Bool { isUsernameValid && isEmailValid && !isLoading } + enum FocusedField { + case username + case email } } -private enum FocusedField { - case username - case email -} - #Preview { NavigationStack { @Namespace var namespace - @StateObject - var state = RegisterScreen.State() - - RegisterScreen(namespace: namespace, state: state) + RegisterScreen(namespace: namespace) } + .environmentObject(EventBus()) } diff --git a/Fyreplace/Views/Screens/RegisterScreenProtocol.swift b/Fyreplace/Views/Screens/RegisterScreenProtocol.swift new file mode 100644 index 0000000..6ce0f14 --- /dev/null +++ b/Fyreplace/Views/Screens/RegisterScreenProtocol.swift @@ -0,0 +1,10 @@ +protocol RegisterScreenProtocol: LoadingViewProtocol { + var username: String { get nonmutating set } + var email: String { get nonmutating set } +} + +extension RegisterScreenProtocol { + var isUsernameValid: Bool { 3 ... 50 ~= username.count } + var isEmailValid: Bool { 3 ... 254 ~= email.count && email.contains("@") } + var canSubmit: Bool { isUsernameValid && isEmailValid && !isLoading } +} diff --git a/Fyreplace/Views/Screens/Screen.swift b/Fyreplace/Views/Screens/Screen.swift index d343808..00ad4dc 100644 --- a/Fyreplace/Views/Screens/Screen.swift +++ b/Fyreplace/Views/Screens/Screen.swift @@ -6,12 +6,6 @@ struct Screen: View { @Namespace private var namespace - @StateObject - private var loginState = LoginScreen.State() - - @StateObject - private var registerState = RegisterScreen.State() - var body: some View { switch destination { case .feed: @@ -27,9 +21,9 @@ struct Screen: View { case .settings: SettingsScreen() case .login: - LoginScreen(namespace: namespace, state: loginState) + LoginScreen(namespace: namespace) case .register: - RegisterScreen(namespace: namespace, state: registerState) + RegisterScreen(namespace: namespace) } } } diff --git a/Fyreplace/Views/Screens/SettingsScreen.swift b/Fyreplace/Views/Screens/SettingsScreen.swift index 2dd98d5..ead0d94 100644 --- a/Fyreplace/Views/Screens/SettingsScreen.swift +++ b/Fyreplace/Views/Screens/SettingsScreen.swift @@ -4,15 +4,33 @@ struct SettingsScreen: View { @SceneStorage("SettingsScreen.choice") private var choice = Destination.login + @AppStorage("account.identifier") + private var identifier = "" + + @AppStorage("account.username") + private var username = "" + + @AppStorage("account.email") + private var email = "" + + @AppStorage("account.isWaitingForRandomCode") + private var isWaitingForRandomCode = false + + @KeychainStorage("connection.token") + private var token + var body: some View { - MultiChoiceScreen( - choices: [.login, .register], - choice: $choice - ) - .navigationTitle("") - #if os(macOS) - .animation(.snappy, value: choice) - #endif + Button("Settings.Logout", role: .destructive, action: logout) + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + + private func logout() { + identifier = "" + username = "" + email = "" + isWaitingForRandomCode = false + token = "" } } diff --git a/Fyreplace/States/StatefulProtocol.swift b/Fyreplace/Views/ViewProtocol.swift similarity index 80% rename from Fyreplace/States/StatefulProtocol.swift rename to Fyreplace/Views/ViewProtocol.swift index dc56314..4bbadbb 100644 --- a/Fyreplace/States/StatefulProtocol.swift +++ b/Fyreplace/Views/ViewProtocol.swift @@ -1,14 +1,14 @@ import OpenAPIRuntime import Sentry -protocol StatefulProtocol { - associatedtype State +protocol ViewProtocol {} - var state: State { get } +protocol LoadingViewProtocol: ViewProtocol { + var isLoading: Bool { get nonmutating set } } @MainActor -extension StatefulProtocol { +extension ViewProtocol { func call(failOn eventBus: EventBus, action: () async throws -> Void) async { do { try await action() @@ -22,11 +22,11 @@ extension StatefulProtocol { } @MainActor -extension StatefulProtocol where State: LoadingViewState { +extension LoadingViewProtocol { func callWhileLoading(failOn eventBus: EventBus, action: () async throws -> Void) async { - state.isLoading = true + isLoading = true await call(failOn: eventBus, action: action) - state.isLoading = false + isLoading = false } } diff --git a/FyreplaceTests/Screens/LoginScreenTests.swift b/FyreplaceTests/Screens/LoginScreenTests.swift index da3bf38..4aef1fe 100644 --- a/FyreplaceTests/Screens/LoginScreenTests.swift +++ b/FyreplaceTests/Screens/LoginScreenTests.swift @@ -4,46 +4,94 @@ import XCTest import Fyreplace final class LoginScreenTests: XCTestCase { - struct FakeScreen: LoginScreenProtocol { - var state: LoginScreen.State + class FakeScreen: LoginScreenProtocol { var eventBus: EventBus var client: APIProtocol + + var isLoading = false + var identifier = "" + var randomCode = "" + var isWaitingForRandomCode = false + var token = "" + + init(eventBus: EventBus, client: APIProtocol) { + self.eventBus = eventBus + self.client = client + } } @MainActor func testIdentifierMustHaveCorrectLength() { - let screen = FakeScreen(state: .init(), eventBus: .init(), client: .fake()) + let screen = FakeScreen(eventBus: .init(), client: .fake()) for i in 0 ..< 3 { - screen.state.identifier = .init(repeating: "a", count: i) - XCTAssertFalse(screen.state.canSubmit) + screen.identifier = .init(repeating: "a", count: i) + XCTAssertFalse(screen.canSubmit) } for i in 3 ... 254 { - screen.state.identifier = .init(repeating: "a", count: i) - XCTAssertTrue(screen.state.canSubmit) + screen.identifier = .init(repeating: "a", count: i) + XCTAssertTrue(screen.canSubmit) } - screen.state.identifier = .init(repeating: "a", count: 255) - XCTAssertFalse(screen.state.canSubmit) + screen.identifier = .init(repeating: "a", count: 255) + XCTAssertFalse(screen.canSubmit) } @MainActor func testInvalidIdentifierProducesFailure() async { let eventBus = StoringEventBus() - let screen = FakeScreen(state: .init(), eventBus: eventBus, client: .fake()) - screen.state.identifier = FakeClient.badIdentifer - await screen.sendEmail() + let screen = FakeScreen(eventBus: eventBus, client: .fake()) + screen.identifier = FakeClient.badIdentifer + await screen.submit() XCTAssertEqual(1, eventBus.storedEvents.count) XCTAssert(eventBus.storedEvents.first is FailureEvent) + XCTAssertFalse(screen.isWaitingForRandomCode) } @MainActor func testValidIdentifierProducesNoFailures() async { let eventBus = StoringEventBus() - let screen = FakeScreen(state: .init(), eventBus: eventBus, client: .fake()) - screen.state.identifier = FakeClient.goodIdentifer - await screen.sendEmail() + let screen = FakeScreen(eventBus: eventBus, client: .fake()) + screen.identifier = FakeClient.goodIdentifer + await screen.submit() + XCTAssertEqual(0, eventBus.storedEvents.count) + XCTAssertTrue(screen.isWaitingForRandomCode) + } + + @MainActor + func testRandomCodeMustHaveCorrentLength() async { + let screen = FakeScreen(eventBus: .init(), client: .fake()) + screen.identifier = FakeClient.goodIdentifer + screen.isWaitingForRandomCode = true + screen.randomCode = "12345" + XCTAssertFalse(screen.canSubmit) + screen.randomCode = "123456" + XCTAssertTrue(screen.canSubmit) + screen.randomCode = "1234567" + XCTAssertFalse(screen.canSubmit) + } + + @MainActor + func testInvalidRandomCodeProducesFailure() async { + let eventBus = StoringEventBus() + let screen = FakeScreen(eventBus: eventBus, client: .fake()) + screen.identifier = FakeClient.goodIdentifer + screen.randomCode = FakeClient.badSecret + screen.isWaitingForRandomCode = true + await screen.submit() + XCTAssertEqual(1, eventBus.storedEvents.count) + XCTAssert(eventBus.storedEvents.first is FailureEvent) + } + + @MainActor + func testValidRandomCodeProducesNoFailures() async { + let eventBus = StoringEventBus() + let screen = FakeScreen(eventBus: eventBus, client: .fake()) + screen.identifier = FakeClient.goodIdentifer + screen.randomCode = FakeClient.goodSecret + screen.isWaitingForRandomCode = true + await screen.submit() XCTAssertEqual(0, eventBus.storedEvents.count) } } diff --git a/FyreplaceTests/Screens/RegisterScreenTests.swift b/FyreplaceTests/Screens/RegisterScreenTests.swift index a23beee..b4280df 100644 --- a/FyreplaceTests/Screens/RegisterScreenTests.swift +++ b/FyreplaceTests/Screens/RegisterScreenTests.swift @@ -4,57 +4,65 @@ import XCTest import Fyreplace final class RegisterScreenTests: XCTestCase { - struct FakeScreen: RegisterScreenProtocol { - var state: RegisterScreen.State + class FakeScreen: RegisterScreenProtocol { var eventBus: EventBus var client: APIProtocol + + var isLoading = false + var username = "" + var email = "" + + init(eventBus: EventBus, client: APIProtocol) { + self.eventBus = eventBus + self.client = client + } } @MainActor func testUsernameMustHaveCorrectLength() { - let screen = FakeScreen(state: .init(), eventBus: .init(), client: .fake()) - screen.state.email = "email@example" + let screen = FakeScreen(eventBus: .init(), client: .fake()) + screen.email = "email@example" for i in 0 ..< 3 { - screen.state.username = .init(repeating: "a", count: i) - XCTAssertFalse(screen.state.canSubmit) + screen.username = .init(repeating: "a", count: i) + XCTAssertFalse(screen.canSubmit) } for i in 3 ... 50 { - screen.state.username = .init(repeating: "a", count: i) - XCTAssertTrue(screen.state.canSubmit) + screen.username = .init(repeating: "a", count: i) + XCTAssertTrue(screen.canSubmit) } - screen.state.username = .init(repeating: "a", count: 51) - XCTAssertFalse(screen.state.canSubmit) + screen.username = .init(repeating: "a", count: 51) + XCTAssertFalse(screen.canSubmit) } @MainActor func testEmailMustHaveCorrectLength() { - let screen = FakeScreen(state: .init(), eventBus: .init(), client: .fake()) - screen.state.username = "Example" + let screen = FakeScreen(eventBus: .init(), client: .fake()) + screen.username = "Example" for i in 0 ..< 3 { - screen.state.email = .init(repeating: "@", count: i) - XCTAssertFalse(screen.state.canSubmit) + screen.email = .init(repeating: "@", count: i) + XCTAssertFalse(screen.canSubmit) } for i in 3 ... 254 { - screen.state.email = .init(repeating: "@", count: i) - XCTAssertTrue(screen.state.canSubmit) + screen.email = .init(repeating: "@", count: i) + XCTAssertTrue(screen.canSubmit) } - screen.state.email = .init(repeating: "@", count: 255) - XCTAssertFalse(screen.state.canSubmit) + screen.email = .init(repeating: "@", count: 255) + XCTAssertFalse(screen.canSubmit) } @MainActor func testEmailMustHaveAtSign() { - let screen = FakeScreen(state: .init(), eventBus: .init(), client: FakeClient()) - screen.state.username = "Example" - screen.state.email = "email" - XCTAssertFalse(screen.state.canSubmit) - screen.state.email = "email@example" - XCTAssertTrue(screen.state.canSubmit) + let screen = FakeScreen(eventBus: .init(), client: .fake()) + screen.username = "Example" + screen.email = "email" + XCTAssertFalse(screen.canSubmit) + screen.email = "email@example" + XCTAssertTrue(screen.canSubmit) } }