diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index 12df63f..c20eb12 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0D185819272723FF0046B928 /* BlueChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D185818272723FF0046B928 /* BlueChip.swift */; }; + 0D185819272723FF0046B928 /* ColoredChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D185818272723FF0046B928 /* ColoredChip.swift */; }; 0D18581B272728D60046B928 /* PhraseChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D18581A272728D60046B928 /* PhraseChip.swift */; }; 0D1922F226BDE29300052649 /* ZcashSDKStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */; }; 0D1922F826BDEB3500052649 /* MockServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D1922F726BDEB3500052649 /* MockServices.swift */; }; @@ -16,6 +16,7 @@ 0D354A0926D5A9D000315F45 /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D354A0626D5A9D000315F45 /* Services.swift */; }; 0D354A0A26D5A9D000315F45 /* KeyStoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D354A0726D5A9D000315F45 /* KeyStoring.swift */; }; 0D354A0B26D5A9D000315F45 /* MnemonicSeedPhraseHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D354A0826D5A9D000315F45 /* MnemonicSeedPhraseHandling.swift */; }; + 0D35CC46277A36E00074316A /* ScrollableWhenScaled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D35CC45277A36E00074316A /* ScrollableWhenScaled.swift */; }; 0D3D04082728B3440032ABC1 /* RecoveryPhraseDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3D04072728B3440032ABC1 /* RecoveryPhraseDisplayView.swift */; }; 0D3D040A2728B3A10032ABC1 /* RecoveryPhraseDisplayStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3D04092728B3A10032ABC1 /* RecoveryPhraseDisplayStore.swift */; }; 0D4E7A0926B364170058B01E /* SecantApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D4E7A0826B364170058B01E /* SecantApp.swift */; }; @@ -28,6 +29,8 @@ 0D535FDF271F4214009A9E3E /* Rubik-VariableFont_wght.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0D535FDD271F4214009A9E3E /* Rubik-VariableFont_wght.ttf */; }; 0D535FE2271F9476009A9E3E /* EnumeratedChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D535FE1271F9476009A9E3E /* EnumeratedChip.swift */; }; 0D5D16F526E24CCF00AD33D1 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D5D16F426E24CCF00AD33D1 /* AppError.swift */; }; + 0D6D628B276A528E002FB4CC /* DropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D6D628A276A528D002FB4CC /* DropDelegate.swift */; }; + 0D7CE63427349B5D0020E050 /* View+WhenDraggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D7CE63327349B5D0020E050 /* View+WhenDraggable.swift */; }; 0D7DF08C271DCC0E00530046 /* ScreenBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D7DF08B271DCC0E00530046 /* ScreenBackground.swift */; }; 0D8A43C4272AEEDE005A6414 /* SecantTextStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D8A43C3272AEEDE005A6414 /* SecantTextStyles.swift */; }; 0D8A43C6272B129C005A6414 /* WordChipGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D8A43C5272B129C005A6414 /* WordChipGrid.swift */; }; @@ -47,9 +50,14 @@ 0DACFA9A27209FA70039EEA5 /* Roboto-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0DACFA8D27209FA70039EEA5 /* Roboto-Light.ttf */; }; 0DACFA9C27209FA70039EEA5 /* Roboto-ThinItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0DACFA8F27209FA70039EEA5 /* Roboto-ThinItalic.ttf */; }; 0DB8AA81271DC7520035BC9D /* DesignGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB8AA80271DC7520035BC9D /* DesignGuide.swift */; }; + 0DC487C32772574C00BE6A63 /* ValidationSucceededView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DC487C22772574C00BE6A63 /* ValidationSucceededView.swift */; }; + 0DDB6A5127737D4A0012A410 /* ValidationFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DDB6A5027737D4A0012A410 /* ValidationFailedView.swift */; }; 0DF2DC51272344E400FA31E2 /* EmptyChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF2DC50272344E400FA31E2 /* EmptyChip.swift */; }; 0DF2DC5427235E3E00FA31E2 /* View+InnerShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF2DC5327235E3E00FA31E2 /* View+InnerShadow.swift */; }; - 0DFE93DF272C6D4B000FCCA5 /* RecoveryFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFE93DE272C6D4B000FCCA5 /* RecoveryFlowTests.swift */; }; + 0DFE93DF272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFE93DE272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift */; }; + 0DFE93E1272C9ECB000FCCA5 /* RecoveryPhraseBackupValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFE93E0272C9ECB000FCCA5 /* RecoveryPhraseBackupValidationView.swift */; }; + 0DFE93E3272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFE93E2272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift */; }; + 0DFE93E6272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFE93E5272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift */; }; 2E58E73B274679F000B2B84B /* OnboardingHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58E73A274679F000B2B84B /* OnboardingHeaderView.swift */; }; 2EA11F5B27467EF800709571 /* OnboardingFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA11F5A27467EF800709571 /* OnboardingFooterView.swift */; }; 2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA11F5C27467F7700709571 /* OnboardingContentView.swift */; }; @@ -118,7 +126,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 0D185818272723FF0046B928 /* BlueChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueChip.swift; sourceTree = ""; }; + 0D185818272723FF0046B928 /* ColoredChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColoredChip.swift; sourceTree = ""; }; 0D18581A272728D60046B928 /* PhraseChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhraseChip.swift; sourceTree = ""; }; 0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZcashSDKStubs.swift; sourceTree = ""; }; 0D1922F726BDEB3500052649 /* MockServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServices.swift; sourceTree = ""; }; @@ -127,6 +135,7 @@ 0D354A0626D5A9D000315F45 /* Services.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; }; 0D354A0726D5A9D000315F45 /* KeyStoring.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyStoring.swift; sourceTree = ""; }; 0D354A0826D5A9D000315F45 /* MnemonicSeedPhraseHandling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicSeedPhraseHandling.swift; sourceTree = ""; }; + 0D35CC45277A36E00074316A /* ScrollableWhenScaled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableWhenScaled.swift; sourceTree = ""; }; 0D3D04072728B3440032ABC1 /* RecoveryPhraseDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseDisplayView.swift; sourceTree = ""; }; 0D3D04092728B3A10032ABC1 /* RecoveryPhraseDisplayStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseDisplayStore.swift; sourceTree = ""; }; 0D4E7A0526B364170058B01E /* secant-testnet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "secant-testnet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -145,6 +154,8 @@ 0D535FDD271F4214009A9E3E /* Rubik-VariableFont_wght.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Rubik-VariableFont_wght.ttf"; sourceTree = ""; }; 0D535FE1271F9476009A9E3E /* EnumeratedChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumeratedChip.swift; sourceTree = ""; }; 0D5D16F426E24CCF00AD33D1 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + 0D6D628A276A528D002FB4CC /* DropDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDelegate.swift; sourceTree = ""; }; + 0D7CE63327349B5D0020E050 /* View+WhenDraggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+WhenDraggable.swift"; sourceTree = ""; }; 0D7DF08B271DCC0E00530046 /* ScreenBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenBackground.swift; sourceTree = ""; }; 0D8A43C3272AEEDE005A6414 /* SecantTextStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecantTextStyles.swift; sourceTree = ""; }; 0D8A43C5272B129C005A6414 /* WordChipGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordChipGrid.swift; sourceTree = ""; }; @@ -164,9 +175,14 @@ 0DACFA8D27209FA70039EEA5 /* Roboto-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Light.ttf"; sourceTree = ""; }; 0DACFA8F27209FA70039EEA5 /* Roboto-ThinItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-ThinItalic.ttf"; sourceTree = ""; }; 0DB8AA80271DC7520035BC9D /* DesignGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignGuide.swift; sourceTree = ""; }; + 0DC487C22772574C00BE6A63 /* ValidationSucceededView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidationSucceededView.swift; sourceTree = ""; }; + 0DDB6A5027737D4A0012A410 /* ValidationFailedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationFailedView.swift; sourceTree = ""; }; 0DF2DC50272344E400FA31E2 /* EmptyChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyChip.swift; sourceTree = ""; }; 0DF2DC5327235E3E00FA31E2 /* View+InnerShadow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+InnerShadow.swift"; sourceTree = ""; }; - 0DFE93DE272C6D4B000FCCA5 /* RecoveryFlowTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecoveryFlowTests.swift; sourceTree = ""; }; + 0DFE93DE272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseBackupTests.swift; sourceTree = ""; }; + 0DFE93E0272C9ECB000FCCA5 /* RecoveryPhraseBackupValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseBackupValidationView.swift; sourceTree = ""; }; + 0DFE93E2272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseValidation.swift; sourceTree = ""; }; + 0DFE93E5272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseValidationTests.swift; sourceTree = ""; }; 2E58E73A274679F000B2B84B /* OnboardingHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHeaderView.swift; sourceTree = ""; }; 2E5C03802738C570008BFFD3 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; 2EA11F5A27467EF800709571 /* OnboardingFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFooterView.swift; sourceTree = ""; }; @@ -182,7 +198,6 @@ 6654C7402715A47300901167 /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; 6654C7432715A4AC00901167 /* OnboardingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStoreTests.swift; sourceTree = ""; }; 665C963E272C26E600BC04FB /* CircularFrameBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularFrameBackground.swift; sourceTree = ""; }; - 66779071273AAC26003A1540 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; 669FDAE8272C23B3007B9422 /* CircularFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularFrame.swift; sourceTree = ""; }; 669FDAEA272C23C2007B9422 /* CircularFrameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularFrameBadge.swift; sourceTree = ""; }; 66A0807A271993C500118B79 /* OnboardingProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProgressIndicator.swift; sourceTree = ""; }; @@ -283,8 +298,11 @@ 0D3D04052728B2D70032ABC1 /* BackupFlow */ = { isa = PBXGroup; children = ( + 0D6D628A276A528D002FB4CC /* DropDelegate.swift */, 0D3D04062728B2EC0032ABC1 /* Views */, 0D3D04092728B3A10032ABC1 /* RecoveryPhraseDisplayStore.swift */, + 0DFE93E2272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift */, + 0D7CE63327349B5D0020E050 /* View+WhenDraggable.swift */, ); path = BackupFlow; sourceTree = ""; @@ -292,8 +310,11 @@ 0D3D04062728B2EC0032ABC1 /* Views */ = { isa = PBXGroup; children = ( + 0DC487C22772574C00BE6A63 /* ValidationSucceededView.swift */, 0D3D04072728B3440032ABC1 /* RecoveryPhraseDisplayView.swift */, 0D8A43C5272B129C005A6414 /* WordChipGrid.swift */, + 0DFE93E0272C9ECB000FCCA5 /* RecoveryPhraseBackupValidationView.swift */, + 0DDB6A5027737D4A0012A410 /* ValidationFailedView.swift */, ); path = Views; sourceTree = ""; @@ -305,7 +326,6 @@ 0D4E7A1926B364180058B01E /* secantTests */, 0D4E7A2426B364180058B01E /* secantUITests */, 0D4E7A0626B364170058B01E /* Products */, - 2EB660DF2747EA6000A06A07 /* Recovered References */, ); sourceTree = ""; }; @@ -353,6 +373,7 @@ 0D4E7A1926B364180058B01E /* secantTests */ = { isa = PBXGroup; children = ( + 0DFE93E4272CB6D0000FCCA5 /* RecoveryPhraseValidationTests */, 0DFE93DD272C6D4B000FCCA5 /* BackupFlowTests */, 6654C7422715A48E00901167 /* OnboardingTests */, 0D4E7A1A26B364180058B01E /* secantTests.swift */, @@ -384,7 +405,7 @@ children = ( 0D535FE1271F9476009A9E3E /* EnumeratedChip.swift */, 0DF2DC50272344E400FA31E2 /* EmptyChip.swift */, - 0D185818272723FF0046B928 /* BlueChip.swift */, + 0D185818272723FF0046B928 /* ColoredChip.swift */, 0D18581A272728D60046B928 /* PhraseChip.swift */, ); path = Chips; @@ -439,6 +460,7 @@ F9C165B3274031F600592F76 /* Bindings.swift */, F9EEB8152742C2210032EEB8 /* WithStateBinding.swift */, F93673D52742CB840099C6AF /* Previews.swift */, + 0D35CC45277A36E00074316A /* ScrollableWhenScaled.swift */, ); path = Util; sourceTree = ""; @@ -473,12 +495,20 @@ 0DFE93DD272C6D4B000FCCA5 /* BackupFlowTests */ = { isa = PBXGroup; children = ( - 0DFE93DE272C6D4B000FCCA5 /* RecoveryFlowTests.swift */, 0D1C1AA227611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift */, + 0DFE93DE272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift */, ); path = BackupFlowTests; sourceTree = ""; }; + 0DFE93E4272CB6D0000FCCA5 /* RecoveryPhraseValidationTests */ = { + isa = PBXGroup; + children = ( + 0DFE93E5272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift */, + ); + path = RecoveryPhraseValidationTests; + sourceTree = ""; + }; 2E5C037F2738C55F008BFFD3 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -490,14 +520,6 @@ path = Onboarding; sourceTree = ""; }; - 2EB660DF2747EA6000A06A07 /* Recovered References */ = { - isa = PBXGroup; - children = ( - 66779071273AAC26003A1540 /* OnboardingScreen.swift */, - ); - name = "Recovered References"; - sourceTree = ""; - }; 660558F4270C85F7009D6954 /* Generated */ = { isa = PBXGroup; children = ( @@ -814,7 +836,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1250; - LastUpgradeCheck = 1250; + LastUpgradeCheck = 1320; TargetAttributes = { 0D4E7A0426B364170058B01E = { CreatedOnToolsVersion = 12.5; @@ -945,16 +967,21 @@ files = ( 2EB660E02747EAB900A06A07 /* OnboardingScreen.swift in Sources */, 660558F8270C862F009D6954 /* XCAssets+Generated.swift in Sources */, + 0D35CC46277A36E00074316A /* ScrollableWhenScaled.swift in Sources */, F96B41E9273B501F0021B49A /* TransactionHistoryView.swift in Sources */, 669FDAE9272C23B3007B9422 /* CircularFrame.swift in Sources */, F96B41E8273B501F0021B49A /* TransactionDetailView.swift in Sources */, 663FABA2271D876C00E495F8 /* SecondaryButton.swift in Sources */, + 0DC487C32772574C00BE6A63 /* ValidationSucceededView.swift in Sources */, 0D8A43C4272AEEDE005A6414 /* SecantTextStyles.swift in Sources */, 0D1922F226BDE29300052649 /* ZcashSDKStubs.swift in Sources */, 0DACFA7F27208CE00039EEA5 /* Clamped.swift in Sources */, + 0DFE93E3272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift in Sources */, 0D354A0B26D5A9D000315F45 /* MnemonicSeedPhraseHandling.swift in Sources */, 0D535FE2271F9476009A9E3E /* EnumeratedChip.swift in Sources */, 6654C73E2715A41300901167 /* OnboardingStore.swift in Sources */, + 0DDB6A5127737D4A0012A410 /* ValidationFailedView.swift in Sources */, + 0D6D628B276A528E002FB4CC /* DropDelegate.swift in Sources */, F9971A5327680DD000A2DB75 /* Profile.swift in Sources */, F93874F0273C4DE200F0E875 /* HomeStore.swift in Sources */, 669FDAEB272C23C2007B9422 /* CircularFrameBadge.swift in Sources */, @@ -968,6 +995,7 @@ F9971A4D27680DC400A2DB75 /* App.swift in Sources */, F9322DC0273B555C00C105B5 /* NavigationLinks.swift in Sources */, F93874F1273C4DE200F0E875 /* HomeView.swift in Sources */, + 0D7CE63427349B5D0020E050 /* View+WhenDraggable.swift in Sources */, 0D3D04082728B3440032ABC1 /* RecoveryPhraseDisplayView.swift in Sources */, F9971A5F27680DF600A2DB75 /* ScanView.swift in Sources */, F9971A4E27680DC400A2DB75 /* AppView.swift in Sources */, @@ -991,7 +1019,7 @@ F9C165C02740403600592F76 /* ApproveView.swift in Sources */, 0DF2DC5427235E3E00FA31E2 /* View+InnerShadow.swift in Sources */, F9971A6B27680E1000A2DB75 /* WalletInfo.swift in Sources */, - 0D185819272723FF0046B928 /* BlueChip.swift in Sources */, + 0D185819272723FF0046B928 /* ColoredChip.swift in Sources */, 2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */, 2E58E73B274679F000B2B84B /* OnboardingHeaderView.swift in Sources */, 0D8A43C6272B129C005A6414 /* WordChipGrid.swift in Sources */, @@ -1005,6 +1033,7 @@ 0D354A0A26D5A9D000315F45 /* KeyStoring.swift in Sources */, F9971A5427680DD000A2DB75 /* ProfileView.swift in Sources */, F9971A6027680DF600A2DB75 /* Scan.swift in Sources */, + 0DFE93E1272C9ECB000FCCA5 /* RecoveryPhraseBackupValidationView.swift in Sources */, F9C165CB2741AB5D00592F76 /* SendView.swift in Sources */, F9971A6527680DFE00A2DB75 /* Settings.swift in Sources */, 6654C7412715A47300901167 /* Onboarding.swift in Sources */, @@ -1017,10 +1046,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0DFE93DF272C6D4B000FCCA5 /* RecoveryFlowTests.swift in Sources */, + 0DFE93DF272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift in Sources */, 6654C7442715A4AC00901167 /* OnboardingStoreTests.swift in Sources */, 0D1C1AA327611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift in Sources */, 0D4E7A1B26B364180058B01E /* secantTests.swift in Sources */, + 0DFE93E6272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1170,7 +1200,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = "\\"; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"secant/Preview Content\""; DEVELOPMENT_TEAM = RLPRR8CPQG; ENABLE_PREVIEWS = YES; @@ -1194,7 +1224,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = "\\"; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"secant/Preview Content\""; DEVELOPMENT_TEAM = RLPRR8CPQG; ENABLE_PREVIEWS = YES; diff --git a/secant.xcodeproj/xcshareddata/xcschemes/secant-testnet.xcscheme b/secant.xcodeproj/xcshareddata/xcschemes/secant-testnet.xcscheme index 073fba0..7360b61 100644 --- a/secant.xcodeproj/xcshareddata/xcschemes/secant-testnet.xcscheme +++ b/secant.xcodeproj/xcshareddata/xcschemes/secant-testnet.xcscheme @@ -1,6 +1,6 @@ Void)? + + func validateDrop(info: DropInfo) -> Bool { + return info.hasItemsConforming(to: [PhraseChip.validationWordTypeIdentifier]) + } + + func performDrop(info: DropInfo) -> Bool { + if let item = info.itemProviders(for: [PhraseChip.validationWordTypeIdentifier]).first { + item.loadItem(forTypeIdentifier: PhraseChip.validationWordTypeIdentifier, options: nil) { text, _ in + DispatchQueue.main.async { + if let data = text as? Data { + // Extract string from data + + let word = String(decoding: data, as: UTF8.self) + dropAction?(.unassigned(word: word as String)) + } + } + } + return true + } + return false + } +} + +extension RecoveryPhraseValidationState { + func groupCompleted(index: Int) -> Bool { + validationWords.first(where: { $0.groupIndex == index }) != nil + } +} diff --git a/secant/Features/BackupFlow/RecoveryPhraseDisplayStore.swift b/secant/Features/BackupFlow/RecoveryPhraseDisplayStore.swift index 99e6b0b..bdba941 100644 --- a/secant/Features/BackupFlow/RecoveryPhraseDisplayStore.swift +++ b/secant/Features/BackupFlow/RecoveryPhraseDisplayStore.swift @@ -50,13 +50,13 @@ extension BackupPhraseEnvironment { static let demo = Self( mainQueue: DispatchQueue.main.eraseToAnyScheduler(), - newPhrase: { Effect(value: .init(words: RecoveryPhrase.demo.words)) }, + newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) }, pasteboard: .test ) static let live = Self( mainQueue: DispatchQueue.main.eraseToAnyScheduler(), - newPhrase: { Effect(value: .init(words: RecoveryPhrase.demo.words)) }, + newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) }, pasteboard: .live ) } @@ -64,25 +64,33 @@ extension BackupPhraseEnvironment { typealias RecoveryPhraseDisplayStore = Store struct RecoveryPhrase: Equatable { - struct Chunk: Hashable { + struct Group: Hashable { var startIndex: Int var words: [String] } let words: [String] - private let chunkSize = 6 + private let groupSize = 6 - func toChunks() -> [Chunk] { - let chunks = words.count / chunkSize - return zip(0 ..< chunks, words.chunked(into: chunkSize)).map { - Chunk(startIndex: $0 * chunkSize + 1, words: $1) + func toGroups() -> [Group] { + let chunks = words.count / groupSize + return zip(0 ..< chunks, words.chunked(into: groupSize)).map { + Group(startIndex: $0 * groupSize + 1, words: $1) } } func toString() -> String { words.joined(separator: " ") } + + func words(fromMissingIndices indices: [Int]) -> [PhraseChip.Kind] { + assert((indices.count - 1) * groupSize <= self.words.count) + + return indices.enumerated().map { index, position in + .unassigned(word: self.words[(index * groupSize) + position]) + } + } } struct RecoveryPhraseDisplayState: Equatable { diff --git a/secant/Features/BackupFlow/RecoveryPhraseValidation.swift b/secant/Features/BackupFlow/RecoveryPhraseValidation.swift new file mode 100644 index 0000000..c5b8a48 --- /dev/null +++ b/secant/Features/BackupFlow/RecoveryPhraseValidation.swift @@ -0,0 +1,182 @@ +// +// RecoveryPhraseValidation.swift +// secant-testnet +// +// Created by Francisco Gindre on 10/29/21. +// + +import Foundation +import ComposableArchitecture +import SwiftUI + +typealias RecoveryPhraseValidationStore = Store +typealias RecoveryPhraseValidationViewStore = ViewStore + +/// Represents the data of a word that has been placed into an empty position, that will be used +/// to validate the completed phrase when all ValidationWords have been placed. +struct ValidationWord: Equatable { + var groupIndex: Int + var word: String +} + +struct RecoveryPhraseValidationState: Equatable { + enum Route: Equatable, CaseIterable { + case success + case failure + } + + static let wordGroupSize = 6 + static let phraseChunks = 4 + + var phrase: RecoveryPhrase + var missingIndices: [Int] + var missingWordChips: [PhraseChip.Kind] + var validationWords: [ValidationWord] + var route: Route? + + var isComplete: Bool { + !validationWords.isEmpty && validationWords.count == missingIndices.count + } + + var isValid: Bool { + guard let resultingPhrase = self.resultingPhrase else { return false } + return resultingPhrase == phrase.words + } +} + +extension RecoveryPhraseValidationViewStore { + func bindingForRoute(_ route: RecoveryPhraseValidationState.Route) -> Binding { + self.binding( + get: { $0.route == route }, + send: { isActive in + return .updateRoute(isActive ? route : nil) + } + ) + } +} + +extension RecoveryPhraseValidationState { + /// creates an initial `RecoveryPhraseValidationState` with no completions and random missing indices. + /// - Note: Use this function to create a random validation puzzle for a given phrase. + static func random(phrase: RecoveryPhrase) -> Self { + let missingIndices = Self.randomIndices() + let missingWordChipKind = phrase.words(fromMissingIndices: missingIndices) + + return RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: missingWordChipKind, + validationWords: [] + ) + } +} + +extension RecoveryPhraseValidationState { + /// Given an array of RecoveryPhraseStepCompletion, missing indices, original phrase and the number of groups it was split into, + /// assembly the resulting phrase. This comes up with the "proposed solution" for the recovery phrase validation challenge. + /// - returns:an array of String containing the recovery phrase words ordered by the original phrase order, or `nil` + /// if a resulting phrase can't be formed becasue the validation state is not complete. + var resultingPhrase: [String]? { + guard missingIndices.count == validationWords.count else { return nil } + + guard validationWords.count == Self.phraseChunks else { return nil } + + var words = phrase.words + let groupLength = words.count / Self.phraseChunks + // iterate based on the completions the user did on the UI + for validationWord in validationWords { + // figure out which phrase group (chunk) this completion belongs to + let groupIndex = validationWord.groupIndex + + // validate that's the right number + assert(groupIndex < Self.phraseChunks) + + // get the missing index that the user did this completion for on the given group + let missingIndex = missingIndices[groupIndex] + + // figure out what this means in terms of the whole recovery phrase + let concreteIndex = groupIndex * groupLength + missingIndex + + assert(concreteIndex < words.count) + + // replace the word on the copy of the original phrase with the completion the user did + words[concreteIndex] = validationWord.word + } + + return words + } + + static func randomIndices() -> [Int] { + return (0.. [String] { + assert(missingIndex >= 0) + assert(missingIndex < self.words.count) + + var wordsApplyingMissing = self.words + + wordsApplyingMissing[missingIndex] = "" + + return wordsApplyingMissing + } +} + +enum RecoveryPhraseValidationAction: Equatable { + case updateRoute(RecoveryPhraseValidationState.Route?) + case reset + case move(wordChip: PhraseChip.Kind, intoGroup: Int) + case succeed + case fail + case proceedToHome + case displayBackedUpPhrase +} + +typealias RecoveryPhraseValidationReducer = Reducer + +extension RecoveryPhraseValidationReducer { + static let `default` = RecoveryPhraseValidationReducer { state, action, environment in + switch action { + case .reset: + state = RecoveryPhraseValidationState.random(phrase: state.phrase) + + case let .move(wordChip, group): + guard + case let PhraseChip.Kind.unassigned(word) = wordChip, + let missingChipIndex = state.missingWordChips.firstIndex(of: wordChip) + else { return .none } + + state.missingWordChips[missingChipIndex] = .empty + state.validationWords.append(ValidationWord(groupIndex: group, word: word)) + + if state.isComplete { + let value: RecoveryPhraseValidationAction = state.isValid ? .succeed : .fail + return Effect(value: value) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + } + return .none + + case .succeed: + state.route = .success + + case .fail: + state.route = .failure + + case .updateRoute(let route): + state.route = route + + case .proceedToHome: + break + + case .displayBackedUpPhrase: + break + } + return .none + } +} diff --git a/secant/Features/BackupFlow/View+WhenDraggable.swift b/secant/Features/BackupFlow/View+WhenDraggable.swift new file mode 100644 index 0000000..fa81c19 --- /dev/null +++ b/secant/Features/BackupFlow/View+WhenDraggable.swift @@ -0,0 +1,46 @@ +// +// PhraseChip+WhenDraggable.swift +// secant-testnet +// +// Created by Francisco Gindre on 11/4/21. +// + +import Foundation +import SwiftUI +import ComposableArchitecture + +extension PhraseChip { + static let validationWordTypeIdentifier = "public.text" + + /// Makes a PhraseChip draggable when it is of kind .unassigned + @ViewBuilder func makeDraggable() -> some View { + switch self.kind { + case .unassigned(let word): + self.onDrag { + NSItemProvider(object: word as NSString) + } + default: + self + } + } +} + +extension View { + /// Makes a View accept drop types Self.validationWordTypeIdentifier when it is of kind .empty + func whenIsDroppable(_ isDroppable: Bool, dropDelegate: DropDelegate) -> some View { + self.modifier(MakeDroppableModifier(isDroppable: isDroppable, drop: dropDelegate)) + } +} + +struct MakeDroppableModifier: ViewModifier { + var isDroppable: Bool + var drop: DropDelegate + + func body(content: Content) -> some View { + if isDroppable { + content.onDrop(of: [PhraseChip.validationWordTypeIdentifier], delegate: drop) + } else { + content + } + } +} diff --git a/secant/Features/BackupFlow/Views/RecoveryPhraseBackupValidationView.swift b/secant/Features/BackupFlow/Views/RecoveryPhraseBackupValidationView.swift new file mode 100644 index 0000000..ea07ce1 --- /dev/null +++ b/secant/Features/BackupFlow/Views/RecoveryPhraseBackupValidationView.swift @@ -0,0 +1,283 @@ +// +// RecoveryPhraseBackupView.swift +// secant-testnet +// +// Created by Francisco Gindre on 10/29/21. +// + +import SwiftUI +import ComposableArchitecture + +struct RecoveryPhraseBackupValidationView: View { + let store: RecoveryPhraseValidationStore + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(alignment: .center) { + header(for: viewStore) + .padding(.horizontal) + .padding(.bottom, 10) + + ZStack { + Asset.Colors.BackgroundColors.phraseGridDarkGray.color + .edgesIgnoringSafeArea(.bottom) + + VStack(alignment: .center, spacing: 35) { + let state = viewStore.state + let groups = state.phrase.toGroups() + + ForEach(Array(zip(groups.indices, groups)), id: \.0) { index, group in + WordChipGrid( + state: state, + groupIndex: index, + wordGroup: group, + misingIndex: index + ) + .frame(alignment: .center) + .background(Asset.Colors.BackgroundColors.phraseGridDarkGray.color) + .whenIsDroppable( + !state.groupCompleted(index: index), + dropDelegate: WordChipDropDelegate { chipKind in + viewStore.send(.move(wordChip: chipKind, intoGroup: index)) + } + ) + } + + Spacer() + } + .padding() + .padding(.top, 0) + .navigationLinkEmpty( + isActive: viewStore.bindingForRoute(.success), + destination: { ValidationSucceededView(store: store) } + ) + .navigationLinkEmpty( + isActive: viewStore.bindingForRoute(.failure), + destination: { ValidationFailedView(store: store) } + ) + } + .frame(alignment: .top) + } + .applyScreenBackground() + .scrollableWhenScaledUp() + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(Text("Verify Your Backup")) + } + } + + @ViewBuilder func header(for viewStore: RecoveryPhraseValidationViewStore) -> some View { + VStack { + if viewStore.isComplete { + completeHeader(for: viewStore.state) + } else { + Text("Drag the words below to match your backed-up copy.") + .bodyText() + } + + viewStore.state.missingWordGrid() + } + .padding(.horizontal, 30) + } + + @ViewBuilder func completeHeader(for state: RecoveryPhraseValidationState) -> some View { + if state.isValid { + Text("Congratulations! You validated your secret recovery phrase.") + .bodyText() + } else { + Text("Your placed words did not match your secret recovery phrase") + .bodyText() + } + } +} + +private extension RecoveryPhraseValidationState { + @ViewBuilder func missingWordGrid() -> some View { + let columns = Array( + repeating: GridItem(.flexible(minimum: 100, maximum: 120), spacing: 20), + count: 2 + ) + + LazyVGrid(columns: columns, alignment: .center, spacing: 20) { + ForEach(0.. [PhraseChip.Kind] { + let validationWord = validationWords.first(where: { $0.groupIndex == groupIndex }) + + return wordGroup.words.enumerated().map { index, word in + guard index == missingIndices[groupIndex] else { + return .ordered(position: (groupSize * groupIndex) + index + 1, word: word) + } + + if let completedWord = validationWord?.word { + return .unassigned(word: completedWord) + } + + return .empty + } + } +} + +extension RecoveryPhraseValidationState { + static let placeholder = RecoveryPhraseValidationState.random(phrase: .placeholder) + + static let placeholderStep1 = RecoveryPhraseValidationState( + phrase: .placeholder, + missingIndices: [2, 0, 3, 5], + missingWordChips: [ + .unassigned(word: "thank"), + .empty, + .unassigned(word: "boil"), + .unassigned(word: "garlic") + ], + validationWords: [ + .init(groupIndex: 2, word: "morning") + ], + route: nil + ) + + static let placeholderStep2 = RecoveryPhraseValidationState( + phrase: .placeholder, + missingIndices: [2, 0, 3, 5], + missingWordChips: [ + .empty, + .empty, + .unassigned(word: "boil"), + .unassigned(word: "garlic") + ], + validationWords: [ + .init(groupIndex: 2, word: "morning"), + .init(groupIndex: 0, word: "thank") + ], + route: nil + ) + + static let placeholderStep3 = RecoveryPhraseValidationState( + phrase: .placeholder, + missingIndices: [2, 0, 3, 5], + missingWordChips: [ + .empty, + .empty, + .unassigned(word: "boil"), + .empty + ], + validationWords: [ + .init(groupIndex: 2, word: "morning"), + .init(groupIndex: 0, word: "thank"), + .init(groupIndex: 3, word: "garlic") + ], + route: nil + ) + + static let placeholderStep4 = RecoveryPhraseValidationState( + phrase: .placeholder, + missingIndices: [2, 0, 3, 5], + missingWordChips: [ + .empty, + .empty, + .empty, + .empty + ], + validationWords: [ + .init(groupIndex: 2, word: "morning"), + .init(groupIndex: 0, word: "thank"), + .init(groupIndex: 3, word: "garlic"), + .init(groupIndex: 1, word: "boil") + ], + route: nil + ) +} + +extension RecoveryPhraseValidationStore { + private static let scheduler = DispatchQueue.main + + static let demo = Store( + initialState: .placeholder, + reducer: .default, + environment: .demo + ) + + static let demoStep1 = Store( + initialState: .placeholderStep1, + reducer: .default, + environment: .demo + ) + + static let demoStep2 = Store( + initialState: .placeholderStep1, + reducer: .default, + environment: .demo + ) + + static let demoStep3 = Store( + initialState: .placeholderStep3, + reducer: .default, + environment: .demo + ) + + static let demoStep4 = Store( + initialState: .placeholderStep4, + reducer: .default, + environment: .demo + ) +} + +private extension WordChipGrid { + init( + state: RecoveryPhraseValidationState, + groupIndex: Int, + wordGroup: RecoveryPhrase.Group, + misingIndex: Int + ) { + let chips = state.wordsChips( + for: groupIndex, + groupSize: RecoveryPhraseValidationState.wordGroupSize, + from: wordGroup + ) + + self.init(chips: chips, coloredChipColor: state.coloredChipColor) + } +} + +private extension RecoveryPhraseValidationState { + var coloredChipColor: Color { + if self.isComplete { + return isValid ? Asset.Colors.Buttons.activeButton.color : Asset.Colors.BackgroundColors.red.color + } else { + return Asset.Colors.Buttons.activeButton.color + } + } +} + +struct RecoveryPhraseBackupView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + RecoveryPhraseBackupValidationView(store: .demoStep4) + } + + NavigationView { + RecoveryPhraseBackupValidationView(store: .demoStep1) + } + + NavigationView { + RecoveryPhraseBackupValidationView(store: .demoStep1) + } + .preferredColorScheme(.dark) + } +} diff --git a/secant/Features/BackupFlow/Views/RecoveryPhraseDisplayView.swift b/secant/Features/BackupFlow/Views/RecoveryPhraseDisplayView.swift index 818630a..8210b66 100644 --- a/secant/Features/BackupFlow/Views/RecoveryPhraseDisplayView.swift +++ b/secant/Features/BackupFlow/Views/RecoveryPhraseDisplayView.swift @@ -10,31 +10,37 @@ import ComposableArchitecture struct RecoveryPhraseDisplayView: View { let store: RecoveryPhraseDisplayStore - + var body: some View { WithViewStore(self.store) { viewStore in ScrollView { - VStack { - if let chunks = viewStore.phrase?.toChunks() { + VStack(alignment: .center, spacing: 0) { + if let groups = viewStore.phrase?.toGroups() { VStack(spacing: 20) { Text("Your Secret Recovery Phrase") .titleText() .multilineTextAlignment(.center) - VStack(alignment: .leading, spacing: 4) { + + VStack(alignment: .center, spacing: 4) { Text("The following 24 words represent your funds and the security used to protect them.") .bodyText() - + Text("Back them up now! There will be a test.") .bodyText() } } - - VStack(alignment: .leading, spacing: 20) { - ForEach(chunks, id: \.startIndex) { chunk in - WordChipGrid(words: chunk.words, startingAt: chunk.startIndex) + .padding(.top, 0) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 35) { + ForEach(groups, id: \.startIndex) { group in + VStack { + WordChipGrid(words: group.words, startingAt: group.startIndex) + } } } - + .padding(.horizontal, 5) + VStack { Button( action: { viewStore.send(.finishedPressed) }, @@ -42,7 +48,7 @@ struct RecoveryPhraseDisplayView: View { ) .activeButtonStyle .frame(height: 60) - + Button( action: { viewStore.send(.copyToBufferPressed) @@ -59,21 +65,21 @@ struct RecoveryPhraseDisplayView: View { Text("Oops no words") } } - .padding() } + .padding(.bottom, 20) .padding(.horizontal) + .padding(.top, 0) + .applyScreenBackground() } - - // TODO: NavigationBar Style - .navigationBarTitleDisplayMode(.inline) - .applyScreenBackground() + .navigationBarTitleDisplayMode(.inline) // TODO: NavigationBar Style + .navigationBarHidden(true) } } // TODO: This should have a #DEBUG tag, but if so, it's not possible to compile this on release mode and submit it to testflight extension RecoveryPhraseDisplayStore { static var demo: RecoveryPhraseDisplayStore { RecoveryPhraseDisplayStore( - initialState: .init(phrase: .demo), + initialState: .init(phrase: .placeholder), reducer: .default, environment: .demo ) @@ -97,19 +103,19 @@ extension RecoveryPhrase { "pizza", "just", "garlic" ] - static let demo = RecoveryPhrase(words: testPhrase) + static let placeholder = RecoveryPhrase(words: testPhrase) static let empty = RecoveryPhrase(words: []) } struct RecoveryPhraseDisplayView_Previews: PreviewProvider { static let scheduler = DispatchQueue.main - static let store = RecoveryPhraseDisplayStore.demo static var previews: some View { NavigationView { RecoveryPhraseDisplayView(store: store) } + .environment(\.sizeCategory, .accessibilityLarge) NavigationView { RecoveryPhraseDisplayView(store: store) diff --git a/secant/Features/BackupFlow/Views/ValidationFailedView.swift b/secant/Features/BackupFlow/Views/ValidationFailedView.swift new file mode 100644 index 0000000..1d1bb2f --- /dev/null +++ b/secant/Features/BackupFlow/Views/ValidationFailedView.swift @@ -0,0 +1,78 @@ +// +// ValidationFailed.swift +// secant-testnet +// +// Created by Francisco Gindre on 12/22/21. +// + +import SwiftUI +import ComposableArchitecture + +struct ValidationFailedView: View { + var store: RecoveryPhraseValidationStore + + var body: some View { + WithViewStore(store) { viewStore in + GeometryReader { proxy in + VStack { + VStack(alignment: .center, spacing: 20) { + Text("Ouch, sorry, no.") + .font(.custom(FontFamily.Rubik.regular.name, size: 30)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 20) + + CircularFrame() + .backgroundImage( + Asset.Assets.Backgrounds.callout1.image + ) + .frame( + width: proxy.size.width * 0.84, + height: proxy.size.width * 0.84 + ) + .badgeIcon(.error) + + Spacer() + + VStack(alignment: .center, spacing: 40) { + VStack(alignment: .center, spacing: 20) { + Text("Your placed words did not match your secret recovery phrase.") + .bodyText() + .fixedSize(horizontal: false, vertical: true) + + Text("Remember, you can't recover your funds if you lose (or incorrectly save) these 24 words.") + .bodyText() + .fixedSize(horizontal: false, vertical: true) + } + + Button( + action: { viewStore.send(.reset) }, + label: { Text("I'm ready to try again") } + ) + .activeButtonStyle + .frame(height: 60) + } + .padding() + + Spacer() + } + .frame(width: proxy.size.width) + .scrollableWhenScaledUp() + } + .padding() + .navigationBarBackButtonHidden(true) + .applyErredScreenBackground() + } + } +} + +struct ValidationFailed_Previews: PreviewProvider { + static var previews: some View { + Group { + ValidationFailedView(store: .demo) + + ValidationFailedView(store: .demo) + .environment(\.sizeCategory, .accessibilityLarge) + } + } +} diff --git a/secant/Features/BackupFlow/Views/ValidationSucceededView.swift b/secant/Features/BackupFlow/Views/ValidationSucceededView.swift new file mode 100644 index 0000000..a53450a --- /dev/null +++ b/secant/Features/BackupFlow/Views/ValidationSucceededView.swift @@ -0,0 +1,105 @@ +// +// SuccessView.swift +// secant-testnet +// +// Created by Adam Stener on 12/8/21. +// + +import SwiftUI +import ComposableArchitecture + +struct ValidationSucceededView: View { + var store: RecoveryPhraseValidationStore + + @ScaledMetric var scaledPadding: CGFloat = 10 + @ScaledMetric var scaledButtonHeight: CGFloat = 130 + + var body: some View { + WithViewStore(store) { viewStore in + GeometryReader { proxy in + VStack { + VStack(spacing: 20) { + Text("Success!") + .font(.custom(FontFamily.Rubik.regular.name, size: 36)) + + Text("Place that backup somewhere safe and venture forth in security.") + .bodyText() + .multilineTextAlignment(.center) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + VStack { + CircularFrame() + .backgroundImage( + Asset.Assets.Backgrounds.callout1.image + ) + .frame( + width: proxy.size.width * 0.84, + height: proxy.size.width * 0.84 + ) + .badgeIcon(.shield) + } + .padding(.vertical, 20) + + Spacer() + + VStack(spacing: 15) { + Button( + action: { + viewStore.send(.proceedToHome, animation: .easeIn(duration: 1)) + }, + label: { + Text("Take me to my wallet!") + .fixedSize(horizontal: false, vertical: true) + } + ) + .activeButtonStyle + .frame( + minHeight: 60, + idealHeight: 60, + maxHeight: .infinity + ) + + Button( + action: { + viewStore.send( + .displayBackedUpPhrase, + animation: .easeIn(duration: 1) + ) + }, + label: { + Text("Show me my phrase again") + .fixedSize(horizontal: false, vertical: true) + } + ) + .secondaryButtonStyle + .frame( + minHeight: 60, + idealHeight: 60, + maxHeight: .infinity + ) + } + .frame(height: scaledButtonHeight) + .padding(.vertical, scaledPadding) + } + .padding(.horizontal) + .scrollableWhenScaledUp() + } + } + .navigationBarBackButtonHidden(true) + .applyScreenBackground() + } +} + +struct ValidationSuccededView_Previews: PreviewProvider { + static var previews: some View { + Group { + ValidationSucceededView(store: .demo) + ValidationSucceededView(store: .demo) + .environment(\.sizeCategory, .accessibilityExtraLarge) + } + } +} diff --git a/secant/Features/BackupFlow/Views/WordChipGrid.swift b/secant/Features/BackupFlow/Views/WordChipGrid.swift index 9d2f618..025cfa2 100644 --- a/secant/Features/BackupFlow/Views/WordChipGrid.swift +++ b/secant/Features/BackupFlow/Views/WordChipGrid.swift @@ -7,16 +7,14 @@ import SwiftUI -/** -A 3x2 grid of numbered or empty chips. -*/ +/// A 3x(N/3) grid of numbered or empty chips. struct WordChipGrid: View { static let spacing: CGFloat = 10 var chips: [PhraseChip.Kind] - + var coloredChipColor: Color var threeColumnGrid = Array( repeating: GridItem( - .flexible(minimum: 60, maximum: 120), + .flexible(minimum: 100, maximum: 120), spacing: Self.spacing, alignment: .topLeading ), @@ -24,52 +22,37 @@ struct WordChipGrid: View { ) var body: some View { - LazyVGrid( - columns: threeColumnGrid, - alignment: .leading, - spacing: Self.spacing - ) { - ForEach(chips, id: \.self) { wordChip in - chipView(for: wordChip) - .frame( - minWidth: 0, - maxWidth: .infinity, - minHeight: 30 - ) + LazyVGrid(columns: threeColumnGrid, alignment: .center, spacing: 10) { + ForEach(chips, id: \.self) { item in + PhraseChip(kind: item) } } } - init(words: [String], startingAt index: Int) { - self.chips = zip(words, index.. some View { - switch chipKind { - case .empty: - EmptyChip() - - case let .ordered(position, word): - EnumeratedChip(index: position, text: word) - - case .unassigned(let word): - BlueChip(word: word) + init(words: [String], startingAt index: Int, coloredChipColor: Color = .clear) { + let chips = zip(words, index.. some View { ZStack { - ScreenBackground() + ScreenBackground(colors: colors) .edgesIgnoringSafeArea(.all) + content } } } extension View { - /** - Adds a Vertical Linear Gradient with the default Colors of VLinearGradient. Supports both Light and Dark Mode - */ + /// Adds a Vertical Linear Gradient with the default Colors of VLinearGradient. + /// Supports both Light and Dark Mode func applyScreenBackground() -> some View { self.modifier( - ScreenBackgroundModifier() + ScreenBackgroundModifier( + colors: [ + Asset.Colors.ScreenBackground.gradientStart.color, + Asset.Colors.ScreenBackground.gradientEnd.color + ] + ) + ) + } + + func applyErredScreenBackground() -> some View { + self.modifier( + ScreenBackgroundModifier( + colors: [ + Asset.Colors.ScreenBackground.redGradientStart.color, + Asset.Colors.ScreenBackground.redGradientEnd.color + ] + ) ) } } diff --git a/secant/UIComponents/Chips/BlueChip.swift b/secant/UIComponents/Chips/ColoredChip.swift similarity index 51% rename from secant/UIComponents/Chips/BlueChip.swift rename to secant/UIComponents/Chips/ColoredChip.swift index 8f68224..666246d 100644 --- a/secant/UIComponents/Chips/BlueChip.swift +++ b/secant/UIComponents/Chips/ColoredChip.swift @@ -7,28 +7,31 @@ import SwiftUI -struct BlueChip: View { +struct ColoredChip: View { var word: String + var color = Asset.Colors.Buttons.activeButton.color var body: some View { Text(word) - .font(FontFamily.Rubik.regular.textStyle(.body)) + .font(.custom(FontFamily.Rubik.regular.name, size: 15)) .frame( minWidth: 0, - maxWidth: 120, + maxWidth: .infinity, minHeight: 30, - idealHeight: 40 + maxHeight: .infinity ) + .fixedSize(horizontal: false, vertical: true) .foregroundColor(Asset.Colors.Text.activeButtonText.color) - .padding(.horizontal, 4) + .padding(.horizontal, 8) .padding(.vertical, 4) - .background(Asset.Colors.Buttons.activeButton.color) + .background(color) .cornerRadius(6) } } -struct BlueChip_Previews: PreviewProvider { +struct ColoredChip_Previews: PreviewProvider { static var previews: some View { - BlueChip(word: "negative") + ColoredChip(word: "negative") + .frame(width: 115) .applyScreenBackground() } } diff --git a/secant/UIComponents/Chips/EmptyChip.swift b/secant/UIComponents/Chips/EmptyChip.swift index 8b7725e..e71a48b 100644 --- a/secant/UIComponents/Chips/EmptyChip.swift +++ b/secant/UIComponents/Chips/EmptyChip.swift @@ -8,6 +8,8 @@ import SwiftUI struct EmptyChip: View { + @Environment(\.colorScheme) var colorScheme + var body: some View { RoundedRectangle(cornerRadius: 6, style: RoundedCornerStyle.continuous) .stroke(Asset.Colors.Text.activeButtonText.color, lineWidth: 0.5) @@ -24,15 +26,24 @@ struct EmptyChip: View { width: 4, blur: 2 ) + .background(chipBackground) .frame( minWidth: 0, maxWidth: .infinity, minHeight: 40, - idealHeight: 40, maxHeight: .infinity, alignment: .leading ) } + + @ViewBuilder var chipBackground: some View { + if colorScheme == .dark { + RoundedRectangle(cornerRadius: 6, style: RoundedCornerStyle.continuous) + .fill(Asset.Colors.ScreenBackground.gradientEnd.color) + } else { + Color.clear + } + } } struct EmptyChip_Previews: PreviewProvider { @@ -49,7 +60,7 @@ struct EmptyChip_Previews: PreviewProvider { Group { ZStack { - ScreenBackground() + Color.gray EmptyChip() .frame(width: 100, height: 40, alignment: .leading) } diff --git a/secant/UIComponents/Chips/EnumeratedChip.swift b/secant/UIComponents/Chips/EnumeratedChip.swift index 5252f01..fa99f99 100644 --- a/secant/UIComponents/Chips/EnumeratedChip.swift +++ b/secant/UIComponents/Chips/EnumeratedChip.swift @@ -8,102 +8,57 @@ import SwiftUI struct EnumeratedChip: View { + let basePadding: CGFloat = 14 + @Clamped(1...24) var index: Int = 1 - + var text: String - + var overlayPadding: CGFloat = 20 + var body: some View { - NumberedText(number: index, text: text) + Text(text) + .foregroundColor(Asset.Colors.Text.button.color) + .font(.custom(FontFamily.Rubik.regular.name, size: 14)) .frame( - minWidth: 0, maxWidth: .infinity, minHeight: 30, maxHeight: .infinity, alignment: .leading ) - .padding(.leading, 14) - .padding(.vertical, 4) - .background(Asset.Colors.BackgroundColors.numberedChip.color) - .cornerRadius(6) - .shadow(color: Asset.Colors.Shadow.numberedTextShadow.color, radius: 3, x: 0, y: 1) - } -} - -struct NumberedText: View { - var number: Int = 1 - var text: String - - @ViewBuilder var numberedText: some View { - GeometryReader { geometry in - (Text("\(number)") - .baselineOffset(geometry.size.height / 4) - .foregroundColor(Asset.Colors.Text.highlightedSuperscriptText.color) - .font(.custom(FontFamily.Roboto.bold.name, size: 12)) + - - Text(" \(text)") - .foregroundColor(Asset.Colors.Text.button.color) - .font(.custom(FontFamily.Rubik.regular.name, size: 14)) - ) + .padding(.leading, basePadding + overlayPadding) + .padding([.trailing, .vertical], 4) + .fixedSize(horizontal: false, vertical: true) .shadow( color: Asset.Colors.Shadow.numberedTextShadow.color, radius: 1, x: 0, y: 1 ) - .fixedSize(horizontal: false, vertical: true) - .frame(height: geometry.size.height, alignment: .center) - } - } - - var body: some View { - numberedText - .layoutPriority(1) + .background(Asset.Colors.BackgroundColors.numberedChip.color) + .cornerRadius(6) + .shadow(color: Asset.Colors.Shadow.numberedTextShadow.color, radius: 3, x: 0, y: 1) + .overlay( + GeometryReader { geometry in + Text("\(index)") + .foregroundColor(Asset.Colors.Text.highlightedSuperscriptText.color) + .font(.custom(FontFamily.Roboto.bold.name, size: 10)) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) + .padding(.leading, basePadding) + .padding(.top, 4) + } + ) } } struct EnumeratedChip_Previews: PreviewProvider { - private static var threeColumnGrid = Array( - repeating: GridItem( - .flexible(minimum: 60, maximum: 120), - spacing: 15, - alignment: .topLeading - ), - count: 3 - ) - private static var words = [ "pyramid", "negative", "page", "crown", "", "zebra" ] @ViewBuilder static var grid: some View { - LazyVGrid( - columns: threeColumnGrid, - alignment: .leading, - spacing: 15 - ) { - ForEach(Array(zip(words.indices, words)), id: \.1) { i, word in - if word.isEmpty { - EmptyChip() - .frame( - minWidth: 0, - maxWidth: .infinity, - minHeight: 40, - maxHeight: .infinity - ) - } else { - EnumeratedChip(index: (i + 1), text: word) - .frame( - minWidth: 0, - maxWidth: .infinity, - minHeight: 30, - maxHeight: .infinity - ) - } - } - } - .padding() + WordChipGrid(words: words, startingAt: 1) } static var previews: some View { diff --git a/secant/UIComponents/Chips/PhraseChip.swift b/secant/UIComponents/Chips/PhraseChip.swift index cd9b820..491ba9a 100644 --- a/secant/UIComponents/Chips/PhraseChip.swift +++ b/secant/UIComponents/Chips/PhraseChip.swift @@ -17,26 +17,13 @@ struct PhraseChip: View { var kind: Kind var body: some View { - chipFor(for: kind) - .frame( - minWidth: 0, - maxWidth: 120, - minHeight: 30, - idealHeight: 40 - ) - .animation(.easeIn) - } - - @ViewBuilder func chipFor(for kind: Kind) -> some View { switch kind { case .empty: EmptyChip() - case let .ordered(position, word): EnumeratedChip(index: position, text: word) - case .unassigned(let word): - BlueChip(word: word) + ColoredChip(word: word) } } } @@ -45,13 +32,13 @@ struct PhraseChip_Previews: PreviewProvider { static var previews: some View { VStack { PhraseChip(kind: .unassigned(word: "negative")) - .frame(height: 40) + .frame(width: 120, height: 40) PhraseChip(kind: .empty) - .frame(height: 40) + .frame(width: 120, height: 40) PhraseChip(kind: .ordered(position: 23, word: "mutual")) - .frame(height: 40) + .frame(width: 120, height: 40) } .applyScreenBackground() } diff --git a/secant/UIComponents/CircularFrame/CircularFrameBadge.swift b/secant/UIComponents/CircularFrame/CircularFrameBadge.swift index 190449a..797e66c 100644 --- a/secant/UIComponents/CircularFrame/CircularFrameBadge.swift +++ b/secant/UIComponents/CircularFrame/CircularFrameBadge.swift @@ -12,16 +12,39 @@ enum Badge: Equatable { case shield case list case person + case error - var image: Image { + @ViewBuilder var image: some View { switch self { - case .shield: return Asset.Assets.Icons.shield.image - case .list: return Asset.Assets.Icons.list.image - case .person: return Asset.Assets.Icons.profile.image + case .shield: + Asset.Assets.Icons.shield.image + .resizable() + .renderingMode(.none) + case .list: + Asset.Assets.Icons.list.image + .resizable() + .renderingMode(.none) + case .person: + Asset.Assets.Icons.profile.image + .resizable() + .renderingMode(.none) + case .error: + ErrorBadge() } } } +struct ErrorBadge: View { + var body: some View { + Text("X") + .font(.custom(FontFamily.Rubik.bold.name, size: 36)) + .foregroundColor(Asset.Colors.BackgroundColors.red.color) + .frame(width: 60, height: 55, alignment: .center) + .background(Asset.Colors.BackgroundColors.numberedChip.color) + .cornerRadius(10) + } +} + struct BadgesOverlay: Animatable, ViewModifier { struct ViewState: Equatable { let index: Int @@ -44,8 +67,6 @@ struct BadgesOverlay: Animatable, ViewModifier { ZStack { ForEach(0.. some View { + if scale > 1 { + ScrollView { + content + } + } else { + content + } + } +} + +extension View { + func scrollableWhenScaledUp() -> some View { + self.modifier(ScrollableWhenScaledUpModifier()) + } +} diff --git a/secantTests/BackupFlowTests/RecoveryFlowTests.swift b/secantTests/BackupFlowTests/RecoveryPhraseBackupTests.swift similarity index 92% rename from secantTests/BackupFlowTests/RecoveryFlowTests.swift rename to secantTests/BackupFlowTests/RecoveryPhraseBackupTests.swift index f47c36b..2e36aa0 100644 --- a/secantTests/BackupFlowTests/RecoveryFlowTests.swift +++ b/secantTests/BackupFlowTests/RecoveryPhraseBackupTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import secant_testnet -class RecoveryFlowTests: XCTestCase { +class RecoveryPhraseBackupTests: XCTestCase { func testGiven24WordsBIP39ChunkItIntoQuarters() throws { let words = [ "bring", "salute", "thank", @@ -25,7 +25,7 @@ class RecoveryFlowTests: XCTestCase { ] let phrase = RecoveryPhrase(words: words) - let chunks = phrase.toChunks() + let chunks = phrase.toGroups() XCTAssertEqual(chunks.count, 4) XCTAssertEqual(chunks[0].startIndex, 1) diff --git a/secantTests/BackupFlowTests/RecoveryPhraseDisplayReducerTests.swift b/secantTests/BackupFlowTests/RecoveryPhraseDisplayReducerTests.swift index 5cb2394..b53a0e6 100644 --- a/secantTests/BackupFlowTests/RecoveryPhraseDisplayReducerTests.swift +++ b/secantTests/BackupFlowTests/RecoveryPhraseDisplayReducerTests.swift @@ -18,13 +18,13 @@ class RecoveryPhraseDisplayReducerTests: XCTestCase { ) store.send(.copyToBufferPressed) { - $0.phrase = .demo + $0.phrase = .placeholder $0.showCopyToBufferAlert = true } XCTAssertEqual( store.environment.pasteboard.getString(), - RecoveryPhrase.demo.toString() + RecoveryPhrase.placeholder.toString() ) } @@ -35,8 +35,8 @@ class RecoveryPhraseDisplayReducerTests: XCTestCase { environment: .demo ) - store.send(.phraseResponse(.success(.demo))) { - $0.phrase = .demo + store.send(.phraseResponse(.success(.placeholder))) { + $0.phrase = .placeholder $0.showCopyToBufferAlert = false } } @@ -44,7 +44,7 @@ class RecoveryPhraseDisplayReducerTests: XCTestCase { private extension RecoveryPhraseDisplayState { static let test = RecoveryPhraseDisplayState( - phrase: .demo, + phrase: .placeholder, showCopyToBufferAlert: false ) diff --git a/secantTests/RecoveryPhraseValidationTests/RecoveryPhraseValidationTests.swift b/secantTests/RecoveryPhraseValidationTests/RecoveryPhraseValidationTests.swift new file mode 100644 index 0000000..0873b8a --- /dev/null +++ b/secantTests/RecoveryPhraseValidationTests/RecoveryPhraseValidationTests.swift @@ -0,0 +1,657 @@ +// +// RecoveryPhraseValidationTests.swift +// secantTests +// +// Created by Francisco Gindre on 10/29/21. +// +// swiftlint:disable type_body_length +import XCTest +import ComposableArchitecture +@testable import secant_testnet + +class RecoveryPhraseValidationTests: XCTestCase { + static let testScheduler = DispatchQueue.test + + let testEnvironment = BackupPhraseEnvironment( + mainQueue: testScheduler.eraseToAnyScheduler(), + newPhrase: { Effect(value: .init(words: RecoveryPhrase.placeholder.words)) }, + pasteboard: .test + ) + + func testPickWordsFromMissingIndices() throws { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let phrase = RecoveryPhrase(words: words) + + let indices = [1, 0, 5, 3] + + let expected = ["salute", "boil", "cancel", "pizza"].map({ PhraseChip.Kind.unassigned(word: $0) }) + + let result = phrase.words(fromMissingIndices: indices) + + XCTAssertEqual(expected, result) + } + + func testWhenInInitialStepChipIsDraggedIntoGroup1FollowingStepIsIncomplete() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let missingWordChips: [PhraseChip.Kind] = ["salute", "boil", "cancel", "pizza"].map({ PhraseChip.Kind.unassigned(word: $0) }) + + let initialStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: missingWordChips, + validationWords: [] + ) + + let store = TestStore(initialState: initialStep, reducer: RecoveryPhraseValidationReducer.default, environment: testEnvironment) + + let expectedMissingChips = [ + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "boil"), + PhraseChip.Kind.unassigned(word: "cancel"), + PhraseChip.Kind.unassigned(word: "pizza") + ] + + let expectedValidationWords = [ValidationWord(groupIndex: 1, word: "salute")] + + store.send(.move(wordChip: .unassigned(word: "salute"), intoGroup: 1)) { + $0.validationWords = expectedValidationWords + $0.missingWordChips = expectedMissingChips + + XCTAssertFalse($0.isComplete) + } + } + + func testWhenInInitialStepChipIsDraggedIntoGroup0FollowingStepIsIncompleteNextStateIsIncomplete() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let missingWordChips = ["salute", "boil", "cancel", "pizza"].map({ PhraseChip.Kind.unassigned(word: $0) }) + + let initialStep = RecoveryPhraseValidationState.initial( + phrase: phrase, + missingIndices: missingIndices, + missingWordsChips: missingWordChips + ) + + let store = TestStore(initialState: initialStep, reducer: RecoveryPhraseValidationReducer.default, environment: testEnvironment) + + let expectedMissingChips = [ + PhraseChip.Kind.unassigned(word: "salute"), + PhraseChip.Kind.unassigned(word: "boil"), + PhraseChip.Kind.unassigned(word: "cancel"), + PhraseChip.Kind.empty + ] + + let expectedValidationWords = [ValidationWord(groupIndex: 0, word: "pizza")] + + store.send(.move(wordChip: missingWordChips[3], intoGroup: 0)) { + $0.missingWordChips = expectedMissingChips + $0.validationWords = expectedValidationWords + + XCTAssertFalse($0.isComplete) + } + } + + func testWhenInIncompleteWith2CompletionsAndAChipIsDroppedInGroup3NextStateIsIncomplete() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "boil"), + PhraseChip.Kind.unassigned(word: "cancel"), + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ValidationWord(groupIndex: 0, word: "salute")] + ) + + let store = TestStore(initialState: currentStep, reducer: RecoveryPhraseValidationReducer.default, environment: testEnvironment) + + let expectedMissingWordChips = [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "cancel"), + PhraseChip.Kind.unassigned(word: "pizza") + ] + + let expectedValidationWords = [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil") + ] + + store.send(.move(wordChip: PhraseChip.Kind.unassigned(word: "boil"), intoGroup: 1)) { + $0.missingWordChips = expectedMissingWordChips + $0.validationWords = expectedValidationWords + + XCTAssertFalse($0.isComplete) + } + } + + func testWhenInIncompleteWith2CompletionsAndAChipIsDroppedInGroup2NextStateIsIncomplete() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "cancel"), + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil") + ] + ) + + let store = TestStore(initialState: currentStep, reducer: RecoveryPhraseValidationReducer.default, environment: testEnvironment) + + let expectedMissingWordChips = [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ] + + let expectedValidationWords = [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel") + ] + + store.send(.move(wordChip: PhraseChip.Kind.unassigned(word: "cancel"), intoGroup: 2)) { + $0.missingWordChips = expectedMissingWordChips + $0.validationWords = expectedValidationWords + + XCTAssertFalse($0.isComplete) + } + } + + func testWhenInIncompleteWith3CompletionsAndAChipIsDroppedInGroup3NextStateIsComplete() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel") + ] + ) + + let store = TestStore(initialState: currentStep, reducer: RecoveryPhraseValidationReducer.default, environment: testEnvironment) + + let expectedMissingWordChips = [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty + ] + + let expectedValidationWords = [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel"), + ValidationWord(groupIndex: 3, word: "pizza") + ] + + store.send(.move(wordChip: PhraseChip.Kind.unassigned(word: "pizza"), intoGroup: 3)) { + $0.missingWordChips = expectedMissingWordChips + $0.validationWords = expectedValidationWords + + XCTAssertTrue($0.isComplete) + XCTAssertTrue($0.isValid) + } + + Self.testScheduler.advance(by: 2) + + store.receive(.succeed) { + XCTAssertTrue($0.isComplete) + $0.route = .success + } + } + + func testWhenInIncompleteWith3CompletionsAndAChipIsDroppedInGroup3NextStateIsFailure() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 2, word: "boil"), + ValidationWord(groupIndex: 1, word: "cancel") + ] + ) + + let store = TestStore(initialState: currentStep, reducer: RecoveryPhraseValidationReducer.default, environment: testEnvironment) + + let expectedMissingWordChips = [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty + ] + + let expectedValidationWords = [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 2, word: "boil"), + ValidationWord(groupIndex: 1, word: "cancel"), + ValidationWord(groupIndex: 3, word: "pizza") + ] + + store.send(.move(wordChip: PhraseChip.Kind.unassigned(word: "pizza"), intoGroup: 3)) { + $0.missingWordChips = expectedMissingWordChips + $0.validationWords = expectedValidationWords + + XCTAssertTrue($0.isComplete) + } + + Self.testScheduler.advance(by: 2) + + store.receive(.fail) { + $0.route = .failure + XCTAssertFalse($0.isValid) + } + } + + func testWhenAWordGroupDoesNotHaveACompletionItHasAnEmptyChipInTheGivenMissingIndex() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel") + ] + ) + + let result = currentStep.wordsChips( + for: 0, + groupSize: 6, + from: phrase.toGroups()[0] + ) + + let expected = [ + PhraseChip.Kind.ordered(position: 1, word: "bring"), + .empty, + .ordered(position: 3, word: "thank"), + .ordered(position: 4, word: "require"), + .ordered(position: 5, word: "spirit"), + .ordered(position: 6, word: "toe") + ] + + XCTAssertEqual(expected, result) + } + + func testWhenAWordGroupHasACompletionItHasABlueChipWithTheCompletedWordInTheGivenMissingIndex() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel") + ] + ) + + let result = currentStep.wordsChips( + for: 0, + groupSize: 6, + from: phrase.toGroups()[0] + ) + + let expected = [ + PhraseChip.Kind.ordered(position: 1, word: "bring"), + .unassigned(word: "salute"), + .ordered(position: 3, word: "thank"), + .ordered(position: 4, word: "require"), + .ordered(position: 5, word: "spirit"), + .ordered(position: 6, word: "toe") + ] + + XCTAssertEqual(expected, result) + } + + func testWhenRecoveryPhraseValidationStateIsNotCompleteResultingPhraseIsNil() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel") + ] + ) + + XCTAssertNil(currentStep.resultingPhrase) + } + + func testRecoveryPhraseValidationStateIsNotCompleteAndNotValidWhenNotCompleted() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let currentStep = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: [ + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.empty, + PhraseChip.Kind.unassigned(word: "pizza") + ], + validationWords: [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel") + ] + ) + + XCTAssertFalse(currentStep.isComplete) + XCTAssertFalse(currentStep.isValid) + } + + func testCreateResultPhraseFromCompletion() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let completion = [ + ValidationWord(groupIndex: 0, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 2, word: "cancel"), + ValidationWord(groupIndex: 3, word: "pizza") + ] + + let result = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: phrase.words(fromMissingIndices: missingIndices), + validationWords: completion, + route: nil + ) + + XCTAssertTrue(result.isValid) + XCTAssertTrue(result.isComplete) + XCTAssertEqual(words, result.resultingPhrase) + } + + func testCreateResultPhraseInvalidPhraseFromCompletion() { + let words = [ + "bring", "salute", "thank", + "require", "spirit", "toe", + // second chunk + "boil", "hill", "casino", + "trophy", "drink", "frown", + // third chunk + "bird", "grit", "close", + "morning", "bind", "cancel", + // Fourth chunk + "daughter", "salon", "quit", + "pizza", "just", "garlic" + ] + + let missingIndices = [1, 0, 5, 3] + + let phrase = RecoveryPhrase(words: words) + + let completion = [ + ValidationWord(groupIndex: 3, word: "salute"), + ValidationWord(groupIndex: 1, word: "boil"), + ValidationWord(groupIndex: 0, word: "cancel"), + ValidationWord(groupIndex: 2, word: "pizza") + ] + + let result = RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: phrase.words(fromMissingIndices: missingIndices), + validationWords: completion, + route: nil + ) + + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.isComplete) + XCTAssertNotEqual(words, result.resultingPhrase) + } +} + +extension RecoveryPhraseValidationState { + static func initial( + phrase: RecoveryPhrase, + missingIndices: [Int], + missingWordsChips: [PhraseChip.Kind] + ) -> Self { + RecoveryPhraseValidationState( + phrase: phrase, + missingIndices: missingIndices, + missingWordChips: missingWordsChips, + validationWords: [] + ) + } +}