From b75def9cc12b56bbc0a04bcb6b214ccfafab587f Mon Sep 17 00:00:00 2001 From: Francisco Gindre Date: Mon, 13 Dec 2021 17:50:04 -0300 Subject: [PATCH] Issue #44: Recovery Phrase Validation flow + tests Rename struct to RecoveryPhraseValidationState. Add docs WordGrid + tests Make Word Groups Droppable and Blue word chips draggable cleanup Rename Stores and adopt aliases and default pattern for reducers Fix drop not working FIX: apply background to header. Spacing Fix compilation errors. Add validation demo to AppView Fix: the empty chips are rendered once, because they are not uniquely identifiable Add the Validation screen to the App Home make mutating functions static fix project warnings Fix Tests Fixed .complete test refactoring the Enum. first step Move given() to RecoveryPhraseValidationState and fix tests Add canary test for computed property of State Move RecoveryPhraseValidationStep to a nested type of RecoveryPhraseValidationState rename RecoveryPhraseValidationState.RecoveryPhraseValidationStep to RecoveryPhraseValidationState.Step Move static functions from RecoveryPhraseValidationStep to RecoveryPhraseValidationState Move creational factory methods together to the same extension Remove unused functions remove associated values from Step enum Fix: Avoid Drop being disable between chips 0.0.1-10 add navigation bar to phrase validation demo Reduce spacing between groups. Code cleanup Remove RecoveryPhraseValidationStep.swift Move remaining code to proper places PR Fixes. Move .initial factory method to test target. Rename given() to apply(chip:group:) and make it a member function Cleanup. Remove .validate, .invalid and .valid states. Figure word chips out of the state. Fix randomIndices() Tie view's title to state Connect Header to State BlueChip is now ColoredChip. Success Screen fix project Connect Success Screen to successful validation Add Red color to backgrounds Validation Failed Screen Connect SuccessValidation Screen to home hide back button on Success view Connect Phrase Display to validation 0.0.1-11 Fix word grid background colors View Modifier to add scrollview when the content is being scaled up by DynamicType Adjust UI spacing and padding to designs Add Placeholder states for SwiftUI Previews Flatten EnumeratedChip Hierarchy Flatten EnumeratedChip view hierarchy Fix: LazyVGrid can't take GeometryReader on its items' bodies Fix: Colored Chip does not adjust Fix: Vertical separation between wordgroups is too tall. Fix: Accesibility fixes for Validation Failed screen Rename ValidationFailedView Accessibility Pass on Validation success screen FIX: Colored chips too big when scaled up Fix: ValidationFailedScreen does not scroll well when scaled up Fix Empty chip shadow color for dark color scheme Fix: chip grid background does not bleed out to bottom of the screen build 12 Fix: pre success/failure screen step shrinks the screen because word grid is missing Resolved PR comments Fixes to resolve PR conversations Fixes to resolve PR conversations Fix PhraseChip preview Make ScrollableWhenScaledUp modifier fileprivate Remove comments and clean up code Fix Swiftlint issues Renamed RecoveryPhraseStepFulfillment to ValidationWord Rename pickWordsFromMissingIndices PR fixes PR suggestions PR suggestions Move words(fromMissingIndices:size) to RecoveryPhrase Make ScrollableWhenScaled struct fileprivate PR Suggestions Part two PR Suggestion changes remove unused PR suggestions suggested rename PR suggestions remove apply(chip:into) move that to Reducer Formatting changes more formatting changes Fix: iPhone 13 Pro Max displays 4 columns instead of three Fix: Recovery Phrase puzzle shows incorrect number of columns and margin alignment on bigger devices Add test to catch state not changing as intended Phrase validation reducer refactor + tests make step computed property a bool make isComplete a single line PR Suggestions Rename ValidationSuccededView.swift Fix Bug: valid phrase should contemplate that the phrase is complete first PR Suggestion refactor and add Unit Tests for resultingPhrase, isComplete, isValid --- secant.xcodeproj/project.pbxproj | 72 +- .../xcschemes/secant-testnet.xcscheme | 2 +- .../Contents.json | 0 .../numberedChip.colorset/Contents.json | 0 .../phraseGridDarkGray.colorset/Contents.json | 38 + .../red.colorset/Contents.json | 38 + .../redGradientEnd.colorset/Contents.json | 38 + .../redGradientStart.colorset}/Contents.json | 10 +- .../Contents.json | 6 +- secant/Features/App/App.swift | 48 +- secant/Features/App/Views/AppView.swift | 45 ++ secant/Features/BackupFlow/DropDelegate.swift | 43 ++ .../RecoveryPhraseDisplayStore.swift | 24 +- .../BackupFlow/RecoveryPhraseValidation.swift | 182 +++++ .../BackupFlow/View+WhenDraggable.swift | 46 ++ .../RecoveryPhraseBackupValidationView.swift | 283 ++++++++ .../Views/RecoveryPhraseDisplayView.swift | 44 +- .../Views/ValidationFailedView.swift | 78 +++ .../Views/ValidationSucceededView.swift | 105 +++ .../BackupFlow/Views/WordChipGrid.swift | 55 +- secant/Features/Home/HomeStore.swift | 4 +- secant/Features/Home/Views/HomeView.swift | 17 +- secant/Generated/XCAssets+Generated.swift | 3 + .../Screens/Onboarding/OnboardingScreen.swift | 4 +- .../Backgrounds/ScreenBackground.swift | 40 +- .../{BlueChip.swift => ColoredChip.swift} | 19 +- secant/UIComponents/Chips/EmptyChip.swift | 15 +- .../UIComponents/Chips/EnumeratedChip.swift | 95 +-- secant/UIComponents/Chips/PhraseChip.swift | 21 +- .../CircularFrame/CircularFrameBadge.swift | 38 +- secant/Util/ScrollableWhenScaled.swift | 29 + ....swift => RecoveryPhraseBackupTests.swift} | 4 +- .../RecoveryPhraseDisplayReducerTests.swift | 10 +- .../RecoveryPhraseValidationTests.swift | 657 ++++++++++++++++++ 34 files changed, 1878 insertions(+), 235 deletions(-) rename secant/Colors.xcassets/{Background Colors => BackgroundColors}/Contents.json (100%) rename secant/Colors.xcassets/{Background Colors => BackgroundColors}/numberedChip.colorset/Contents.json (100%) create mode 100644 secant/Colors.xcassets/BackgroundColors/phraseGridDarkGray.colorset/Contents.json create mode 100644 secant/Colors.xcassets/BackgroundColors/red.colorset/Contents.json create mode 100644 secant/Colors.xcassets/ScreenBackground/redGradientEnd.colorset/Contents.json rename secant/Colors.xcassets/{Background Colors/phraseGridDarkGray.colorset => ScreenBackground/redGradientStart.colorset}/Contents.json (80%) create mode 100644 secant/Features/BackupFlow/DropDelegate.swift create mode 100644 secant/Features/BackupFlow/RecoveryPhraseValidation.swift create mode 100644 secant/Features/BackupFlow/View+WhenDraggable.swift create mode 100644 secant/Features/BackupFlow/Views/RecoveryPhraseBackupValidationView.swift create mode 100644 secant/Features/BackupFlow/Views/ValidationFailedView.swift create mode 100644 secant/Features/BackupFlow/Views/ValidationSucceededView.swift rename secant/UIComponents/Chips/{BlueChip.swift => ColoredChip.swift} (51%) create mode 100644 secant/Util/ScrollableWhenScaled.swift rename secantTests/BackupFlowTests/{RecoveryFlowTests.swift => RecoveryPhraseBackupTests.swift} (92%) create mode 100644 secantTests/RecoveryPhraseValidationTests/RecoveryPhraseValidationTests.swift 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: [] + ) + } +}