mirror of
https://github.com/AU-COVIDSafe/mobile-ios.git
synced 2025-04-19 13:05:21 +00:00
COVIDSafe code from version 1.1
This commit is contained in:
commit
3640e52eb2
330 changed files with 261540 additions and 0 deletions
46
CovidSafe/Feedback/Sources/ADGColorPallete.swift
Normal file
46
CovidSafe/Feedback/Sources/ADGColorPallete.swift
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
let ADGTintColor = UIColor(0x0052CC)
|
||||
let ADGChromeColor = UIColor(0xF4F5F7)
|
||||
let ADGHairlineColor = UIColor(0xEBECF0)
|
||||
let ADGTextColor = UIColor(0x172B4D)
|
||||
let ADGTextColorSecondary = UIColor(0xA5ADBA)
|
||||
|
||||
public enum NavigationBarStyle {
|
||||
case blue
|
||||
case white
|
||||
|
||||
var backgroundColor: UIColor {
|
||||
switch self {
|
||||
case .blue: return ADGTintColor
|
||||
case .white: return UIColor.white
|
||||
}
|
||||
}
|
||||
|
||||
var tintColor: UIColor {
|
||||
switch self {
|
||||
case .blue: return UIColor.white
|
||||
case .white: return ADGTintColor
|
||||
}
|
||||
}
|
||||
|
||||
var titleTextColor: UIColor {
|
||||
switch self {
|
||||
case .blue: return UIColor.white
|
||||
case .white: return ADGTextColor
|
||||
}
|
||||
}
|
||||
|
||||
var titleFont: UIFont { return UIFont.systemFont(ofSize: 17.0, weight: UIFont.Weight.medium) }
|
||||
|
||||
var statusBarStyle: UIStatusBarStyle {
|
||||
switch self {
|
||||
case .blue: return .lightContent
|
||||
case .white: return .default
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultStyle: NavigationBarStyle { return .blue }
|
||||
}
|
18
CovidSafe/Feedback/Sources/Action.swift
Normal file
18
CovidSafe/Feedback/Sources/Action.swift
Normal file
|
@ -0,0 +1,18 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class Action: Operation {
|
||||
override func main() {
|
||||
if isCancelled {
|
||||
return
|
||||
}
|
||||
autoreleasepool {
|
||||
self.run()
|
||||
}
|
||||
}
|
||||
|
||||
func run() {
|
||||
preconditionFailure("This abstract method must be overridden.")
|
||||
}
|
||||
}
|
21
CovidSafe/Feedback/Sources/AlertController.swift
Normal file
21
CovidSafe/Feedback/Sources/AlertController.swift
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class AlertController: UIAlertController {
|
||||
var lastPresentingViewController: UIViewController?
|
||||
var useCustomTransition: Bool = true
|
||||
var feedbackSettings: FeedbackSettings?
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if let viewController = presentingViewController {
|
||||
lastPresentingViewController = viewController
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.tintColor = ADGTintColor
|
||||
}
|
||||
}
|
72
CovidSafe/Feedback/Sources/AsyncAction.swift
Executable file
72
CovidSafe/Feedback/Sources/AsyncAction.swift
Executable file
|
@ -0,0 +1,72 @@
|
|||
// Copyright © 2015 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
open class AsyncAction: Operation {
|
||||
|
||||
fileprivate var _executing = false
|
||||
fileprivate var _finished = false
|
||||
|
||||
override fileprivate(set) open var isExecuting: Bool {
|
||||
get {
|
||||
return _executing
|
||||
}
|
||||
set {
|
||||
willChangeValue(forKey: "isExecuting")
|
||||
_executing = newValue
|
||||
didChangeValue(forKey: "isExecuting")
|
||||
}
|
||||
}
|
||||
|
||||
override fileprivate(set) open var isFinished: Bool {
|
||||
get {
|
||||
return _finished
|
||||
}
|
||||
set {
|
||||
willChangeValue(forKey: "isFinished")
|
||||
_finished = newValue
|
||||
didChangeValue(forKey: "isFinished")
|
||||
}
|
||||
}
|
||||
|
||||
override open var completionBlock: (() -> Void)? {
|
||||
set {
|
||||
super.completionBlock = newValue
|
||||
}
|
||||
get {
|
||||
return {
|
||||
super.completionBlock?()
|
||||
self.actionCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override open var isAsynchronous: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override open func start() {
|
||||
if isCancelled {
|
||||
isFinished = true
|
||||
return
|
||||
}
|
||||
|
||||
isExecuting = true
|
||||
autoreleasepool {
|
||||
self.run()
|
||||
}
|
||||
}
|
||||
|
||||
func run() {
|
||||
preconditionFailure("This abstract method must be overridden.")
|
||||
}
|
||||
|
||||
func actionCompleted() {
|
||||
//optional
|
||||
}
|
||||
|
||||
func finishedExecutingOperation() {
|
||||
isExecuting = false
|
||||
isFinished = true
|
||||
}
|
||||
}
|
38
CovidSafe/Feedback/Sources/BundleInfoExtension.swift
Normal file
38
CovidSafe/Feedback/Sources/BundleInfoExtension.swift
Normal file
|
@ -0,0 +1,38 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
private class JMCBundleHandle: NSObject { }
|
||||
|
||||
extension Foundation.Bundle {
|
||||
var version: String? {
|
||||
return infoDictionary?["CFBundleVersion"] as? String
|
||||
}
|
||||
|
||||
var versionShort: String? {
|
||||
return infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
|
||||
var name: String? {
|
||||
return infoDictionary?["CFBundleName"] as? String
|
||||
}
|
||||
|
||||
var displayName: String? {
|
||||
return infoDictionary?["CFBundleDisplayName"] as? String
|
||||
}
|
||||
|
||||
var identifier: String? {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
var infoAsDictionary: [String: NSObject] {
|
||||
|
||||
return [
|
||||
"appVersion": (version ?? "") as NSObject,
|
||||
"appVersionShort": versionShort as NSObject? ?? "" as NSObject,
|
||||
"appName": (name ?? "") as NSObject,
|
||||
"appDisplayName": (displayName ?? "") as NSObject,
|
||||
"appId": (identifier ?? "") as NSObject
|
||||
]
|
||||
}
|
||||
}
|
19
CovidSafe/Feedback/Sources/DeviceInfoExtension.swift
Normal file
19
CovidSafe/Feedback/Sources/DeviceInfoExtension.swift
Normal file
|
@ -0,0 +1,19 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIDevice {
|
||||
var infoAsDictionary: [String: NSObject] {
|
||||
return ["devName": name as NSObject,
|
||||
"systemName": systemName as NSObject,
|
||||
"systemVersion": systemVersion as NSObject,
|
||||
"model": model as NSObject,
|
||||
"uuid": identifierForVendorString as NSObject]
|
||||
}
|
||||
}
|
||||
|
||||
extension UIDevice {
|
||||
var identifierForVendorString: String {
|
||||
return identifierForVendor?.uuidString ?? ""
|
||||
}
|
||||
}
|
8
CovidSafe/Feedback/Sources/Errors.swift
Normal file
8
CovidSafe/Feedback/Sources/Errors.swift
Normal file
|
@ -0,0 +1,8 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum JMCError: Error {
|
||||
case httpResponseError
|
||||
case nilHTTPResponseError
|
||||
}
|
72
CovidSafe/Feedback/Sources/FeedbackSettings.swift
Normal file
72
CovidSafe/Feedback/Sources/FeedbackSettings.swift
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
public var defaultFeedbackSettings: FeedbackSettings?
|
||||
|
||||
public struct FeedbackSettings {
|
||||
let JIRATarget: JMCTarget
|
||||
let issueType: String
|
||||
let issueComponents: [String]
|
||||
let customFields: [String: AnyObject]
|
||||
let reporterAvatarImage: CGImage?
|
||||
var reporterUsernameOrEmail: String?
|
||||
let getReporterInfoAsynchronously: ((@escaping (String?, URL?) -> Void) -> Void)?
|
||||
let navigationBarStyle: NavigationBarStyle
|
||||
|
||||
public init(
|
||||
JIRATarget: JMCTarget,
|
||||
issueType: String? = nil,
|
||||
issueComponents: [String]? = nil,
|
||||
customFields: [String: AnyObject]? = nil,
|
||||
navigationBarStyle: NavigationBarStyle,
|
||||
reporterAvatarImage: CGImage? = nil,
|
||||
reporterUsernameOrEmail: String? = nil,
|
||||
getReporterInfoAsynchronously: ((@escaping (_ usernameOrEmail: String?, _ avatarImageURL: URL?) -> Void) -> Void)? = nil
|
||||
) {
|
||||
let defaultIssueType = JIRAIssueType.support.rawValue
|
||||
let defaultIssueComponents = ["iOS"]
|
||||
let defaultCustomFields = [String: AnyObject]()
|
||||
|
||||
self.JIRATarget = JIRATarget
|
||||
self.issueType = (issueType ?? defaultFeedbackSettings?.issueType) ?? defaultIssueType
|
||||
self.issueComponents = (issueComponents ?? defaultFeedbackSettings?.issueComponents) ?? defaultIssueComponents
|
||||
self.customFields = (customFields ?? defaultFeedbackSettings?.customFields) ?? defaultCustomFields
|
||||
self.reporterAvatarImage = reporterAvatarImage ?? defaultFeedbackSettings?.reporterAvatarImage
|
||||
self.reporterUsernameOrEmail = reporterUsernameOrEmail ?? defaultFeedbackSettings?.reporterUsernameOrEmail
|
||||
self.getReporterInfoAsynchronously = getReporterInfoAsynchronously ?? defaultFeedbackSettings?.getReporterInfoAsynchronously
|
||||
self.navigationBarStyle = navigationBarStyle
|
||||
}
|
||||
|
||||
public init(
|
||||
issueType: String? = nil,
|
||||
issueComponents: [String]? = nil,
|
||||
customFields: [String: AnyObject]? = nil,
|
||||
navigationBarStyle: NavigationBarStyle = .defaultStyle,
|
||||
reporterAvatarImage: CGImage? = nil,
|
||||
reporterUsernameOrEmail: String? = nil,
|
||||
getReporterInfoAsynchronously: ((@escaping (_ usernameOrEmail: String?, _ avatarImageURL: URL?) -> Void) -> Void)? = nil
|
||||
) throws {
|
||||
let target = try defaultFeedbackSettings?.JIRATarget ?? JMCTarget.createTargetFromJSONOnDisk()
|
||||
self.init(
|
||||
JIRATarget: target,
|
||||
issueType: issueType,
|
||||
issueComponents: issueComponents,
|
||||
customFields: customFields,
|
||||
navigationBarStyle: navigationBarStyle,
|
||||
reporterAvatarImage: reporterAvatarImage,
|
||||
reporterUsernameOrEmail: reporterUsernameOrEmail,
|
||||
getReporterInfoAsynchronously: getReporterInfoAsynchronously
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum JIRAIssueType: String {
|
||||
case support = "Support"
|
||||
case bug = "Bug"
|
||||
case task = "Task"
|
||||
case improvement = "Improvement"
|
||||
case story = "Story"
|
||||
case epic = "Epic"
|
||||
}
|
274
CovidSafe/Feedback/Sources/FeedbackViewController.swift
Normal file
274
CovidSafe/Feedback/Sources/FeedbackViewController.swift
Normal file
|
@ -0,0 +1,274 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
final class FeedbackViewController: UIViewController {
|
||||
|
||||
@IBOutlet var issueTextView: UITextView!
|
||||
@IBOutlet var issuePlaceholderLabel: UILabel!
|
||||
@IBOutlet var emailTextField: UITextField!
|
||||
@IBOutlet var scrollView: UIScrollView!
|
||||
@IBOutlet var thankYouView: UIView!
|
||||
|
||||
private var sendFeebackAction: SendFeedbackAction?
|
||||
|
||||
var settings: FeedbackSettings?
|
||||
var onDidFinish: (() -> Void)?
|
||||
var flowNavBarStyle: UIStatusBarStyle = UIApplication.shared.statusBarStyle
|
||||
|
||||
enum State {
|
||||
case idle
|
||||
case sending
|
||||
case sent
|
||||
}
|
||||
|
||||
private var state: State = .idle {
|
||||
didSet {
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var sendBarButtonItem: UIBarButtonItem = {
|
||||
let item = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendButtonTapped))
|
||||
item.tintColor = .covidSafeColor
|
||||
return item
|
||||
}()
|
||||
|
||||
private lazy var doneButtonItem: UIBarButtonItem = {
|
||||
let item = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneButtonTapped))
|
||||
item.tintColor = .covidSafeColor
|
||||
return item
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
if #available(iOS 13.0, *) {
|
||||
isModalInPresentation = true
|
||||
}
|
||||
setup()
|
||||
}
|
||||
|
||||
@objc func cancel() {
|
||||
if issueTextView.isFirstResponder {
|
||||
issueTextView.resignFirstResponder()
|
||||
}
|
||||
|
||||
if emailTextField.isFirstResponder {
|
||||
emailTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
func presentKeyboard() {
|
||||
issueTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
switch state {
|
||||
case .idle:
|
||||
dismissKeyboard()
|
||||
showSendButton()
|
||||
issueTextView.isEditable = true
|
||||
emailTextField.isEnabled = true
|
||||
case .sending:
|
||||
showSpinner()
|
||||
issueTextView.isEditable = false
|
||||
emailTextField.isEnabled = false
|
||||
case .sent:
|
||||
hideCancelButton()
|
||||
showDoneButton()
|
||||
showThankYouView()
|
||||
}
|
||||
}
|
||||
|
||||
private func showSendButton() {
|
||||
navigationItem.rightBarButtonItem = sendBarButtonItem
|
||||
}
|
||||
|
||||
private func showSpinner() {
|
||||
let activityView = UIActivityIndicatorView(style: .gray)
|
||||
activityView.startAnimating()
|
||||
let spinnerBarItem = UIBarButtonItem(customView: activityView)
|
||||
navigationItem.rightBarButtonItem = spinnerBarItem
|
||||
}
|
||||
|
||||
private func showDoneButton() {
|
||||
navigationItem.rightBarButtonItem = doneButtonItem
|
||||
}
|
||||
|
||||
private func hideCancelButton() {
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
}
|
||||
|
||||
private func showThankYouView() {
|
||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||
self?.scrollView.isHidden = true
|
||||
self?.thankYouView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
self.title = NSLocalizedString("newFeedbackFlow_navigationTitle",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Title for feedback flow navigation"
|
||||
)
|
||||
|
||||
issueTextView.textContainer.lineFragmentPadding = 0.0
|
||||
setupDelegates()
|
||||
setupKeyboardNotifications()
|
||||
setupBarButtonItems()
|
||||
}
|
||||
|
||||
private func setupDelegates() {
|
||||
issueTextView.delegate = self
|
||||
emailTextField.addTarget(self, action: #selector(updateSendButton), for: .editingChanged)
|
||||
}
|
||||
|
||||
private func setupKeyboardNotifications() {
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
private func setupBarButtonItems() {
|
||||
navigationItem.rightBarButtonItem = sendBarButtonItem
|
||||
sendBarButtonItem.isEnabled = false
|
||||
}
|
||||
|
||||
@objc private func updateSendButton() {
|
||||
sendBarButtonItem.isEnabled = !issueTextView.text.isEmpty && !(emailTextField.text?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
private func updatePlaceholder() {
|
||||
issuePlaceholderLabel.isHidden = !issueTextView.text.isEmpty
|
||||
}
|
||||
|
||||
@objc private func sendButtonTapped(_ sender: Any) {
|
||||
guard emailTextField.isValid else {
|
||||
let errorMessage = "Please enter a valid email address!"
|
||||
showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
state = .sending
|
||||
send()
|
||||
}
|
||||
|
||||
@objc private func doneButtonTapped(_ sender: Any) {
|
||||
finish()
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
onDidFinish?()
|
||||
// Make sure we call the closure only once
|
||||
onDidFinish = nil
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let settings = settings else {
|
||||
assertionFailure("Feedback settings not provided, feedback will be lost")
|
||||
state = .idle
|
||||
return
|
||||
}
|
||||
|
||||
let deviceInfo = UIDevice.current.infoAsDictionary
|
||||
let modelName = UIDevice.modelName
|
||||
let bundleInfo = Bundle.main.infoAsDictionary
|
||||
|
||||
var customFields = settings.customFields
|
||||
|
||||
if let email = emailTextField.text {
|
||||
customFields["E-mail"] = email as AnyObject
|
||||
}
|
||||
|
||||
if let osVersion = deviceInfo["systemVersion"] {
|
||||
customFields["OS version"] = osVersion
|
||||
}
|
||||
|
||||
if let appVersion = bundleInfo["appVersion"] {
|
||||
customFields["App version"] = appVersion
|
||||
}
|
||||
|
||||
customFields["Phone model"] = modelName as AnyObject
|
||||
|
||||
let issue = Issue(
|
||||
feedback: issueTextView.text,
|
||||
components: settings.issueComponents,
|
||||
type: settings.issueType,
|
||||
customFields: customFields,
|
||||
reporterUsernameOrEmail: nil
|
||||
)
|
||||
let action = SendFeedbackAction(issue: issue, screenshotImageOrNil: nil) { outcome in
|
||||
switch outcome {
|
||||
case .success:
|
||||
self.state = .sent
|
||||
delayOnMainQueue(2) {
|
||||
self.finish()
|
||||
}
|
||||
|
||||
case.error:
|
||||
self.state = .idle
|
||||
let errorMessage = NSLocalizedString("newFeedback_send_errorMessage",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Generic error message shown when feedback could not be sent"
|
||||
)
|
||||
|
||||
self.showErrorMessage(errorMessage)
|
||||
|
||||
case .cancelled:
|
||||
break
|
||||
}
|
||||
}
|
||||
action.start()
|
||||
sendFeebackAction = action
|
||||
}
|
||||
|
||||
private func showErrorMessage(_ message: String) {
|
||||
let alert = AlertController(title: "COVIDSafe", message: message, preferredStyle: .alert)
|
||||
let okActionTitle = NSLocalizedString("global_ok_button_title",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Title for ok button")
|
||||
let okAction = UIAlertAction(title: okActionTitle, style: .default)
|
||||
alert.addAction(okAction)
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc func adjustForKeyboard(notification: Notification) {
|
||||
guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
|
||||
let keyboardScreenEndFrame = keyboardValue.cgRectValue
|
||||
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
|
||||
|
||||
if notification.name == UIResponder.keyboardWillHideNotification {
|
||||
scrollView.contentInset = .zero
|
||||
} else {
|
||||
if #available(iOS 11.0, *) {
|
||||
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
|
||||
} else {
|
||||
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height, right: 0)
|
||||
}
|
||||
}
|
||||
|
||||
scrollView.scrollIndicatorInsets = scrollView.contentInset
|
||||
}
|
||||
}
|
||||
|
||||
func delayOnMainQueue(_ delay: Double, closure:@escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: closure)
|
||||
}
|
||||
|
||||
extension FeedbackViewController: UITextViewDelegate {
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
updatePlaceholder()
|
||||
updateSendButton()
|
||||
}
|
||||
}
|
||||
|
||||
private extension UITextField {
|
||||
var isValid: Bool {
|
||||
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
|
||||
let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
|
||||
return emailPred.evaluate(with: text)
|
||||
}
|
||||
}
|
26
CovidSafe/Feedback/Sources/GetJMCTargetAction.swift
Normal file
26
CovidSafe/Feedback/Sources/GetJMCTargetAction.swift
Normal file
|
@ -0,0 +1,26 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class GetJMCTargeAction: AsyncAction {
|
||||
let onComplete: (Outcome<JMCTarget>) -> Void
|
||||
|
||||
init(onComplete: @escaping (Outcome<JMCTarget>) -> Void) {
|
||||
self.onComplete = onComplete
|
||||
super.init()
|
||||
}
|
||||
|
||||
override func run() {
|
||||
do {
|
||||
let target = try JMCTarget.createTargetFromJSONOnDisk()
|
||||
finishedExecutingOperationWithOutcome(.success(target))
|
||||
} catch {
|
||||
finishedExecutingOperationWithOutcome(.error(error))
|
||||
}
|
||||
}
|
||||
|
||||
func finishedExecutingOperationWithOutcome(_ outcome: Outcome<JMCTarget>) {
|
||||
finishedExecutingOperation()
|
||||
onComplete(outcome)
|
||||
}
|
||||
}
|
156
CovidSafe/Feedback/Sources/HTTPPostFeedbackAction.swift
Normal file
156
CovidSafe/Feedback/Sources/HTTPPostFeedbackAction.swift
Normal file
|
@ -0,0 +1,156 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
open class HTTPPostFeedbackAction: AsyncAction {
|
||||
let issue: Issue
|
||||
let screenshotImageOrNil: UIImage?
|
||||
let target: JMCTarget
|
||||
let onComplete: (Outcome<Void>) -> Void
|
||||
var issueData: Data!
|
||||
var customFieldsData: Data!
|
||||
|
||||
var issueJSONInfo: [String: NSObject] {
|
||||
// Any other interesting pieces of data to grab?
|
||||
let issueInfo = issue.infoAsDictionary
|
||||
let deviceInfo = UIDevice.current.infoAsDictionary
|
||||
let languageInfo = NSLocale.infoAsDictionary
|
||||
let bundleInfo = Foundation.Bundle.main.infoAsDictionary
|
||||
return [issueInfo, deviceInfo, languageInfo, bundleInfo].reduce([:], +)
|
||||
}
|
||||
|
||||
var customFieldsDataOrNil: Data? {
|
||||
if issue.customFields.isEmpty {
|
||||
return nil
|
||||
} else {
|
||||
return customFieldsData
|
||||
}
|
||||
}
|
||||
|
||||
var screenshotImageDataOrNil: Data? {
|
||||
switch screenshotImageOrNil {
|
||||
case .some(let screenshotImage):
|
||||
return screenshotImage.jpegData(compressionQuality: 1.0)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public init(issue: Issue, screenshotImageOrNil: UIImage? = nil, target: JMCTarget, onComplete: @escaping (Outcome<Void>) -> Void) {
|
||||
self.issue = issue
|
||||
self.screenshotImageOrNil = screenshotImageOrNil
|
||||
self.target = target
|
||||
self.onComplete = onComplete
|
||||
super.init()
|
||||
}
|
||||
|
||||
override open func run() {
|
||||
// IMPORTANT: Encoding memory threshold is 10 mb by default.
|
||||
do {
|
||||
issueData = try serializeIssueData()
|
||||
customFieldsData = try serializeCustomFieldsData()
|
||||
} catch {
|
||||
finishedExecutingOperationWithOutcome(.error(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: target.postIssueURLString) else {
|
||||
assertionFailure("Cannot create URL from host & path")
|
||||
return
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
var request = URLRequest(url: url)
|
||||
var data = Data()
|
||||
|
||||
data.append(multipartFormData: issueData, withName: "issue", fileName: "issue.json", boundary: boundary, mimeType: "application/json")
|
||||
|
||||
if let customFieldData = customFieldsDataOrNil {
|
||||
data.append(multipartFormData: customFieldData, withName: "customfields", fileName: "customfields.json", boundary: boundary, mimeType: "application/json")
|
||||
}
|
||||
|
||||
if let screenshotImageData = screenshotImageDataOrNil {
|
||||
data.append(multipartFormData: screenshotImageData, withName: "screenshot", fileName: "screenshot.jpg", boundary: boundary, mimeType: "image/jpeg")
|
||||
}
|
||||
|
||||
data.appendString("--\(boundary)--\r\n")
|
||||
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("\(data.count)", forHTTPHeaderField:"Content-Length")
|
||||
request.setValue("-x-jmc-requestid", forHTTPHeaderField: UIDevice.current.identifierForVendorString)
|
||||
request.httpBody = data
|
||||
|
||||
let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
DispatchQueue.main.async {
|
||||
self.onHTTPResponse(response as? HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
|
||||
dataTask.resume()
|
||||
}
|
||||
|
||||
func onHTTPResponse(_ HTTPResponseOrNil: HTTPURLResponse?) {
|
||||
guard let HTTPResponse = HTTPResponseOrNil else {
|
||||
finishedExecutingOperationWithOutcome(.error(JMCError.nilHTTPResponseError))
|
||||
return
|
||||
}
|
||||
|
||||
switch HTTPResponse.statusCode {
|
||||
case let statusCode where statusCode.isSuccessHTTPStatuCode:
|
||||
finishedExecutingOperationWithOutcome(.success(()))
|
||||
default:
|
||||
finishedExecutingOperationWithOutcome(.error(JMCError.httpResponseError))
|
||||
}
|
||||
}
|
||||
|
||||
func serializeIssueData() throws -> Data {
|
||||
return try JSONSerialization.data(withJSONObject: issueJSONInfo, options: [])
|
||||
}
|
||||
|
||||
func serializeCustomFieldsData() throws -> Data {
|
||||
return try JSONSerialization.data(withJSONObject: issue.customFields, options: [])
|
||||
}
|
||||
|
||||
func finishedExecutingOperationWithOutcome(_ outcome: Outcome<Void>) {
|
||||
finishedExecutingOperation()
|
||||
onComplete(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
extension JMCTarget {
|
||||
var postIssueURLString: String {
|
||||
return "https://" + host + "/" + "rest/jconnect/" + "1.0" + "/issue/create?" + "project=" + projectKey + "&apikey=" + apiKey
|
||||
}
|
||||
}
|
||||
|
||||
extension Int {
|
||||
fileprivate var isSuccessHTTPStatuCode: Bool {
|
||||
return 200...299 ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Add function for combining dictionaries.
|
||||
private func + <K, V>(lhs: [K : V], rhs: [K : V]) -> [K : V] {
|
||||
var combined = lhs
|
||||
|
||||
for (k, v) in rhs {
|
||||
combined[k] = v
|
||||
}
|
||||
|
||||
return combined
|
||||
}
|
||||
|
||||
extension Data {
|
||||
mutating func append(multipartFormData data: Data, withName name: String, fileName: String, boundary: String, mimeType: String) {
|
||||
appendString("--\(boundary)\r\n")
|
||||
appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(fileName)\"\r\n")
|
||||
appendString("Content-Type: \(mimeType)\r\n\r\n")
|
||||
append(data)
|
||||
appendString("\r\n")
|
||||
}
|
||||
|
||||
mutating func appendString(_ string: String) {
|
||||
append(string.data(using: .utf8)!)
|
||||
}
|
||||
}
|
54
CovidSafe/Feedback/Sources/Issue.swift
Normal file
54
CovidSafe/Feedback/Sources/Issue.swift
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
let maxSummaryLength = 240
|
||||
public struct Issue {
|
||||
let summary: String
|
||||
let description: String
|
||||
let components: [String]
|
||||
let type: String
|
||||
let customFields: [String: AnyObject]
|
||||
|
||||
public init(summary: String, description: String, components: [String], type: String, customFields: [String: AnyObject] = [:], reporterUsernameOrEmail: String? = nil) {
|
||||
self.summary = summary
|
||||
self.description = description.withReporterUsernameOrEmailAppended(reporterUsernameOrEmail)
|
||||
self.components = components
|
||||
self.type = type
|
||||
self.customFields = customFields
|
||||
}
|
||||
|
||||
public init(feedback: String, components: [String], type: String, customFields: [String: AnyObject] = [:], reporterUsernameOrEmail: String? = nil) {
|
||||
switch feedback.unicodeScalars.count {
|
||||
case let count where count > maxSummaryLength:
|
||||
let truncatedSummary = String(feedback.unicodeScalars.prefix(maxSummaryLength))
|
||||
self.init(
|
||||
summary: truncatedSummary,
|
||||
description: feedback,
|
||||
components: components,
|
||||
type: type,
|
||||
customFields: customFields,
|
||||
reporterUsernameOrEmail: reporterUsernameOrEmail
|
||||
)
|
||||
default:
|
||||
self.init(
|
||||
summary: feedback,
|
||||
description: feedback,
|
||||
components: components,
|
||||
type: type,
|
||||
customFields: customFields,
|
||||
reporterUsernameOrEmail: reporterUsernameOrEmail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
fileprivate func withReporterUsernameOrEmailAppended(_ reporterUsernameOrEmail: String?) -> String {
|
||||
guard let reporterUsernameOrEmail = reporterUsernameOrEmail else {
|
||||
return self
|
||||
}
|
||||
|
||||
return "\(self) \n\n Submitted by: \(reporterUsernameOrEmail)"
|
||||
}
|
||||
}
|
12
CovidSafe/Feedback/Sources/IssueInfo.swift
Normal file
12
CovidSafe/Feedback/Sources/IssueInfo.swift
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Issue {
|
||||
var infoAsDictionary: [String: NSObject] {
|
||||
return ["summary": summary as NSObject,
|
||||
"description": description as NSObject,
|
||||
"components": components as NSObject,
|
||||
"type": type as NSObject]
|
||||
}
|
||||
}
|
15
CovidSafe/Feedback/Sources/JMCTarget.swift
Normal file
15
CovidSafe/Feedback/Sources/JMCTarget.swift
Normal file
|
@ -0,0 +1,15 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct JMCTarget {
|
||||
let host: String
|
||||
let apiKey: String
|
||||
let projectKey: String
|
||||
|
||||
public init(host: String, apiKey: String, projectKey: String) {
|
||||
self.host = host
|
||||
self.apiKey = apiKey
|
||||
self.projectKey = projectKey
|
||||
}
|
||||
}
|
45
CovidSafe/Feedback/Sources/JMCTargetJSONFromDisk.swift
Normal file
45
CovidSafe/Feedback/Sources/JMCTargetJSONFromDisk.swift
Normal file
|
@ -0,0 +1,45 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension JMCTarget {
|
||||
static let JSONFileName = "JMCTarget"
|
||||
|
||||
public static func createTargetFromJSONOnDisk() throws -> JMCTarget {
|
||||
guard let JMCTargetJSONPath = Foundation.Bundle.main.path(forResource: JSONFileName, ofType: "json") else {
|
||||
throw JMCTargetJSONOnDiskError.jsonFileMissingFromBundleError
|
||||
}
|
||||
return try JMCTarget(JSONFilePath: JMCTargetJSONPath)
|
||||
}
|
||||
|
||||
init(JSONFilePath: String) throws {
|
||||
guard let JMCTargetData = try? Data(contentsOf: URL(fileURLWithPath: JSONFilePath)) else {
|
||||
throw JMCTargetJSONOnDiskError.readJSONFileError
|
||||
}
|
||||
try self.init(JSONData: JMCTargetData)
|
||||
}
|
||||
|
||||
init(JSONData: Data) throws {
|
||||
let JSONObject = try JSONSerialization.jsonObject(with: JSONData, options: [])
|
||||
guard let JMCTargetDictionary = JSONObject as? [String : NSObject] else {
|
||||
throw JMCTargetJSONOnDiskError.jsonDictionaryToInstanceError
|
||||
}
|
||||
try self.init(JSONDictionary: JMCTargetDictionary )
|
||||
}
|
||||
|
||||
init(JSONDictionary: [String: NSObject]) throws {
|
||||
guard let host = JSONDictionary["host"] as? String,
|
||||
let apiKey = JSONDictionary["apiKey"] as? String,
|
||||
let projectKey = JSONDictionary["projectKey"] as? String else {
|
||||
throw JMCTargetJSONOnDiskError.jsonDictionaryToInstanceError
|
||||
}
|
||||
self.init(host: host, apiKey: apiKey, projectKey: projectKey)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enum JMCTargetJSONOnDiskError: Error {
|
||||
case jsonFileMissingFromBundleError
|
||||
case readJSONFileError
|
||||
case jsonDictionaryToInstanceError
|
||||
}
|
31
CovidSafe/Feedback/Sources/LanguageInfoExtension.swift
Normal file
31
CovidSafe/Feedback/Sources/LanguageInfoExtension.swift
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension NSLocale {
|
||||
class var languageDisplayNameOrNil: String? {
|
||||
// Consider going to NSBundle preferredLocalizations to get language the user is actually seeing in the running app.
|
||||
guard let languageCode = preferredLanguages[safe: 0] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return current.localizedString(forLanguageCode: languageCode)
|
||||
}
|
||||
|
||||
class var preferredLanguageDisplayName: String? {
|
||||
guard let languageDisplayName = languageDisplayNameOrNil else {
|
||||
return nil
|
||||
}
|
||||
return languageDisplayName
|
||||
}
|
||||
|
||||
class var infoAsDictionary: [String: NSObject] {
|
||||
return ["language": preferredLanguageDisplayName as NSObject? ?? "" as NSObject]
|
||||
}
|
||||
}
|
||||
|
||||
extension Array {
|
||||
fileprivate subscript (safe index: UInt) -> Element? {
|
||||
return Int(index) < count ? self[Int(index)] : nil
|
||||
}
|
||||
}
|
9
CovidSafe/Feedback/Sources/Logging.swift
Normal file
9
CovidSafe/Feedback/Sources/Logging.swift
Normal file
|
@ -0,0 +1,9 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var formattedLoggingStatement: String {
|
||||
return "JIRA Mobile Connect: \(self)"
|
||||
}
|
||||
}
|
168
CovidSafe/Feedback/Sources/NewFeedbackFlowController.swift
Normal file
168
CovidSafe/Feedback/Sources/NewFeedbackFlowController.swift
Normal file
|
@ -0,0 +1,168 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class NewFeedbackFlowController: UINavigationController {
|
||||
let newFeedbackViewController: FeedbackViewController
|
||||
var leftBarButtonItem: UIBarButtonItem!
|
||||
var originalStatusBarStyle: UIStatusBarStyle?
|
||||
var onDidFinish: (() -> Void)?
|
||||
|
||||
let navBarStyle: NavigationBarStyle
|
||||
let shouldUseCustomTransition: Bool
|
||||
|
||||
init(screenshot: UIImage?, settings: FeedbackSettings? = nil) throws {
|
||||
newFeedbackViewController = try NewFeedbackFlowViewControllerFactory()
|
||||
.createNewFeedbackViewControllerWithScreenshotImage(screenshot, settings: settings)
|
||||
|
||||
navBarStyle = settings?.navigationBarStyle ?? .defaultStyle
|
||||
|
||||
shouldUseCustomTransition = screenshot != nil
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
newFeedbackViewController.onDidFinish = {
|
||||
self.dismissFeedbackFlow()
|
||||
}
|
||||
viewControllers = [newFeedbackViewController]
|
||||
|
||||
leftBarButtonItem = self.createCancelBarButtonItem()
|
||||
newFeedbackViewController.navigationItem.leftBarButtonItem = leftBarButtonItem
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported by NewFeedbackFlowController")
|
||||
}
|
||||
}
|
||||
|
||||
extension NewFeedbackFlowController {
|
||||
// MARK: View Lifecycle & UIKit
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
transitioningDelegate = self
|
||||
newFeedbackViewController.flowNavBarStyle = UIApplication.shared.statusBarStyle
|
||||
navigationBar.style(navigationBarStyle: navBarStyle)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
applyStatusBarStyleIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
returnStatusBarStyleToOriginalValueIfNeeded()
|
||||
}
|
||||
|
||||
// Feedback flow considered finished when this view controller's view has disappeared.
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
self.onDidFinish?()
|
||||
}
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return navBarStyle.statusBarStyle
|
||||
}
|
||||
|
||||
func applyStatusBarStyleIfNeeded() {
|
||||
if Foundation.Bundle.main.viewControllerBasedStatusBarAppearance == false {
|
||||
originalStatusBarStyle = UIApplication.shared.statusBarStyle
|
||||
newFeedbackViewController.flowNavBarStyle = navBarStyle.statusBarStyle
|
||||
}
|
||||
}
|
||||
|
||||
func returnStatusBarStyleToOriginalValueIfNeeded() {
|
||||
if Foundation.Bundle.main.viewControllerBasedStatusBarAppearance == false {
|
||||
if let originalStatusBarStyle = originalStatusBarStyle {
|
||||
newFeedbackViewController.flowNavBarStyle = originalStatusBarStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NewFeedbackFlowController {
|
||||
// MARK: User Actions
|
||||
|
||||
@objc func cancel() {
|
||||
newFeedbackViewController.cancel()
|
||||
dismissFeedbackFlow()
|
||||
}
|
||||
|
||||
func presentKeyboard() {
|
||||
newFeedbackViewController.presentKeyboard()
|
||||
}
|
||||
|
||||
func dismissFeedbackFlow() {
|
||||
if let actionController = self.presentingViewController as? UIAlertController {
|
||||
if let presentingViewController = actionController.presentingViewController {
|
||||
actionController.view.isHidden = true
|
||||
presentingViewController.dismiss(animated: false) {
|
||||
self.onDidFinish?()
|
||||
actionController.view.isHidden = false
|
||||
presentingViewController.present(actionController, animated: true, completion: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true) {
|
||||
self.onDidFinish?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NewFeedbackFlowController {
|
||||
// MARK: Factories
|
||||
|
||||
func createCancelBarButtonItem() -> UIBarButtonItem {
|
||||
let buttonTitle = NSLocalizedString("global_cancel_button_title",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Cancel button title"
|
||||
)
|
||||
|
||||
let item = UIBarButtonItem(
|
||||
title: buttonTitle,
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(NewFeedbackFlowController.cancel)
|
||||
)
|
||||
|
||||
item.tintColor = .covidSafeColor
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
extension NewFeedbackFlowController: UIViewControllerTransitioningDelegate {
|
||||
// MARK: Custom Transition Presentation
|
||||
|
||||
func animationController(
|
||||
forPresented presented: UIViewController,
|
||||
presenting: UIViewController,
|
||||
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private class NewFeedbackFlowViewControllerFactory {
|
||||
let flowStoryboard = UIStoryboard(name: "NewFeedbackFlow", bundle: Bundle.main)
|
||||
|
||||
func createNewFeedbackViewControllerWithScreenshotImage(
|
||||
_ screenshotImage: UIImage?,
|
||||
settings: FeedbackSettings? = nil) throws -> FeedbackViewController {
|
||||
let id = "FeedbackViewController"
|
||||
|
||||
// swiftlint:disable:next force_cast
|
||||
let vc = flowStoryboard.instantiateViewController(withIdentifier: id) as! FeedbackViewController
|
||||
|
||||
vc.settings = try settings ?? FeedbackSettings()
|
||||
|
||||
return vc
|
||||
}
|
||||
}
|
||||
|
||||
extension Foundation.Bundle {
|
||||
fileprivate var viewControllerBasedStatusBarAppearance: Bool {
|
||||
return (infoDictionary?["UIViewControllerBasedStatusBarAppearance"] as? Bool) ?? true
|
||||
}
|
||||
}
|
35
CovidSafe/Feedback/Sources/Outcome.swift
Normal file
35
CovidSafe/Feedback/Sources/Outcome.swift
Normal file
|
@ -0,0 +1,35 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public typealias VoidOutcome = Outcome<Void>
|
||||
|
||||
public enum Outcome<T> {
|
||||
case success(T)
|
||||
case error(Error)
|
||||
case cancelled
|
||||
|
||||
public init(resultOrNil: T?, errorOrNil: Error?) {
|
||||
if let error = errorOrNil {
|
||||
self = .error(error)
|
||||
return
|
||||
}
|
||||
if let result = resultOrNil {
|
||||
self = .success(result)
|
||||
return
|
||||
}
|
||||
self = .error(ProgrammerError.encounteredNilResultAndNilErrorOutcome)
|
||||
}
|
||||
|
||||
public init<A>(somethingOrNothing: A?, resultIfSomething: T, errorIfNothing: Error) {
|
||||
if somethingOrNothing != nil {
|
||||
self = .success(resultIfSomething)
|
||||
} else {
|
||||
self = .error(errorIfNothing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProgrammerError: Error {
|
||||
case encounteredNilResultAndNilErrorOutcome
|
||||
}
|
150
CovidSafe/Feedback/Sources/PresentFeedbackExtensions.swift
Normal file
150
CovidSafe/Feedback/Sources/PresentFeedbackExtensions.swift
Normal file
|
@ -0,0 +1,150 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
private var feedbackNotOnScreen = true
|
||||
|
||||
extension UIWindow {
|
||||
public func presentFeedback(_ promptUser: Bool = false, settings: FeedbackSettings? = nil) {
|
||||
guard let presentedViewController = rootViewController?.topmostPresentedViewController else {
|
||||
print("\(self), Cannot present feedback prompt because window does not have a presented view controller.".formattedLoggingStatement)
|
||||
return
|
||||
}
|
||||
|
||||
guard feedbackNotOnScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
presentedViewController.presentFeedback( promptUser, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
func presentFeedbackWithPromptAndScreenshotTransition(_ settings: FeedbackSettings? = nil) {
|
||||
guard feedbackNotOnScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
let vc = ViewControllerFactory().createPropmtController(true, settings: settings)
|
||||
self.topmostPresentedViewController.present(vc, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func presentNewFeedbackFlowWithScreenshotTransition(_ settings: FeedbackSettings? = nil) {
|
||||
guard let window = view.window else {
|
||||
return
|
||||
}
|
||||
|
||||
guard feedbackNotOnScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
feedbackNotOnScreen = false
|
||||
ViewControllerFactory().createNewFeedbackFlowControllerForScreenshotView(window, settings: settings, onFlowDidFinish: { [weak self] in
|
||||
self?.flowDidFinish()
|
||||
}) { flowController in
|
||||
self.topmostPresentedViewController.present(flowController, animated: true) {
|
||||
if !flowController.shouldUseCustomTransition {
|
||||
flowController.presentKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc // This method should only be used from ObjC
|
||||
public func presentFeedback(
|
||||
_ promptUser: Bool = false,
|
||||
issueType: String? = nil,
|
||||
issueComponents: [String]? = nil,
|
||||
reporterAvatarImage: CGImage? = nil,
|
||||
reporterUsernameOrEmail: String? = nil
|
||||
) {
|
||||
guard let settings = try? FeedbackSettings(
|
||||
issueType: issueType,
|
||||
issueComponents: issueComponents,
|
||||
reporterAvatarImage:
|
||||
reporterAvatarImage,
|
||||
reporterUsernameOrEmail:
|
||||
reporterUsernameOrEmail
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard feedbackNotOnScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
presentFeedback( promptUser, settings: settings)
|
||||
}
|
||||
|
||||
public func presentFeedback(_ promptUser: Bool = false, settings: FeedbackSettings? = nil) {
|
||||
guard feedbackNotOnScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
if promptUser {
|
||||
let vc = ViewControllerFactory().createPropmtController(false, settings: settings)
|
||||
self.topmostPresentedViewController.present(vc, animated: true, completion: nil)
|
||||
} else {
|
||||
self.presentNewFeedbackFlow(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func presentNewFeedbackFlow(_ settings: FeedbackSettings? = nil) {
|
||||
guard feedbackNotOnScreen else {
|
||||
return
|
||||
}
|
||||
|
||||
feedbackNotOnScreen = false
|
||||
ViewControllerFactory().createNewFeedbackFlowController(settings, onFlowDidFinish: { [weak self] in
|
||||
self?.flowDidFinish()
|
||||
}) { flowController in
|
||||
self.topmostPresentedViewController.present(flowController, animated: true) {
|
||||
flowController.presentKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flowDidFinish() {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async(execute: flowDidFinish)
|
||||
return
|
||||
}
|
||||
|
||||
feedbackNotOnScreen = true
|
||||
}
|
||||
}
|
||||
|
||||
extension AlertController {
|
||||
func addNewFeedbackFlowAction() {
|
||||
let title = NSLocalizedString("entryPrompt_newFeedback_button_title",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Button title for button that launches new feedback flow"
|
||||
)
|
||||
self.addDefaultAction(localizedTitle: title) { [weak self] _ in
|
||||
if let strongSelf = self {
|
||||
if strongSelf.useCustomTransition {
|
||||
strongSelf.presentNewFeedbackFlowWithScreenshotTransition_1()
|
||||
} else {
|
||||
strongSelf.presentNewFeedbackFlow_1()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presentNewFeedbackFlowWithScreenshotTransition_1() {
|
||||
guard let presentingViewController = self.lastPresentingViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
presentingViewController.presentNewFeedbackFlowWithScreenshotTransition(self.feedbackSettings)
|
||||
}
|
||||
|
||||
func presentNewFeedbackFlow_1() {
|
||||
guard let presentingViewController = self.lastPresentingViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
presentingViewController.presentNewFeedbackFlow(self.feedbackSettings)
|
||||
}
|
||||
}
|
46
CovidSafe/Feedback/Sources/SendFeedbackAction.swift
Normal file
46
CovidSafe/Feedback/Sources/SendFeedbackAction.swift
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
open class SendFeedbackAction: AsyncAction {
|
||||
let issue: Issue
|
||||
let screenshotImageOrNil: UIImage?
|
||||
let onComplete: (Outcome<Void>) -> Void
|
||||
|
||||
var getTargetAction: GetJMCTargeAction!
|
||||
var postFeedbackAction: HTTPPostFeedbackAction!
|
||||
|
||||
public init(issue: Issue, screenshotImageOrNil: UIImage? = nil, onComplete: @escaping (Outcome<Void>) -> Void) {
|
||||
self.issue = issue
|
||||
self.screenshotImageOrNil = screenshotImageOrNil
|
||||
self.onComplete = onComplete
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override open func run() {
|
||||
getTargetAction = GetJMCTargeAction { outcome in
|
||||
switch outcome {
|
||||
case .success(let JMCTarget):
|
||||
self.postFeedbackToTarget(JMCTarget)
|
||||
case .error(let error):
|
||||
self.finishedExecutingOperationWithOutcome(.error(error))
|
||||
case .cancelled:
|
||||
break
|
||||
}
|
||||
}
|
||||
getTargetAction.start()
|
||||
}
|
||||
|
||||
open func postFeedbackToTarget(_ target: JMCTarget) {
|
||||
postFeedbackAction = HTTPPostFeedbackAction(issue: issue, screenshotImageOrNil: screenshotImageOrNil, target: target) { outcome in
|
||||
self.finishedExecutingOperationWithOutcome(outcome)
|
||||
}
|
||||
postFeedbackAction.start()
|
||||
}
|
||||
|
||||
func finishedExecutingOperationWithOutcome(_ outcome: Outcome<Void>) {
|
||||
finishedExecutingOperation()
|
||||
onComplete(outcome)
|
||||
}
|
||||
}
|
41
CovidSafe/Feedback/Sources/UIColor+Hex.swift
Normal file
41
CovidSafe/Feedback/Sources/UIColor+Hex.swift
Normal file
|
@ -0,0 +1,41 @@
|
|||
// Copyright © 2016 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
|
||||
/// Hex sRGB color initializer.
|
||||
///
|
||||
/// - parameter hex: Pass in a sRGB color integer using hex notation, i.e. 0xFFFFFF. Make sure to only include 6 hex digits.
|
||||
///
|
||||
/// - returns: Initialized opaque UIColor, i.e. alpha is set to 1.0.
|
||||
public convenience init(_ hex: Int) {
|
||||
assert(
|
||||
0...0xFFFFFF ~= hex,
|
||||
"Hex value given to UIColor initializer should only include RGB values, i.e. the hex value should have six digits." //swiftlint:disable:this line_length
|
||||
)
|
||||
let red = (hex & 0xFF0000) >> 16
|
||||
let green = (hex & 0x00FF00) >> 8
|
||||
let blue = (hex & 0x0000FF)
|
||||
self.init(red: red, green: green, blue: blue)
|
||||
}
|
||||
|
||||
/// RGB integer color initializer.
|
||||
///
|
||||
/// - parameter red: Red component as integer. In iOS 9 or below, this value should be between 0 and 255. iOS 10
|
||||
/// and above uses an extended color space to support wide color.
|
||||
/// - parameter green: Green component as integer. In iOS 9 or below, this value should be between 0 and 255. iOS 10
|
||||
/// and above uses an extended color space to support wide color.
|
||||
/// - parameter blue: Blue component as integer. In iOS 9 or below, this value should be between 0 and 255. iOS 10
|
||||
/// and above uses an extended color space to support wide color.
|
||||
///
|
||||
/// - returns: Initialized opaque UIColor, i.e. alpha is set to 1.0.
|
||||
public convenience init(red: Int, green: Int, blue: Int) {
|
||||
self.init(
|
||||
red: CGFloat(red) / 255.0,
|
||||
green: CGFloat(green) / 255.0,
|
||||
blue: CGFloat(blue) / 255.0,
|
||||
alpha: 1.0
|
||||
)
|
||||
}
|
||||
}
|
38
CovidSafe/Feedback/Sources/UIKitExtensions.swift
Normal file
38
CovidSafe/Feedback/Sources/UIKitExtensions.swift
Normal file
|
@ -0,0 +1,38 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
// MARK: Localization extensions
|
||||
extension AlertController {
|
||||
static func createAlertSheetController(localizedTitle: String, localizedMessage: String) -> AlertController {
|
||||
return AlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .actionSheet)
|
||||
}
|
||||
|
||||
static func createAlertController(localizedTitle: String, localizedMessage: String) -> AlertController {
|
||||
return AlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .alert)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIAlertAction {
|
||||
convenience init(localizedTitle: String, style: UIAlertAction.Style, handler: ((UIAlertAction) -> Void)?) {
|
||||
self.init(title: localizedTitle, style: style, handler: handler)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIAlertController cancel extension
|
||||
extension UIAlertController {
|
||||
func addCancelAction() {
|
||||
let cancelActionTitle = NSLocalizedString("global_cancel_button_title",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Cancel button title"
|
||||
)
|
||||
let cancelAction = UIAlertAction(title: cancelActionTitle, style: .cancel, handler: nil)
|
||||
self.addAction(cancelAction)
|
||||
}
|
||||
|
||||
func addDefaultAction(localizedTitle: String, handler: @escaping ((UIAlertAction) -> Void)) {
|
||||
let defaultAction = UIAlertAction(localizedTitle: localizedTitle, style: .default, handler: handler)
|
||||
self.addAction(defaultAction)
|
||||
}
|
||||
}
|
16
CovidSafe/Feedback/Sources/UINavigationBar+Style.swift
Normal file
16
CovidSafe/Feedback/Sources/UINavigationBar+Style.swift
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UINavigationBar {
|
||||
func style(navigationBarStyle: NavigationBarStyle) {
|
||||
isTranslucent = false
|
||||
barTintColor = navigationBarStyle.backgroundColor
|
||||
tintColor = navigationBarStyle.tintColor
|
||||
backgroundColor = navigationBarStyle.backgroundColor
|
||||
titleTextAttributes = [
|
||||
NSAttributedString.Key.foregroundColor: navigationBarStyle.titleTextColor,
|
||||
NSAttributedString.Key.font: navigationBarStyle.titleFont
|
||||
]
|
||||
}
|
||||
}
|
15
CovidSafe/Feedback/Sources/UIWindow+TopMost.swift
Normal file
15
CovidSafe/Feedback/Sources/UIWindow+TopMost.swift
Normal file
|
@ -0,0 +1,15 @@
|
|||
// Copyright © 2017 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIWindow {
|
||||
public var topmostPresentedViewController: UIViewController? {
|
||||
return rootViewController?.topmostPresentedViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
public var topmostPresentedViewController: UIViewController {
|
||||
return presentedViewController?.topmostPresentedViewController ?? self
|
||||
}
|
||||
}
|
57
CovidSafe/Feedback/Sources/ViewControllerFactory.swift
Normal file
57
CovidSafe/Feedback/Sources/ViewControllerFactory.swift
Normal file
|
@ -0,0 +1,57 @@
|
|||
// Copyright © 2020 Australian Government All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class ViewControllerFactory {
|
||||
|
||||
func createPropmtController(_ useCustomTransition: Bool, settings: FeedbackSettings? = nil) -> UIViewController {
|
||||
let title = NSLocalizedString("entryPrompt_alert_title",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Title for initial alert when feedback is launched"
|
||||
)
|
||||
|
||||
let message = NSLocalizedString("entryPrompt_alert_message",
|
||||
tableName: "Feedback",
|
||||
bundle: Bundle.main,
|
||||
comment: "Prompt message for initial alert when feedback is launched"
|
||||
)
|
||||
|
||||
let alertController: AlertController
|
||||
if UIScreen.main.traitCollection.horizontalSizeClass == .regular {
|
||||
alertController = AlertController.createAlertController(localizedTitle: title, localizedMessage: message)
|
||||
} else {
|
||||
alertController = AlertController.createAlertSheetController(localizedTitle: title, localizedMessage: message)
|
||||
}
|
||||
alertController.feedbackSettings = settings
|
||||
alertController.addNewFeedbackFlowAction()
|
||||
alertController.addCancelAction()
|
||||
alertController.useCustomTransition = useCustomTransition
|
||||
|
||||
return alertController
|
||||
}
|
||||
|
||||
func createNewFeedbackFlowControllerForScreenshotView(
|
||||
_ viewForScreenshot: UIView,
|
||||
settings: FeedbackSettings? = nil,
|
||||
onFlowDidFinish: (() -> Void)? = nil,
|
||||
onComplete: @escaping (NewFeedbackFlowController) -> Void
|
||||
) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
func createNewFeedbackFlowController(
|
||||
_ settings: FeedbackSettings? = nil,
|
||||
onFlowDidFinish: (() -> Void)? = nil,
|
||||
onComplete: (NewFeedbackFlowController) -> Void
|
||||
) {
|
||||
do {
|
||||
let flowController = try NewFeedbackFlowController(screenshot: nil, settings: settings)
|
||||
flowController.onDidFinish = onFlowDidFinish
|
||||
onComplete(flowController)
|
||||
} catch {
|
||||
assertionFailure("\(error)".formattedLoggingStatement)
|
||||
onFlowDidFinish?()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue