// // ViewController.swift // MVMCoreUI // // Created by Scott Pfeil on 11/5/19. // Copyright © 2019 Verizon Wireless. All rights reserved. // import UIKit import MVMCore @objc open class ViewController: UIViewController, MVMCoreViewControllerProtocol, MVMCoreViewManagerViewControllerProtocol, MoleculeDelegateProtocol, FormHolderProtocol, MVMCoreActionDelegateProtocol, ActionDelegateProtocol, MVMCoreLoadDelegateProtocol, UITextFieldDelegate, UITextViewDelegate, ObservingTextFieldDelegate, MVMCoreUIDetailViewProtocol, PageProtocol, PageBehaviorHandlerProtocol { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- @objc public var pageType: String? @objc public var loadObject: MVMCoreLoadObject? public var model: MVMControllerModelProtocol? public var pageModel: PageModelProtocol? { get { model } set { model = newValue as? MVMControllerModelProtocol } } /// Set if this page is containted in a manager. public weak var manager: (UIViewController & MVMCoreViewManagerProtocol)? /// A temporary iVar backer for delegateObject() until we change the protocol open lazy var delegateObjectIVar: MVMCoreUIDelegateObject = { MVMCoreUIDelegateObject.create(withDelegateForAll: self) }() public func delegateObject() -> DelegateObject? { delegateObjectIVar } public var formValidator: FormValidator? public var behaviors: [PageBehaviorProtocol]? public var needsUpdateUI = false private var observingForResponses: NSObjectProtocol? private var initialLoadFinished = false public var previousScreenSize = CGSize.zero public var selectedField: UIView? public var pageUpdateQueue: OperationQueue = { let pageUpdateQueue = OperationQueue() pageUpdateQueue.maxConcurrentOperationCount = 1 pageUpdateQueue.qualityOfService = .userInteractive return pageUpdateQueue }() /// Checks if the screen width has changed open func screenSizeChanged() -> Bool { !MVMCoreGetterUtility.cgfequalwiththreshold(previousScreenSize.width, view.bounds.size.width, 0.1) } //-------------------------------------------------- // MARK: - Response handling //-------------------------------------------------- open func observeForResponseJSONUpdates() { guard observingForResponses == nil, (pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0) else { return } observingForResponses = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil, queue: pageUpdateQueue, using: responseJSONUpdated(notification:)) } open func stopObservingForResponseJSONUpdates() { guard let observingForResponses = observingForResponses else { return } NotificationCenter.default.removeObserver(observingForResponses) self.observingForResponses = nil } open func pagesToListenFor() -> [String]? { guard let pageType = loadObject?.pageType else { return nil } return [pageType] } open func modulesToListenFor() -> [String]? { let requestModules = loadObject?.requestParameters?.allModules() ?? [] let behaviorModules = behaviors?.flatMap { $0.modulesToListenFor() } ?? [] return requestModules + behaviorModules } @objc open func responseJSONUpdated(notification: Notification) { // Checks for a page we are listening for. var newData = false if let pagesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyPageMap), let pageType = pagesToListenFor()?.first(where: { (pageTypeListened) -> Bool in guard let page = pagesLoaded.optionalDictionaryForKey(pageTypeListened), let pageType = page.optionalStringForKey(KeyPageType), pageType == pageTypeListened else { return false } return true }) { newData = true loadObject?.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType) } // Checks for modules we are listening for. if let modulesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyModuleMap), let modulesListened = modulesToListenFor() { for moduleName in modulesListened { if let module = modulesLoaded.optionalDictionaryForKey(moduleName) { newData = true var currentModules = loadObject?.modulesJSON ?? [:] currentModules.updateValue(module, forKey: moduleName) loadObject?.modulesJSON = currentModules } } } guard newData else { return } do { // TODO: Parse parsePageJSON modifies the page model on a different thread than // the UI update which could cause discrepancies. Parse should return the resulting // object and assignment should be synchronized on handleNewData(model: ). try parsePageJSON() MVMCoreDispatchUtility.performBlock(onMainThread: { self.handleNewData() }) } catch { if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: pageType))") { MVMCoreLoggingHandler.addError(toLog: coreError) } } } open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer) -> Bool { pageType = loadObject.pageType self.loadObject = loadObject // Verifies all modules needed are loaded. guard ViewController.verifyRequiredModulesLoaded(for: loadObject, error: error) else { return false } // Parse the model for the page. do { try parsePageJSON() } catch let parsingError { // Log all parsing errors and fail load. if let errorObject = MVMCoreLoadHandler.sharedGlobal()?.error(for: loadObject, causedBy: parsingError) { errorObject.messageToDisplay = MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorUnableToProcess) errorObject.messageToLog = describe(parsingError: parsingError) error.pointee = errorObject } return false } if let pageData = loadObject.dataForPage { executeBehaviors { (behavior: PageLocalDataShareBehavior) in behavior.receiveLocalPageData(pageData, delegateObjectIVar) } } ///Check with behavior if it can continue do{ let allFinishedProcessingLoad = try executeThrowingBehaviors {(behavior: PageMoleculeTransformationBehavior) in return try behavior.shouldFinishProcessingLoad(loadObject) } guard allFinishedProcessingLoad else { return false } } catch let behaviorError { if let errorObject = MVMCoreErrorObject.createErrorObject(for: behaviorError, location: MVMCoreLoadHandler.sharedGlobal()?.errorLocation(forRequest: loadObject)) { error.pointee = errorObject } return false } return true } func describe(parsingError: Error) -> String { if let error = parsingError as? HumanReadableDecodingErrorProtocol { return "Error parsing template. \(error.readableDescription)" } return "Error parsing template. \((parsingError as NSError).localizedFailureReason ?? parsingError.localizedDescription)" } open func parsePageJSON() throws { if let backgroundRequest = loadObject?.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject?.identifier { MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageProcessingComplete(pageType: pageType, requestUUID: identifier, webUrl: nil)) } } open class func verifyRequiredModulesLoaded(for loadObject: MVMCoreLoadObject?, error: AutoreleasingUnsafeMutablePointer) -> Bool { guard let pageType = loadObject?.pageType, var modulesRequired = MVMCoreUIViewControllerMappingObject.shared()?.modulesRequired(forPageType: pageType), !modulesRequired.isEmpty else { return true } guard let loadedModules = loadObject?.modulesJSON else { return false } for case let key as String in Array(loadedModules.keys) { guard modulesRequired.count > 0 else { break } if let index = modulesRequired.firstIndex(where: {($0 as? String) == key}) { modulesRequired.remove(at: index) } } guard modulesRequired.count == 0 else { if let loadObject = loadObject, let errorObject = MVMCoreLoadHandler.sharedGlobal()?.error(for: loadObject, withTitle:nil, message: MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorCritical), code: ErrorCode.requiredModuleNotPresent.rawValue, domain: ErrorDomainNative) { errorObject.messageToLog = modulesRequired.description error.pointee = errorObject } return false } return true } /// Creates a legacy navigation model. open func createDefaultLegacyNavigationModel() -> NavigationItemModel { let navigationModel = NavigationItemModel() navigationModel.title = model?.screenHeading return navigationModel } /// Processes any new data. Called after the page is loaded the first time and on response updates for this page, Triggers a render refresh. @MainActor open func handleNewData() { if model?.navigationBar == nil { let navigationItem = createDefaultLegacyNavigationModel() model?.navigationBar = navigationItem } executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.onPageNew(rootMolecules: getRootMolecules(), delegateObjectIVar) } if formValidator == nil { let rules = model?.formRules formValidator = FormValidator(rules) } updateUI() // Notify the manager of new data. // Warning: Some flows cause table reloads. Until the UI update is decoupled, should be after the updateUI. manager?.newDataReceived?(in: self) } /// Applies the latest model to the UI. open func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { guard molecules == nil else { return } executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willRender(rootMolecules: getRootMolecules(), delegateObjectIVar) } if let backgroundColor = model?.backgroundColor { view.backgroundColor = backgroundColor.uiColor } needsUpdateUI = true view.setNeedsLayout() } public func generateMoleculeView(from model: MoleculeModelProtocol) -> MoleculeViewProtocol? { executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willSetupMolecule(with: model, updating: nil) } guard let moleculeView = ModelRegistry.createMolecule(model, delegateObject: delegateObjectIVar) else { return nil } executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.didSetupMolecule(view: moleculeView, withModel: model) } return moleculeView } public func updateMoleculeView(_ view: MoleculeViewProtocol, from model: MoleculeModelProtocol) { executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willSetupMolecule(with: model, updating: view) } view.set(with: model, delegateObjectIVar, nil) executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.didSetupMolecule(view: view, withModel: model) } } //-------------------------------------------------- // MARK: - Navigation Item //-------------------------------------------------- open func getNavigationModel() -> NavigationItemModelProtocol? { // TODO: remove legacy. Temporary, convert legacy to navigation model. if model?.navigationBar == nil { let navigationItem = createDefaultLegacyNavigationModel() model?.navigationBar = navigationItem } return model?.navigationBar } //-------------------------------------------------- // MARK: - View Lifecycle //-------------------------------------------------- /// Called only once in viewDidLoad open func initialLoad() { observeForResponseJSONUpdates() } /// Called on screen size update. open func updateViews() { _ = formValidator?.validate() } override open func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Loaded : \(self)") // We use our own margins. viewRespectsSystemMinimumLayoutMargins = false // Presents from the bottom. modalPresentationStyle = MVMCoreGetterUtility.isOnIPad() ? .formSheet : .overCurrentContext // Do some initial loading. if !initialLoadFinished { initialLoadFinished = true initialLoad() } handleNewData() } open override func viewDidLayoutSubviews() { // Add to fix a constraint bug where the width is zero and things get messed up. guard isViewLoaded, view.bounds.width > 1 else { super.viewDidLayoutSubviews() return } // First update should be explicit (hence the zero check) if needsUpdateUI || (previousScreenSize != .zero && screenSizeChanged()) { needsUpdateUI = false updateViews() } previousScreenSize = view.bounds.size; super.viewDidLayoutSubviews() } open func pageShown() { // Track. MVMCoreUISession.sharedGlobal()?.currentPageType = pageType MVMCoreUILoggingHandler.shared()?.defaultLogPageState(forController: self) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.onPageShown(self?.delegateObjectIVar) } if let backgroundRequest = loadObject?.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject?.identifier { MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageRenderComplete(pageType: pageType, requestUUID: identifier, templateName: loadObject?.pageJSON?.optionalStringForKey("template"), controllerName: "\(type(of: self))", error: loadObject?.responseInfoMap?.optionalStringForKey("message"))) } } open override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.willShowPage(self?.delegateObjectIVar) } } open override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if manager == nil { pageShown() } } open override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.willHidePage(self?.delegateObjectIVar) } } open override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.onPageHidden(self?.delegateObjectIVar) } } deinit { stopObservingForResponseJSONUpdates() MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Deallocated : \(self)") } open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { MVMCoreGetterUtility.isOnIPad() ? UIInterfaceOrientationMask.all : UIInterfaceOrientationMask.portrait } open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) // Updates the detail view width coordinator.animate(alongsideTransition: { UIViewControllerTransitionCoordinatorContext in }) { UIViewControllerTransitionCoordinatorContext in self.view.setNeedsLayout() } } //-------------------------------------------------- // MARK: - MVMCoreViewManagerViewControllerProtocol //-------------------------------------------------- open func viewControllerReady(inManager manager: UIViewController & MVMCoreViewManagerProtocol) { pageShown() } //-------------------------------------------------- // MARK: - MVMCoreLoadDelegateProtocol //-------------------------------------------------- // TODO: Move this function out of here after architecture cleanup. open func loadFinished(_ loadObject: MVMCoreLoadObject?, loadedViewController: (UIViewController & MVMCoreViewControllerProtocol)?, error: MVMCoreErrorObject?) { MVMCoreUILoggingHandler.log(withDelegateLoadFinished: loadObject, loadedViewController: loadedViewController, error: error) // Open the support panel if error == nil, (loadObject?.requestParameters?.openSupportPanel ?? false) || (loadObject?.systemParametersJSON?.boolForKey(KeyOpenSupport) ?? false) { MVMCoreUISession.sharedGlobal()?.splitViewController?.showRightPanel(animated: true) } } /// Override this method to avoid adding form params. open func addFormParams(requestParameters: MVMCoreRequestParameters, additionalData: [AnyHashable: Any]?) { formValidator?.addFormParams(requestParameters: requestParameters, model: MVMCoreUIActionHandler.getSourceModel(from: additionalData) as? MoleculeModelProtocol & FormItemProtocol) } public func handleFieldErrors(_ fieldErrors: [Any]?, loadObject: MVMCoreLoadObject) { for case let fieldError as [AnyHashable: Any] in fieldErrors ?? [] { guard let fieldName = fieldError["fieldName"] as? String, let userError = fieldError["userMessage"] as? String, let entryFieldModel = formValidator?.fields[fieldName] as? EntryFieldModel else { continue } entryFieldModel.shouldClearText = fieldError["clearText"] as? Bool ?? true entryFieldModel.dynamicErrorMessage = userError } } //-------------------------------------------------- // MARK: - MVMCoreActionDelegateProtocol //-------------------------------------------------- open func logAction(withActionInformation actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) { MVMCoreUILoggingHandler.shared()?.defaultLogAction(forController: self, actionInformation: actionInformation, additionalData: additionalData) } open func performAction(with model: ActionModelProtocol, additionalData: [AnyHashable : Any]?, delegateObject: DelegateObject?) async throws { var additionalData = additionalData if let model = model as? ActionOpenPageModel { addFormParams(requestParameters: model.requestParameters, additionalData: additionalData) model.requestParameters.parentPageType = loadObject?.pageJSON?.optionalStringForKey("parentPageType") var pageForwardedData = additionalData ?? [:] executeBehaviors { (behavior: PageLocalDataShareBehavior) in let dataMap = behavior.compileLocalPageDataForTransfer(delegateObjectIVar) pageForwardedData.merge(dataMap) { current, _ in current } } additionalData = pageForwardedData } do { if let behavior = behaviors?.compactMap({ $0 as? PageCustomActionHandlerBehavior }).first(where: { $0.canHandleAction(with: model, additionalData: additionalData) }) { logAction(withActionInformation: model.toJSON(), additionalData: additionalData) try await behavior.handleAction(with: model, additionalData: additionalData) } else { try await MVMCoreUIActionHandler.shared()?.handleAction(with: model, additionalData: additionalData, delegateObject: delegateObject) } } catch { handleAction(error: error, model: model, additionalData: additionalData, delegateObject: delegateObject) throw error } } open func handleAction(error: Error, model: ActionModelProtocol, additionalData: [AnyHashable : Any]?, delegateObject: DelegateObject?) { let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: MVMCoreActionHandler.getErrorLocation(with: delegateObject?.actionDelegate, actionType: model.actionType))! switch (model) { case let model as ActionOpenPageModel: errorObject.silentError = model.background ?? false default: errorObject.silentError = false } MVMCoreUIActionHandler.shared()?.defaultHandleActionError(errorObject, additionalData: additionalData, delegateObject: delegateObject) } //-------------------------------------------------- // MARK: - MoleculeDelegateProtocol //-------------------------------------------------- open func getTemplateModel() -> TemplateModelProtocol? { model } open func getRootMolecules() -> [MoleculeModelProtocol] { model?.rootMolecules ?? [] } // Needed otherwise when subclassed, the extension gets called. open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { } public func replaceMoleculeData(_ moleculeModels: [MoleculeModelProtocol]) { pageUpdateQueue.addOperation { let replacedModels:[MoleculeModelProtocol] = moleculeModels.compactMap { model in guard self.attemptToReplace(with: model) else { return nil } return model } if replacedModels.count > 0 { DispatchQueue.main.sync { self.updateUI(for: replacedModels) } } } } open func attemptToReplace(with replacementModel: MoleculeModelProtocol) -> Bool { guard var templateModel = getTemplateModel() else { return false } var didReplace = false do { didReplace = try templateModel.replaceMolecule(with: replacementModel) if !didReplace { MVMCoreLoggingHandler.addError(toLog: MVMCoreErrorObject(title: nil, messageToLog: "Failed to find '\(replacementModel.id)' in the current screen.", code: ErrorCode.viewControllerProcessingJSON.rawValue, domain: ErrorDomainSystem, location: String(describing: type(of: self)))!) } } catch { let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: String(describing: type(of: self)))! if let error = error as? HumanReadableDecodingErrorProtocol { coreError.messageToLog = "Error replacing molecule \"\(replacementModel.id)\": \(error.readableDescription)" } MVMCoreLoggingHandler.addError(toLog: coreError) } return didReplace } //-------------------------------------------------- // MARK: - MVMCoreUIDetailViewProtocol //-------------------------------------------------- public func isLeftPanelAccessible() -> Bool { // TODO: Remove when hamburger menu is fully phased out. if loadObject?.pageJSON?.boolForKey(KeyHideMainMenu) ?? false { return false } return MVMCoreUISession.sharedGlobal()?.launchAppLoadedSuccessfully ?? false } public func isRightPanelAccessible() -> Bool { // TODO: Remove when FAB is 100%. if loadObject?.pageJSON?.boolForKey(KeyHideMainMenu) ?? false { return false } return (MVMCoreUISession.sharedGlobal()?.launchAppLoadedSuccessfully ?? false) || showRightPanelForScreenBeforeLaunchApp() } open func showRightPanelForScreenBeforeLaunchApp() -> Bool { loadObject?.pageJSON?.lenientBoolForKey("showRightPanel") ?? false } // TODO: make molecular open func isOverridingRightButton() -> Bool { guard let rightPanelLink = loadObject?.pageJSON?.optionalDictionaryForKey("rightPanelButtonLink") else { return false } MVMCoreActionHandler.shared()?.handleAction(with: rightPanelLink, additionalData: nil, delegateObject: delegateObject()) return true } // TODO: make molecular open func isOverridingLeftButton() -> Bool { guard let leftPanelLink = loadObject?.pageJSON?.optionalDictionaryForKey("leftPanelButtonLink") else { return false } MVMCoreActionHandler.shared()?.handleAction(with: leftPanelLink, additionalData: nil, delegateObject: delegateObject()) return true } // Eventually will be moved to Model open func bottomProgress() -> Float? { guard let progressString = loadObject?.pageJSON?.optionalStringForKey(KeyProgressPercent), let progress = Float(progressString) else { return nil } return progress / Float(100) } //-------------------------------------------------- // MARK: - UITextFieldDelegate //-------------------------------------------------- // To Remove TextFields Bug: Keyboard is not dismissing after reaching textfield max length limit open func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return true } open func textFieldDidBeginEditing(_ textField: UITextField) { selectedField = textField // TODO: Make this into a protocol if UIAccessibility.isVoiceOverRunning { if let toolBar = textField.inputAccessoryView as? UIToolbar, let _ = toolBar.items?.last, let pickerView = textField.inputView as? UIPickerView { view.accessibilityElements = [pickerView, toolBar] } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { UIAccessibility.post(notification: .layoutChanged, argument: textField.inputView) } } } open func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { if textField === selectedField { if UIAccessibility.isVoiceOverRunning { view.accessibilityElements = nil UIAccessibility.post(notification: .layoutChanged, argument: textField) } selectedField = nil } } @objc open func dismissFieldInput(_ sender: Any?) { selectedField?.resignFirstResponder() } //-------------------------------------------------- // MARK: - UITextViewDelegate //-------------------------------------------------- public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { selectedField = textView return true } open func textViewDidBeginEditing(_ textView: UITextView) { selectedField = textView } open func textViewDidEndEditing(_ textView: UITextView) { if textView === selectedField { selectedField = nil } } }