diff --git a/ipad.xcodeproj/project.pbxproj b/ipad.xcodeproj/project.pbxproj index c956d394..4a3e417e 100644 --- a/ipad.xcodeproj/project.pbxproj +++ b/ipad.xcodeproj/project.pbxproj @@ -221,6 +221,7 @@ A7AE5275237CC3660044DBB7 /* unapproved-cross.json in Resources */ = {isa = PBXBuildFile; fileRef = A7AE5272237CC3660044DBB7 /* unapproved-cross.json */; }; A7AE5276237CC3660044DBB7 /* check-mark-success.json in Resources */ = {isa = PBXBuildFile; fileRef = A7AE5273237CC3660044DBB7 /* check-mark-success.json */; }; A7AE5277237CC3660044DBB7 /* sync-circle.json in Resources */ = {isa = PBXBuildFile; fileRef = A7AE5274237CC3660044DBB7 /* sync-circle.json */; }; + E42434792D1499A800C7EF20 /* ScrollAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = E42434782D14999600C7EF20 /* ScrollAlert.swift */; }; F5EB30A127B5C77400F22712 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 29CEC91F23676424003B21B9 /* Main.storyboard */; }; /* End PBXBuildFile section */ @@ -467,6 +468,7 @@ A7AE5272237CC3660044DBB7 /* unapproved-cross.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "unapproved-cross.json"; sourceTree = ""; }; A7AE5273237CC3660044DBB7 /* check-mark-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "check-mark-success.json"; sourceTree = ""; }; A7AE5274237CC3660044DBB7 /* sync-circle.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "sync-circle.json"; sourceTree = ""; }; + E42434782D14999600C7EF20 /* ScrollAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollAlert.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -724,6 +726,7 @@ 299BF01A23FC9CE9001732CD /* PDFViewer */, 294EF3102370884300EEB3B6 /* Banner */, 29BAC50523676EA400A620F4 /* Alert.swift */, + E42434782D14999600C7EF20 /* ScrollAlert.swift */, 29BAC50F2367984000A620F4 /* GradiantView.swift */, ); path = UI; @@ -1774,6 +1777,7 @@ 2907E4B72368D24900946B3F /* InputModal.swift in Sources */, 29547B4A23F0BF4F000173F1 /* SelectedWaterBodyCollectionViewCell.swift in Sources */, 2991969023846AE000634F81 /* ShiftViewController.swift in Sources */, + E42434792D1499A800C7EF20 /* ScrollAlert.swift in Sources */, 29DB01092370EFD60046E605 /* TextAreaInputCollectionViewCell.swift in Sources */, 5C82D3EA2374BCFA00B065BA /* FoundationExtension.swift in Sources */, 29CEC91E23676424003B21B9 /* ViewController.swift in Sources */, @@ -2059,7 +2063,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.4; + MARKETING_VERSION = 2.8.5; PRODUCT_BUNDLE_IDENTIFIER = ca.bc.gov.InvasivesBC; PRODUCT_NAME = Inspect; PROVISIONING_PROFILE_SPECIFIER = "InvasivesBC Muscles - 2023/24"; @@ -2089,7 +2093,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.4; + MARKETING_VERSION = 2.8.5; PRODUCT_BUNDLE_IDENTIFIER = ca.bc.gov.InvasivesBC; PRODUCT_NAME = Inspect; PROVISIONING_PROFILE_SPECIFIER = "InvasivesBC Muscles - 2023/24"; diff --git a/ipad/Utilities/Core/Extensions/UIViewControllerExtensions.swift b/ipad/Utilities/Core/Extensions/UIViewControllerExtensions.swift index 7f127063..ef61a60f 100644 --- a/ipad/Utilities/Core/Extensions/UIViewControllerExtensions.swift +++ b/ipad/Utilities/Core/Extensions/UIViewControllerExtensions.swift @@ -145,3 +145,21 @@ extension UIViewController { return alert } } + +extension UIViewController { + func topMostViewController() -> UIViewController { + if let presented = self.presentedViewController { + return presented.topMostViewController() + } + + if let navigation = self as? UINavigationController { + return navigation.visibleViewController?.topMostViewController() ?? navigation + } + + if let tab = self as? UITabBarController { + return tab.selectedViewController?.topMostViewController() ?? tab + } + + return self + } +} diff --git a/ipad/Utilities/Core/UI/Alert.swift b/ipad/Utilities/Core/UI/Alert.swift index 9f0a70e5..534f5065 100644 --- a/ipad/Utilities/Core/UI/Alert.swift +++ b/ipad/Utilities/Core/UI/Alert.swift @@ -17,57 +17,34 @@ class Alert { and return call back when user makes selection */ static func show(title: String, message: String, yes: @escaping()-> Void, no: @escaping()-> Void) { - //self.showDefaultAlert(title: title, message: message, yes: yes, no: no) - self.showCustomAlert(title: title, message: message, yes: yes, no: no); + self.showCustomAlert(title: title, message: message, yes: yes, no: no) } /** Show an alert message with an okay button */ static func show(title: String, message: String) { - //self.showDefaultAlert(title: title, message: message) - self.showCustomAlert(title: title, message: message); + ModalAlert.show(title: title, message: message) } - // MARK: Default iOS alerts - private static func showDefaultAlert(title: String, message: String, yes: @escaping()-> Void, no: @escaping()-> Void) { - DispatchQueue.main.async(execute: { - let alertWindow = UIWindow(frame: UIScreen.main.bounds) - alertWindow.rootViewController = UIViewController() - alertWindow.windowLevel = UIWindow.Level.alert + 1 - - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "Yes", style: UIAlertAction.Style.default, handler: { action in - return yes(); - })) - alert.addAction(UIAlertAction(title: "No", style: UIAlertAction.Style.cancel, handler: { action in - return no(); - })) - - alertWindow.makeKeyAndVisible() - alertWindow.rootViewController?.present(alert, animated: true, completion: nil) - }) + /** + Show a validation alert message for inspections + */ + static func showValidation(title: String, message: String) { + if let topVC = UIApplication.shared.windows.first?.rootViewController?.topMostViewController() { + let alertVC = ScrollableAlertViewController() + alertVC.modalPresentationStyle = .overFullScreen + alertVC.modalTransitionStyle = .crossDissolve + alertVC.configure(title: title, message: message) + topVC.present(alertVC, animated: true) + } } - - private static func showDefaultAlert(title: String, message: String) { - DispatchQueue.main.async(execute: { - let alertWindow = UIWindow(frame: UIScreen.main.bounds) - alertWindow.rootViewController = UIViewController() - alertWindow.windowLevel = UIWindow.Level.alert + 1 - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - let defaultAction2 = UIAlertAction(title: "OK", style: .default, handler: { action in - }) - alert.addAction(defaultAction2) - - alertWindow.makeKeyAndVisible() - alertWindow.rootViewController?.present(alert, animated: true, completion: nil) - }) - } + // MARK: Custom alerts using Modal pod private static func showCustomAlert(title: String, message: String, yes: @escaping()-> Void, no: @escaping()-> Void) { - ModalAlert.show(title: title, message: message, yes: yes, no: no); + ModalAlert.show(title: title, message: message, yes: yes, no: no) } private static func showCustomAlert(title: String, message: String) { diff --git a/ipad/Utilities/Core/UI/ScrollAlert.swift b/ipad/Utilities/Core/UI/ScrollAlert.swift new file mode 100644 index 00000000..03e14fab --- /dev/null +++ b/ipad/Utilities/Core/UI/ScrollAlert.swift @@ -0,0 +1,95 @@ +// +// ScrollAlert.swift +// ipad +// +// Created by Paul Garewal on 2024-12-19. +// Copyright © 2024 Amir Shayegh. All rights reserved. +// + +import UIKit + +class ScrollableAlertViewController: UIViewController { + + private let titleLabel = UILabel() + private let textView = UITextView() + private let okButton = UIButton(type: .system) + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + private func setupView() { + // Configure background + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + + // Container for the alert + let alertContainer = UIView() + alertContainer.backgroundColor = .white + alertContainer.layer.cornerRadius = 12 + alertContainer.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(alertContainer) + + // Title label + titleLabel.font = .boldSystemFont(ofSize: 17) + titleLabel.textColor = .black + titleLabel.numberOfLines = 1 + titleLabel.textAlignment = .center + titleLabel.translatesAutoresizingMaskIntoConstraints = false + alertContainer.addSubview(titleLabel) + + // Scrollable text view + textView.isEditable = false + textView.isScrollEnabled = true + textView.font = .systemFont(ofSize: 14) + textView.textColor = .black + textView.backgroundColor = .clear + textView.showsVerticalScrollIndicator = true + textView.translatesAutoresizingMaskIntoConstraints = false + alertContainer.addSubview(textView) + + // OK button + okButton.setTitle("OK", for: .normal) + okButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + okButton.setTitleColor(.systemBlue, for: .normal) + okButton.translatesAutoresizingMaskIntoConstraints = false + okButton.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + okButton.backgroundColor = .clear + alertContainer.addSubview(okButton) + okButton.addTarget(self, action: #selector(dismissAlert), for: .touchUpInside) + + NSLayoutConstraint.activate([ + // Alert container + alertContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor), + alertContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor), + alertContainer.widthAnchor.constraint(equalToConstant: 270), + + // Title label + titleLabel.topAnchor.constraint(equalTo: alertContainer.topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: alertContainer.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: alertContainer.trailingAnchor, constant: -16), + + // Text view + textView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + textView.leadingAnchor.constraint(equalTo: alertContainer.leadingAnchor, constant: 16), + textView.trailingAnchor.constraint(equalTo: alertContainer.trailingAnchor, constant: -16), + textView.heightAnchor.constraint(equalToConstant: 200), + + // OK button + okButton.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 8), + okButton.bottomAnchor.constraint(equalTo: alertContainer.bottomAnchor, constant: -8), + okButton.leadingAnchor.constraint(equalTo: alertContainer.leadingAnchor), + okButton.trailingAnchor.constraint(equalTo: alertContainer.trailingAnchor), + okButton.heightAnchor.constraint(equalToConstant: 44) + ]) + } + + @objc private func dismissAlert() { + dismiss(animated: true) + } + + func configure(title: String, message: String) { + titleLabel.text = title + textView.text = message + } +} diff --git a/ipad/ViewControllers/Shift/ShiftViewController.swift b/ipad/ViewControllers/Shift/ShiftViewController.swift index bc9f571c..6395a44f 100644 --- a/ipad/ViewControllers/Shift/ShiftViewController.swift +++ b/ipad/ViewControllers/Shift/ShiftViewController.swift @@ -290,90 +290,81 @@ class ShiftViewController: BaseViewController { } func validationMessage() -> String { - var message: String = "" - guard let model = self.model else { return message } - var counter = 1 + var messages: [String] = [] + guard let model = self.model else { return "" } + + // Group validation messages by category + + // Shift Time Validations if model.startTime.isEmpty { - message = "\(message)\n\(counter)- Missing Shift Start time." - counter += 1 + messages.append("⏰ Shift Start time is required") } if model.endTime.isEmpty { - message = "\(message)\n\(counter)- Missing Shift End time." - counter += 1 + messages.append("⏰ Shift End time is required") } + // Inspection Count Validations if model.inspections.count > 0 && model.boatsInspected == false { - message = "\(message)\n\(counter)- You indicated that no boats were inspected, but inspections exist." - counter += 1 + messages.append("⚠️ Inspection count mismatch: You indicated no boats were inspected, but inspections exist") } if model.inspections.count < 1 && model.boatsInspected == true { - message = "\(message)\n\(counter)- You indicated that boats were inspected but inspections are missing." - counter += 1 + messages.append("⚠️ Inspection count mismatch: You indicated boats were inspected but no inspections are recorded") } + // Station Validations if model.station.isEmpty { - message = "\(message)\n\(counter)- Please choose a station." - counter += 1 + messages.append("📍 Station selection is required") } if model.stationComments.isEmpty && ShiftModel.stationRequired(model.station) { - message = "\(message)\n\(counter)- Please add station information." - counter += 1 + messages.append("📍 Station information is required") } - for inspection in model.inspections { + // Inspection Detail Validations + for (index, inspection) in model.inspections.enumerated() { if inspection.inspectionTime.isEmpty { - message = "\(message)\n\(counter)- Missing Time of Inspection." - counter += 1 + messages.append("🕒 Inspection #\(index + 1): Time of inspection is required") } - if inspection.unknownPreviousWaterBody == true || - inspection.commercialManufacturerAsPreviousWaterBody == true || - inspection.previousDryStorage == true { - if inspection.previousMajorCities.isEmpty { - message = "\(message)\n\(counter)- Please add Closest Major City for Previous Waterbody." - counter += 1 - } + // Previous Waterbody Validations + if (inspection.unknownPreviousWaterBody || + inspection.commercialManufacturerAsPreviousWaterBody || + inspection.previousDryStorage) { + messages.append("🌊 Inspection #\(index + 1): Previous waterbody requires closest major city") } - if inspection.unknownDestinationWaterBody == true || - inspection.commercialManufacturerAsDestinationWaterBody == true || - inspection.destinationDryStorage == true { - if inspection.destinationMajorCities.isEmpty { - message = "\(message)\n\(counter)- Please add Closest Major City for Destination Waterbody." - counter += 1 - } + // Destination Waterbody Validations + if (inspection.unknownDestinationWaterBody || + inspection.commercialManufacturerAsDestinationWaterBody || + inspection.destinationDryStorage) && inspection.destinationMajorCities.isEmpty { + messages.append("🎯 Inspection #\(index + 1): Destination waterbody requires closest major city") } - if !inspection.highRiskAssessments.isEmpty { - for highRisk in inspection.highRiskAssessments { - if highRisk.sealIssued == true && highRisk.sealNumber <= 0 { - message = "\(message)\n\(counter)- Please input the Seal #." - counter += 1 - } - - if highRisk.decontaminationOrderIssued == true && highRisk.decontaminationOrderNumber <= 0 { - message = "\(message)\n\(counter)- Please input the Decontamination order number." - counter += 1 - } + // High Risk Assessment Validations + for (riskIndex, highRisk) in inspection.highRiskAssessments.enumerated() { + if highRisk.sealIssued && highRisk.sealNumber <= 0 { + messages.append("🏷️ Inspection #\(index + 1) Risk #\(riskIndex + 1): Seal number is required") + } + + if highRisk.decontaminationOrderIssued && highRisk.decontaminationOrderNumber <= 0 { + messages.append("📄 Inspection #\(index + 1) Risk #\(riskIndex + 1): Decontamination order number is required") } } } - // Check for invalid inspections + // Form Validation Status let invalidInspections = model.inspections.filter { !$0.formDidValidate } if !invalidInspections.isEmpty { - message = "\(message)\n\(counter)- One or more inspections contain validation errors. Please review each inspection." - counter += 1 + messages.append("❌ One or more inspections contain validation errors") } - if !message.isEmpty { - model.set(status: .Errors) - } + if !messages.isEmpty { + model.set(status: .Errors) + } - return message + return messages.joined(separator: "\n\n") } func createTestModel() { diff --git a/ipad/ViewControllers/Watercraft Inspections/WatercraftInspectionViewController.swift b/ipad/ViewControllers/Watercraft Inspections/WatercraftInspectionViewController.swift index 5e4490f9..04a28dae 100644 --- a/ipad/ViewControllers/Watercraft Inspections/WatercraftInspectionViewController.swift +++ b/ipad/ViewControllers/Watercraft Inspections/WatercraftInspectionViewController.swift @@ -209,7 +209,7 @@ class WatercraftInspectionViewController: BaseViewController { if canSubmit() { self.navigationController?.popViewController(animated: true) } else { - Alert.show(title: "Incomplete", message: validationMessage()) + Alert.showValidation(title: "Inspection Incomplete", message: validationMessage()) } } @@ -332,11 +332,11 @@ class WatercraftInspectionViewController: BaseViewController { } enum Section: String { - case basicInformation = "- BASIC INFORMATION -" - case watercraftDetails = "- WATERCRAFT DETAILS -" - case journeyDetails = "- JOURNEY DETAILS -" - case inspectionDetails = "- INSPECTION DETAILS -" - case inspectionOutcomes = "- INSPECTION OUTCOMES -" + case basicInformation = " BASIC INFORMATION " + case watercraftDetails = " WATERCRAFT DETAILS " + case journeyDetails = " JOURNEY DETAILS " + case inspectionDetails = " INSPECTION DETAILS " + case inspectionOutcomes = " INSPECTION OUTCOMES " } /// Validation struct for the errors, their messages, and the condition where they should be called in validation process @@ -684,36 +684,57 @@ class WatercraftInspectionViewController: BaseViewController { } } + // Build the errors into a readable format, by section var message = "" - // Build the errors into a readable format, by section for sectionError in validationErrors { let section = sectionError.section.rawValue let errors = sectionError.errors if !errors.isEmpty { - message += "\(section)\n\n" + // Add section header + message += "📋 \(section)\n\n" + + // Add bullet points with emojis for error in errors { - message += "· \(error)\n\n" + let emoji = getEmoji(for: error) + message += "\(emoji) \(error)\n" } - message += "\n" + message += "\n\n" } } + model.set(value: message.isEmpty, for: "formDidValidate") if (message.isEmpty) { - model.set(status: .Draft) + model.set(status: .Draft) } return message } + private func getEmoji(for error: String) -> String { + if error.lowercased().contains("time") { + return "⏰" + } else if error.lowercased().contains("waterbody") { + return "🌊" + } else if error.lowercased().contains("destination") { + return "🎯" + } else if error.lowercased().contains("seal") { + return "🏷️" + } else if error.lowercased().contains("decontamination") { + return "💧" + } else { + return "❌" + } + } + @objc func didTapCheckmarkButton(sender: UIBarButtonItem) { self.dismissKeyboard() if canSubmit() { self.navigationController?.popViewController(animated: true) } else { - Alert.show(title: "Incomplete", message: validationMessage()) + Alert.showValidation(title: "Inspection Incomplete", message: validationMessage()) } }