//
// C o p y r i g h t ( c ) 2 0 1 9 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
import UIKit
import PromiseKit
private protocol OnboardingCodeViewTextFieldDelegate {
func textFieldDidDeletePrevious ( )
}
// MARK: -
// E d i t i n g a c o d e s h o u l d f e e l s e a m l e s s , a s e v e n t h o u g h
// t h e U I T e x t F i e l d o n l y l e t s y o u e d i t a s i n g l e d i g i t a t
// a t i m e . F o r d e l e t e s t o w o r k p r o p e r l y , w e n e e d t o
// d e t e c t d e l e t e e v e n t s t h a t w o u l d a f f e c t t h e _ p r e v i o u s _
// d i g i t .
private class OnboardingCodeViewTextField : UITextField {
fileprivate var codeDelegate : OnboardingCodeViewTextFieldDelegate ?
override func deleteBackward ( ) {
var isDeletePrevious = false
if let selectedTextRange = selectedTextRange {
let cursorPosition = offset ( from : beginningOfDocument , to : selectedTextRange . start )
if cursorPosition = = 0 {
isDeletePrevious = true
}
}
super . deleteBackward ( )
if isDeletePrevious {
codeDelegate ? . textFieldDidDeletePrevious ( )
}
}
}
// MARK: -
protocol OnboardingCodeViewDelegate {
func codeViewDidChange ( )
}
// MARK: -
// T h e O n b o a r d i n g C o d e V i e w i s a s p e c i a l " v e r i f i c a t i o n c o d e "
// e d i t o r t h a t s h o u l d f e e l l i k e e d i t i n g a s i n g l e p i e c e
// o f t e x t ( a l a U I T e x t F i e l d ) e v e n t h o u g h t h e i n d i v i d u a l
// d i g i t s o f t h e c o d e a r e v i s u a l l y s e p a r a t e d .
//
// W e u s e a s e p a r a t e U I L a b e l f o r e a c h d i g i t , a n d m o v e
// a r o u n d a s i n g l e U I T e x t f i e l d t o l e t t h e u s e r e d i t t h e
// l a s t / n e x t d i g i t .
private class OnboardingCodeView : UIView {
var delegate : OnboardingCodeViewDelegate ?
public init ( ) {
super . init ( frame : . zero )
createSubviews ( )
updateViewState ( )
}
required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
private let digitCount = 6
private var digitLabels = [ UILabel ] ( )
private var digitStrokes = [ UIView ] ( )
// W e u s e a s i n g l e t e x t f i e l d t o e d i t t h e " c u r r e n t " d i g i t .
// T h e " c u r r e n t " d i g i t i s u s u a l l y t h e " l a s t "
fileprivate let textfield = OnboardingCodeViewTextField ( )
private var currentDigitIndex = 0
private var textfieldConstraints = [ NSLayoutConstraint ] ( )
// T h e c u r r e n t c o m p l e t e t e x t - t h e " m o d e l " f o r t h i s v i e w .
private var digitText = " "
var isComplete : Bool {
return digitText . count = = digitCount
}
var verificationCode : String {
return digitText
}
private func createSubviews ( ) {
textfield . textAlignment = . left
textfield . delegate = self
textfield . keyboardType = . numberPad
textfield . textColor = Theme . primaryColor
textfield . font = UIFont . ows_dynamicTypeLargeTitle1Clamped
textfield . codeDelegate = self
var digitViews = [ UIView ] ( )
( 0. . < digitCount ) . forEach { ( _ ) in
let ( digitView , digitLabel , digitStroke ) = makeCellView ( text : " " , hasStroke : true )
digitLabels . append ( digitLabel )
digitStrokes . append ( digitStroke )
digitViews . append ( digitView )
}
let ( hyphenView , _ , _ ) = makeCellView ( text : " - " , hasStroke : false )
digitViews . insert ( hyphenView , at : 3 )
let stackView = UIStackView ( arrangedSubviews : digitViews )
stackView . axis = . horizontal
stackView . alignment = . center
stackView . spacing = 8
addSubview ( stackView )
stackView . autoPinHeightToSuperview ( )
stackView . autoHCenterInSuperview ( )
self . addSubview ( textfield )
}
private func makeCellView ( text : String , hasStroke : Bool ) -> ( UIView , UILabel , UIView ) {
let digitView = UIView ( )
let digitLabel = UILabel ( )
digitLabel . text = text
digitLabel . font = UIFont . ows_dynamicTypeLargeTitle1Clamped
digitLabel . textColor = Theme . primaryColor
digitLabel . textAlignment = . center
digitView . addSubview ( digitLabel )
digitLabel . autoCenterInSuperview ( )
let strokeColor = ( hasStroke ? Theme . primaryColor : UIColor . clear )
let strokeView = digitView . addBottomStroke ( color : strokeColor , strokeWidth : 1 )
let vMargin : CGFloat = 4
let cellHeight : CGFloat = digitLabel . font . lineHeight + vMargin * 2
let cellWidth : CGFloat = cellHeight * 2 / 3
digitView . autoSetDimensions ( to : CGSize ( width : cellWidth , height : cellHeight ) )
return ( digitView , digitLabel , strokeView )
}
private func digit ( at index : Int ) -> String {
guard index < digitText . count else {
return " "
}
return digitText . substring ( from : index ) . substring ( to : 1 )
}
// E n s u r e t h a t a l l l a b e l s a r e d i s p l a y i n g t h e c o r r e c t
// d i g i t ( i f a n y ) a n d t h a t t h e U I T e x t F i e l d h a s r e p l a c e d
// t h e " c u r r e n t " d i g i t .
private func updateViewState ( ) {
currentDigitIndex = min ( digitCount - 1 ,
digitText . count )
( 0. . < digitCount ) . forEach { ( index ) in
let digitLabel = digitLabels [ index ]
digitLabel . text = digit ( at : index )
digitLabel . isHidden = index = = currentDigitIndex
}
NSLayoutConstraint . deactivate ( textfieldConstraints )
textfieldConstraints . removeAll ( )
let digitLabelToReplace = digitLabels [ currentDigitIndex ]
textfield . text = digit ( at : currentDigitIndex )
textfieldConstraints . append ( textfield . autoAlignAxis ( . horizontal , toSameAxisOf : digitLabelToReplace ) )
textfieldConstraints . append ( textfield . autoAlignAxis ( . vertical , toSameAxisOf : digitLabelToReplace ) )
// M o v e c u r s o r t o e n d o f t e x t .
let newPosition = textfield . endOfDocument
textfield . selectedTextRange = textfield . textRange ( from : newPosition , to : newPosition )
}
public override func becomeFirstResponder ( ) -> Bool {
return textfield . becomeFirstResponder ( )
}
func setHasError ( _ hasError : Bool ) {
let backgroundColor = ( hasError ? UIColor . ows_destructiveRed : Theme . primaryColor )
for digitStroke in digitStrokes {
digitStroke . backgroundColor = backgroundColor
}
}
fileprivate func set ( verificationCode : String ) {
digitText = verificationCode
updateViewState ( )
self . delegate ? . codeViewDidChange ( )
}
}
// MARK: -
extension OnboardingCodeView : UITextFieldDelegate {
public func textField ( _ textField : UITextField , shouldChangeCharactersIn range : NSRange , replacementString newString : String ) -> Bool {
var oldText = " "
if let textFieldText = textField . text {
oldText = textFieldText
}
let left = oldText . substring ( to : range . location )
let right = oldText . substring ( from : range . location + range . length )
let unfiltered = left + newString + right
let characterSet = CharacterSet ( charactersIn : " 0123456789 " )
let filtered = unfiltered . components ( separatedBy : characterSet . inverted ) . joined ( )
let filteredAndTrimmed = filtered . substring ( to : 1 )
textField . text = filteredAndTrimmed
digitText = digitText . substring ( to : currentDigitIndex ) + filteredAndTrimmed
updateViewState ( )
self . delegate ? . codeViewDidChange ( )
// I n f o r m o u r c a l l e r t h a t w e t o o k c a r e o f p e r f o r m i n g t h e c h a n g e .
return false
}
public func textFieldShouldReturn ( _ textField : UITextField ) -> Bool {
self . delegate ? . codeViewDidChange ( )
return false
}
}
// MARK: -
extension OnboardingCodeView : OnboardingCodeViewTextFieldDelegate {
public func textFieldDidDeletePrevious ( ) {
guard digitText . count > 0 else {
return
}
digitText = digitText . substring ( to : currentDigitIndex - 1 )
updateViewState ( )
}
}
// MARK: -
@objc
public class OnboardingVerificationViewController : OnboardingBaseViewController {
private enum CodeState {
case sent
case readyForResend
case resent
}
// MARK: -
private var codeState = CodeState . sent
private var titleLabel : UILabel ?
private var backLink : UIView ?
private let onboardingCodeView = OnboardingCodeView ( )
private var codeStateLink : OWSFlatButton ?
private let errorLabel = UILabel ( )
@objc
public func hideBackLink ( ) {
backLink ? . isHidden = true
}
override public func loadView ( ) {
super . loadView ( )
view . backgroundColor = Theme . backgroundColor
view . layoutMargins = . zero
let titleLabel = self . titleLabel ( text : " " )
self . titleLabel = titleLabel
let backLink = self . linkButton ( title : NSLocalizedString ( " ONBOARDING_VERIFICATION_BACK_LINK " ,
comment : " Label for the link that lets users change their phone number in the onboarding views. " ) ,
selector : #selector ( backLinkTapped ) )
self . backLink = backLink
onboardingCodeView . delegate = self
errorLabel . text = NSLocalizedString ( " ONBOARDING_VERIFICATION_INVALID_CODE " ,
comment : " Label indicating that the verification code is incorrect in the 'onboarding verification' view. " )
errorLabel . textColor = . ows_destructiveRed
errorLabel . font = UIFont . ows_dynamicTypeBodyClamped . ows_mediumWeight ( )
errorLabel . textAlignment = . center
errorLabel . autoSetDimension ( . height , toSize : errorLabel . font . lineHeight )
// W r a p t h e e r r o r l a b e l i n a r o w s o t h a t w e c a n s h o w / h i d e i t w i t h o u t a f f e c t i n g v i e w l a y o u t .
let errorRow = UIView ( )
errorRow . addSubview ( errorLabel )
errorLabel . autoPinEdgesToSuperviewEdges ( )
let codeStateLink = self . linkButton ( title : " " ,
selector : #selector ( resendCodeLinkTapped ) )
codeStateLink . enableMultilineLabel ( )
self . codeStateLink = codeStateLink
let topSpacer = UIView . vStretchingSpacer ( )
let bottomSpacer = UIView . vStretchingSpacer ( )
let stackView = UIStackView ( arrangedSubviews : [
titleLabel ,
UIView . spacer ( withHeight : 12 ) ,
backLink ,
topSpacer ,
onboardingCodeView ,
UIView . spacer ( withHeight : 12 ) ,
errorRow ,
bottomSpacer ,
codeStateLink
] )
stackView . axis = . vertical
stackView . alignment = . fill
stackView . layoutMargins = UIEdgeInsets ( top : 32 , left : 32 , bottom : 32 , right : 32 )
stackView . isLayoutMarginsRelativeArrangement = true
view . addSubview ( stackView )
stackView . autoPinWidthToSuperview ( )
stackView . autoPin ( toTopLayoutGuideOf : self , withInset : 0 )
autoPinView ( toBottomOfViewControllerOrKeyboard : stackView , avoidNotch : true )
// E n s u r e w h i t e s p a c e i s b a l a n c e d , s o i n p u t s a r e v e r t i c a l l y c e n t e r e d .
topSpacer . autoMatch ( . height , to : . height , of : bottomSpacer )
startCodeCountdown ( )
updateCodeState ( )
setHasInvalidCode ( false )
}
// MARK: - C o d e S t a t e
private let countdownDuration : TimeInterval = 60
private var codeCountdownTimer : Timer ?
private var codeCountdownStart : NSDate ?
deinit {
codeCountdownTimer ? . invalidate ( )
}
private func startCodeCountdown ( ) {
codeCountdownStart = NSDate ( )
codeCountdownTimer = Timer . weakScheduledTimer ( withTimeInterval : 0.25 , target : self , selector : #selector ( codeCountdownTimerFired ) , userInfo : nil , repeats : true )
}
@objc
public func codeCountdownTimerFired ( ) {
guard let codeCountdownStart = codeCountdownStart else {
owsFailDebug ( " Missing codeCountdownStart. " )
return
}
guard let codeCountdownTimer = codeCountdownTimer else {
owsFailDebug ( " Missing codeCountdownTimer. " )
return
}
let countdownInterval = abs ( codeCountdownStart . timeIntervalSinceNow )
guard countdownInterval < countdownDuration else {
// C o u n t d o w n c o m p l e t e .
codeCountdownTimer . invalidate ( )
self . codeCountdownTimer = nil
if codeState != . sent {
owsFailDebug ( " Unexpected codeState: \( codeState ) " )
}
codeState = . readyForResend
updateCodeState ( )
return
}
// U p d a t e t h e " c o d e s t a t e " U I t o r e f l e c t t h e c o u n t d o w n .
updateCodeState ( )
}
private func updateCodeState ( ) {
AssertIsOnMainThread ( )
guard let codeCountdownStart = codeCountdownStart else {
owsFailDebug ( " Missing codeCountdownStart. " )
return
}
guard let titleLabel = titleLabel else {
owsFailDebug ( " Missing titleLabel. " )
return
}
guard let codeStateLink = codeStateLink else {
owsFailDebug ( " Missing codeStateLink. " )
return
}
var e164PhoneNumber = " "
if let phoneNumber = onboardingController . phoneNumber {
e164PhoneNumber = phoneNumber . e164
}
let formattedPhoneNumber = PhoneNumber . bestEffortLocalizedPhoneNumber ( withE164 : e164PhoneNumber )
// U p d a t e t i t l e L a b e l
switch codeState {
case . sent , . readyForResend :
titleLabel . text = String ( format : NSLocalizedString ( " ONBOARDING_VERIFICATION_TITLE_DEFAULT_FORMAT " ,
comment : " Format for the title of the 'onboarding verification' view. Embeds {{the user's phone number}}. " ) ,
formattedPhoneNumber )
case . resent :
titleLabel . text = String ( format : NSLocalizedString ( " ONBOARDING_VERIFICATION_TITLE_RESENT_FORMAT " ,
comment : " Format for the title of the 'onboarding verification' view after the verification code has been resent. Embeds {{the user's phone number}}. " ) ,
formattedPhoneNumber )
}
// U p d a t e c o d e S t a t e L i n k
switch codeState {
case . sent :
let countdownInterval = abs ( codeCountdownStart . timeIntervalSinceNow )
let countdownRemaining = max ( 0 , countdownDuration - countdownInterval )
let formattedCountdown = OWSFormat . formatDurationSeconds ( Int ( round ( countdownRemaining ) ) )
let text = String ( format : NSLocalizedString ( " ONBOARDING_VERIFICATION_CODE_COUNTDOWN_FORMAT " ,
comment : " Format for the label of the 'sent code' label of the 'onboarding verification' view. Embeds {{the time until the code can be resent}}. " ) ,
formattedCountdown )
codeStateLink . setTitle ( title : text , font : . ows_dynamicTypeBodyClamped , titleColor : Theme . secondaryColor )
case . readyForResend :
codeStateLink . setTitle ( title : NSLocalizedString ( " ONBOARDING_VERIFICATION_ORIGINAL_CODE_MISSING_LINK " ,
comment : " Label for link that can be used when the original code did not arrive. " ) ,
font : . ows_dynamicTypeBodyClamped ,
titleColor : . ows_materialBlue )
case . resent :
codeStateLink . setTitle ( title : NSLocalizedString ( " ONBOARDING_VERIFICATION_RESENT_CODE_MISSING_LINK " ,
comment : " Label for link that can be used when the resent code did not arrive. " ) ,
font : . ows_dynamicTypeBodyClamped ,
titleColor : . ows_materialBlue )
}
}
public override func viewDidAppear ( _ animated : Bool ) {
super . viewDidAppear ( animated )
_ = onboardingCodeView . becomeFirstResponder ( )
}
// MARK: - E v e n t s
@objc func backLinkTapped ( ) {
Logger . info ( " " )
self . navigationController ? . popViewController ( animated : true )
}
@objc func resendCodeLinkTapped ( ) {
Logger . info ( " " )
switch codeState {
case . sent :
// I g n o r e t a p s u n t i l t h e c o u n t d o w n e x p i r e s .
break
case . readyForResend , . resent :
showResendActionSheet ( )
}
}
private func showResendActionSheet ( ) {
Logger . info ( " " )
let actionSheet = UIAlertController ( title : NSLocalizedString ( " ONBOARDING_VERIFICATION_RESEND_CODE_ALERT_TITLE " ,
comment : " Title for the 'resend code' alert in the 'onboarding verification' view. " ) ,
message : NSLocalizedString ( " ONBOARDING_VERIFICATION_RESEND_CODE_ALERT_MESSAGE " ,
comment : " Message for the 'resend code' alert in the 'onboarding verification' view. " ) ,
preferredStyle : . actionSheet )
actionSheet . addAction ( UIAlertAction ( title : NSLocalizedString ( " ONBOARDING_VERIFICATION_RESEND_CODE_BY_SMS_BUTTON " ,
comment : " Label for the 'resend code by SMS' button in the 'onboarding verification' view. " ) ,
style : . default ) { _ in
self . onboardingController . tryToRegister ( fromViewController : self , smsVerification : true )
} )
actionSheet . addAction ( UIAlertAction ( title : NSLocalizedString ( " ONBOARDING_VERIFICATION_RESEND_CODE_BY_VOICE_BUTTON " ,
comment : " Label for the 'resend code by voice' button in the 'onboarding verification' view. " ) ,
style : . default ) { _ in
self . onboardingController . tryToRegister ( fromViewController : self , smsVerification : false )
} )
actionSheet . addAction ( OWSAlerts . cancelAction )
self . present ( actionSheet , animated : true )
}
private func tryToVerify ( ) {
Logger . info ( " " )
guard onboardingCodeView . isComplete else {
self . setHasInvalidCode ( true )
return
}
setHasInvalidCode ( false )
onboardingController . update ( verificationCode : onboardingCodeView . verificationCode )
onboardingController . tryToVerify ( fromViewController : self , completion : { ( outcome ) in
if outcome = = . invalidVerificationCode {
self . setHasInvalidCode ( true )
}
} )
}
private func setHasInvalidCode ( _ value : Bool ) {
onboardingCodeView . setHasError ( value )
errorLabel . isHidden = ! value
}
@objc
public func setVerificationCodeAndTryToVerify ( _ verificationCode : String ) {
AssertIsOnMainThread ( )
let filteredCode = verificationCode . digitsOnly
guard filteredCode . count > 0 else {
owsFailDebug ( " Invalid code: \( verificationCode ) " )
return
}
onboardingCodeView . set ( verificationCode : filteredCode )
}
}
// MARK: -
extension OnboardingVerificationViewController : OnboardingCodeViewDelegate {
public func codeViewDidChange ( ) {
AssertIsOnMainThread ( )
setHasInvalidCode ( false )
tryToVerify ( )
}
}