diff --git a/Fyreplace.xcodeproj/project.pbxproj b/Fyreplace.xcodeproj/project.pbxproj index c90e946..f50bb73 100644 --- a/Fyreplace.xcodeproj/project.pbxproj +++ b/Fyreplace.xcodeproj/project.pbxproj @@ -18,17 +18,20 @@ 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 */; }; + 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 */; }; 4D5251EF2C1098E900018CD2 /* MultiChoiceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5251EE2C1098E900018CD2 /* MultiChoiceScreen.swift */; }; 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 */; }; 4D54C9302BF2608F001DE071 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D54C92F2BF2608F001DE071 /* Assets.xcassets */; }; 4D54C9342BF2608F001DE071 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4D54C9332BF2608F001DE071 /* Preview Assets.xcassets */; }; - 4D54C93E2BF26090001DE071 /* DestinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C93D2BF26090001DE071 /* DestinationTests.swift */; }; - 4D54C9482BF26090001DE071 /* NavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C9472BF26090001DE071 /* NavigationTests.swift */; }; 4D54C9692BF4E8F4001DE071 /* FeedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C9682BF4E8F4001DE071 /* FeedScreen.swift */; }; 4D54C96B2BF4E97E001DE071 /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C96A2BF4E97E001DE071 /* NotificationsScreen.swift */; }; 4D54C96D2BF4E9BE001DE071 /* ArchiveScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D54C96C2BF4E9BE001DE071 /* ArchiveScreen.swift */; }; @@ -42,18 +45,15 @@ 4D9B3B452C36F46F00A8F7AD /* NSTextContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */; }; 4D9B3B472C36F50300A8F7AD /* UITextContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */; }; 4D9DC5032C11BF2500BA0507 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9DC5022C11BF2500BA0507 /* Config.swift */; }; + 4DA7BFB72C5FD479005CC4FF /* PerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */; }; + 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */; }; 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 */; }; - 4DB2E3742C428C08007F958D /* AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB2E3732C428C08007F958D /* AppTests.swift */; }; - 4DB2E3772C428D8B007F958D /* XCUIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB2E3762C428D8B007F958D /* XCUIApplication.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 */; }; - 4DFB90642C57B98C00D4DABF /* AnyPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB90632C57B98C00D4DABF /* AnyPublisher.swift */; }; - 4DFB906C2C58FC6900D4DABF /* LoginScreen+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB906B2C58FC6900D4DABF /* LoginScreen+ViewModel.swift */; }; 4DFB90702C5908DE00D4DABF /* LoginScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB906F2C5908DE00D4DABF /* LoginScreenTests.swift */; }; - 4DFB90732C59162000D4DABF /* RegisterScreen+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB90722C59162000D4DABF /* RegisterScreen+ViewModel.swift */; }; 4DFB90762C59173C00D4DABF /* RegisterScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB90752C59173C00D4DABF /* RegisterScreenTests.swift */; }; /* End PBXBuildFile section */ @@ -87,12 +87,17 @@ 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 = ""; }; + 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 = ""; }; 4D5251EB2C1097A600018CD2 /* Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Destination.swift; sourceTree = ""; }; 4D5251EE2C1098E900018CD2 /* MultiChoiceScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiChoiceScreen.swift; sourceTree = ""; }; 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 = ""; }; 4D54C92D2BF2608A001DE071 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; @@ -100,9 +105,7 @@ 4D54C9312BF2608F001DE071 /* Fyreplace.release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Fyreplace.release.entitlements; sourceTree = ""; }; 4D54C9332BF2608F001DE071 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 4D54C9392BF26090001DE071 /* FyreplaceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FyreplaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 4D54C93D2BF26090001DE071 /* DestinationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationTests.swift; sourceTree = ""; }; 4D54C9432BF26090001DE071 /* FyreplaceUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FyreplaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 4D54C9472BF26090001DE071 /* NavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTests.swift; sourceTree = ""; }; 4D54C9572BF266F9001DE071 /* COPYING.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = COPYING.txt; sourceTree = ""; }; 4D54C9582BF266F9001DE071 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 4D54C9592BF266F9001DE071 /* Makefile */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.make; path = Makefile; sourceTree = ""; }; @@ -124,11 +127,11 @@ 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTextContentType.swift; sourceTree = ""; }; 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITextContentType.swift; sourceTree = ""; }; 4D9DC5022C11BF2500BA0507 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + 4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; + 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeClient.swift; sourceTree = ""; }; 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 = ""; }; - 4DB2E3732C428C08007F958D /* AppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTests.swift; sourceTree = ""; }; - 4DB2E3762C428D8B007F958D /* XCUIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIApplication.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 = ""; }; @@ -136,10 +139,7 @@ 4DCEF8662C452ECC00F53085 /* .env */ = {isa = PBXFileReference; lastKnownFileType = text; path = .env; sourceTree = ""; }; 4DD826FD2BF9FDC500799CEB /* Config.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Config.sh; sourceTree = ""; }; 4DE140CC2C52687F00A699AE /* DestinationCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationCommands.swift; sourceTree = ""; }; - 4DFB90632C57B98C00D4DABF /* AnyPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyPublisher.swift; sourceTree = ""; }; - 4DFB906B2C58FC6900D4DABF /* LoginScreen+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoginScreen+ViewModel.swift"; sourceTree = ""; }; 4DFB906F2C5908DE00D4DABF /* LoginScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenTests.swift; sourceTree = ""; }; - 4DFB90722C59162000D4DABF /* RegisterScreen+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RegisterScreen+ViewModel.swift"; sourceTree = ""; }; 4DFB90752C59173C00D4DABF /* RegisterScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterScreenTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -207,6 +207,15 @@ path = Screens; sourceTree = ""; }; + 4D51F2822C621B7D0018E76E /* Events */ = { + isa = PBXGroup; + children = ( + 4D51F2852C6232A30018E76E /* Event.swift */, + 4D51F2832C621B8D0018E76E /* EventBus.swift */, + ); + path = Events; + sourceTree = ""; + }; 4D5251E82C10532100018CD2 /* Navigation */ = { isa = PBXGroup; children = ( @@ -257,9 +266,11 @@ 4D54C9322BF2608F001DE071 /* Preview Content */, 4D9B3B432C36E64F00A8F7AD /* Extensions */, 4D13AF7C2C4E904E00845FDB /* Data */, + 4D51F2822C621B7D0018E76E /* Events */, 4D54C95E2BF27C78001DE071 /* Views */, - 4DFB906A2C58FC4400D4DABF /* ViewModels */, + 4DFB906A2C58FC4400D4DABF /* States */, 4DB10B4E2C4FEBCE00634BF6 /* Commands */, + 4DA7BFB92C5FDEA1005CC4FF /* Fakes */, 4D54C92B2BF2608A001DE071 /* FyreplaceApp.swift */, ); path = Fyreplace; @@ -276,8 +287,8 @@ 4D54C93C2BF26090001DE071 /* FyreplaceTests */ = { isa = PBXGroup; children = ( - 4DFB906E2C5908BF00D4DABF /* ViewModels */, - 4D54C93D2BF26090001DE071 /* DestinationTests.swift */, + 4DFB906E2C5908BF00D4DABF /* Screens */, + 4D5348602C6646F80001EFDE /* StoringEventBus.swift */, ); path = FyreplaceTests; sourceTree = ""; @@ -285,9 +296,7 @@ 4D54C9462BF26090001DE071 /* FyreplaceUITests */ = { isa = PBXGroup; children = ( - 4DB2E3752C428D7F007F958D /* Extensions */, - 4D54C9472BF26090001DE071 /* NavigationTests.swift */, - 4DB2E3732C428C08007F958D /* AppTests.swift */, + 4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */, ); path = FyreplaceUITests; sourceTree = ""; @@ -347,28 +356,27 @@ isa = PBXGroup; children = ( 4D9B3B412C36E23A00A8F7AD /* Array+RawRepresentable.swift */, - 4DFB90632C57B98C00D4DABF /* AnyPublisher.swift */, 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */, 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */, ); path = Extensions; sourceTree = ""; }; - 4DB10B4E2C4FEBCE00634BF6 /* Commands */ = { + 4DA7BFB92C5FDEA1005CC4FF /* Fakes */ = { isa = PBXGroup; children = ( - 4DE140CC2C52687F00A699AE /* DestinationCommands.swift */, - 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */, + 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */, ); - path = Commands; + path = Fakes; sourceTree = ""; }; - 4DB2E3752C428D7F007F958D /* Extensions */ = { + 4DB10B4E2C4FEBCE00634BF6 /* Commands */ = { isa = PBXGroup; children = ( - 4DB2E3762C428D8B007F958D /* XCUIApplication.swift */, + 4DE140CC2C52687F00A699AE /* DestinationCommands.swift */, + 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */, ); - path = Extensions; + path = Commands; sourceTree = ""; }; 4DD826FE2BFA0EBE00799CEB /* Config */ = { @@ -385,22 +393,22 @@ path = Config; sourceTree = ""; }; - 4DFB906A2C58FC4400D4DABF /* ViewModels */ = { + 4DFB906A2C58FC4400D4DABF /* States */ = { isa = PBXGroup; children = ( - 4DFB906B2C58FC6900D4DABF /* LoginScreen+ViewModel.swift */, - 4DFB90722C59162000D4DABF /* RegisterScreen+ViewModel.swift */, + 4D51F27F2C621ADB0018E76E /* StatefulProtocol.swift */, + 4D53485B2C66423C0001EFDE /* LoadingViewState.swift */, ); - path = ViewModels; + path = States; sourceTree = ""; }; - 4DFB906E2C5908BF00D4DABF /* ViewModels */ = { + 4DFB906E2C5908BF00D4DABF /* Screens */ = { isa = PBXGroup; children = ( 4DFB906F2C5908DE00D4DABF /* LoginScreenTests.swift */, 4DFB90752C59173C00D4DABF /* RegisterScreenTests.swift */, ); - path = ViewModels; + path = Screens; sourceTree = ""; }; /* End PBXGroup section */ @@ -576,6 +584,7 @@ buildActionMask = 2147483647; files = ( 4D9B3B3D2C34B13E00A8F7AD /* LogoHeader.swift in Sources */, + 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */, 4D54C9712BF4EA15001DE071 /* SettingsScreen.swift in Sources */, 4D13AF7B2C4E8F4200845FDB /* EnvironmentPicker.swift in Sources */, 4D9B3B422C36E23A00A8F7AD /* Array+RawRepresentable.swift in Sources */, @@ -585,23 +594,24 @@ 4D5251F12C109D0D00018CD2 /* Screen.swift in Sources */, 4DE140CD2C52688000A699AE /* DestinationCommands.swift in Sources */, 4D54C96B2BF4E97E001DE071 /* NotificationsScreen.swift in Sources */, + 4D51F2802C621ADB0018E76E /* StatefulProtocol.swift in Sources */, 4D9B3B472C36F50300A8F7AD /* UITextContentType.swift in Sources */, 4D9B3B382C334B3A00A8F7AD /* LoginScreen.swift in Sources */, 4D5251EF2C1098E900018CD2 /* MultiChoiceScreen.swift in Sources */, 4DB10B502C4FEBFC00634BF6 /* HelpCommands.swift in Sources */, 4D9B3B452C36F46F00A8F7AD /* NSTextContentType.swift in Sources */, 4D54C96F2BF4E9DF001DE071 /* DraftsScreen.swift in Sources */, - 4DFB90732C59162000D4DABF /* RegisterScreen+ViewModel.swift in Sources */, + 4D51F2862C6232A30018E76E /* Event.swift in Sources */, 4DB2E36F2C418F5C007F958D /* DynamicForm.swift in Sources */, - 4DFB90642C57B98C00D4DABF /* AnyPublisher.swift in Sources */, - 4DFB906C2C58FC6900D4DABF /* LoginScreen+ViewModel.swift in Sources */, 4DCE062B2C08E5E200F69AF1 /* CompactNavigation.swift in Sources */, 4DCE062D2C08E65300F69AF1 /* RegularNavigation.swift in Sources */, 4D54C9692BF4E8F4001DE071 /* FeedScreen.swift in Sources */, + 4D53485C2C66423C0001EFDE /* LoadingViewState.swift in Sources */, 4D9B3B3A2C334B6300A8F7AD /* RegisterScreen.swift in Sources */, 4DB2E36D2C416611007F958D /* SubmitButton.swift in Sources */, 4D54C96D2BF4E9BE001DE071 /* ArchiveScreen.swift in Sources */, 4D13AF812C4E907200845FDB /* Environment.swift in Sources */, + 4D51F2842C621B8D0018E76E /* EventBus.swift in Sources */, 4D5251F52C10A9F100018CD2 /* DynamicNavigation.swift in Sources */, 4D9DC5032C11BF2500BA0507 /* Config.swift in Sources */, 4D54C92C2BF2608A001DE071 /* FyreplaceApp.swift in Sources */, @@ -613,9 +623,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D5348612C6646F80001EFDE /* StoringEventBus.swift in Sources */, 4DFB90702C5908DE00D4DABF /* LoginScreenTests.swift in Sources */, 4DFB90762C59173C00D4DABF /* RegisterScreenTests.swift in Sources */, - 4D54C93E2BF26090001DE071 /* DestinationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -623,9 +633,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4D54C9482BF26090001DE071 /* NavigationTests.swift in Sources */, - 4DB2E3772C428D8B007F958D /* XCUIApplication.swift in Sources */, - 4DB2E3742C428C08007F958D /* AppTests.swift in Sources */, + 4DA7BFB72C5FD479005CC4FF /* PerformanceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -858,7 +866,6 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_IDENTITY = "-"; GENERATE_INFOPLIST_FILE = YES; PRODUCT_BUNDLE_IDENTIFIER = app.fyreplace.FyreplaceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -892,7 +899,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_IDENTITY = "-"; GENERATE_INFOPLIST_FILE = YES; PRODUCT_BUNDLE_IDENTIFIER = app.fyreplace.FyreplaceUITests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Fyreplace/Commands/DestinationCommands.swift b/Fyreplace/Commands/DestinationCommands.swift index 3a8a069..7e93df2 100644 --- a/Fyreplace/Commands/DestinationCommands.swift +++ b/Fyreplace/Commands/DestinationCommands.swift @@ -1,8 +1,7 @@ -import Combine import SwiftUI struct DestinationCommands: Commands { - var subject: PassthroughSubject + let eventBus: EventBus var body: some Commands { CommandGroup(after: .sidebar) { @@ -10,7 +9,9 @@ struct DestinationCommands: Commands { ForEach(Destination.all) { destination in Button(destination.titleKey) { - subject.send(destination) + Task { + await eventBus.send(.navigationShortcut(to: destination)) + } } .keyboardShortcut(destination.keyboardShortcut) } @@ -19,14 +20,3 @@ struct DestinationCommands: Commands { } } } - -struct DestinationCommandEnvironmentKey: EnvironmentKey { - static let defaultValue = AnyPublisher.empty -} - -extension EnvironmentValues { - var destinationCommands: AnyPublisher { - get { self[DestinationCommandEnvironmentKey.self] } - set { self[DestinationCommandEnvironmentKey.self] = newValue } - } -} diff --git a/Fyreplace/Config/Config.swift b/Fyreplace/Config/Config.swift index 7d26744..8548d09 100644 --- a/Fyreplace/Config/Config.swift +++ b/Fyreplace/Config/Config.swift @@ -1,3 +1,4 @@ +import OpenAPIURLSession import SwiftUI struct Config { @@ -54,24 +55,34 @@ struct Config { struct Api { let main: URL let dev: URL - let local: URL? + #if DEBUG + let local: URL + #endif init(_ data: [String: Any]) { main = data.url("Main")! dev = data.url("Dev")! - local = data.url("Local") + #if DEBUG + local = data.url("Local")! + #endif } - func url(for environment: ServerEnvironment) -> URL? { - switch environment { + func url(for environment: ServerEnvironment) -> URL { + return switch environment { case .main: main case .dev: dev - case .local: - local + #if DEBUG + case .local: + local + #endif } } + + func client(for environment: ServerEnvironment) -> Client { + return Client(serverURL: url(for: environment), transport: URLSessionTransport()) + } } } diff --git a/Fyreplace/Data/Environment.swift b/Fyreplace/Data/Environment.swift index 65e0ed7..bf69099 100644 --- a/Fyreplace/Data/Environment.swift +++ b/Fyreplace/Data/Environment.swift @@ -3,7 +3,9 @@ import SwiftUI enum ServerEnvironment: String, CaseIterable, Identifiable { case main case dev - case local + #if DEBUG + case local + #endif var id: String { rawValue } @@ -13,8 +15,10 @@ enum ServerEnvironment: String, CaseIterable, Identifiable { .init(localized: "Environment.Main") case .dev: .init(localized: "Environment.Dev") - case .local: - .init(localized: "Environment.Local") + #if DEBUG + case .local: + .init(localized: "Environment.Local") + #endif } } diff --git a/Fyreplace/Events/Event.swift b/Fyreplace/Events/Event.swift new file mode 100644 index 0000000..a53803b --- /dev/null +++ b/Fyreplace/Events/Event.swift @@ -0,0 +1,36 @@ +import SwiftUI + +protocol Event {} + +struct ErrorEvent: Event { + let error: UnexpectedError + + init(_ error: UnexpectedError) { + self.error = error + } +} + +extension Event { + typealias error = ErrorEvent +} + +struct FailureEvent: Event { + let title: LocalizedStringKey + let text: LocalizedStringKey +} + +extension Event { + typealias failure = FailureEvent +} + +struct NavigationShortcutEvent: Event { + let destination: Destination + + init(to destination: Destination) { + self.destination = destination + } +} + +extension Event { + typealias navigationShortcut = NavigationShortcutEvent +} diff --git a/Fyreplace/Events/EventBus.swift b/Fyreplace/Events/EventBus.swift new file mode 100644 index 0000000..a3c5311 --- /dev/null +++ b/Fyreplace/Events/EventBus.swift @@ -0,0 +1,17 @@ +import Combine +import SwiftUI + +class EventBus: ObservableObject { + private let subject = PassthroughSubject() + + let events: AnyPublisher + + init() { + events = subject.eraseToAnyPublisher() + } + + @MainActor + func send(_ event: Event) { + subject.send(event) + } +} diff --git a/Fyreplace/Extensions/AnyPublisher.swift b/Fyreplace/Extensions/AnyPublisher.swift deleted file mode 100644 index e0d8ac1..0000000 --- a/Fyreplace/Extensions/AnyPublisher.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Combine - -extension AnyPublisher { - static var empty: Self { - PassthroughSubject().eraseToAnyPublisher() - } -} diff --git a/Fyreplace/Extensions/Array+RawRepresentable.swift b/Fyreplace/Extensions/Array+RawRepresentable.swift index 5cedb9f..fa9c538 100644 --- a/Fyreplace/Extensions/Array+RawRepresentable.swift +++ b/Fyreplace/Extensions/Array+RawRepresentable.swift @@ -1,8 +1,6 @@ import Foundation extension Array: RawRepresentable where Element: Codable { - public typealias RawValue = String - public init?(rawValue: String) { self.init() guard let data = rawValue.data(using: .utf8), diff --git a/Fyreplace/Fakes/FakeClient.swift b/Fyreplace/Fakes/FakeClient.swift new file mode 100644 index 0000000..497412c --- /dev/null +++ b/Fyreplace/Fakes/FakeClient.swift @@ -0,0 +1,250 @@ +import Foundation + +struct FakeClient: APIProtocol {} + +extension APIProtocol { + typealias fake = FakeClient +} + +// MARK: Chapters + +extension FakeClient { + func createChapter(_: Operations.createChapter.Input) async throws -> Operations.createChapter.Output { + fatalError("Not implemented") + } + + func deleteChapter(_: Operations.deleteChapter.Input) async throws -> Operations.deleteChapter.Output { + fatalError("Not implemented") + } + + func setChapterImage(_: Operations.setChapterImage.Input) async throws -> Operations.setChapterImage.Output { + fatalError("Not implemented") + } + + func setChapterPosition(_: Operations.setChapterPosition.Input) async throws -> Operations.setChapterPosition.Output { + fatalError("Not implemented") + } + + func setChapterText(_: Operations.setChapterText.Input) async throws -> Operations.setChapterText.Output { + fatalError("Not implemented") + } +} + +// MARK: Comments + +extension FakeClient { + func acknowledgeComment(_: Operations.acknowledgeComment.Input) async throws -> Operations.acknowledgeComment.Output { + fatalError("Not implemented") + } + + func countComments(_: Operations.countComments.Input) async throws -> Operations.countComments.Output { + fatalError("Not implemented") + } + + func createComment(_: Operations.createComment.Input) async throws -> Operations.createComment.Output { + fatalError("Not implemented") + } + + func deleteComment(_: Operations.deleteComment.Input) async throws -> Operations.deleteComment.Output { + fatalError("Not implemented") + } + + func listComments(_: Operations.listComments.Input) async throws -> Operations.listComments.Output { + fatalError("Not implemented") + } + + func setCommentReported(_: Operations.setCommentReported.Input) async throws -> Operations.setCommentReported.Output { + fatalError("Not implemented") + } +} + +// MARK: Emails + +extension FakeClient { + func activateEmail(_: Operations.activateEmail.Input) async throws -> Operations.activateEmail.Output { + fatalError("Not implemented") + } + + func countEmails(_: Operations.countEmails.Input) async throws -> Operations.countEmails.Output { + fatalError("Not implemented") + } + + func createEmail(_: Operations.createEmail.Input) async throws -> Operations.createEmail.Output { + fatalError("Not implemented") + } + + func deleteEmail(_: Operations.deleteEmail.Input) async throws -> Operations.deleteEmail.Output { + fatalError("Not implemented") + } + + func listEmails(_: Operations.listEmails.Input) async throws -> Operations.listEmails.Output { + fatalError("Not implemented") + } + + func setMainEmail(_: Operations.setMainEmail.Input) async throws -> Operations.setMainEmail.Output { + fatalError("Not implemented") + } +} + +// MARK: Posts + +extension FakeClient { + func countPosts(_: Operations.countPosts.Input) async throws -> Operations.countPosts.Output { + fatalError("Not implemented") + } + + func createPost(_: Operations.createPost.Input) async throws -> Operations.createPost.Output { + fatalError("Not implemented") + } + + func deletePost(_: Operations.deletePost.Input) async throws -> Operations.deletePost.Output { + fatalError("Not implemented") + } + + func getPost(_: Operations.getPost.Input) async throws -> Operations.getPost.Output { + fatalError("Not implemented") + } + + func listPosts(_: Operations.listPosts.Input) async throws -> Operations.listPosts.Output { + fatalError("Not implemented") + } + + func listPostsFeed(_: Operations.listPostsFeed.Input) async throws -> Operations.listPostsFeed.Output { + fatalError("Not implemented") + } + + func publishPost(_: Operations.publishPost.Input) async throws -> Operations.publishPost.Output { + fatalError("Not implemented") + } + + func setPostReported(_: Operations.setPostReported.Input) async throws -> Operations.setPostReported.Output { + fatalError("Not implemented") + } + + func setPostSubscribed(_: Operations.setPostSubscribed.Input) async throws -> Operations.setPostSubscribed.Output { + fatalError("Not implemented") + } + + func votePost(_: Operations.votePost.Input) async throws -> Operations.votePost.Output { + fatalError("Not implemented") + } +} + +// MARK: Reports + +extension FakeClient { + func listReports(_: Operations.listReports.Input) async throws -> Operations.listReports.Output { + fatalError("Not implemented") + } +} + +// MARK: Stored files + +extension FakeClient { + func getStoredFile(_: Operations.getStoredFile.Input) async throws -> Operations.getStoredFile.Output { + fatalError("Not implemented") + } +} + +// MARK: Subscriptions + +extension FakeClient { + func clearUnreadSubscriptions(_: Operations.clearUnreadSubscriptions.Input) async throws -> Operations.clearUnreadSubscriptions.Output { + fatalError("Not implemented") + } + + func deleteSubscription(_: Operations.deleteSubscription.Input) async throws -> Operations.deleteSubscription.Output { + fatalError("Not implemented") + } + + func listUnreadSubscriptions(_: Operations.listUnreadSubscriptions.Input) async throws -> Operations.listUnreadSubscriptions.Output { + fatalError("Not implemented") + } +} + +// MARK: Tokens + +extension FakeClient { + static let badIdentifer = "bad-identifier" + static let goodIdentifer = "good-identifier" + static let badSecret = "bad-secret" + static let goodSecret = "good-secret" + static let badToken = "bad-token" + static let goodToken = "good-token" + + func createNewToken(_ input: Operations.createNewToken.Input) async throws -> Operations.createNewToken.Output { + return switch input.body { + case let .json(json) where json.identifier == Self.goodIdentifer: + .ok(.init()) + + case .json: + .notFound(.init()) + } + } + + func createToken(_ input: Operations.createToken.Input) async throws -> Operations.createToken.Output { + return switch input.body { + case let .json(json) where json.identifier == Self.goodIdentifer && json.secret == Self.goodSecret: + .created(.init(body: .plainText(.init(stringLiteral: Self.goodToken)))) + + case .json: + .notFound(.init()) + } + } + + func getNewToken(_: Operations.getNewToken.Input) async throws -> Operations.getNewToken.Output { + return .ok(.init(body: .plainText(.init(stringLiteral: Self.goodToken)))) + } +} + +// MARK: Users + +extension FakeClient { + func countBlockedUsers(_: Operations.countBlockedUsers.Input) async throws -> Operations.countBlockedUsers.Output { + fatalError("Not implemented") + } + + func createUser(_: Operations.createUser.Input) async throws -> Operations.createUser.Output { + fatalError("Not implemented") + } + + func deleteCurrentUser(_: Operations.deleteCurrentUser.Input) async throws -> Operations.deleteCurrentUser.Output { + fatalError("Not implemented") + } + + func deleteCurrentUserAvatar(_: Operations.deleteCurrentUserAvatar.Input) async throws -> Operations.deleteCurrentUserAvatar.Output { + fatalError("Not implemented") + } + + func getCurrentUser(_: Operations.getCurrentUser.Input) async throws -> Operations.getCurrentUser.Output { + fatalError("Not implemented") + } + + func getUser(_: Operations.getUser.Input) async throws -> Operations.getUser.Output { + fatalError("Not implemented") + } + + func listBlockedUsers(_: Operations.listBlockedUsers.Input) async throws -> Operations.listBlockedUsers.Output { + fatalError("Not implemented") + } + + func setCurrentUserAvatar(_: Operations.setCurrentUserAvatar.Input) async throws -> Operations.setCurrentUserAvatar.Output { + fatalError("Not implemented") + } + + func setCurrentUserBio(_: Operations.setCurrentUserBio.Input) async throws -> Operations.setCurrentUserBio.Output { + fatalError("Not implemented") + } + + func setUserBanned(_: Operations.setUserBanned.Input) async throws -> Operations.setUserBanned.Output { + fatalError("Not implemented") + } + + func setUserBlocked(_: Operations.setUserBlocked.Input) async throws -> Operations.setUserBlocked.Output { + fatalError("Not implemented") + } + + func setUserReported(_: Operations.setUserReported.Input) async throws -> Operations.setUserReported.Output { + fatalError("Not implemented") + } +} diff --git a/Fyreplace/FyreplaceApp.swift b/Fyreplace/FyreplaceApp.swift index d5fc5fe..5de864c 100644 --- a/Fyreplace/FyreplaceApp.swift +++ b/Fyreplace/FyreplaceApp.swift @@ -5,12 +5,6 @@ import SwiftUI @main struct FyreplaceApp: App { init() { - if ProcessInfo.processInfo.arguments.contains("--ui-tests"), - let bundleId = Bundle.main.bundleIdentifier - { - UserDefaults.standard.removePersistentDomain(forName: bundleId) - } - guard let dsn = Config.default.sentry.dsn, !dsn.isEmpty else { return } SentrySDK.start { @@ -20,15 +14,15 @@ struct FyreplaceApp: App { } var body: some Scene { - let destinationsSubject = PassthroughSubject() + let eventBus = EventBus() WindowGroup { - MainView(destinationCommands: destinationsSubject.eraseToAnyPublisher()) + MainView(eventBus: eventBus) } .commands { ToolbarCommands() SidebarCommands() - DestinationCommands(subject: destinationsSubject) + DestinationCommands(eventBus: eventBus) HelpCommands() } } diff --git a/Fyreplace/Resources/Localizable.xcstrings b/Fyreplace/Resources/Localizable.xcstrings index 868ef1a..95c1d5b 100644 --- a/Fyreplace/Resources/Localizable.xcstrings +++ b/Fyreplace/Resources/Localizable.xcstrings @@ -94,6 +94,66 @@ } } }, + "Error.BadRequest.Message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An incorrect request was sent to the server." + } + } + } + }, + "Error.BadRequest.Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bad request" + } + } + } + }, + "Error.Connection" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server seems to be unreachable." + } + } + } + }, + "Error.Unknown" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown error. This should never happen…" + } + } + } + }, + "Login.Error.NotFound.Message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No user account was found with this username or email." + } + } + } + }, + "Login.Error.NotFound.Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User not found" + } + } + } + }, "Login.Header" : { "localizations" : { "en" : { @@ -234,6 +294,16 @@ } } }, + "Ok" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + } + } + }, "Register.Email" : { "localizations" : { "en" : { diff --git a/Fyreplace/States/LoadingViewState.swift b/Fyreplace/States/LoadingViewState.swift new file mode 100644 index 0000000..c471506 --- /dev/null +++ b/Fyreplace/States/LoadingViewState.swift @@ -0,0 +1,6 @@ +import Foundation + +class LoadingViewState: ObservableObject { + @Published + var isLoading = false +} diff --git a/Fyreplace/States/StatefulProtocol.swift b/Fyreplace/States/StatefulProtocol.swift new file mode 100644 index 0000000..dc56314 --- /dev/null +++ b/Fyreplace/States/StatefulProtocol.swift @@ -0,0 +1,49 @@ +import OpenAPIRuntime +import Sentry + +protocol StatefulProtocol { + associatedtype State + + var state: State { get } +} + +@MainActor +extension StatefulProtocol { + func call(failOn eventBus: EventBus, action: () async throws -> Void) async { + do { + try await action() + } catch is ClientError { + eventBus.send(Event.error(ConnectionError())) + } catch { + eventBus.send(.error(UnknownError())) + SentrySDK.capture(error: error) + } + } +} + +@MainActor +extension StatefulProtocol where State: LoadingViewState { + func callWhileLoading(failOn eventBus: EventBus, action: () async throws -> Void) async { + state.isLoading = true + await call(failOn: eventBus, action: action) + state.isLoading = false + } +} + +class UnexpectedError: LocalizedError { + var errorDescription: String? { + nil + } +} + +class ConnectionError: UnexpectedError { + override var errorDescription: String { + .init(localized: "Error.Connection") + } +} + +class UnknownError: UnexpectedError { + override var errorDescription: String? { + .init(localized: "Error.Unknown") + } +} diff --git a/Fyreplace/ViewModels/LoginScreen+ViewModel.swift b/Fyreplace/ViewModels/LoginScreen+ViewModel.swift deleted file mode 100644 index a4e039c..0000000 --- a/Fyreplace/ViewModels/LoginScreen+ViewModel.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -extension LoginScreen { - final class ViewModel: ObservableObject { - @Published - var identifier = "" - - var canSubmit: Bool { 3 ... 254 ~= identifier.count } - } -} diff --git a/Fyreplace/ViewModels/RegisterScreen+ViewModel.swift b/Fyreplace/ViewModels/RegisterScreen+ViewModel.swift deleted file mode 100644 index be4f8fa..0000000 --- a/Fyreplace/ViewModels/RegisterScreen+ViewModel.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -extension RegisterScreen { - final class ViewModel: ObservableObject { - @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 } - } -} diff --git a/Fyreplace/Views/Forms/EnvironmentPicker.swift b/Fyreplace/Views/Forms/EnvironmentPicker.swift index d0630ff..25bf997 100644 --- a/Fyreplace/Views/Forms/EnvironmentPicker.swift +++ b/Fyreplace/Views/Forms/EnvironmentPicker.swift @@ -3,15 +3,12 @@ import SwiftUI struct EnvironmentPicker: View { let namespace: Namespace.ID - @Environment(\.config) - private var config - @AppStorage("connection.environment") private var selection = ServerEnvironment.default var body: some View { Picker("Environment.Title", selection: $selection) { - ForEach(ServerEnvironment.allCases.filter { config.app.api.url(for: $0) != nil }) { environment in + 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 8503954..bcaf0d5 100644 --- a/Fyreplace/Views/Forms/SubmitButton.swift +++ b/Fyreplace/Views/Forms/SubmitButton.swift @@ -3,21 +3,35 @@ import SwiftUI struct SubmitButton: View { let text: LocalizedStringKey let canSubmit: Bool + let isLoading: Bool let submit: () -> Void var body: some View { HStack { Spacer() - Button(action: submit) { + + let button = Button(action: submit) { Text(text).padding(.horizontal) } .disabled(!canSubmit) .animation(.default, value: canSubmit) .controlSize(.large) .accessibilityIdentifier("submit") + #if os(macOS) - .buttonStyle(.borderedProminent) + if canSubmit { + button.buttonStyle(.borderedProminent) + } else { + button + } + #else + if isLoading { + ProgressView() + } else { + button + } #endif + Spacer() } } diff --git a/Fyreplace/Views/MainView.swift b/Fyreplace/Views/MainView.swift index 9e879b7..5a401a7 100644 --- a/Fyreplace/Views/MainView.swift +++ b/Fyreplace/Views/MainView.swift @@ -1,8 +1,49 @@ -import Combine import SwiftUI -struct MainView: View { - var destinationCommands: AnyPublisher +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() + } + + func removeFailure() async { + state.failures.removeFirst() + await wait() + tryShowSomething() + } + + private func wait() async { + try? await Task.sleep(for: .milliseconds(100)) + } + + private func tryShowSomething() { + if !state.errors.isEmpty { + state.showError = true + } else if !state.failures.isEmpty { + state.showFailure = true + } + } +} + +struct MainView: View, MainViewProtocol { + let eventBus: EventBus + + @Environment(\.config) + private var config #if os(macOS) @Environment(\.controlActiveState) @@ -12,6 +53,12 @@ struct MainView: View { private var status #endif + @AppStorage("connection.environment") + private var environment = ServerEnvironment.default + + @StateObject + var state = State() + var body: some View { #if os(macOS) let navigation = RegularNavigation() @@ -19,13 +66,75 @@ struct MainView: View { let navigation = DynamicNavigation() #endif - navigation.environment( - \.destinationCommands, - status == .inactive ? .empty : destinationCommands - ) + navigation + .environment(\.isInForeground, status != .inactive) + .environment(\.api, config.app.api.client(for: environment)) + .environmentObject(eventBus) + .alert( + isPresented: $state.showError, + error: state.errors.first + ) { + Button("Ok", role: .cancel) { + Task { + await removeError() + } + } + } + .alert( + state.failures.first?.title ?? "", + isPresented: $state.showFailure, + presenting: state.failures.first, + actions: { _ in + Button("Ok", role: .cancel) { + Task { + await removeFailure() + } + } + }, message: { (failure: FailureEvent) in + Text(failure.text) + } + ) + .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 { - MainView(destinationCommands: .empty) + MainView(eventBus: .init()) +} + +struct ForegroundEnvironmentKey: EnvironmentKey { + static let defaultValue = true +} + +extension EnvironmentValues { + var isInForeground: Bool { + get { self[ForegroundEnvironmentKey.self] } + set { self[ForegroundEnvironmentKey.self] = newValue } + } +} + +struct APIClientEnvironmentKey: EnvironmentKey { + static let defaultValue: APIProtocol = .fake() +} + +extension EnvironmentValues { + var api: APIProtocol { + get { self[APIClientEnvironmentKey.self] } + set { self[APIClientEnvironmentKey.self] = newValue } + } } diff --git a/Fyreplace/Views/Navigation/RegularNavigation.swift b/Fyreplace/Views/Navigation/RegularNavigation.swift index 847cd85..df1e9c6 100644 --- a/Fyreplace/Views/Navigation/RegularNavigation.swift +++ b/Fyreplace/Views/Navigation/RegularNavigation.swift @@ -1,8 +1,11 @@ import SwiftUI struct RegularNavigation: View { - @Environment(\.destinationCommands) - private var destinationCommands + @EnvironmentObject + private var eventBus: EventBus + + @Environment(\.isInForeground) + private var isInForeground #if os(macOS) @SceneStorage("RegularNavigation.selectedDestination") @@ -35,8 +38,12 @@ struct RegularNavigation: View { Screen(destination: finalDestination) } } - .onReceive(destinationCommands) { - selectedDestination = $0 + .onReceive( + eventBus.events + .filter { _ in isInForeground } + .compactMap { $0 as? NavigationShortcutEvent } + ) { + selectedDestination = $0.destination } } } diff --git a/Fyreplace/Views/Screens/LoginScreen.swift b/Fyreplace/Views/Screens/LoginScreen.swift index 8b4db06..c09f800 100644 --- a/Fyreplace/Views/Screens/LoginScreen.swift +++ b/Fyreplace/Views/Screens/LoginScreen.swift @@ -1,18 +1,55 @@ import SwiftUI -struct LoginScreen: View { +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 viewModel: ViewModel + var state: State + + @EnvironmentObject + var eventBus: EventBus + + @Environment(\.api) + var client @FocusState private var focused: Bool var body: some View { DynamicForm { - let submitButton = SubmitButton(text: "Login.Submit", canSubmit: viewModel.canSubmit, submit: submit) - .matchedGeometryEffect(id: "submit", in: namespace) + 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) @@ -26,7 +63,7 @@ struct LoginScreen: View { ) { EnvironmentPicker(namespace: namespace) - TextField("Login.Identifier", text: $viewModel.identifier, prompt: Text("Login.Identifier.Prompt")) + TextField("Login.Identifier", text: $state.identifier, prompt: Text("Login.Identifier.Prompt")) .autocorrectionDisabled() .focused($focused) .onSubmit(submit) @@ -36,7 +73,7 @@ struct LoginScreen: View { .labelsHidden() #endif } - .onAppear { focused = viewModel.identifier.isEmpty } + .onAppear { focused = state.identifier.isEmpty } #if !os(macOS) submitButton @@ -46,8 +83,19 @@ struct LoginScreen: View { } private func submit() { - guard viewModel.canSubmit else { return } + guard state.canSubmit else { return } focused = false + + Task { + await sendEmail() + } + } + + final class State: LoadingViewState { + @Published + var identifier = "" + + var canSubmit: Bool { 3 ... 254 ~= identifier.count && !isLoading } } } @@ -56,9 +104,9 @@ struct LoginScreen: View { @Namespace var namespace - @StateObject - var viewModel = LoginScreen.ViewModel() + @State + var state = LoginScreen.State() - LoginScreen(namespace: namespace, viewModel: viewModel) + LoginScreen(namespace: namespace, state: state) } } diff --git a/Fyreplace/Views/Screens/RegisterScreen.swift b/Fyreplace/Views/Screens/RegisterScreen.swift index 74be754..b9b90df 100644 --- a/Fyreplace/Views/Screens/RegisterScreen.swift +++ b/Fyreplace/Views/Screens/RegisterScreen.swift @@ -1,18 +1,25 @@ import SwiftUI -struct RegisterScreen: View { +protocol RegisterScreenProtocol: StatefulProtocol where State == RegisterScreen.State {} + +struct RegisterScreen: View, RegisterScreenProtocol { let namespace: Namespace.ID @ObservedObject - var viewModel: ViewModel + var state: State @FocusState private var focused: FocusedField? var body: some View { DynamicForm { - let submitButton = SubmitButton(text: "Register.Submit", canSubmit: viewModel.canSubmit, submit: submit) - .matchedGeometryEffect(id: "submit", in: namespace) + 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) @@ -30,7 +37,7 @@ struct RegisterScreen: View { ) { EnvironmentPicker(namespace: namespace) - TextField("Register.Username", text: $viewModel.username, prompt: usernamePrompt) + TextField("Register.Username", text: $state.username, prompt: usernamePrompt) .textContentType(.username) .autocorrectionDisabled() .focused($focused, equals: .username) @@ -39,7 +46,7 @@ struct RegisterScreen: View { .accessibilityIdentifier("username") .matchedGeometryEffect(id: "first-field", in: namespace) - TextField("Register.Email", text: $viewModel.email, prompt: emailPrompt) + TextField("Register.Email", text: $state.email, prompt: emailPrompt) .textContentType(.email) .autocorrectionDisabled() .focused($focused, equals: .email) @@ -48,9 +55,9 @@ struct RegisterScreen: View { .accessibilityIdentifier("email") } .onAppear { - if viewModel.username.isEmpty { + if state.username.isEmpty { focused = .username - } else if viewModel.email.isEmpty { + } else if state.email.isEmpty { focused = .email } } @@ -63,9 +70,21 @@ struct RegisterScreen: View { } private func submit() { - guard viewModel.canSubmit else { return } + guard state.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 } + } } private enum FocusedField { @@ -79,8 +98,8 @@ private enum FocusedField { var namespace @StateObject - var viewModel = RegisterScreen.ViewModel() + var state = RegisterScreen.State() - RegisterScreen(namespace: namespace, viewModel: viewModel) + RegisterScreen(namespace: namespace, state: state) } } diff --git a/Fyreplace/Views/Screens/Screen.swift b/Fyreplace/Views/Screens/Screen.swift index c4806b4..d343808 100644 --- a/Fyreplace/Views/Screens/Screen.swift +++ b/Fyreplace/Views/Screens/Screen.swift @@ -7,10 +7,10 @@ struct Screen: View { private var namespace @StateObject - private var loginViewModel = LoginScreen.ViewModel() + private var loginState = LoginScreen.State() @StateObject - private var registerViewModel = RegisterScreen.ViewModel() + private var registerState = RegisterScreen.State() var body: some View { switch destination { @@ -27,9 +27,9 @@ struct Screen: View { case .settings: SettingsScreen() case .login: - LoginScreen(namespace: namespace, viewModel: loginViewModel) + LoginScreen(namespace: namespace, state: loginState) case .register: - RegisterScreen(namespace: namespace, viewModel: registerViewModel) + RegisterScreen(namespace: namespace, state: registerState) } } } diff --git a/FyreplaceTests/DestinationTests.swift b/FyreplaceTests/DestinationTests.swift deleted file mode 100644 index 774585e..0000000 --- a/FyreplaceTests/DestinationTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import XCTest - -@testable -import Fyreplace - -final class DestinationTests: XCTestCase { - func testPropertiesExists() { - for destination in Destination.allCases { - XCTAssertNotEqual(destination.titleKey, "") - - if destination.topLevel { - XCTAssertNotEqual(destination.icon, "") - } - } - } - - func testPropertiesAreUnique() { - for first in Destination.allCases { - for second in Destination.allCases where first != second { - XCTAssertNotEqual(first.titleKey, second.titleKey) - - if first.icon != "" { - XCTAssertNotEqual(first.icon, second.icon) - } - } - } - } - - func testParentsDoNotLoop() { - for destination in Destination.allCases { - XCTAssertNotEqual(destination, destination.parent) - XCTAssertNotEqual(destination, destination.parent?.parent) - } - } -} diff --git a/FyreplaceTests/Screens/LoginScreenTests.swift b/FyreplaceTests/Screens/LoginScreenTests.swift new file mode 100644 index 0000000..da3bf38 --- /dev/null +++ b/FyreplaceTests/Screens/LoginScreenTests.swift @@ -0,0 +1,49 @@ +import XCTest + +@testable +import Fyreplace + +final class LoginScreenTests: XCTestCase { + struct FakeScreen: LoginScreenProtocol { + var state: LoginScreen.State + var eventBus: EventBus + var client: APIProtocol + } + + @MainActor + func testIdentifierMustHaveCorrectLength() { + let screen = FakeScreen(state: .init(), eventBus: .init(), client: .fake()) + + for i in 0 ..< 3 { + screen.state.identifier = .init(repeating: "a", count: i) + XCTAssertFalse(screen.state.canSubmit) + } + + for i in 3 ... 254 { + screen.state.identifier = .init(repeating: "a", count: i) + XCTAssertTrue(screen.state.canSubmit) + } + + screen.state.identifier = .init(repeating: "a", count: 255) + XCTAssertFalse(screen.state.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() + XCTAssertEqual(1, eventBus.storedEvents.count) + XCTAssert(eventBus.storedEvents.first is FailureEvent) + } + + @MainActor + func testValidIdentifierProducesNoFailures() async { + let eventBus = StoringEventBus() + let screen = FakeScreen(state: .init(), eventBus: eventBus, client: .fake()) + screen.state.identifier = FakeClient.goodIdentifer + await screen.sendEmail() + XCTAssertEqual(0, eventBus.storedEvents.count) + } +} diff --git a/FyreplaceTests/Screens/RegisterScreenTests.swift b/FyreplaceTests/Screens/RegisterScreenTests.swift new file mode 100644 index 0000000..a23beee --- /dev/null +++ b/FyreplaceTests/Screens/RegisterScreenTests.swift @@ -0,0 +1,60 @@ +import XCTest + +@testable +import Fyreplace + +final class RegisterScreenTests: XCTestCase { + struct FakeScreen: RegisterScreenProtocol { + var state: RegisterScreen.State + var eventBus: EventBus + var client: APIProtocol + } + + @MainActor + func testUsernameMustHaveCorrectLength() { + let screen = FakeScreen(state: .init(), eventBus: .init(), client: .fake()) + screen.state.email = "email@example" + + for i in 0 ..< 3 { + screen.state.username = .init(repeating: "a", count: i) + XCTAssertFalse(screen.state.canSubmit) + } + + for i in 3 ... 50 { + screen.state.username = .init(repeating: "a", count: i) + XCTAssertTrue(screen.state.canSubmit) + } + + screen.state.username = .init(repeating: "a", count: 51) + XCTAssertFalse(screen.state.canSubmit) + } + + @MainActor + func testEmailMustHaveCorrectLength() { + let screen = FakeScreen(state: .init(), eventBus: .init(), client: .fake()) + screen.state.username = "Example" + + for i in 0 ..< 3 { + screen.state.email = .init(repeating: "@", count: i) + XCTAssertFalse(screen.state.canSubmit) + } + + for i in 3 ... 254 { + screen.state.email = .init(repeating: "@", count: i) + XCTAssertTrue(screen.state.canSubmit) + } + + screen.state.email = .init(repeating: "@", count: 255) + XCTAssertFalse(screen.state.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) + } +} diff --git a/FyreplaceTests/StoringEventBus.swift b/FyreplaceTests/StoringEventBus.swift new file mode 100644 index 0000000..5a3555f --- /dev/null +++ b/FyreplaceTests/StoringEventBus.swift @@ -0,0 +1,11 @@ +@testable +import Fyreplace + +class StoringEventBus: EventBus { + var storedEvents: [Event] = [] + + override func send(_ event: Event) { + super.send(event) + storedEvents.append(event) + } +} diff --git a/FyreplaceTests/ViewModels/LoginScreenTests.swift b/FyreplaceTests/ViewModels/LoginScreenTests.swift deleted file mode 100644 index 7e65c9d..0000000 --- a/FyreplaceTests/ViewModels/LoginScreenTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import XCTest - -@testable -import Fyreplace - -final class LoginScreenViewModelTests: XCTestCase { - func testIdentifierMustHaveCorrectLength() { - let viewModel = LoginScreen.ViewModel() - - for i in 0 ..< 3 { - viewModel.identifier = .init(repeating: "a", count: i) - XCTAssertFalse(viewModel.canSubmit) - } - - for i in 3 ... 254 { - viewModel.identifier = .init(repeating: "a", count: i) - XCTAssertTrue(viewModel.canSubmit) - } - - viewModel.identifier = .init(repeating: "a", count: 255) - XCTAssertFalse(viewModel.canSubmit) - } -} diff --git a/FyreplaceTests/ViewModels/RegisterScreenTests.swift b/FyreplaceTests/ViewModels/RegisterScreenTests.swift deleted file mode 100644 index 07c6aa8..0000000 --- a/FyreplaceTests/ViewModels/RegisterScreenTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -import XCTest - -@testable -import Fyreplace - -final class RegisterScreenViewModelTests: XCTestCase { - func testUsernameMustHaveCorrectLength() { - let viewModel = RegisterScreen.ViewModel() - viewModel.email = "email@example" - - for i in 0 ..< 3 { - viewModel.username = .init(repeating: "a", count: i) - XCTAssertFalse(viewModel.canSubmit) - } - - for i in 3 ... 50 { - viewModel.username = .init(repeating: "a", count: i) - XCTAssertTrue(viewModel.canSubmit) - } - - viewModel.username = .init(repeating: "a", count: 51) - XCTAssertFalse(viewModel.canSubmit) - } - - func testEmailMustHaveCorrectLength() { - let viewModel = RegisterScreen.ViewModel() - viewModel.username = "Example" - - for i in 0 ..< 3 { - viewModel.email = .init(repeating: "@", count: i) - XCTAssertFalse(viewModel.canSubmit) - } - - for i in 3 ... 254 { - viewModel.email = .init(repeating: "@", count: i) - XCTAssertTrue(viewModel.canSubmit) - } - - viewModel.email = .init(repeating: "@", count: 255) - XCTAssertFalse(viewModel.canSubmit) - } - - func testEmailMustHaveAtSign() { - let viewModel = RegisterScreen.ViewModel() - viewModel.username = "Example" - viewModel.email = "email" - XCTAssertFalse(viewModel.canSubmit) - viewModel.email = "email@example" - XCTAssertTrue(viewModel.canSubmit) - } -} diff --git a/FyreplaceUITests/AppTests.swift b/FyreplaceUITests/AppTests.swift deleted file mode 100644 index f41b2a1..0000000 --- a/FyreplaceUITests/AppTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest - -class AppTests: XCTestCase { - var app: XCUIApplication! - - override func setUpWithError() throws { - app = XCUIApplication() - app.launchArguments += ["--ui-tests"] - app.launch() - } - - override func tearDownWithError() throws { - app.terminate() - } -} diff --git a/FyreplaceUITests/Extensions/XCUIApplication.swift b/FyreplaceUITests/Extensions/XCUIApplication.swift deleted file mode 100644 index 5ada0e9..0000000 --- a/FyreplaceUITests/Extensions/XCUIApplication.swift +++ /dev/null @@ -1,19 +0,0 @@ -import XCTest - -extension XCUIApplication { - var segmentedButtonGroups: XCUIElementQuery { - #if os(macOS) - radioGroups - #else - segmentedControls - #endif - } - - var segmentedButtons: XCUIElementQuery { - #if os(macOS) - radioButtons - #else - buttons - #endif - } -} diff --git a/FyreplaceUITests/NavigationTests.swift b/FyreplaceUITests/NavigationTests.swift deleted file mode 100644 index 0aa5e89..0000000 --- a/FyreplaceUITests/NavigationTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import XCTest - -final class NavigationTests: AppTests { - func testNavigationIsComplete() { - let feed = app.buttons["Feed"].firstMatch - let notifications = app.buttons["Notifications"].firstMatch - let archive = app.buttons["Archive"].firstMatch - let drafts = app.buttons["Drafts"].firstMatch - let published = app.buttons["Published"].firstMatch - let settings = app.buttons["Settings"].firstMatch - let login = app.segmentedButtons["Login"].firstMatch - let register = app.segmentedButtons["Sign up"].firstMatch - let tabs = app.segmentedButtonGroups["tabs"].firstMatch - - XCTAssert(feed.exists) - XCTAssert(notifications.exists) - XCTAssert(drafts.exists) - XCTAssert(settings.exists) - - for (requiredDestination, optionalDestination) in [(notifications, archive), (drafts, published)] { - if !optionalDestination.exists { - requiredDestination.tap() - XCTAssert(tabs.exists) - XCTAssert(optionalDestination.exists) - } else { - requiredDestination.tap() - XCTAssert(!tabs.exists) - } - } - - settings.tap() - XCTAssert(tabs.exists) - XCTAssert(login.exists) - XCTAssert(register.exists) - } - - func testNavigationShowsCorrectScreen() { - for name in ["Feed", "Notifications", "Archive", "Drafts", "Published"] { - app.buttons[name].firstMatch.tap() - XCTAssert(app.descendants(matching: .any)[name.lowercased()].exists) - } - - app.buttons["Settings"].firstMatch.tap() - app.segmentedButtons["Login"].firstMatch.tap() - XCTAssert(app.descendants(matching: .any)["login"].exists) - app.segmentedButtons["Sign up"].firstMatch.tap() - XCTAssert(app.descendants(matching: .any)["register"].exists) - } -} diff --git a/FyreplaceUITests/PerformanceTests.swift b/FyreplaceUITests/PerformanceTests.swift new file mode 100644 index 0000000..6007526 --- /dev/null +++ b/FyreplaceUITests/PerformanceTests.swift @@ -0,0 +1,9 @@ +import XCTest + +class PerformanceTests: XCTestCase { + func testApplicationLaunchTime() { + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +}