// // ViewController.swift // MVMCoreUI // // Created by Scott Pfeil on 11/5/19. // Copyright © 2019 Verizon Wireless. All rights reserved. // import UIKit @objc open class ViewController: UIViewController, MVMCoreViewControllerProtocol, MVMCoreViewManagerViewControllerProtocol, MoleculeDelegateProtocol, FormHolderProtocol, MVMCoreActionDelegateProtocol, 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 var manager: (UIViewController & MVMCoreViewManagerProtocol)? /// A temporary iVar backer for delegateObject() until we change the protocol public 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 = false private var initialLoadFinished = false public var previousScreenSize = CGSize.zero public var selectedField: UIView? // Stores the previous tab bar index. public var tabBarIndex: Int? /// 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, (pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0) else { return } observingForResponses = true NotificationCenter.default.addObserver(self, selector: #selector(responseJSONUpdated(notification:)), name: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil) } open func stopObservingForResponseJSONUpdates() { guard observingForResponses else { return } NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil) observingForResponses = false } open func pagesToListenFor() -> [String]? { guard let pageType = loadObject?.pageType else { return nil } return [pageType] } open func modulesToListenFor() -> [String]? { loadObject?.requestParameters?.allModules() } @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 { try parsePageJSON() MVMCoreDispatchUtility.performBlock(onMainThread: { self.handleNewDataAndUpdateUI() }) } catch { if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: pageType))") { MVMCoreLoggingHandler.shared()?.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. handleLoggingFor(parsingError: parsingError) if let errorObject = MVMCoreErrorObject.createErrorObject(for: parsingError, location: MVMCoreLoadHandler.sharedGlobal()?.errorLocation(forRequest: loadObject)) { errorObject.messageToDisplay = MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorUnableToProcess) error.pointee = errorObject } return false } if let pageData = loadObject.dataForPage { executeBehaviors { (behavior: PageLocalDataShareBehavior) in behavior.receiveLocalPageData(pageData, delegateObjectIVar) } } return true } func handleLoggingFor(parsingError: Error) { if let registryError = parsingError as? ModelRegistry.Error { switch (registryError) { case .decoderErrorModelNotMapped(let identifier, let codingKey, let codingPath) where identifier != nil && codingKey != nil && codingPath != nil: MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Model identifier \"\(identifier!)\" is not mapped for \"\(codingKey!.stringValue)\" @ \(codingPath!.map { return $0.stringValue })") case .decoderErrorObjectNotPresent(let codingKey, codingPath: let codingPath): MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Required model \"\(codingKey.stringValue)\" was not found @ \(codingPath.map { return $0.stringValue })") default: MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Registry error: \(registryError)") } } if let decodingError = parsingError as? DecodingError { switch (decodingError) { case .keyNotFound(let codingKey, let context): MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Key \(codingKey.stringValue) was not found @ \(context.codingPath.map { return $0.stringValue })") case .valueNotFound(_, let context): MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Value not found @ \(context.codingPath.map { return $0.stringValue })") case .typeMismatch(_, let context): MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Type mismatch @ \(context.codingPath.map { return $0.stringValue })") case .dataCorrupted(let context): MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: Data corrupted @ \(context.codingPath.map { return $0.stringValue })") @unknown default: MVMCoreLoggingHandler.shared()?.handleDebugMessage("Error parsing template: \(parsingError)") } } } open func parsePageJSON() throws { } 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 errorObject = MVMCoreErrorObject(title: nil, message: MVMCoreGetterUtility.hardcodedString(withKey: HardcodedErrorCritical), messageToLog: modulesRequired.description, code: ErrorCode.requiredModuleNotPresent.rawValue, domain: ErrorDomainNative, location: MVMCoreLoadHandler.sharedGlobal()?.errorLocation(forRequest: loadObject!)) { error.pointee = errorObject } return false } return true } /// Calls processNewData and then sets the ui to update with updateView open func handleNewDataAndUpdateUI() { handleNewData() needsUpdateUI = true view.setNeedsLayout() } /// 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, 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) } if let backgroundColor = model?.backgroundColor { view.backgroundColor = backgroundColor.uiColor } // Update splitview properties if self == MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() { MVMCoreUISplitViewController.main()?.setBottomProgressBarProgress(bottomProgress() ?? 0) updateTabBar() } // Notify the manager of new data manager?.newDataReceived?(in: self) } //-------------------------------------------------- // 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: - TabBar //-------------------------------------------------- open func updateTabBar() { guard MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() == self else { return } MVMCoreUISplitViewController.main()?.tabBar?.delegateObject = delegateObjectIVar if let index = (model as? TabPageModelProtocol)?.tabBarIndex { MVMCoreUISplitViewController.main()?.tabBar?.highlightTab(at: index) } else if let index = loadObject?.requestParameters?.actionMap?["tabBarIndex"] as? Int { MVMCoreUISplitViewController.main()?.tabBar?.highlightTab(at: index) } else if let index = self.tabBarIndex { MVMCoreUISplitViewController.main()?.tabBar?.highlightTab(at: index) } else if let index = MVMCoreUISplitViewController.main()?.tabBar?.currentTabIndex() { // Store current tab index for cases like back button. self.tabBarIndex = index } if let hidden = (model as? TabPageModelProtocol)?.tabBarHidden { MVMCoreUISplitViewController.main()?.updateTabBarShowing(!hidden) } else if let hidden = loadObject?.requestParameters?.actionMap?["tabBarHidden"] as? Bool { MVMCoreUISplitViewController.main()?.updateTabBarShowing(!hidden) } else { MVMCoreUISplitViewController.main()?.updateTabBarShowing(true) } } //-------------------------------------------------- // 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() } handleNewDataAndUpdateUI() } 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() { // Update split view properties if this is the current detail controller. if self == MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() { MVMCoreUISplitViewController.main()?.setupPanels() MVMCoreUISplitViewController.main()?.setBottomProgressBarProgress(bottomProgress() ?? 0) updateTabBar() } // Track. MVMCoreUISession.sharedGlobal()?.currentPageType = pageType MVMCoreUILoggingHandler.shared()?.defaultLogPageState(forController: self) executeBehaviors { [weak self] (behavior: PageVisibilityBehavior) in behavior.onPageShown(self?.delegateObjectIVar) } } open override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if manager == nil { pageShown() } } 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 ?? (loadObject?.systemParametersJSON?.boolForKey(KeyOpenSupport) ?? false) == true { MVMCoreUISession.sharedGlobal()?.splitViewController?.showRightPanel(animated: true) } } /// Override this method to avoid adding form params. open func addFormParams(requestParameters: MVMCoreRequestParameters, actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) { formValidator?.addFormParams(requestParameters: requestParameters, model: additionalData?[KeySourceModel] 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 handleOpenPage(for requestParameters: MVMCoreRequestParameters, actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) { addFormParams(requestParameters: requestParameters, actionInformation: actionInformation, additionalData: additionalData) requestParameters.parentPageType = loadObject?.pageJSON?.optionalStringForKey("parentPageType") var pageForwardedData = additionalData ?? [:] executeBehaviors { (behavior: PageLocalDataShareBehavior) in let dataMap = behavior.compileLocalPageDataForTransfer(delegateObjectIVar) pageForwardedData.merge(dataMap) { current, _ in current } } MVMCoreActionHandler.defaultHandleOpenPage(for: requestParameters, actionInformation: actionInformation, additionalData: pageForwardedData, delegateObject: delegateObject()) } open func logAction(withActionInformation actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) { MVMCoreUILoggingHandler.shared()?.defaultLogAction(forController: self, actionInformation: actionInformation, additionalData: additionalData) } open func handleUnknownActionType(_ actionType: String?, actionInformation: [AnyHashable: Any]?, additionalData: [AnyHashable: Any]?) { var handled = false executeBehaviors { (behavior: PageCustomActionHandlerBehavior) in if !handled { handled = behavior.handleAction(type: actionType, information: actionInformation, additionalData: additionalData) } } if !handled { MVMCoreUIActionHandler.defaultHandleUnknownActionType(actionType, actionInformation: actionInformation, additionalData: additionalData, delegateObject: delegateObjectIVar) } } //-------------------------------------------------- // MARK: - MoleculeDelegateProtocol //-------------------------------------------------- open func getRootMolecules() -> [MoleculeModelProtocol] { model?.rootMolecules ?? [] } open func getModuleWithName(_ name: String?) -> [AnyHashable: Any]? { guard let name = name else { return nil } return loadObject?.modulesJSON?.optionalDictionaryForKey(name) } open func getModuleWithName(_ moleculeName: String) -> MoleculeModelProtocol? { guard let moduleJSON = loadObject?.modulesJSON?.optionalDictionaryForKey(moleculeName), let moleculeName = moduleJSON.optionalStringForKey("moleculeName"), let modelType = ModelRegistry.getType(for: moleculeName, with: MoleculeModelProtocol.self) else { return nil } do { return try modelType.decode(jsonDict: moduleJSON) as? MoleculeModelProtocol } catch { MVMCoreUILoggingHandler.logDebugMessage(withDelegate: "error: \(error)") } return nil } // Needed otherwise when subclassed, the extension gets called. open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { } //-------------------------------------------------- // 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 } } //-------------------------------------------------- // MARK: - Behavior Execution //-------------------------------------------------- public func executeBehaviors(_ behaviorBlock: (_ behavior: T) -> Void) { behaviors?.compactMap { $0 as? T }.forEach { behaviorBlock($0) } } }