Skip to content

Commit

Permalink
Merge pull request #35 from tobyspark/tweaks-misc
Browse files Browse the repository at this point in the history
Tweaks: Informational content restructure, Recording pips

- Messaging: app info and help → app instructions and recording instructions
- App instructions stop and recording instructions have no overlap
- Recording instructions have mini-TOC at top to jump to steps for specific type
- iPad: instruction sheets size appropriately
- Recording has sonic feedback
- Recordings time-out
- City logo on participant info and consent screens
- ORBIT and City logos respond to light/dark theme
- Accessibility messaging tweaks
  • Loading branch information
tobyspark authored May 1, 2020
2 parents 4be1b09 + af51ff2 commit 5397c89
Show file tree
Hide file tree
Showing 8 changed files with 100 additions and 90 deletions.
11 changes: 6 additions & 5 deletions ORBIT Camera/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,8 @@
<navigationItem key="navigationItem" id="mOI-FS-AaM">
<barButtonItem key="rightBarButtonItem" title="Item" image="questionmark.circle" catalog="system" id="Qyt-XI-k0g">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityLabel" value="Help"/>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityHint" value="Brings up a help screen with the how-to steps for the currently selected video type"/>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityLabel" value="Recording instructions - please read"/>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityHint" value="Brings up an instructions screen with the how-to steps for recording each type of video"/>
</userDefinedRuntimeAttributes>
<connections>
<segue destination="eDj-Yg-tEO" kind="popoverPresentation" identifier="showHelp" popoverAnchorBarButtonItem="Qyt-XI-k0g" id="C5e-uX-2XX">
Expand Down Expand Up @@ -649,8 +649,8 @@
<navigationItem key="navigationItem" title="Things" id="Zdf-7t-Un8">
<barButtonItem key="rightBarButtonItem" title="Info" image="info.circle" catalog="system" id="tLb-og-RZ7">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityLabel" value="App Info"/>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityHint" value="Find out more about the ORBIT project, and register"/>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityLabel" value="ORBIT instructions – please read"/>
<userDefinedRuntimeAttribute type="string" keyPath="accessibilityHint" value="Brings up an instructions screen that you will need to read to participate"/>
</userDefinedRuntimeAttributes>
<connections>
<segue destination="hQ4-tG-xkv" kind="popoverPresentation" identifier="showInfo" popoverAnchorBarButtonItem="tLb-og-RZ7" id="ZJu-Cn-xgj">
Expand Down Expand Up @@ -769,6 +769,7 @@
<viewLayoutGuide key="safeArea" id="7eM-8t-mVq"/>
</view>
<connections>
<outlet property="logoView" destination="2aO-i4-d58" id="cn3-05-8tx"/>
<outlet property="scrollView" destination="FfH-Ja-3I0" id="SXc-ea-B1k"/>
<outlet property="sheetButton" destination="ZDt-uB-H9e" id="BY8-Ga-hRv"/>
<outlet property="stackView" destination="W4X-x0-kh1" id="EFQ-UT-eyo"/>
Expand Down Expand Up @@ -801,7 +802,7 @@
<segue reference="VfW-pl-Nv3"/>
</inferredMetricsTieBreakers>
<resources>
<image name="ORBIT logo" width="200" height="200"/>
<image name="ORBIT logo" width="300" height="300"/>
<image name="arrow.up.circle" catalog="system" width="128" height="121"/>
<image name="camera.circle.fill" catalog="system" width="128" height="121"/>
<image name="checkmark.circle" catalog="system" width="128" height="121"/>
Expand Down
32 changes: 16 additions & 16 deletions ORBIT Camera/DetailViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ class DetailViewController: UIViewController {
/// The camera object that encapsulates capture new video functionality
private let camera = Camera()

/// The timer to limit recording length
private var recordTimeOut: Timer? = nil

/// Implementation detail: need to be able to differentiate whether scrolling is happening due to direct manipulation or actioned animation
private var isManuallyScrolling = false

Expand Down Expand Up @@ -384,13 +387,13 @@ class DetailViewController: UIViewController {
self.pageIndex -= 1
}

detailHeaderElement.accessibilityLabel = "Video review detail"
detailHeaderElement.accessibilityHint = "The following relates to the selected video"
detailHeaderElement.accessibilityLabel = "Details of your selected video"
detailHeaderElement.accessibilityHint = "The following relates to the selected video. It is currently being shown on the screen."
detailHeaderElement.accessibilityTraits = super.accessibilityTraits.union(.header)

recordedElement.accessibilityLabel = "" // Set in configurePage

rerecordElement.accessibilityLabel = "Re-record video" // Set in configurePage
rerecordElement.accessibilityLabel = "Re-record selected video" // Set in configurePage
rerecordElement.accessibilityHint = "If you wish to re-record, activate to bring up the camera controls"
rerecordElement.accessibilityTraits = super.accessibilityTraits.union(.button)

Expand All @@ -400,7 +403,7 @@ class DetailViewController: UIViewController {

publishedElement.accessibilityLabel = "" // Set in configurePage

deleteElement.accessibilityLabel = "Delete video"
deleteElement.accessibilityLabel = "Delete selected video"
deleteElement.accessibilityHint = "Removes this video from the thing's collection"
deleteElement.accessibilityTraits = super.accessibilityTraits.union(.button)

Expand Down Expand Up @@ -633,6 +636,15 @@ class DetailViewController: UIViewController {
navigationItem.hidesBackButton = true
accessibilityElements = [cameraRecordElement]
cameraRecordElement.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(view.bounds, in: view)

// Start the time-out
recordTimeOut = Timer.scheduledTimer(withTimeInterval: Settings.recordTimeOutSecs, repeats: false, block: { [weak self] (timer) in
guard let self = self else { return }
if case .active = self.recordButton.recordingState {
self.recordButton.toggleRecord() // This is a bit shonky, chance of a simultaneous press.
self.recordButtonAction(sender: self.recordButton)
}
})
case .idle:
camera.recordStop()

Expand Down Expand Up @@ -753,18 +765,6 @@ class DetailViewController: UIViewController {
layoutAccessibilityElements()
}

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segue.identifier {
case "showHelp":
guard let helpViewController = segue.destination as? HelpViewController
else { return }

helpViewController.kind = pageKind
default:
break
}
}

// TODO: Refactor away, pager category should be protocol stringconvertible or somesuch.
func videoKind(description: String) -> Video.Kind {
switch description {
Expand Down
50 changes: 26 additions & 24 deletions ORBIT Camera/HelpViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,29 @@ class HelpViewController: UIViewController {
@IBOutlet weak var webView: WKWebView!
@IBOutlet weak var webViewHeightContstraint: NSLayoutConstraint!

var kind: Video.Kind?
var kindElementIds: [Video.Kind: String]?

override func viewDidLoad() {
super.viewDidLoad()

let result = MarkdownParser.videoKindParse(markdownResource: "TutorialScript", startKey: "help-start-header")
kindElementIds = result.kindElementIDs
// Hide until we're ready, reveal by WKNavigationDelegate
scrollView.alpha = 0

let html = MarkdownParser.html(markdownResource: "Recording")

// Webview: handle page load completion, etc.
webView.navigationDelegate = self
webView.loadHTMLString(result.html, baseURL: nil)

// Webview: inject CSS on page load
let cssInjectJS = """
var style = document.createElement('style');
style.innerHTML = "\(MarkdownParser.css.components(separatedBy: .newlines).joined())";
document.head.appendChild(style);
"""
let userScript = WKUserScript(source: cssInjectJS,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true)
webView.configuration.userContentController.addUserScript(userScript)

webView.loadHTMLString(html, baseURL: nil)
}

override func viewDidAppear(_ animated: Bool) {
Expand Down Expand Up @@ -59,6 +71,10 @@ class HelpViewController: UIViewController {
dismissElement,
scrollView!
]

// iPad: size to be within the videoview, half width and stopping above pager and add button
let insetToClearControls: CGFloat = 83 // a magic number
preferredContentSize = CGSize(width: UIScreen.main.bounds.width/2, height: UIScreen.main.bounds.width - insetToClearControls)
}
}

Expand All @@ -69,24 +85,10 @@ extension HelpViewController: WKNavigationDelegate {
webView.evaluateJavaScript("document.documentElement.scrollHeight") { [weak self] (height, error) in
guard let self = self else { return }
self.webViewHeightContstraint.constant = height as! CGFloat
}
// Scroll the scrollView to the desired element, and set accessibility focus to it.
//
// Except that, as of iOS 11.3.2, the javascript focus() method is ignored when not user-initiated.
// Which is a bummer, as per a 2016 Apple Accessibility mailing list post, that's how you set the accessibility focus.
// Want to swizzle obj-c? You can hack into WKWebView and flip the isUserInteractive flag or somesuch. But this ain't that kind of project.
// Alternatives around setting the javascript location.hash didn't work out (but loading the page with the fragment identifier has to be possible somehow).
// Less deterministic, but there is some logic to scrolling and then telling UIAccessibility to find the first on-screen element
// And that seems to work? If you set it to focus the enclosing scroll view? Which is fixed to the screen, rather than the web view, which very much ain't.
// ...but, well, not reliably. Hmm.
if let kind = kind,
let kindElementIds = kindElementIds,
let anchor = kindElementIds[kind]
{
webView.evaluateJavaScript("document.getElementById('\(anchor)').offsetTop") { [weak self] (offset, error) in
guard let self = self else { return }
self.scrollView.contentOffset.y = self.webView.frame.minY + (offset as! CGFloat) - 8
UIAccessibility.post(notification: .layoutChanged, argument: self.scrollView)

// Reveal the content
UIView.animate(withDuration: 0.3) { [weak self] in
self?.scrollView.alpha = 1
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion ORBIT Camera/InfoViewController+requestCredential.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ extension InfoViewController {
{
switch error {
case .transportError:
informedConsentErrorLabel.text = "There was a network problem submitting your consent. Is your iOS connected to the internet?\n\nIf this problem persists, please contact [email protected]"
informedConsentErrorLabel.text = "There was a problem submitting your consent. The ORBIT servers could not be reached. Is this iOS device connected to the internet?\n\nIf this problem persists, please contact [email protected]"
case .responseError:
informedConsentErrorLabel.text = "There was a problem submitting your consent. The app received an unexpected response from the ORBIT servers.\n\nIf this problem persists, please contact [email protected]"
case .badRequest(let response):
Expand Down
13 changes: 11 additions & 2 deletions ORBIT Camera/InfoViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class InfoViewController: UIViewController {
/// Content is enclosed in scroll view
@IBOutlet weak var scrollView: UIScrollView!

@IBOutlet weak var logoView: UIImageView!

@IBOutlet weak var stackView: UIStackView!

@IBOutlet weak var webView: WKWebView!
Expand Down Expand Up @@ -170,6 +172,8 @@ class InfoViewController: UIViewController {
sheetButtonElement.accessibilityLabel = sheetButton.accessibilityLabel
sheetButtonElement.accessibilityHint = sheetButton.accessibilityHint

logoView.image = UIImage(named: "City logo")

html = MarkdownParser.html(markdownResource: "ParticipantInformation")

let button = UIButton(type: .system)
Expand Down Expand Up @@ -263,7 +267,7 @@ class InfoViewController: UIViewController {
case .appInfo:
isModalInPresentation = false

headingElement.accessibilityLabel = "App information sheet"
headingElement.accessibilityLabel = "ORBIT instructions sheet"

let closeImage = UIImage(systemName: "xmark.circle")!
sheetButton.setImage(closeImage, for: .normal)
Expand All @@ -272,7 +276,7 @@ class InfoViewController: UIViewController {
sheetButtonElement.accessibilityLabel = sheetButton.accessibilityLabel
sheetButtonElement.accessibilityHint = sheetButton.accessibilityHint

html = MarkdownParser.html(markdownResource: "TutorialScript")
html = MarkdownParser.html(markdownResource: "Introduction")
}

webView.loadHTMLString(html, baseURL: Bundle.main.resourceURL)
Expand All @@ -296,6 +300,8 @@ class InfoViewController: UIViewController {
sheetButtonElement = UIAccessibilityElement(accessibilityContainer: view!)
sheetButtonElement.accessibilityTraits = sheetButton.accessibilityTraits

logoView.image = UIImage(named: "ORBIT logo")

// Webview: inject CSS on page load
let cssInjectJS = """
var style = document.createElement('style');
Expand Down Expand Up @@ -346,6 +352,9 @@ class InfoViewController: UIViewController {
sheetButtonElement!,
scrollView!
]

// iPad: size to be more like full-screen
preferredContentSize = CGSize(width: UIScreen.main.bounds.width*2/3, height: UIScreen.main.bounds.height)
}

@objc func handleKeyboardShow(notification: Notification) {
Expand Down
43 changes: 1 addition & 42 deletions ORBIT Camera/MarkdownParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,48 +43,6 @@ struct MarkdownParser {
return result
}

/// Return a HTML page and a dictionary of HTML element IDs keyed by Video.Kind.
/// The results are generated from a markdown file bundled as an app's resource, with metadata values such as
/// `train-header: training-video-tutorial`
/// The file extension must be '.markdown'
// The Ink markdown parser doesn't like Windows line-endings, so this will replace CRLF with LF on import
static func videoKindParse(markdownResource: String, startKey: String? = nil) -> (html: String, kindElementIDs: Dictionary<Video.Kind, String>) {
guard
let url = Bundle.main.url(forResource: markdownResource, withExtension: "markdown"),
let markdown = try? String(contentsOf: url).replacingOccurrences(of: "\r\n", with: "\n")
else {
os_log("Could not load %{public}s.markdown", markdownResource)
assertionFailure()
return ("", [:])
}

let result = MarkdownParser.inkParser.parse(markdown)

// If startKey header markdown provided, start the HTML at the corresponding header tag
// Find the slugified header id value within the header tag, and then work backwards to the opening bracket
let html: String
if let startKey = startKey,
let startIDValue = result.metadata[startKey]?.slugify(),
let startIDRange = result.html.range(of: startIDValue),
let startIndex = result.html[..<startIDRange.lowerBound].lastIndex(of: "<")
{
html = String(result.html[startIndex...])
} else {
html = result.html
}

let kindElementIDs = Video.Kind.allCases.reduce(into: Dictionary<Video.Kind, String>(), { (dict, kind) in
let key = kind.description.replacingOccurrences(of: " ", with: "-") + "-header"
if let markdownValue = result.metadata[key] {
dict[kind] = markdownValue.slugify()
} else {
os_log("Could not find expected markdown metadata key: %{public}s", key)
assertionFailure()
}
})
return (MarkdownParser.htmlPage(bodyHTML: html, title: markdownResource), kindElementIDs)
}

/// Wrap the supplied HTML body content in a full HTML page
static func htmlPage(bodyHTML: String, title: String) -> String {
return """
Expand All @@ -101,6 +59,7 @@ struct MarkdownParser {
"""
}

/// App-style CSS to inject as script tag
// Use only single-quotes.
static let css = """
body {
Expand Down
37 changes: 37 additions & 0 deletions ORBIT Camera/RecordButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import UIKit
import AVFoundation
import os

class RecordButton: UIControl {
Expand Down Expand Up @@ -37,10 +38,32 @@ class RecordButton: UIControl {
case .idle:
recordingState = .active(Date())
os_log("RecordButton.state active")
AudioServicesPlaySystemSound(RecordButton.systemSoundVideoBegin)
pipCount = 0
pipTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self](timer) in
guard let self = self else { return }
self.pipCount += 1
if self.pipCount > Int(Settings.recordTimeOutSecs) - 5 {
AudioServicesPlaySystemSound(RecordButton.systemSoundTink)
return
}
if self.pipCount % 20 == 0 {
AudioServicesPlaySystemSound(RecordButton.systemSoundTink)
return
}
if self.pipCount % 5 == 0 {
AudioServicesPlaySystemSound(RecordButton.systemSoundTockAlt)
return
}
AudioServicesPlaySystemSound(RecordButton.systemSoundTock)
})
case .active(let date):
recordingState = .idle
let duration = DateInterval(start: date, end: Date()).duration
os_log("RecordButton.state idle, active for %fs", duration)
pipTimer?.invalidate()
pipTimer = nil
AudioServicesPlaySystemSound(RecordButton.systemSoundVideoEnd)
}
setNeedsDisplay()
}
Expand Down Expand Up @@ -76,4 +99,18 @@ class RecordButton: UIControl {
}
context.fillEllipse(in: CGRect(origin: buttonOrigin, size: buttonSize))
}

private var pipTimer: Timer?
private var pipCount: Int = 0
// SystemSoundID File name Category
// 1117 begin_video_record.caf BeginVideoRecording
private static let systemSoundVideoBegin: SystemSoundID = 1117
// 1118 end_video_record.caf EndVideoRecording
private static let systemSoundVideoEnd: SystemSoundID = 1118
// 1103 Tink.caf sq_tock.caf KeyPressed
private static let systemSoundTink: SystemSoundID = 1103
// 1104 Tock.caf sq_tock.caf KeyPressed
private static let systemSoundTock: SystemSoundID = 1104
// 1105 Tock.caf sq_tock.caf KeyPressed
private static let systemSoundTockAlt: SystemSoundID = 1105
}
2 changes: 2 additions & 0 deletions ORBIT Camera/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ struct Settings {
static let captureSessionPreset: AVCaptureSession.Preset = .hd1920x1080
static let recordingResolution = CGSize(width: 1080, height: 1080)

static let recordTimeOutSecs: TimeInterval = 120

static let recordButtonRingWidth: CGFloat = 6

static let dateFormatter: DateFormatter = {
Expand Down

0 comments on commit 5397c89

Please sign in to comment.