diff --git a/Solution/iOS/RocketApp.xcodeproj/project.pbxproj b/Solution/iOS/RocketApp.xcodeproj/project.pbxproj index 30a84697..d8f92497 100644 --- a/Solution/iOS/RocketApp.xcodeproj/project.pbxproj +++ b/Solution/iOS/RocketApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -13,8 +13,23 @@ 165B20552999449B0047F70A /* Infrastructure in Resources */ = {isa = PBXBuildFile; fileRef = 165B20532999449B0047F70A /* Infrastructure */; }; 16BC7FFC29CC9AE5002AAE15 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 16BC7FFB29CC9AE5002AAE15 /* Assets.xcassets */; }; 635FE7B12A20CDD40018A585 /* RocketList in Frameworks */ = {isa = PBXBuildFile; productRef = 635FE7B02A20CDD40018A585 /* RocketList */; }; + 6E3F7D202C972CFD00554BA3 /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 6E3F7D1F2C972CFD00554BA3 /* App */; }; + 6EA6ACB52C96F40A00EA0B8A /* Login in Frameworks */ = {isa = PBXBuildFile; productRef = 6EA6ACB42C96F40A00EA0B8A /* Login */; }; + E50818DC2CE2A31A00B9AE68 /* Login in Frameworks */ = {isa = PBXBuildFile; productRef = E50818DB2CE2A31A00B9AE68 /* Login */; }; + E50DF9542C9751A700BE1B5D /* RocketList in Frameworks */ = {isa = PBXBuildFile; productRef = E50DF9532C9751A700BE1B5D /* RocketList */; }; + E55944B42C97328A00594002 /* UIToolkit in Frameworks */ = {isa = PBXBuildFile; productRef = E55944B32C97328A00594002 /* UIToolkit */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + E5A6B6FA2C97216000376970 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 160CA40528A506E4004A274A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 160CA40C28A506E4004A274A; + remoteInfo = RocketApp; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 1607AECC28D21C240030F1DF /* AllTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = AllTests.xctestplan; sourceTree = ""; }; 1607AECD28D21D040030F1DF /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -24,17 +39,34 @@ 165B20522999449B0047F70A /* Features */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Features; sourceTree = ""; }; 165B20532999449B0047F70A /* Infrastructure */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Infrastructure; sourceTree = ""; }; 16BC7FFB29CC9AE5002AAE15 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E5A6B6F42C97216000376970 /* RocketAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RocketAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E5A6B6F52C97216000376970 /* RocketAppUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = RocketAppUITests; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 160CA40A28A506E4004A274A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6E3F7D202C972CFD00554BA3 /* App in Frameworks */, + 6EA6ACB52C96F40A00EA0B8A /* Login in Frameworks */, 635FE7B12A20CDD40018A585 /* RocketList in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + E5A6B6F12C97216000376970 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E50818DC2CE2A31A00B9AE68 /* Login in Frameworks */, + E50DF9542C9751A700BE1B5D /* RocketList in Frameworks */, + E55944B42C97328A00594002 /* UIToolkit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -52,6 +84,7 @@ children = ( 1607AECD28D21D040030F1DF /* README.md */, 163E0EBC28C7988100CECFD8 /* RocketApp */, + E5A6B6F52C97216000376970 /* RocketAppUITests */, 160CA40E28A506E4004A274A /* Products */, 163E0F2D28D093BD00CECFD8 /* Frameworks */, ); @@ -61,6 +94,7 @@ isa = PBXGroup; children = ( 160CA40D28A506E4004A274A /* RocketApp.app */, + E5A6B6F42C97216000376970 /* RocketAppUITests.xctest */, ); name = Products; sourceTree = ""; @@ -103,11 +137,39 @@ name = RocketApp; packageProductDependencies = ( 635FE7B02A20CDD40018A585 /* RocketList */, + 6EA6ACB42C96F40A00EA0B8A /* Login */, + 6E3F7D1F2C972CFD00554BA3 /* App */, ); productName = RocketApp; productReference = 160CA40D28A506E4004A274A /* RocketApp.app */; productType = "com.apple.product-type.application"; }; + E5A6B6F32C97216000376970 /* RocketAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E5A6B6FE2C97216000376970 /* Build configuration list for PBXNativeTarget "RocketAppUITests" */; + buildPhases = ( + E5A6B6F02C97216000376970 /* Sources */, + E5A6B6F12C97216000376970 /* Frameworks */, + E5A6B6F22C97216000376970 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E5A6B6FB2C97216000376970 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E5A6B6F52C97216000376970 /* RocketAppUITests */, + ); + name = RocketAppUITests; + packageProductDependencies = ( + E55944B32C97328A00594002 /* UIToolkit */, + E50DF9532C9751A700BE1B5D /* RocketList */, + E50818DB2CE2A31A00B9AE68 /* Login */, + ); + productName = RocketAppUITests; + productReference = E5A6B6F42C97216000376970 /* RocketAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -115,13 +177,17 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1340; + LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1420; TargetAttributes = { 160CA40C28A506E4004A274A = { CreatedOnToolsVersion = 13.4.1; LastSwiftMigration = 1340; }; + E5A6B6F32C97216000376970 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 160CA40C28A506E4004A274A; + }; }; }; buildConfigurationList = 160CA40828A506E4004A274A /* Build configuration list for PBXProject "RocketApp" */; @@ -142,6 +208,7 @@ projectRoot = ""; targets = ( 160CA40C28A506E4004A274A /* RocketApp */, + E5A6B6F32C97216000376970 /* RocketAppUITests */, ); }; /* End PBXProject section */ @@ -158,6 +225,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5A6B6F22C97216000376970 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -191,8 +265,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5A6B6F02C97216000376970 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + E5A6B6FB2C97216000376970 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 160CA40C28A506E4004A274A /* RocketApp */; + targetProxy = E5A6B6FA2C97216000376970 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 160CA42F28A506E6004A274A /* Debug */ = { isa = XCBuildConfiguration; @@ -379,6 +468,53 @@ }; name = Release; }; + E5A6B6FC2C97216000376970 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L4GD77D338; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = cz.quanti.RocketAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = RocketApp; + }; + name = Debug; + }; + E5A6B6FD2C97216000376970 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L4GD77D338; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = cz.quanti.RocketAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = RocketApp; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -400,6 +536,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E5A6B6FE2C97216000376970 /* Build configuration list for PBXNativeTarget "RocketAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E5A6B6FC2C97216000376970 /* Debug */, + E5A6B6FD2C97216000376970 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ @@ -407,6 +552,26 @@ isa = XCSwiftPackageProductDependency; productName = RocketList; }; + 6E3F7D1F2C972CFD00554BA3 /* App */ = { + isa = XCSwiftPackageProductDependency; + productName = App; + }; + 6EA6ACB42C96F40A00EA0B8A /* Login */ = { + isa = XCSwiftPackageProductDependency; + productName = Login; + }; + E50818DB2CE2A31A00B9AE68 /* Login */ = { + isa = XCSwiftPackageProductDependency; + productName = Login; + }; + E50DF9532C9751A700BE1B5D /* RocketList */ = { + isa = XCSwiftPackageProductDependency; + productName = RocketList; + }; + E55944B32C97328A00594002 /* UIToolkit */ = { + isa = XCSwiftPackageProductDependency; + productName = UIToolkit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 160CA40528A506E4004A274A /* Project object */; diff --git a/Solution/iOS/RocketApp.xcodeproj/xcshareddata/xcschemes/RocketApp.xcscheme b/Solution/iOS/RocketApp.xcodeproj/xcshareddata/xcschemes/RocketApp.xcscheme index f567a104..fd95eab3 100644 --- a/Solution/iOS/RocketApp.xcodeproj/xcshareddata/xcschemes/RocketApp.xcscheme +++ b/Solution/iOS/RocketApp.xcodeproj/xcshareddata/xcschemes/RocketApp.xcscheme @@ -51,6 +51,19 @@ default = "YES"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Solution/iOS/RocketApp/Features/Package.swift b/Solution/iOS/RocketApp/Features/Package.swift index fb40ae48..9670a663 100644 --- a/Solution/iOS/RocketApp/Features/Package.swift +++ b/Solution/iOS/RocketApp/Features/Package.swift @@ -8,6 +8,14 @@ let package = Package( platforms: [.iOS(.v16), .macOS(.v12)], products: [ + .library( + name: "App", + targets: ["App"] + ), + .library( + name: "Login", + targets: ["Login"] + ), .library( name: "RocketDetail", targets: ["RocketDetail"] @@ -35,6 +43,21 @@ let package = Package( ], targets: [ + .target( + name: "App", + dependencies: [ + "Login", + "RocketList", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") + ] + ), + .target( + name: "Login", + dependencies: [ + .product(name: "UIToolkit", package: "Infrastructure"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") + ] + ), .target( name: "RocketDetail", dependencies: [ diff --git a/Solution/iOS/RocketApp/Features/Sources/App/AppCore.swift b/Solution/iOS/RocketApp/Features/Sources/App/AppCore.swift new file mode 100644 index 00000000..4c156a60 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/App/AppCore.swift @@ -0,0 +1,43 @@ +import ComposableArchitecture +import Foundation +import Login +import RocketList + +public struct AppCore: ReducerProtocol { + public enum State: Equatable { + case login(LoginCore.State) + case rocketList(RocketListCore.State) + + public init() { + self = .login(LoginCore.State()) + } + } + + public enum Action { + case login(LoginCore.Action) + case rocketList(RocketListCore.Action) + } + + public init() {} + + public var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .login(.loginSucceeded): + state = .rocketList(RocketListCore.State()) + return .none + case .rocketList(.logoutTapped): + state = .login(LoginCore.State()) + return .none + default: + return .none + } + } + .ifCaseLet(/State.login, action: /Action.login) { + LoginCore() + } + .ifCaseLet(/State.rocketList, action: /Action.rocketList) { + RocketListCore() + } + } +} diff --git a/Solution/iOS/RocketApp/Features/Sources/App/AppView.swift b/Solution/iOS/RocketApp/Features/Sources/App/AppView.swift new file mode 100644 index 00000000..150e07c3 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/App/AppView.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import Login +import RocketList +import SwiftUI + +public struct AppView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + SwitchStore(store) { + CaseLet( + state: /AppCore.State.login, + action: AppCore.Action.login, + then: LoginView.init + ) + CaseLet( + state: /AppCore.State.rocketList, + action: AppCore.Action.rocketList, + then: RocketListView.init + ) + } + } +} + +#Preview { + AppView( + store: Store( + initialState: AppCore.State(), + reducer: AppCore() + ) + ) +} diff --git a/Solution/iOS/RocketApp/Features/Sources/Login/AccessibilityKeys+Login.swift b/Solution/iOS/RocketApp/Features/Sources/Login/AccessibilityKeys+Login.swift new file mode 100644 index 00000000..7cd062c4 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/Login/AccessibilityKeys+Login.swift @@ -0,0 +1,10 @@ +import UIToolkit + +extension AccessibilityKeys { + public enum Login { + public static let titleStaticText = "titleStaticTextID" + public static let usernameTextField = "usernameTextFieldID" + public static let passwordSecureField = "passwordSecureFieldID" + public static let loginButton = "loginButtonID" + } +} diff --git a/Solution/iOS/RocketApp/Features/Sources/Login/LoginCore.swift b/Solution/iOS/RocketApp/Features/Sources/Login/LoginCore.swift new file mode 100644 index 00000000..4b483b5a --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/Login/LoginCore.swift @@ -0,0 +1,54 @@ +import ComposableArchitecture +import Foundation + +public struct LoginCore: ReducerProtocol { + public struct State: Equatable { + @BindingState public var username: String + @BindingState public var password: String + public var isAuthFailedMessageDisplayed: Bool + + public init(username: String = "", password: String = "", isAuthFailedMessageDisplayed: Bool = false) { + self.username = username + self.password = password + self.isAuthFailedMessageDisplayed = isAuthFailedMessageDisplayed + } + } + + public enum Action: BindableAction { + case binding(BindingAction) + case loginTapped + case loginSucceeded + } + + public init() {} + + public var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .loginTapped: + guard isValid(username: state.username, password: state.password) else { + state.isAuthFailedMessageDisplayed = true + return .none + } + + return EffectTask(value: .loginSucceeded) + case .binding: + state.isAuthFailedMessageDisplayed = false + return .none + case .loginSucceeded: + return .none + } + } + } +} + +func isValid(username: String, password: String) -> Bool { + let validPairs: [String: String] = [ + "username1": "test1234", + "astronaut1": "space" + ] + + return validPairs[username] == password +} diff --git a/Solution/iOS/RocketApp/Features/Sources/Login/LoginView.swift b/Solution/iOS/RocketApp/Features/Sources/Login/LoginView.swift new file mode 100644 index 00000000..95fc3151 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/Login/LoginView.swift @@ -0,0 +1,69 @@ +import ComposableArchitecture +import SwiftUI +import UIToolkit + +public struct LoginView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithViewStore(self.store) { viewStore in + VStack { + Text("Sorry, your credentials are not valid") + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.quanti) + .transition(.opacity) + .opacity(viewStore.isAuthFailedMessageDisplayed ? 1 : 0) + .accessibilityHidden(!viewStore.isAuthFailedMessageDisplayed) + .animation(.interpolatingSpring, value: viewStore.isAuthFailedMessageDisplayed) + + content + } + } + } + + var content: some View { + WithViewStore(self.store) { viewStore in + VStack { + Text("Login") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.top, 20) + .accessibilityIdentifier(AccessibilityKeys.Login.titleStaticText) + + Spacer() + + TextField("Username", text: viewStore.binding(\.$username)) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .accessibilityIdentifier(AccessibilityKeys.Login.usernameTextField) + + SecureField("Password", text: viewStore.binding(\.$password)) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier(AccessibilityKeys.Login.passwordSecureField) + Spacer() + + Button("Login") { + viewStore.send(.loginTapped) + } + .buttonStyle(QuantiButtonStyle()) + .accessibilityIdentifier(AccessibilityKeys.Login.loginButton) + } + .padding(.horizontal, 20) + } + } +} + +#Preview { + LoginView( + store: Store( + initialState: LoginCore.State(), + reducer: LoginCore() + ) + ) +} diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketDetail/AccessibilityKeys+RocketDetail.swift b/Solution/iOS/RocketApp/Features/Sources/RocketDetail/AccessibilityKeys+RocketDetail.swift new file mode 100644 index 00000000..e8d51c40 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/RocketDetail/AccessibilityKeys+RocketDetail.swift @@ -0,0 +1,8 @@ +import UIToolkit + +extension AccessibilityKeys { + public enum RocketDetail { + public static let titleStaticText = "titleStaticTextID" + public static let launchButton = "launchButtonID" + } +} diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailCore.swift b/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailCore.swift index 35df8396..21eaae8c 100644 --- a/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailCore.swift +++ b/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailCore.swift @@ -33,15 +33,15 @@ public struct RocketDetailCore: ReducerProtocol { case .setToUSMetrics: state.isUSMetrics.toggle() return .none - + case .rocketLaunch: return .none - + case .rocketLaunchDismiss: if state.rocketLaunch != nil { return .send(.rocketLaunch(.presented(.onDisappear))) } - + return .none } } diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailView.swift b/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailView.swift index d6ebd2b7..195d243e 100644 --- a/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailView.swift +++ b/Solution/iOS/RocketApp/Features/Sources/RocketDetail/RocketDetailView.swift @@ -57,6 +57,7 @@ public struct RocketDetailView: View { section(.overview) { Text(viewStore.rocketData.overview) .font(.body) + .accessibilityIdentifier(AccessibilityKeys.RocketDetail.titleStaticText) } .padding(.bottom) @@ -98,7 +99,12 @@ public struct RocketDetailView: View { .padding(.horizontal) .navigationTitle(viewStore.rocketData.name) .onAppear { viewStore.send(.rocketLaunchDismiss) } - .navigationBarItems(trailing: Button(.launch) { viewStore.send(.rocketLaunchTapped) }) + .navigationBarItems( + trailing: Button(.launch) { + viewStore.send(.rocketLaunchTapped) + } + .accessibilityIdentifier(AccessibilityKeys.RocketDetail.launchButton) + ) .navigationDestination( store: self.store.scope( state: \.$rocketLaunch, @@ -130,7 +136,7 @@ public struct RocketDetailView: View { } } - private func paramWindow(type: RocketDetail.RocketParameters, backgroundColor: Color = .pink) -> some View { + private func paramWindow(type: RocketDetail.RocketParameters, backgroundColor: Color = .quanti) -> some View { VStack(spacing: 4) { Text( type.detail( diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/AccessibilityKeys+RocketLaunch.swift b/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/AccessibilityKeys+RocketLaunch.swift new file mode 100644 index 00000000..74735fc7 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/AccessibilityKeys+RocketLaunch.swift @@ -0,0 +1,7 @@ +import UIToolkit + +extension AccessibilityKeys { + public enum RocketLaunch { + public static let titleStaticText = "titleStaticTextID" + } +} diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/RocketLaunchView.swift b/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/RocketLaunchView.swift index 797f7110..ef90bcd4 100644 --- a/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/RocketLaunchView.swift +++ b/Solution/iOS/RocketApp/Features/Sources/RocketLaunch/RocketLaunchView.swift @@ -216,6 +216,7 @@ public struct RocketLaunchView: View { .font(.headline) .bold() .foregroundColor(viewStore.textColor) + .accessibilityIdentifier(AccessibilityKeys.RocketLaunch.titleStaticText) } private var flash: some View { diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListCore.swift b/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListCore.swift index c3bd49bb..594e3e6b 100644 --- a/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListCore.swift +++ b/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListCore.swift @@ -10,7 +10,7 @@ public struct RocketListCore: ReducerProtocol { public struct State: Equatable { var loadingStatus: Loadable, RocketsClientAsyncError> = .notRequested @PresentationState var rocketDetail: RocketDetailCore.State? - + public init() {} } @@ -19,6 +19,7 @@ public struct RocketListCore: ReducerProtocol { case fetchData case dataFetched(TaskResult<[RocketDetail]>) case rocketDetail(PresentationAction) + case logoutTapped } public init() {} @@ -57,8 +58,8 @@ public struct RocketListCore: ReducerProtocol { case let .dataFetched(.failure(error)): state.loadingStatus = .failure(RocketsClientAsyncError(from: error)) return .none - - case .rocketDetail: + + case .rocketDetail, .logoutTapped: return .none } } diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListView.swift b/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListView.swift index 7e9b6e08..7a7011bc 100644 --- a/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListView.swift +++ b/Solution/iOS/RocketApp/Features/Sources/RocketList/RocketListView.swift @@ -26,15 +26,23 @@ public struct RocketListView: View { public var body: some View { NavigationStack { - Group { - switch viewStore.loadingStatus { - case let .success(data): - rocketsListView(rocketData: data) - case let .failure(error): - errorView(error: error) - default: - loadingView + VStack { + Group { + switch viewStore.loadingStatus { + case let .success(data): + rocketsListView(rocketData: data) + case let .failure(error): + errorView(error: error) + default: + loadingView + } } + + Spacer() + Button("Logout") { + viewStore.send(.logoutTapped) + } + .buttonStyle(QuantiButtonStyle()) } .navigationTitle(.rockets) } diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketListCell/AccessibilityKeys+RocketListCell.swift b/Solution/iOS/RocketApp/Features/Sources/RocketListCell/AccessibilityKeys+RocketListCell.swift new file mode 100644 index 00000000..60370232 --- /dev/null +++ b/Solution/iOS/RocketApp/Features/Sources/RocketListCell/AccessibilityKeys+RocketListCell.swift @@ -0,0 +1,7 @@ +import UIToolkit + +extension AccessibilityKeys { + public enum RocketListCell { + public static let arrowImage = "arrowImageID" + } +} diff --git a/Solution/iOS/RocketApp/Features/Sources/RocketListCell/RocketListCellView.swift b/Solution/iOS/RocketApp/Features/Sources/RocketListCell/RocketListCellView.swift index ed8b2361..3d601666 100644 --- a/Solution/iOS/RocketApp/Features/Sources/RocketListCell/RocketListCellView.swift +++ b/Solution/iOS/RocketApp/Features/Sources/RocketListCell/RocketListCellView.swift @@ -34,6 +34,7 @@ public struct RocketListCellView: View { Image.linkArrow .resizable() .frame(width: 32, height: 32) + .accessibilityIdentifier(AccessibilityKeys.RocketListCell.arrowImage) } } } diff --git a/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/AccessibilityKeys.swift b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/AccessibilityKeys.swift new file mode 100644 index 00000000..e66b3a9f --- /dev/null +++ b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/AccessibilityKeys.swift @@ -0,0 +1 @@ +public struct AccessibilityKeys {} diff --git a/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Components/QuantiButton.swift b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Components/QuantiButton.swift new file mode 100644 index 00000000..1097dd0a --- /dev/null +++ b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Components/QuantiButton.swift @@ -0,0 +1,15 @@ +import SwiftUI + +public struct QuantiButtonStyle: ButtonStyle { + + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity) + .padding() + .background(configuration.isPressed ? Color.quanti.opacity(0.3) : Color.quanti) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } +} diff --git a/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Resources/Assets.xcassets/quanti.colorset/Contents.json b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Resources/Assets.xcassets/quanti.colorset/Contents.json new file mode 100644 index 00000000..2dd2fb5c --- /dev/null +++ b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Resources/Assets.xcassets/quanti.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x87", + "green" : "0x51", + "red" : "0xF1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Resources/Color+quanti.swift b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Resources/Color+quanti.swift new file mode 100644 index 00000000..583ae48d --- /dev/null +++ b/Solution/iOS/RocketApp/Infrastructure/Sources/UIToolkit/Resources/Color+quanti.swift @@ -0,0 +1,5 @@ +import SwiftUI + +public extension Color { + static let quanti = Color("quanti", bundle: .module) +} diff --git a/Solution/iOS/RocketApp/RocketApp.swift b/Solution/iOS/RocketApp/RocketApp.swift index ab9c94a4..95100762 100644 --- a/Solution/iOS/RocketApp/RocketApp.swift +++ b/Solution/iOS/RocketApp/RocketApp.swift @@ -1,15 +1,15 @@ import ComposableArchitecture -import RocketList +import App import SwiftUI @main struct RocketApp: App { var body: some Scene { WindowGroup { - RocketListView( + AppView( store: Store( - initialState: RocketListCore.State(), - reducer: RocketListCore()._printChanges() + initialState: AppCore.State(), + reducer: AppCore() ) ) } diff --git a/Solution/iOS/RocketAppUITests/Screens/LoginScreen.swift b/Solution/iOS/RocketAppUITests/Screens/LoginScreen.swift new file mode 100644 index 00000000..640f45d4 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Screens/LoginScreen.swift @@ -0,0 +1,50 @@ +import Login +import RocketList +import UIToolkit +import XCTest + +struct LoginScreen: Screen { + let app: XCUIApplication + + private let title: XCUIElement + private let usernameField: XCUIElement + private let passwordField: XCUIElement + private let loginButton: XCUIElement + + init(app: XCUIApplication) { + self.app = app + title = app.staticTexts[AccessibilityKeys.Login.titleStaticText] + usernameField = app.textFields[AccessibilityKeys.Login.usernameTextField] + passwordField = app.secureTextFields[AccessibilityKeys.Login.passwordSecureField] + loginButton = app.buttons[AccessibilityKeys.Login.loginButton] + } + + @discardableResult + func checkLoginTitle() -> Self { + XCTAssert(title.waitForExistence(timeout: Timeouts.defaultTimeout)) + return self + } + + @discardableResult + func enter(username: String) -> Self { + XCTAssert(usernameField.waitForExistence(timeout: Timeouts.defaultTimeout)) + usernameField.tap() + usernameField.typeText(username) + return self + } + + @discardableResult + func enter(password: String) -> Self { + XCTAssert(passwordField.waitForExistence(timeout: Timeouts.defaultTimeout)) + passwordField.tap() + passwordField.typeText(password) + return self + } + + @discardableResult + func tapLoginButton() -> Self { + XCTAssert(loginButton.waitForExistence(timeout: Timeouts.defaultTimeout)) + loginButton.tap() + return self + } +} diff --git a/Solution/iOS/RocketAppUITests/Screens/RocketDetailScreen.swift b/Solution/iOS/RocketAppUITests/Screens/RocketDetailScreen.swift new file mode 100644 index 00000000..65370577 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Screens/RocketDetailScreen.swift @@ -0,0 +1,30 @@ +import Login +import RocketList +import UIToolkit +import XCTest + +struct RocketDetailScreen: Screen { + let app: XCUIApplication + + private let title: XCUIElement + private let launchButton: XCUIElement + + init(app: XCUIApplication) { + self.app = app + title = app.staticTexts[AccessibilityKeys.RocketDetail.titleStaticText] + launchButton = app.navigationBars.buttons[AccessibilityKeys.RocketDetail.launchButton] + } + + @discardableResult + func checkRocketTitle() -> Self { + XCTAssert(title.waitForExistence(timeout: Timeouts.defaultTimeout)) + return self + } + + @discardableResult + func tapLaunchButton() -> Self { + XCTAssert(launchButton.waitForExistence(timeout: Timeouts.defaultTimeout)) + launchButton.tap() + return self + } +} diff --git a/Solution/iOS/RocketAppUITests/Screens/RocketLaunchScreen.swift b/Solution/iOS/RocketAppUITests/Screens/RocketLaunchScreen.swift new file mode 100644 index 00000000..3228a2f8 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Screens/RocketLaunchScreen.swift @@ -0,0 +1,22 @@ +import Login +import RocketList +import UIToolkit +import XCTest + +struct RocketLaunchScreen: Screen { + let app: XCUIApplication + + private let title: XCUIElement + + init(app: XCUIApplication) { + self.app = app + + title = app.staticTexts[AccessibilityKeys.RocketLaunch.titleStaticText] + } + + @discardableResult + func checkRocketLaunchText() -> Self { + XCTAssert(title.waitForExistence(timeout: Timeouts.defaultTimeout)) + return self + } +} diff --git a/Solution/iOS/RocketAppUITests/Screens/RocketListScreen.swift b/Solution/iOS/RocketAppUITests/Screens/RocketListScreen.swift new file mode 100644 index 00000000..849f57d5 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Screens/RocketListScreen.swift @@ -0,0 +1,24 @@ +import Login +import RocketList +import UIToolkit +import XCTest + +struct RocketListScreen: Screen { + let app: XCUIApplication + + private let arrowImages: XCUIElementQuery + + init(app: XCUIApplication) { + self.app = app + + arrowImages = app.images.matching(identifier: AccessibilityKeys.RocketListCell.arrowImage) + } + + @discardableResult + func goToRocketDetail(index: Int) -> Self { + let rocket = arrowImages.element(boundBy: index) + XCTAssert(rocket.waitForExistence(timeout: Timeouts.defaultTimeout)) + rocket.tap() + return self + } +} diff --git a/Solution/iOS/RocketAppUITests/Tests/RocketLaunch.swift b/Solution/iOS/RocketAppUITests/Tests/RocketLaunch.swift new file mode 100644 index 00000000..f67f7cef --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Tests/RocketLaunch.swift @@ -0,0 +1,18 @@ +import XCTest + +final class RocketLaunch: BaseTestCase { + func testRocketLaunch() { + LoginScreen(app: app) + .checkLoginTitle() + .enter(username: TestConstants.LoginCredentials.username) + .enter(password: TestConstants.LoginCredentials.password) + .tapLoginButton() + RocketListScreen(app: app) + .goToRocketDetail(index: 0) + RocketDetailScreen(app: app) + .checkRocketTitle() + .tapLaunchButton() + RocketLaunchScreen(app: app) + .checkRocketLaunchText() + } +} diff --git a/Solution/iOS/RocketAppUITests/Tests/Test.swift b/Solution/iOS/RocketAppUITests/Tests/Test.swift new file mode 100644 index 00000000..ed4eafc4 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Tests/Test.swift @@ -0,0 +1,8 @@ +import XCTest + +final class Test: BaseTestCase { + func testTest() { + LoginScreen(app: app) + .checkLoginTitle() + } +} diff --git a/Solution/iOS/RocketAppUITests/Utils/BaseScreen.swift b/Solution/iOS/RocketAppUITests/Utils/BaseScreen.swift new file mode 100644 index 00000000..b4ba4411 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Utils/BaseScreen.swift @@ -0,0 +1,5 @@ +import XCTest + +protocol Screen { + var app: XCUIApplication { get } +} diff --git a/Solution/iOS/RocketAppUITests/Utils/BaseTestCase.swift b/Solution/iOS/RocketAppUITests/Utils/BaseTestCase.swift new file mode 100644 index 00000000..902659b4 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Utils/BaseTestCase.swift @@ -0,0 +1,18 @@ +import XCTest + +class BaseTestCase: XCTestCase { + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + + app = XCUIApplication() + app.launchArguments = ["UITestMode", "testsFreshRun"] + app.launch() + } + + override func tearDown() { + super.tearDown() + } +} diff --git a/Solution/iOS/RocketAppUITests/Utils/TestConstants.swift b/Solution/iOS/RocketAppUITests/Utils/TestConstants.swift new file mode 100644 index 00000000..c5560582 --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Utils/TestConstants.swift @@ -0,0 +1,8 @@ +import Foundation + +enum TestConstants { + enum LoginCredentials { + static let username = "astronaut1" + static let password = "space" + } +} diff --git a/Solution/iOS/RocketAppUITests/Utils/Timeouts.swift b/Solution/iOS/RocketAppUITests/Utils/Timeouts.swift new file mode 100644 index 00000000..d339a27c --- /dev/null +++ b/Solution/iOS/RocketAppUITests/Utils/Timeouts.swift @@ -0,0 +1,5 @@ +import XCTest + +enum Timeouts { + static let defaultTimeout: Double = 5 +}