//
// 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 ] ( )
// 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 ) = makeCellView ( text : " " , hasStroke : true )
digitLabels . append ( digitLabel )
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 ) {
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 ( )
if hasStroke {
let strokeView = UIView . container ( )
strokeView . backgroundColor = Theme . primaryColor
digitView . addSubview ( strokeView )
strokeView . autoPinWidthToSuperview ( )
strokeView . autoPinEdge ( toSuperviewEdge : . bottom )
strokeView . autoSetDimension ( . height , toSize : 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 )
}
private func digit ( at index : Int ) -> String {
guard index < digitText . count else {
return " "
}
return digitText . substring ( from : index ) . trim ( after : 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 ( )
}
}
// 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 . trim ( after : 1 )
textField . text = filteredAndTrimmed
digitText = digitText . trim ( after : 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 pending
case possiblyNotDelivered
case resent
}
// MARK: -
private var codeState = CodeState . pending
private var titleLabel : UILabel ?
private let phoneNumberTextField = UITextField ( )
private let onboardingCodeView = OnboardingCodeView ( )
private var codeStateLink : OWSFlatButton ?
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. " ) ,
selector : #selector ( backLinkTapped ) )
onboardingCodeView . delegate = self
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 ,
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 ( )
}
// MARK: - C o d e S t a t e
private let countdownDuration : TimeInterval = 60
private var codeCountdownTimer : Timer ?
private var codeCountdownStart : NSDate ?
deinit {
if let codeCountdownTimer = codeCountdownTimer {
codeCountdownTimer . invalidate ( )
}
}
private func startCodeCountdown ( ) {
codeCountdownStart = NSDate ( )
codeCountdownTimer = Timer . weakScheduledTimer ( withTimeInterval : 1 , 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 != . pending {
owsFailDebug ( " Unexpected codeState: \( codeState ) " )
}
codeState = . possiblyNotDelivered
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 . pending , . possiblyNotDelivered :
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 . pending :
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 'pending 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 )
// c o d e S t a t e L i n k . s e t B a c k g r o u n d C o l o r s ( u p C o l o r : T h e m e . b a c k g r o u n d C o l o r )
case . possiblyNotDelivered :
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 . pending :
// 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 . possiblyNotDelivered , . 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 {
return
}
onboardingController . tryToVerify ( fromViewController : self , verificationCode : onboardingCodeView . verificationCode , pin : nil )
}
}
// MARK: -
extension OnboardingVerificationViewController : OnboardingCodeViewDelegate {
public func codeViewDidChange ( ) {
AssertIsOnMainThread ( )
tryToVerify ( )
}
}