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
This commit is contained in:
Francisco Gindre 2021-12-13 17:50:04 -03:00
parent 80bbdc8cda
commit b75def9cc1
34 changed files with 1878 additions and 235 deletions

View File

@ -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 = "<group>"; };
0D185818272723FF0046B928 /* ColoredChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColoredChip.swift; sourceTree = "<group>"; };
0D18581A272728D60046B928 /* PhraseChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhraseChip.swift; sourceTree = "<group>"; };
0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZcashSDKStubs.swift; sourceTree = "<group>"; };
0D1922F726BDEB3500052649 /* MockServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServices.swift; sourceTree = "<group>"; };
@ -127,6 +135,7 @@
0D354A0626D5A9D000315F45 /* Services.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = "<group>"; };
0D354A0726D5A9D000315F45 /* KeyStoring.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyStoring.swift; sourceTree = "<group>"; };
0D354A0826D5A9D000315F45 /* MnemonicSeedPhraseHandling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MnemonicSeedPhraseHandling.swift; sourceTree = "<group>"; };
0D35CC45277A36E00074316A /* ScrollableWhenScaled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableWhenScaled.swift; sourceTree = "<group>"; };
0D3D04072728B3440032ABC1 /* RecoveryPhraseDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseDisplayView.swift; sourceTree = "<group>"; };
0D3D04092728B3A10032ABC1 /* RecoveryPhraseDisplayStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseDisplayStore.swift; sourceTree = "<group>"; };
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 = "<group>"; };
0D535FE1271F9476009A9E3E /* EnumeratedChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumeratedChip.swift; sourceTree = "<group>"; };
0D5D16F426E24CCF00AD33D1 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
0D6D628A276A528D002FB4CC /* DropDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDelegate.swift; sourceTree = "<group>"; };
0D7CE63327349B5D0020E050 /* View+WhenDraggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+WhenDraggable.swift"; sourceTree = "<group>"; };
0D7DF08B271DCC0E00530046 /* ScreenBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenBackground.swift; sourceTree = "<group>"; };
0D8A43C3272AEEDE005A6414 /* SecantTextStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecantTextStyles.swift; sourceTree = "<group>"; };
0D8A43C5272B129C005A6414 /* WordChipGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordChipGrid.swift; sourceTree = "<group>"; };
@ -164,9 +175,14 @@
0DACFA8D27209FA70039EEA5 /* Roboto-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Light.ttf"; sourceTree = "<group>"; };
0DACFA8F27209FA70039EEA5 /* Roboto-ThinItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-ThinItalic.ttf"; sourceTree = "<group>"; };
0DB8AA80271DC7520035BC9D /* DesignGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignGuide.swift; sourceTree = "<group>"; };
0DC487C22772574C00BE6A63 /* ValidationSucceededView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidationSucceededView.swift; sourceTree = "<group>"; };
0DDB6A5027737D4A0012A410 /* ValidationFailedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationFailedView.swift; sourceTree = "<group>"; };
0DF2DC50272344E400FA31E2 /* EmptyChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyChip.swift; sourceTree = "<group>"; };
0DF2DC5327235E3E00FA31E2 /* View+InnerShadow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+InnerShadow.swift"; sourceTree = "<group>"; };
0DFE93DE272C6D4B000FCCA5 /* RecoveryFlowTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecoveryFlowTests.swift; sourceTree = "<group>"; };
0DFE93DE272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseBackupTests.swift; sourceTree = "<group>"; };
0DFE93E0272C9ECB000FCCA5 /* RecoveryPhraseBackupValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseBackupValidationView.swift; sourceTree = "<group>"; };
0DFE93E2272CA1AA000FCCA5 /* RecoveryPhraseValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseValidation.swift; sourceTree = "<group>"; };
0DFE93E5272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseValidationTests.swift; sourceTree = "<group>"; };
2E58E73A274679F000B2B84B /* OnboardingHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHeaderView.swift; sourceTree = "<group>"; };
2E5C03802738C570008BFFD3 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = "<group>"; };
2EA11F5A27467EF800709571 /* OnboardingFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFooterView.swift; sourceTree = "<group>"; };
@ -182,7 +198,6 @@
6654C7402715A47300901167 /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = "<group>"; };
6654C7432715A4AC00901167 /* OnboardingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStoreTests.swift; sourceTree = "<group>"; };
665C963E272C26E600BC04FB /* CircularFrameBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularFrameBackground.swift; sourceTree = "<group>"; };
66779071273AAC26003A1540 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = "<group>"; };
669FDAE8272C23B3007B9422 /* CircularFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularFrame.swift; sourceTree = "<group>"; };
669FDAEA272C23C2007B9422 /* CircularFrameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularFrameBadge.swift; sourceTree = "<group>"; };
66A0807A271993C500118B79 /* OnboardingProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProgressIndicator.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -305,7 +326,6 @@
0D4E7A1926B364180058B01E /* secantTests */,
0D4E7A2426B364180058B01E /* secantUITests */,
0D4E7A0626B364170058B01E /* Products */,
2EB660DF2747EA6000A06A07 /* Recovered References */,
);
sourceTree = "<group>";
};
@ -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 = "<group>";
@ -473,12 +495,20 @@
0DFE93DD272C6D4B000FCCA5 /* BackupFlowTests */ = {
isa = PBXGroup;
children = (
0DFE93DE272C6D4B000FCCA5 /* RecoveryFlowTests.swift */,
0D1C1AA227611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift */,
0DFE93DE272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift */,
);
path = BackupFlowTests;
sourceTree = "<group>";
};
0DFE93E4272CB6D0000FCCA5 /* RecoveryPhraseValidationTests */ = {
isa = PBXGroup;
children = (
0DFE93E5272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift */,
);
path = RecoveryPhraseValidationTests;
sourceTree = "<group>";
};
2E5C037F2738C55F008BFFD3 /* Onboarding */ = {
isa = PBXGroup;
children = (
@ -490,14 +520,6 @@
path = Onboarding;
sourceTree = "<group>";
};
2EB660DF2747EA6000A06A07 /* Recovered References */ = {
isa = PBXGroup;
children = (
66779071273AAC26003A1540 /* OnboardingScreen.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
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;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1250"
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE9",
"green" : "0xE1",
"red" : "0xD8"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x3A",
"green" : "0x36",
"red" : "0x31"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.173",
"green" : "0.047",
"red" : "0.780"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.173",
"green" : "0.047",
"red" : "0.780"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xB1",
"green" : "0xB1",
"red" : "0xF5"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.694",
"green" : "0.694",
"red" : "0.961"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -6,8 +6,8 @@
"components" : {
"alpha" : "1.000",
"blue" : "0xE3",
"green" : "0xD4",
"red" : "0xC3"
"green" : "0xE7",
"red" : "0xF9"
}
},
"idiom" : "universal"
@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
"blue" : "0.890",
"green" : "0.906",
"red" : "0.976"
}
},
"idiom" : "universal"

View File

@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE6",
"green" : "0xE5",
"red" : "0xE0"
"blue" : "0x00",
"green" : "0x00",
"red" : "0x00"
}
},
"idiom" : "universal"

View File

@ -1,13 +1,18 @@
import ComposableArchitecture
struct AppState: Equatable {
enum Route {
enum Route: Equatable {
case startup
case onboarding
case home
case phraseValidation
case phraseDisplay
}
var homeState: HomeState
var onboardingState: OnboardingState
var phraseValidationState: RecoveryPhraseValidationState
var phraseDisplayState: RecoveryPhraseDisplayState
var route: Route = .startup
}
@ -15,10 +20,11 @@ enum AppAction: Equatable {
case updateRoute(AppState.Route)
case home(HomeAction)
case onboarding(OnboardingAction)
case phraseDisplay(RecoveryPhraseDisplayAction)
case phraseValidation(RecoveryPhraseValidationAction)
}
struct AppEnvironment: Equatable {
}
struct AppEnvironment: Equatable {}
// MARK: - AppReducer
@ -29,7 +35,9 @@ extension AppReducer {
[
routeReducer,
homeReducer,
onboardingReducer
onboardingReducer,
phraseValidationReducer.debug(),
phraseDisplayReducer.debug()
]
)
@ -37,13 +45,25 @@ extension AppReducer {
switch action {
case let .updateRoute(route):
state.route = route
case .home(.reset):
state.route = .startup
case .onboarding(.createNewWallet):
case .onboarding(.createNewWallet),
.phraseValidation(.proceedToHome):
state.route = .home
case .phraseValidation(.displayBackedUpPhrase),
.phraseDisplay(.createPhrase):
state.route = .phraseDisplay
case .phraseDisplay(.finishedPressed):
state.route = .phraseValidation
default:
break
}
return .none
}
@ -58,6 +78,18 @@ extension AppReducer {
action: /AppAction.onboarding,
environment: { _ in }
)
private static let phraseValidationReducer: AppReducer = RecoveryPhraseValidationReducer.default.pullback(
state: \AppState.phraseValidationState,
action: /AppAction.phraseValidation,
environment: { _ in BackupPhraseEnvironment.demo }
)
private static let phraseDisplayReducer: AppReducer = RecoveryPhraseDisplayReducer.default.pullback(
state: \AppState.phraseDisplayState,
action: /AppAction.phraseDisplay,
environment: { _ in BackupPhraseEnvironment.demo }
)
}
// MARK: - AppStore
@ -80,7 +112,11 @@ extension AppState {
static var placeholder: Self {
.init(
homeState: .placeholder,
onboardingState: .init()
onboardingState: .init(),
phraseValidationState: RecoveryPhraseValidationState.placeholder,
phraseDisplayState: RecoveryPhraseDisplayState(
phrase: .placeholder
)
)
}
}

View File

@ -18,6 +18,7 @@ struct AppView: View {
)
}
.navigationViewStyle(StackNavigationViewStyle())
case .onboarding:
OnboardingScreen(
store: store.scope(
@ -25,10 +26,45 @@ struct AppView: View {
action: AppAction.onboarding
)
)
case .startup:
ZStack(alignment: .topTrailing) {
StartupView(sendAction: viewStore.send)
}
case .phraseValidation:
NavigationView {
RecoveryPhraseBackupValidationView(
store: store.scope(
state: \.phraseValidationState,
action: AppAction.phraseValidation
)
)
.toolbar(
content: {
ToolbarItem(
placement: .navigationBarLeading,
content: {
Button(
action: { viewStore.send(.updateRoute(.startup)) },
label: { Text("Back") }
)
}
)
}
)
.navigationViewStyle(StackNavigationViewStyle())
}
case .phraseDisplay:
NavigationView {
RecoveryPhraseDisplayView(
store: store.scope(
state: \.phraseDisplayState,
action: AppAction.phraseDisplay
)
)
}
}
}
}
@ -43,9 +79,18 @@ private struct StartupView: View {
Button("Go To Home") {
sendAction(.updateRoute(.home))
}
Button("Go To Onboarding") {
sendAction(.updateRoute(.onboarding))
}
Button("Go To Phrase Validation Demo") {
sendAction(.updateRoute(.phraseValidation))
}
Button("Go To Phrase Display Demo") {
sendAction(.updateRoute(.phraseDisplay))
}
}
}
.navigationBarTitle("Startup")

View File

@ -0,0 +1,43 @@
//
// DropDelegate.swift
// secant-testnet
//
// Created by Francisco Gindre on 11/16/21.
//
import Foundation
import SwiftUI
import OrderedCollections
import ComposableArchitecture
/// Drop delegate that accepts items conforming to `PhraseChip.validationWordTypeIdentifier`
struct WordChipDropDelegate: DropDelegate {
var dropAction: ((PhraseChip.Kind) -> 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
}
}

View File

@ -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<RecoveryPhraseDisplayState, RecoveryPhraseDisplayAction>
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 {

View File

@ -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<RecoveryPhraseValidationState, RecoveryPhraseValidationAction>
typealias RecoveryPhraseValidationViewStore = ViewStore<RecoveryPhraseValidationState, RecoveryPhraseValidationAction>
/// 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<Bool> {
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..<phraseChunks).map { _ in
Int.random(in: 0 ..< wordGroupSize)
}
}
}
extension RecoveryPhrase.Group {
/// Returns an array of words where the word at the missing index will be an empty string
func words(with missingIndex: Int) -> [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<RecoveryPhraseValidationState, RecoveryPhraseValidationAction, BackupPhraseEnvironment>
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
}
}

View File

@ -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
}
}
}

View File

@ -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..<missingWordChips.count) { chipIndex in
PhraseChip(kind: missingWordChips[chipIndex])
.makeDraggable()
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 30
)
}
}
.padding(0)
}
}
extension RecoveryPhraseValidationState {
func wordsChips(
for groupIndex: Int,
groupSize: Int,
from wordGroup: RecoveryPhrase.Group
) -> [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)
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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..<index + words.count).map({ word, index in
word.isEmpty ? .empty : .ordered(position: index, word: word)
})
init(chips: [PhraseChip.Kind], coloredChipColor: Color) {
self.chips = chips
self.coloredChipColor = coloredChipColor
}
@ViewBuilder func chipView(for chipKind: PhraseChip.Kind) -> 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..<index + words.count).map { word, index in
word.isEmpty ? PhraseChip.Kind.empty : .ordered(position: index, word: word)
}
self.init(chips: chips, coloredChipColor: coloredChipColor)
}
}
struct WordChipGrid_Previews: PreviewProvider {
private static var words = [
"pyramid", "negative", "page",
"crown", "", "zebra"
"morning", "", "zebra"
]
static var previews: some View {
VStack {
WordChipGrid(words: words, startingAt: 1)
}
.padding()
WordChipGrid(words: words, startingAt: 1)
.frame(maxHeight: .infinity)
.fixedSize()
.environment(\.sizeCategory, .accessibilityLarge)
.padding()
}
}

View File

@ -45,7 +45,8 @@ extension HomeReducer {
action: /HomeAction.profile,
environment: { _ in
return ProfileEnvironment()
})
}
)
.run(&state, action, ())
case .reset:
return .none
@ -103,7 +104,6 @@ extension HomeViewStore {
}
// MARK: PlaceHolders
extension HomeState {
static var placeholder: Self {
.init(

View File

@ -24,7 +24,8 @@ struct HomeView: View {
initialState: .placeholder,
reducer: SendReducer.default(
whenDone: { HomeViewStore(store).send(.updateRoute(nil)) }
).debug(),
)
.debug(),
environment: ()
)
)
@ -45,13 +46,13 @@ struct HomeView: View {
List {
Section(header: Text("Navigation Stack Routes")) {
ForEach(navigationRouteValues) { routeValue in
Text("\(String(describing: routeValue.route))")
.navigationLink(
isActive: viewStore.bindingForRoute(routeValue.route),
destination: {
view(for: routeValue.route)
}
)
Text("\(String(describing: routeValue.route))")
.navigationLink(
isActive: viewStore.bindingForRoute(routeValue.route),
destination: {
view(for: routeValue.route)
}
)
}
}

View File

@ -40,6 +40,7 @@ internal enum Asset {
internal enum BackgroundColors {
internal static let numberedChip = ColorAsset(name: "numberedChip")
internal static let phraseGridDarkGray = ColorAsset(name: "phraseGridDarkGray")
internal static let red = ColorAsset(name: "red")
}
internal enum Buttons {
internal static let activeButton = ColorAsset(name: "ActiveButton")
@ -67,6 +68,8 @@ internal enum Asset {
internal enum ScreenBackground {
internal static let gradientEnd = ColorAsset(name: "gradientEnd")
internal static let gradientStart = ColorAsset(name: "gradientStart")
internal static let redGradientEnd = ColorAsset(name: "redGradientEnd")
internal static let redGradientStart = ColorAsset(name: "redGradientStart")
}
internal enum Shadow {
internal static let emptyChipInnerShadow = ColorAsset(name: "emptyChipInnerShadow")

View File

@ -15,9 +15,6 @@ struct OnboardingScreen: View {
var body: some View {
GeometryReader { proxy in
ZStack {
ScreenBackground()
.edgesIgnoringSafeArea(.all)
OnboardingHeaderView(
store: store.scope(
state: { state in
@ -45,6 +42,7 @@ struct OnboardingScreen: View {
OnboardingFooterView(store: store)
}
}
.applyScreenBackground()
}
}

View File

@ -6,14 +6,12 @@
//
import SwiftUI
/**
A Vertical LinearGradient that takes an array of Colors and renders them vertically in a centered fashion mostly used as a background for Screen views..
*/
/// A Vertical LinearGradient that takes an array of Colors and renders them vertically
/// in a centered fashion mostly used as a background for Screen views..
struct ScreenBackground: View {
var colors = [
Asset.Colors.ScreenBackground.gradientStart.color,
Asset.Colors.ScreenBackground.gradientEnd.color
]
var colors: [Color]
var body: some View {
LinearGradient(
colors: colors,
@ -24,22 +22,40 @@ struct ScreenBackground: View {
}
struct ScreenBackgroundModifier: ViewModifier {
var colors: [Color]
func body(content: Content) -> 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
]
)
)
}
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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()
}

View File

@ -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..<viewStore.badges.count) { badgeIndex in
viewStore.badges[viewStore.index].image
.resizable()
.renderingMode(.none)
.frame(
width: proxy.size.width * 0.35,
height: proxy.size.height * 0.35,
@ -82,7 +103,6 @@ struct BadgeOverlay: Animatable, ViewModifier {
Spacer()
badge.image
.resizable()
.frame(
width: proxy.size.width * 0.35,
height: proxy.size.height * 0.35,
@ -128,6 +148,10 @@ struct Badge_Previews: PreviewProvider {
CircularFrame()
.frame(width: size, height: size)
.badgeIcon(.person)
CircularFrame()
.frame(width: size, height: size)
.badgeIcon(.error)
}
.preferredColorScheme(.light)
.previewLayout(.fixed(width: size + 50, height: size + 50))

View File

@ -0,0 +1,29 @@
//
// ScrollableWhenScaled.swift
// secant-testnet
//
// Created by Francisco Gindre on 12/27/21.
//
import SwiftUI
// swiftlint:disable:next private_over_fileprivate strict_fileprivate
fileprivate struct ScrollableWhenScaledUpModifier: ViewModifier {
@ScaledMetric var scale: CGFloat = 1
func body(content: Content) -> some View {
if scale > 1 {
ScrollView {
content
}
} else {
content
}
}
}
extension View {
func scrollableWhenScaledUp() -> some View {
self.modifier(ScrollableWhenScaledUpModifier())
}
}

View File

@ -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)

View File

@ -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
)

View File

@ -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: []
)
}
}