All checks were successful
TestFlight / Build and upload (push) Successful in 1m21s
463 lines
16 KiB
Swift
463 lines
16 KiB
Swift
//
|
|
// MainViewController.swift
|
|
// XIONControlPanel
|
|
//
|
|
// Created by Charles Magahern on 12/29/15.
|
|
// Copyright © 2015 XION. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class MainViewController: UIViewController, SwitchesViewControllerDelegate, ServerMultiplexDelegate
|
|
{
|
|
fileprivate var _serverMultiplex: ServerMultiplex = ServerMultiplex()
|
|
fileprivate var _visualizationController: VisualizationViewController = VisualizationViewController()
|
|
fileprivate var _headerView: HeaderView = HeaderView()
|
|
fileprivate var _refreshTimer: Timer?
|
|
fileprivate var _isApplicationActive: Bool = false
|
|
fileprivate var _isViewVisible: Bool = false
|
|
|
|
fileprivate var _switchesController: SwitchesViewController = SwitchesViewController()
|
|
fileprivate var _lightsController: SwitchesViewController = SwitchesViewController()
|
|
fileprivate var _audioSectionView: AudioSectionView = AudioSectionView()
|
|
fileprivate var _audioMixerController: AudioMixerViewController = AudioMixerViewController()
|
|
fileprivate var _audioMixerDimmingView: UIControl = UIControl(frame: .zero)
|
|
fileprivate var _isAudioMixerPresented: Bool = false
|
|
fileprivate var _isAudioMixerAnimating: Bool = false
|
|
|
|
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
|
{
|
|
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
|
|
|
_serverMultiplex.delegate = self
|
|
_serverMultiplex.addServer(HomeAssistantServer(URL(string: "https://ha.xaibatsu.com/api")!))
|
|
// _serverMultiplex.addServer(HubitatServer(URL(string: "https://midna.xionsf.com:6000/apps/api/1/")!))
|
|
// _serverMultiplex.addServer(WemoServer(URL(string: "http://midna.xionsf.com:5000")!))
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder)
|
|
{
|
|
fatalError("unsupported")
|
|
}
|
|
|
|
// MARK: Overrides
|
|
|
|
override func viewDidLoad()
|
|
{
|
|
super.viewDidLoad()
|
|
|
|
self.view.backgroundColor = UIColor.black
|
|
|
|
self.addChild(_visualizationController)
|
|
self.view.addSubview(_visualizationController.view)
|
|
|
|
_switchesController.delegate = self
|
|
self.addChild(_switchesController)
|
|
self.view.addSubview(_switchesController.view)
|
|
|
|
_lightsController.delegate = self
|
|
_lightsController.cellHeightScale = 0.7
|
|
_lightsController.actionCellLayout = .horizontal
|
|
_lightsController.labelText = "lights"
|
|
self.addChild(_lightsController)
|
|
self.view.addSubview(_lightsController.view)
|
|
|
|
_audioSectionView.mixerSettingsHandler = { [weak self] in
|
|
self?._presentAudioMixer()
|
|
}
|
|
self.view.addSubview(_audioSectionView)
|
|
|
|
_audioMixerController.closeHandler = { [weak self] in
|
|
self?._dismissAudioMixer()
|
|
}
|
|
|
|
self.view.addSubview(_headerView)
|
|
|
|
_audioMixerDimmingView.backgroundColor = UIColor.black
|
|
_audioMixerDimmingView.alpha = 0.0
|
|
_audioMixerDimmingView.isHidden = true
|
|
_audioMixerDimmingView.accessibilityIdentifier = "audio.dimmingView"
|
|
_audioMixerDimmingView.accessibilityLabel = "Dismiss Mixer"
|
|
_audioMixerDimmingView.accessibilityTraits = .button
|
|
_audioMixerDimmingView.addTarget(
|
|
self,
|
|
action: #selector(_audioMixerDimmingViewTapped),
|
|
for: .touchUpInside
|
|
)
|
|
self.view.addSubview(_audioMixerDimmingView)
|
|
|
|
_updateConnectivityStatus(.disconnected)
|
|
_updateSizeClassPresentation()
|
|
}
|
|
|
|
override func viewDidLayoutSubviews()
|
|
{
|
|
super.viewDidLayoutSubviews()
|
|
|
|
let bounds = self.view.bounds
|
|
let headerBounds = CGRect(
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: bounds.size.width,
|
|
height: rint(bounds.size.height / 8.0)
|
|
)
|
|
let bodyBounds = CGRect(
|
|
x: 0.0,
|
|
y: headerBounds.maxY,
|
|
width: bounds.size.width,
|
|
height: bounds.size.height - headerBounds.size.height
|
|
)
|
|
|
|
_headerView.frame = headerBounds
|
|
_audioMixerDimmingView.frame = bounds
|
|
|
|
if _visualizationShouldBeVisible() {
|
|
let sectionSpacing: CGFloat = 8.0
|
|
let visualizationFrame = CGRect(
|
|
x: bodyBounds.origin.x,
|
|
y: bodyBounds.origin.y,
|
|
width: rint(0.5 * bodyBounds.size.width),
|
|
height: bodyBounds.size.height / 1.5
|
|
)
|
|
_visualizationController.view.frame = visualizationFrame
|
|
|
|
let lightsControllerFrame = CGRect(
|
|
x: bodyBounds.origin.x,
|
|
y: visualizationFrame.maxY,
|
|
width: visualizationFrame.width,
|
|
height: bodyBounds.height - visualizationFrame.height
|
|
)
|
|
_lightsController.view.frame = lightsControllerFrame.insetBy(
|
|
dx: 18.0,
|
|
dy: 0.0
|
|
)
|
|
|
|
let rightColumnFrame = CGRect(
|
|
x: visualizationFrame.maxX,
|
|
y: bodyBounds.origin.y,
|
|
width: bodyBounds.width - visualizationFrame.width,
|
|
height: bodyBounds.height
|
|
)
|
|
let audioContentHeight = min(
|
|
AudioSectionView.preferredHeight(forWidth: rightColumnFrame.width),
|
|
bodyBounds.height * 0.22
|
|
)
|
|
let audioBottomInset = view.safeAreaInsets.bottom
|
|
let audioHeight = audioContentHeight + audioBottomInset
|
|
_audioSectionView.bottomContentInset = audioBottomInset
|
|
_switchesController.view.frame = CGRect(
|
|
x: rightColumnFrame.minX,
|
|
y: rightColumnFrame.minY,
|
|
width: rightColumnFrame.width,
|
|
height: rightColumnFrame.height - audioHeight - sectionSpacing
|
|
)
|
|
_audioSectionView.frame = CGRect(
|
|
x: rightColumnFrame.minX,
|
|
y: _switchesController.view.frame.maxY + sectionSpacing,
|
|
width: rightColumnFrame.width,
|
|
height: audioHeight
|
|
)
|
|
} else {
|
|
let sectionSpacing: CGFloat = 8.0
|
|
let audioHeight = min(
|
|
AudioSectionView.preferredHeight(forWidth: bodyBounds.width),
|
|
bodyBounds.height * 0.18
|
|
)
|
|
let lightsHeight = rint(bodyBounds.height * 0.27)
|
|
let switchesHeight = bodyBounds.height - audioHeight - lightsHeight - (sectionSpacing * 2.0)
|
|
|
|
_visualizationController.view.frame = .zero
|
|
_audioSectionView.bottomContentInset = 0.0
|
|
_switchesController.view.frame = CGRect(
|
|
x: bodyBounds.minX,
|
|
y: bodyBounds.minY,
|
|
width: bodyBounds.width,
|
|
height: switchesHeight
|
|
)
|
|
_audioSectionView.frame = CGRect(
|
|
x: bodyBounds.minX + sectionSpacing,
|
|
y: _switchesController.view.frame.maxY + sectionSpacing,
|
|
width: bodyBounds.width - (sectionSpacing * 2.0),
|
|
height: audioHeight
|
|
)
|
|
_lightsController.view.frame = CGRect(
|
|
x: bodyBounds.minX + sectionSpacing,
|
|
y: _audioSectionView.frame.maxY + sectionSpacing,
|
|
width: bodyBounds.width - (sectionSpacing * 2),
|
|
height: lightsHeight
|
|
)
|
|
}
|
|
|
|
if _audioMixerController.parent === self, !_isAudioMixerAnimating {
|
|
_audioMixerController.view.frame = _isAudioMixerPresented
|
|
? _audioMixerPresentedFrame()
|
|
: _audioMixerHiddenFrame()
|
|
}
|
|
|
|
_updateSizeClassPresentation()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool)
|
|
{
|
|
super.viewDidAppear(animated)
|
|
|
|
_isViewVisible = true
|
|
_updateActiveState()
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool)
|
|
{
|
|
super.viewDidDisappear(animated)
|
|
|
|
_isViewVisible = false
|
|
_updateActiveState()
|
|
}
|
|
|
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
|
|
{
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
_updateSizeClassPresentation()
|
|
}
|
|
|
|
override var prefersStatusBarHidden : Bool
|
|
{
|
|
return true
|
|
}
|
|
|
|
// MARK: SwitchesViewControllerDelegate
|
|
|
|
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice])
|
|
{
|
|
_updateVisualization(true)
|
|
|
|
for device in devices {
|
|
_serverMultiplex.toggleDeviceState(
|
|
device,
|
|
state: device.state
|
|
) { [weak self] error in
|
|
guard let error else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self._updateConnectivityStatus(.error)
|
|
self._serverMultiplex.refreshDevices()
|
|
|
|
var stderr = StandardErrorOutputStream()
|
|
print(error.localizedDescription, to: &stderr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Internal
|
|
|
|
internal func _visualizationShouldBeVisible() -> Bool
|
|
{
|
|
switch self.traitCollection.horizontalSizeClass {
|
|
case .regular:
|
|
return true
|
|
|
|
case .compact:
|
|
return false
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
internal func _updateSizeClassPresentation()
|
|
{
|
|
_visualizationController.view.isHidden = !_visualizationShouldBeVisible()
|
|
}
|
|
|
|
internal func _updateVisualization(_ animated: Bool)
|
|
{
|
|
let activatedDevicesCount = _serverMultiplex.devices.filter { $0.state == .on }.count
|
|
let totalDeviceCount = _serverMultiplex.devices.count
|
|
|
|
let percentageActivated = (totalDeviceCount > 0 ? Float(activatedDevicesCount) / Float(totalDeviceCount) : 0.0)
|
|
if (percentageActivated != _visualizationController.percentActivated) {
|
|
_visualizationController.setPercentActivated(percentageActivated, animated: animated)
|
|
}
|
|
}
|
|
|
|
internal func _presentAudioMixer()
|
|
{
|
|
guard !_isAudioMixerPresented, !_isAudioMixerAnimating else { return }
|
|
|
|
if _audioMixerController.parent == nil {
|
|
addChild(_audioMixerController)
|
|
view.addSubview(_audioMixerController.view)
|
|
_audioMixerController.didMove(toParent: self)
|
|
}
|
|
|
|
_isAudioMixerPresented = true
|
|
_isAudioMixerAnimating = true
|
|
_audioMixerDimmingView.alpha = 0.0
|
|
_audioMixerDimmingView.isHidden = false
|
|
_audioMixerController.view.isHidden = false
|
|
_audioMixerController.view.frame = _audioMixerHiddenFrame()
|
|
_audioMixerController.view.setNeedsLayout()
|
|
_audioMixerController.view.layoutIfNeeded()
|
|
view.bringSubviewToFront(_audioMixerDimmingView)
|
|
view.bringSubviewToFront(_audioMixerController.view)
|
|
|
|
UIView.animate(
|
|
withDuration: 0.45,
|
|
delay: 0.0,
|
|
usingSpringWithDamping: 0.88,
|
|
initialSpringVelocity: 0.25,
|
|
options: [.allowUserInteraction, .beginFromCurrentState],
|
|
animations: {
|
|
self._audioMixerDimmingView.alpha = 0.55
|
|
self._audioMixerController.view.frame = self._audioMixerPresentedFrame()
|
|
},
|
|
completion: { _ in
|
|
self._isAudioMixerAnimating = false
|
|
self._audioMixerController.view.frame = self._audioMixerPresentedFrame()
|
|
}
|
|
)
|
|
}
|
|
|
|
internal func _dismissAudioMixer()
|
|
{
|
|
guard _isAudioMixerPresented, !_isAudioMixerAnimating else { return }
|
|
|
|
_isAudioMixerPresented = false
|
|
_isAudioMixerAnimating = true
|
|
UIView.animate(
|
|
withDuration: 0.35,
|
|
delay: 0.0,
|
|
options: [.curveEaseIn, .allowUserInteraction, .beginFromCurrentState],
|
|
animations: {
|
|
self._audioMixerDimmingView.alpha = 0.0
|
|
self._audioMixerController.view.frame = self._audioMixerHiddenFrame()
|
|
},
|
|
completion: { _ in
|
|
self._isAudioMixerAnimating = false
|
|
self._audioMixerController.view.isHidden = true
|
|
self._audioMixerDimmingView.isHidden = true
|
|
}
|
|
)
|
|
}
|
|
|
|
@objc private func _audioMixerDimmingViewTapped()
|
|
{
|
|
_dismissAudioMixer()
|
|
}
|
|
|
|
private func _audioMixerPresentedFrame() -> CGRect
|
|
{
|
|
let switchesFrame = _switchesController.view.frame
|
|
return CGRect(
|
|
x: switchesFrame.minX - AudioMixerViewController.controlsWidth,
|
|
y: switchesFrame.minY,
|
|
width: switchesFrame.width + AudioMixerViewController.controlsWidth,
|
|
height: _audioSectionView.frame.maxY - switchesFrame.minY
|
|
)
|
|
}
|
|
|
|
private func _audioMixerHiddenFrame() -> CGRect
|
|
{
|
|
return _audioMixerPresentedFrame().offsetBy(
|
|
dx: UIScreen.main.bounds.width,
|
|
dy: 0.0
|
|
)
|
|
}
|
|
|
|
internal func _updateConnectivityStatus(_ status: ConnectionStatus)
|
|
{
|
|
DispatchQueue.main.async { () -> Void in
|
|
self._headerView.connectionStatusView.connectivityStatus = status
|
|
self._headerView.setNeedsLayout()
|
|
self._visualizationController.connectionStatus = status
|
|
}
|
|
}
|
|
|
|
internal func applicationDidBecomeActive()
|
|
{
|
|
_isApplicationActive = true
|
|
_updateActiveState()
|
|
}
|
|
|
|
internal func applicationWillResignActive()
|
|
{
|
|
_isApplicationActive = false
|
|
_updateActiveState()
|
|
}
|
|
|
|
private func _updateActiveState()
|
|
{
|
|
let shouldBeActive = _isApplicationActive && _isViewVisible
|
|
|
|
if shouldBeActive, _refreshTimer == nil {
|
|
UIApplication.shared.isIdleTimerDisabled = true
|
|
_headerView.xionLogoView.beginAnimating()
|
|
_updateConnectivityStatus(.connecting)
|
|
_serverMultiplex.refreshDevices()
|
|
|
|
let timer = Timer(timeInterval: 10, repeats: true) { [weak self] _ in
|
|
self?._serverMultiplex.refreshDevices()
|
|
}
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
_refreshTimer = timer
|
|
} else if !shouldBeActive, _refreshTimer != nil {
|
|
_refreshTimer?.invalidate()
|
|
_refreshTimer = nil
|
|
_serverMultiplex.disconnect()
|
|
_updateConnectivityStatus(.disconnected)
|
|
_headerView.xionLogoView.stopAnimating()
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
}
|
|
}
|
|
|
|
// MARK: Server Multiplex Delegate
|
|
|
|
func serverMultiplex(_ multiplex: ServerMultiplex, didAddDevices devices: [AnyDevice])
|
|
{
|
|
_switchesController.devices = multiplex.switchDevices
|
|
_lightsController.devices = multiplex.lightDevices
|
|
|
|
_updateVisualization(false)
|
|
}
|
|
|
|
func serverMultiplex(_ multiplex: ServerMultiplex, devicesStateChanged devices: [AnyDevice])
|
|
{
|
|
_switchesController.devicesStateChanged(devices.switches)
|
|
_lightsController.devicesStateChanged(devices.lights)
|
|
_updateVisualization(true)
|
|
}
|
|
|
|
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
|
|
{
|
|
// Update connection status too, if applicable
|
|
let groupConnectionStatus = multiplex.groupConnectionStatus()
|
|
_updateConnectivityStatus(groupConnectionStatus)
|
|
}
|
|
|
|
func serverMultiplexConnectionStatusDidChange(_ multiplex: ServerMultiplex)
|
|
{
|
|
_updateConnectivityStatus(multiplex.groupConnectionStatus())
|
|
}
|
|
|
|
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
|
|
{
|
|
_updateConnectivityStatus(.error)
|
|
|
|
var stderr = StandardErrorOutputStream()
|
|
print(error.localizedDescription, to: &stderr)
|
|
}
|
|
}
|
|
|
|
extension Array where Element == AnyDevice
|
|
{
|
|
var lights: [AnyDevice] { filter { $0.type == .light } }
|
|
var switches: [AnyDevice] { filter { $0.type == .switch } }
|
|
}
|
|
|
|
extension ServerMultiplex
|
|
{
|
|
var lightDevices: [AnyDevice] { devices.filter { $0.type == .light } }
|
|
var switchDevices: [AnyDevice] { devices.filter { $0.type == .switch } }
|
|
}
|