diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj index 45b26bb1cdfdc8987a628fb5c9e2d2b3b90fcd8d..d7f2b693f1d259a43eeba29072ca11dff4cefe41 100644 --- a/Ring/Ring.xcodeproj/project.pbxproj +++ b/Ring/Ring.xcodeproj/project.pbxproj @@ -110,6 +110,9 @@ 0E49096C1FEAB225005CAA50 /* CallsAdapterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E49096B1FEAB225005CAA50 /* CallsAdapterDelegate.swift */; }; 0E49096E1FEAC0DE005CAA50 /* CallsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E49096D1FEAC0DE005CAA50 /* CallsService.swift */; }; 0E4909701FEAC1C6005CAA50 /* CallModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E49096F1FEAC1C6005CAA50 /* CallModel.swift */; }; + 0E4909751FEAC943005CAA50 /* CallViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E4909741FEAC943005CAA50 /* CallViewController.storyboard */; }; + 0E49097A1FEAC9E1005CAA50 /* CallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4909791FEAC9E1005CAA50 /* CallViewController.swift */; }; + 0E49097C1FEACA4B005CAA50 /* CallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E49097B1FEACA4B005CAA50 /* CallViewModel.swift */; }; 0E6949791FA7E71C0029B60A /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6949781FA7E71C0029B60A /* BaseViewController.swift */; }; 0E983E6E1FC77C3E0082103E /* ConversationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E983E6D1FC77C3E0082103E /* ConversationModel.swift */; }; 0E9D84491FA7DA6A00C561EB /* ChatTabBarItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9D84481FA7DA6A00C561EB /* ChatTabBarItemViewModel.swift */; }; @@ -365,6 +368,9 @@ 0E49096B1FEAB225005CAA50 /* CallsAdapterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallsAdapterDelegate.swift; sourceTree = "<group>"; }; 0E49096D1FEAC0DE005CAA50 /* CallsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallsService.swift; sourceTree = "<group>"; }; 0E49096F1FEAC1C6005CAA50 /* CallModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallModel.swift; sourceTree = "<group>"; }; + 0E4909741FEAC943005CAA50 /* CallViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = CallViewController.storyboard; sourceTree = "<group>"; }; + 0E4909791FEAC9E1005CAA50 /* CallViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewController.swift; sourceTree = "<group>"; }; + 0E49097B1FEACA4B005CAA50 /* CallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewModel.swift; sourceTree = "<group>"; }; 0E6949781FA7E71C0029B60A /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = "<group>"; }; 0E983E6D1FC77C3E0082103E /* ConversationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationModel.swift; sourceTree = "<group>"; }; 0E9D84481FA7DA6A00C561EB /* ChatTabBarItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTabBarItemViewModel.swift; sourceTree = "<group>"; }; @@ -944,6 +950,16 @@ path = DBHelpers; sourceTree = "<group>"; }; + 0E4909711FEAC822005CAA50 /* Calls */ = { + isa = PBXGroup; + children = ( + 0E4909741FEAC943005CAA50 /* CallViewController.storyboard */, + 0E4909791FEAC9E1005CAA50 /* CallViewController.swift */, + 0E49097B1FEACA4B005CAA50 /* CallViewModel.swift */, + ); + path = Calls; + sourceTree = "<group>"; + }; 0E5AFE0A1F8EBC040040D539 /* Cells */ = { isa = PBXGroup; children = ( @@ -996,6 +1012,7 @@ isa = PBXGroup; children = ( 5CE66F721FBF765D00EE9291 /* InitialLoading */, + 0E4909711FEAC822005CAA50 /* Calls */, 0E9D84471FA7D9EC00C561EB /* TabBar */, 0EDE34C51F868D2D00FFA15C /* Shared */, 1A0C4EBD1F1D48DD00550433 /* Walkthrough */, @@ -1463,6 +1480,7 @@ 623660AA20092081002598C1 /* src in Resources */, 1A2D18B11F2915B600B2C785 /* SmartlistViewController.storyboard in Resources */, 0E403F831F7D79B000C80BC2 /* MessageCellGenerated.xib in Resources */, + 0E4909751FEAC943005CAA50 /* CallViewController.storyboard in Resources */, 04399A031D1C2D9D00E99CD9 /* Images.xcassets in Resources */, 1A2041841F1EA0FC00C08435 /* CreateAccountViewController.storyboard in Resources */, 0E2D5F551F9145F200D574BF /* LinkNewDeviceCell.xib in Resources */, @@ -1600,6 +1618,7 @@ 1A5DC0201F355DCF0075E8EF /* ContactsService.swift in Sources */, 1A2D18C71F29180700B2C785 /* DeviceModel.swift in Sources */, 1A20418F1F1EAC0E00C08435 /* Coordinator.swift in Sources */, + 0E49097C1FEACA4B005CAA50 /* CallViewModel.swift in Sources */, 1A2D18A11F27A6D600B2C785 /* LinkDeviceViewController.swift in Sources */, 1A0C4EDC1F1D4B7E00550433 /* WelcomeViewController.swift in Sources */, 1A2D18D81F2918EE00B2C785 /* MeDetailViewController.swift in Sources */, @@ -1608,6 +1627,7 @@ 56BBC99F1ED714CB00CDAF8B /* MessagesAdapter.mm in Sources */, 0E49096A1FEAB156005CAA50 /* CallsAdapter.mm in Sources */, 1A2D18A61F27F7A400B2C785 /* UIViewController+Rx.swift in Sources */, + 0E49097A1FEAC9E1005CAA50 /* CallViewController.swift in Sources */, 1A5DC0241F3564360075E8EF /* ContactRequestModel.swift in Sources */, 0E4909701FEAC1C6005CAA50 /* CallModel.swift in Sources */, 1A5DC03F1F35678D0075E8EF /* ContactRequestsViewController.swift in Sources */, diff --git a/Ring/Ring/AppDelegate.swift b/Ring/Ring/AppDelegate.swift index fae477b3eb239524b4d480ca92e6b2c0b5132bb2..26d69c12adba891d1856ad7fb66d709e77c71d46 100644 --- a/Ring/Ring/AppDelegate.swift +++ b/Ring/Ring/AppDelegate.swift @@ -36,6 +36,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private let conversationsService = ConversationsService(withMessageAdapter: MessagesAdapter()) private let contactsService = ContactsService(withContactsAdapter: ContactsAdapter()) private let presenceService = PresenceService(withPresenceAdapter: PresenceAdapter()) + private let callService = CallsService(withCallsAdapter: CallsAdapter()) private let networkService = NetworkService() private var conversationManager: ConversationsManager? private var contactRequestManager: ContactRequestManager? @@ -47,8 +48,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { withConversationService: self.conversationsService, withContactsService: self.contactsService, withPresenceService: self.presenceService, - withNetworkService: self.networkService - ) + withNetworkService: self.networkService, + withCallService: self.callService) }() private lazy var appCoordinator: AppCoordinator = { return AppCoordinator(with: self.injectionBag) diff --git a/Ring/Ring/Calls/CallViewController.storyboard b/Ring/Ring/Calls/CallViewController.storyboard new file mode 100644 index 0000000000000000000000000000000000000000..133e330870fcb8462735716c7ca8c6725e7fae09 --- /dev/null +++ b/Ring/Ring/Calls/CallViewController.storyboard @@ -0,0 +1,123 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13771" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="ngv-XP-7A7"> + <device id="retina4_7" orientation="portrait"> + <adaptation id="fullscreen"/> + </device> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13772"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--Calls--> + <scene sceneID="XKD-ru-Nw9"> + <objects> + <viewController title="Calls" id="ngv-XP-7A7" customClass="CallViewController" customModule="Ring" customModuleProvider="target" sceneMemberID="viewController"> + <layoutGuides> + <viewControllerLayoutGuide type="top" id="WrD-XI-6aI"/> + <viewControllerLayoutGuide type="bottom" id="4n1-G8-SAO"/> + </layoutGuides> + <view key="view" contentMode="scaleToFill" id="QpJ-Sx-9dG"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="uC8-vY-dHO"> + <rect key="frame" x="0.0" y="0.0" width="375" height="618"/> + <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="cOr-ft-BIO"> + <rect key="frame" x="0.0" y="0.0" width="375" height="618"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> + </view> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> + <blurEffect style="light"/> + </visualEffectView> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="B3b-V0-rjx"> + <rect key="frame" x="157.5" y="538" width="60" height="60"/> + <color key="backgroundColor" red="1" green="0.0" blue="0.0" alpha="1" colorSpace="calibratedRGB"/> + <constraints> + <constraint firstAttribute="width" constant="60" id="oOU-Hx-5HZ"/> + <constraint firstAttribute="height" constant="60" id="s7U-o7-NEg"/> + </constraints> + <state key="normal" title="Cancel"> + <color key="titleColor" white="1" alpha="1" colorSpace="calibratedWhite"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <real key="value" value="30"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="boolean" keyPath="roundedCorners" value="YES"/> + </userDefinedRuntimeAttributes> + </button> + <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="ic_contact_picture" translatesAutoresizingMaskIntoConstraints="NO" id="fnt-PQ-Q6P"> + <rect key="frame" x="137.5" y="64" width="100" height="100"/> + <constraints> + <constraint firstAttribute="width" constant="100" id="Miw-Nd-4Fa"/> + <constraint firstAttribute="height" constant="100" id="V9c-7W-Frv"/> + </constraints> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <real key="value" value="50"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="73Y-N1-Yga"> + <rect key="frame" x="187.5" y="172" width="0.0" height="0.0"/> + <fontDescription key="fontDescription" type="system" pointSize="26"/> + <color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SdV-jx-Mla"> + <rect key="frame" x="187.5" y="530" width="0.0" height="0.0"/> + <fontDescription key="fontDescription" type="system" pointSize="20"/> + <color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zMN-6z-uXT"> + <rect key="frame" x="187.5" y="188" width="0.0" height="0.0"/> + <fontDescription key="fontDescription" type="system" pointSize="20"/> + <color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> + <constraints> + <constraint firstItem="zMN-6z-uXT" firstAttribute="centerX" secondItem="QpJ-Sx-9dG" secondAttribute="centerX" id="8Mt-nX-xlY"/> + <constraint firstItem="zMN-6z-uXT" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="QpJ-Sx-9dG" secondAttribute="leading" constant="8" id="Bf4-J4-K9c"/> + <constraint firstItem="fnt-PQ-Q6P" firstAttribute="top" secondItem="WrD-XI-6aI" secondAttribute="bottom" constant="44" id="C6d-Dz-lnR"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="SdV-jx-Mla" secondAttribute="trailing" constant="8" id="EDd-Cg-QHP"/> + <constraint firstItem="B3b-V0-rjx" firstAttribute="centerX" secondItem="QpJ-Sx-9dG" secondAttribute="centerX" id="Foq-ZE-uj9"/> + <constraint firstItem="uC8-vY-dHO" firstAttribute="leading" secondItem="QpJ-Sx-9dG" secondAttribute="leading" id="G08-ef-Ucc"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="73Y-N1-Yga" secondAttribute="trailing" constant="8" id="Gcb-08-NRr"/> + <constraint firstItem="4n1-G8-SAO" firstAttribute="top" secondItem="B3b-V0-rjx" secondAttribute="bottom" constant="20" id="HwS-Ng-Ojz"/> + <constraint firstItem="73Y-N1-Yga" firstAttribute="top" secondItem="fnt-PQ-Q6P" secondAttribute="bottom" constant="8" id="JC6-KJ-L8L"/> + <constraint firstItem="SdV-jx-Mla" firstAttribute="centerX" secondItem="QpJ-Sx-9dG" secondAttribute="centerX" id="MXS-7j-cD5"/> + <constraint firstItem="uC8-vY-dHO" firstAttribute="top" secondItem="QpJ-Sx-9dG" secondAttribute="top" id="Rse-54-gPI"/> + <constraint firstItem="zMN-6z-uXT" firstAttribute="top" secondItem="73Y-N1-Yga" secondAttribute="bottom" constant="16" id="YQp-tl-h73"/> + <constraint firstItem="SdV-jx-Mla" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="QpJ-Sx-9dG" secondAttribute="leading" constant="8" id="Zms-si-GOc"/> + <constraint firstItem="fnt-PQ-Q6P" firstAttribute="centerX" secondItem="QpJ-Sx-9dG" secondAttribute="centerX" id="b3O-Sw-To4"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="zMN-6z-uXT" secondAttribute="trailing" constant="8" id="cZ0-u0-t7T"/> + <constraint firstItem="B3b-V0-rjx" firstAttribute="top" secondItem="SdV-jx-Mla" secondAttribute="bottom" constant="8" id="dCo-8J-8Ba"/> + <constraint firstItem="4n1-G8-SAO" firstAttribute="top" secondItem="uC8-vY-dHO" secondAttribute="bottom" id="iVJ-Fo-imi"/> + <constraint firstItem="73Y-N1-Yga" firstAttribute="centerX" secondItem="QpJ-Sx-9dG" secondAttribute="centerX" id="p8J-P2-tcm"/> + <constraint firstItem="73Y-N1-Yga" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="QpJ-Sx-9dG" secondAttribute="leading" constant="8" id="ryb-Wh-KM2"/> + <constraint firstAttribute="trailing" secondItem="uC8-vY-dHO" secondAttribute="trailing" id="tmf-Ae-VCF"/> + </constraints> + </view> + <simulatedTabBarMetrics key="simulatedBottomBarMetrics"/> + <connections> + <outlet property="cancelButton" destination="B3b-V0-rjx" id="dU9-MG-0y3"/> + <outlet property="durationLabel" destination="zMN-6z-uXT" id="Uuf-ph-lrC"/> + <outlet property="infoBottomLabel" destination="SdV-jx-Mla" id="yX9-em-p4w"/> + <outlet property="nameLabel" destination="73Y-N1-Yga" id="XcQ-V6-ZrF"/> + <outlet property="profileImageView" destination="fnt-PQ-Q6P" id="MgB-Ev-bTc"/> + </connections> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="OFk-0u-Pap" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + <point key="canvasLocation" x="-74.400000000000006" y="131.78410794602701"/> + </scene> + </scenes> + <resources> + <image name="ic_contact_picture" width="128" height="128"/> + </resources> +</document> diff --git a/Ring/Ring/Calls/CallViewController.swift b/Ring/Ring/Calls/CallViewController.swift new file mode 100644 index 0000000000000000000000000000000000000000..51f977c2e7b4040d3183b19c8645be92de3486ff --- /dev/null +++ b/Ring/Ring/Calls/CallViewController.swift @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2017 Savoir-faire Linux Inc. + * + * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> + * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import UIKit +import Chameleon +import RxSwift +import Reusable + +class CallViewController: UIViewController, StoryboardBased, ViewModelBased { + + @IBOutlet weak var profileImageView: UIImageView! + @IBOutlet weak var nameLabel: UILabel! + @IBOutlet weak var durationLabel: UILabel! + @IBOutlet weak var infoBottomLabel: UILabel! + @IBOutlet weak var cancelButton: UIButton! + + var viewModel: CallViewModel! + + fileprivate let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + self.setupUI() + self.setupBindings() + } + + func setupUI() { + self.cancelButton.backgroundColor = UIColor.red + } + + func setupBindings() { + + //Cancel button action + self.cancelButton.rx.tap + .subscribe(onNext: { [weak self] in + self?.removeFromScreen() + self?.viewModel.cancelCall() + }).disposed(by: self.disposeBag) + + //Data bindings + self.viewModel.dismisVC + .observeOn(MainScheduler.instance) + .subscribe(onNext: { [weak self] dismiss in + if dismiss { + self?.removeFromScreen() + } + }).disposed(by: self.disposeBag) + + self.viewModel.contactName + .observeOn(MainScheduler.instance) + .bind(to: self.nameLabel.rx.text) + .disposed(by: self.disposeBag) + + self.viewModel.callDuration + .observeOn(MainScheduler.instance) + .bind(to: self.durationLabel.rx.text) + .disposed(by: self.disposeBag) + + self.viewModel.bottomInfo + .observeOn(MainScheduler.instance) + .bind(to: self.infoBottomLabel.rx.text) + .disposed(by: self.disposeBag) + } + + func removeFromScreen() { + self.dismiss(animated: false) + } + +} diff --git a/Ring/Ring/Calls/CallViewModel.swift b/Ring/Ring/Calls/CallViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..6791413c899a494f47bb371f980419b721fe34e4 --- /dev/null +++ b/Ring/Ring/Calls/CallViewModel.swift @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2017 Savoir-faire Linux Inc. + * + * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com> + * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import RxSwift +import SwiftyBeaver +import Contacts + +class CallViewModel: Stateable, ViewModel { + + //stateable + private let stateSubject = PublishSubject<State>() + lazy var state: Observable<State> = { + return self.stateSubject.asObservable() + }() + + fileprivate let callService: CallsService + fileprivate let contactsService: ContactsService + fileprivate let accountService: AccountsService + private let disposeBag = DisposeBag() + fileprivate let log = SwiftyBeaver.self + + var call: CallModel? + + // data for ViewCintroller binding + + lazy var dismisVC: Observable<Bool> = { + return callService.currentCall.map({[weak self] call in + return call.state == .over || call.state == .failure && call.callId == self?.call?.callId + }).map({ hide in + return hide + }) + }() + + lazy var contactName: Observable<String> = { + return callService.currentCall.filter({ [weak self] call in + return call.state != .over && call.state != .inactive && call.callId == self?.call?.callId + }).map({ call in + if !call.displayName.isEmpty { + return call.displayName + } else if !call.registeredName.isEmpty { + return call.registeredName + } else { + return L10n.Calls.unknown + } + }) + }() + + lazy var callDuration: Observable<String> = { + let timer = Observable<Int>.interval(1, scheduler: MainScheduler.instance) + .takeUntil(self.callService.currentCall + .filter { [weak self] call in + call.state == .over && + call.callId == self?.call?.callId + }) + .map({ elapsed in + return CallViewModel.formattedDurationFrom(interval: elapsed) + }) + return self.callService.currentCall.filter({ call in + return call.state == .current + }).flatMap({ _ in + return timer + }) + }() + + lazy var bottomInfo: Observable<String> = { + return callService.currentCall.map({ [weak self] call in + if call.state == .connecting || call.state == .ringing && call.callType == .outgoing && call.callId == self?.call?.callId { + return L10n.Calls.calling + } else if call.state == .over { + return L10n.Calls.callFinished + } else { + return "" + } + }) + }() + required init(with injectionBag: InjectionBag) { + self.callService = injectionBag.callService + self.contactsService = injectionBag.contactsService + self.accountService = injectionBag.accountService + } + static func formattedDurationFrom(interval: Int) -> String { + let seconds = interval % 60 + let minutes = (interval / 60) % 60 + let hours = (interval / 3600) + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + + func cancelCall() { + guard let call = self.call else { + return + } + self.callService.hangUp(callId: call.callId) + .subscribe(onCompleted: { [weak self] in + self?.log.info("Call canceled") + }, onError: { [weak self] error in + self?.log.error("Failed to cancel the call") + }).disposed(by: self.disposeBag) + } + + + + func answerCall() { + guard let call = self.call else { + return + } + self.callService.accept(callId: call.callId) + .subscribe(onCompleted: { [weak self] in + self?.log.info("Call answered") + }, onError: { [weak self] error in + self?.log.error("Failed to answer the call") + }).disposed(by: self.disposeBag) + } + + func placeCall(with uri: String, userName: String) { + guard let account = self.accountService.currentAccount else { + return + } + self.callService.placeCall(withAccount: account, + toRingId: uri, + userName: userName) + .subscribe(onSuccess: { [unowned self] callModel in + self.call = callModel + self.log.info("Call placed: \(callModel.callId)") + }, onError: { [unowned self] error in + self.log.error("Failed to place the call") + }).disposed(by: self.disposeBag) + } +} diff --git a/Ring/Ring/Constants/Generated/Images.swift b/Ring/Ring/Constants/Generated/Images.swift index e1bbda148e5211854eaec91ed2d57c18107872b4..c5d661591d1b1be4b87cc47ef7bf9d84bae7bc57 100644 --- a/Ring/Ring/Constants/Generated/Images.swift +++ b/Ring/Ring/Constants/Generated/Images.swift @@ -50,6 +50,7 @@ enum Asset { static let addPerson = ImageAsset(name: "add_person") static let backgroundRing = ImageAsset(name: "background_ring") static let blockIcon = ImageAsset(name: "block_icon") + static let callButton = ImageAsset(name: "call_button") static let contactRequestIcon = ImageAsset(name: "contact_request_icon") static let conversationIcon = ImageAsset(name: "conversation_icon") static let device = ImageAsset(name: "device") @@ -66,6 +67,7 @@ enum Asset { addPerson, backgroundRing, blockIcon, + callButton, contactRequestIcon, conversationIcon, device, diff --git a/Ring/Ring/Constants/Generated/Storyboards.swift b/Ring/Ring/Constants/Generated/Storyboards.swift index e797a8bdc24e03d5c5fef5c63ca9e87cbce77ad6..d0f975836f18d5951865db88e2926e09a14f0450 100644 --- a/Ring/Ring/Constants/Generated/Storyboards.swift +++ b/Ring/Ring/Constants/Generated/Storyboards.swift @@ -50,6 +50,11 @@ extension UIViewController { // swiftlint:disable explicit_type_interface identifier_name line_length type_body_length type_name enum StoryboardScene { + enum CallViewController: StoryboardType { + static let storyboardName = "CallViewController" + + static let initialScene = InitialSceneType<Ring.CallViewController>(storyboard: CallViewController.self) + } enum ContactRequestsViewController: StoryboardType { static let storyboardName = "ContactRequestsViewController" diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift index b674d44af00e91b0c0bad22c437c0c39c9433190..f79b829a2e92a27b65936e694fad3e191b1df06e 100644 --- a/Ring/Ring/Constants/Generated/Strings.swift +++ b/Ring/Ring/Constants/Generated/Strings.swift @@ -51,6 +51,12 @@ enum L10n { static let dbFailedMessage = L10n.tr("Localizable", "alerts.dbFailedMessage") /// An error happned when launching Ring static let dbFailedTitle = L10n.tr("Localizable", "alerts.dbFailedTitle") + /// Incoming call from + static let incomingCallAllertTitle = L10n.tr("Localizable", "alerts.incomingCallAllertTitle") + /// Accept + static let incomingCallButtonAccept = L10n.tr("Localizable", "alerts.incomingCallButtonAccept") + /// Ignore + static let incomingCallButtonIgnore = L10n.tr("Localizable", "alerts.incomingCallButtonIgnore") /// Cancel static let profileCancelPhoto = L10n.tr("Localizable", "alerts.profileCancelPhoto") /// Take photo @@ -59,6 +65,19 @@ enum L10n { static let profileUploadPhoto = L10n.tr("Localizable", "alerts.profileUploadPhoto") } + enum Calls { + /// Call finished + static let callFinished = L10n.tr("Localizable", "calls.callFinished") + /// Calling... + static let calling = L10n.tr("Localizable", "calls.calling") + /// Call + static let callItemTitle = L10n.tr("Localizable", "calls.callItemTitle") + /// wants to talk to you + static let incomingCallInfo = L10n.tr("Localizable", "calls.incomingCallInfo") + /// Unknown + static let unknown = L10n.tr("Localizable", "calls.unknown") + } + enum Createaccount { /// Choose strong password you will remember to protect your Ring account. static let chooseStrongPassword = L10n.tr("Localizable", "createAccount.chooseStrongPassword") diff --git a/Ring/Ring/Coordinators/InjectionBag.swift b/Ring/Ring/Coordinators/InjectionBag.swift index 0968c3a988c76d4478c1de329afd3696206906d5..0cbbda4dc1ac319bb778f5c96c209e4c750fec81 100644 --- a/Ring/Ring/Coordinators/InjectionBag.swift +++ b/Ring/Ring/Coordinators/InjectionBag.swift @@ -30,6 +30,7 @@ class InjectionBag { let contactsService: ContactsService let presenceService: PresenceService let networkService: NetworkService + let callService: CallsService init (withDaemonService daemonService: DaemonService, withAccountService accountService: AccountsService, @@ -37,7 +38,8 @@ class InjectionBag { withConversationService conversationService: ConversationsService, withContactsService contactsService: ContactsService, withPresenceService presenceService: PresenceService, - withNetworkService networkService: NetworkService) { + withNetworkService networkService: NetworkService, + withCallService callService: CallsService) { self.daemonService = daemonService self.accountService = accountService self.nameService = nameService @@ -45,6 +47,7 @@ class InjectionBag { self.contactsService = contactsService self.presenceService = presenceService self.networkService = networkService + self.callService = callService } } diff --git a/Ring/Ring/Features/ContactRequests/ContactRequestsCoordinator.swift b/Ring/Ring/Features/ContactRequests/ContactRequestsCoordinator.swift index 54a6800aa09c90bf231662a441031db6ee0cc564..7207ba563ff4b6db96d8f85bd8e1d4018efc6949 100644 --- a/Ring/Ring/Features/ContactRequests/ContactRequestsCoordinator.swift +++ b/Ring/Ring/Features/ContactRequests/ContactRequestsCoordinator.swift @@ -46,6 +46,8 @@ class ContactRequestsCoordinator: Coordinator, StateableResponsive { switch state { case .conversationDetail (let conversationViewModel): self.showConversation(withConversationViewModel: conversationViewModel) + default: + break } }).disposed(by: self.disposeBag) } diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift index 29119f927ca1c27ea60cbdaa9e3011a6b654e0dd..eeacef1fb9cfa56b2f4cec9d02156101db9afd42 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift @@ -107,14 +107,19 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo self.tableView.contentInset.bottom = messageAccessoryView.frame.size.height self.tableView.scrollIndicatorInsets.bottom = messageAccessoryView.frame.size.height - //invite button + //set navigation buttons - call and send contact request let inviteItem = UIBarButtonItem() inviteItem.image = UIImage(named: "add_person") inviteItem.rx.tap.throttle(0.5, scheduler: MainScheduler.instance) .subscribe(onNext: { [unowned self] in self.inviteItemTapped() }).disposed(by: self.disposeBag) - + let callItem = UIBarButtonItem() + callItem.image = UIImage(asset: Asset.callButton) + callItem.rx.tap.throttle(0.5, scheduler: MainScheduler.instance) + .subscribe(onNext: { [unowned self] in + self.placeCall() + }).disposed(by: self.disposeBag) self.viewModel.inviteButtonIsAvailable.asObservable().bind(to: inviteItem.rx.isEnabled).disposed(by: disposeBag) //block contact button @@ -125,13 +130,14 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo self.blockItemTapped() }).disposed(by: self.disposeBag) - self.navigationItem.rightBarButtonItems = [blockItem, inviteItem] + self.navigationItem.rightBarButtonItems = [blockItem, inviteItem, callItem] Observable<[UIBarButtonItem]> .combineLatest(self.viewModel.inviteButtonIsAvailable.asObservable(), self.viewModel.blockButtonIsAvailable.asObservable(), resultSelector: { inviteButton, blockButton in var buttons = [UIBarButtonItem]() + buttons.append(callItem) if blockButton { buttons.append(blockItem) } @@ -161,6 +167,10 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo self.present(alert, animated: true, completion: nil) } + func placeCall() { + self.viewModel.startCall() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift index 28dff04672baf3c48ca34f0b926890fca739eb3a..92c09bd6fc459717dae962bc55b67ddb8c123d64 100644 --- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift +++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift @@ -23,7 +23,7 @@ import UIKit import RxSwift import SwiftyBeaver -class ConversationViewModel: ViewModel { +class ConversationViewModel: Stateable, ViewModel { /** logguer @@ -38,6 +38,11 @@ class ConversationViewModel: ViewModel { private let presenceService: PresenceService private let injectionBag: InjectionBag + private let stateSubject = PublishSubject<State>() + lazy var state: Observable<State> = { + return self.stateSubject.asObservable() + }() + required init(with injectionBag: InjectionBag) { self.injectionBag = injectionBag self.accountService = injectionBag.accountService @@ -335,4 +340,11 @@ class ConversationViewModel: ViewModel { return } } + + func startCall() { + if self.conversation.value.messages.isEmpty { + self.sendContactRequest() + } + self.stateSubject.onNext(ConversationsState.startCall(contactRingId: self.conversation.value.recipientRingId, userName: self.userName.value)) + } } diff --git a/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift b/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift index 459dab5e794d88640c1df8db66595773c70581f5..dbf9ff062c223de72bd53c271f7d4caa50fc34b2 100644 --- a/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift +++ b/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift @@ -26,6 +26,7 @@ import RxSwift /// - conversationDetail: user want to see a conversation detail enum ConversationsState: State { case conversationDetail(conversationViewModel: ConversationViewModel) + case startCall(contactRingId: String, userName: String) } /// This Coordinator drives the conversation navigation (Smartlist / Conversation detail) @@ -42,19 +43,27 @@ class ConversationsCoordinator: Coordinator, StateableResponsive { let disposeBag = DisposeBag() let stateSubject = PublishSubject<State>() - let conversationsService: ConversationsService - let accountService: AccountsService + let callService: CallsService required init (with injectionBag: InjectionBag) { self.injectionBag = injectionBag - self.conversationsService = injectionBag.conversationsService - self.accountService = injectionBag.accountService + + self.callService = injectionBag.callService + + self.callService.newCall.asObservable() + .map({ call in + return call + }).subscribe(onNext: { (call) in + self.showCallAlert(call: call) + }).disposed(by: self.disposeBag) self.stateSubject.subscribe(onNext: { [unowned self] (state) in guard let state = state as? ConversationsState else { return } switch state { case .conversationDetail (let conversationViewModel): self.showConversation(withConversationViewModel: conversationViewModel) + case .startCall(let contactRingId, let name): + self.startOutgoingCall(contactRingId: contactRingId, userName: name) } }).disposed(by: self.disposeBag) self.navigationViewController.viewModel = ChatTabBarItemViewModel(with: self.injectionBag) @@ -69,6 +78,39 @@ class ConversationsCoordinator: Coordinator, StateableResponsive { private func showConversation (withConversationViewModel conversationViewModel: ConversationViewModel) { let conversationViewController = ConversationViewController.instantiate(with: self.injectionBag) conversationViewController.viewModel = conversationViewModel - self.present(viewController: conversationViewController, withStyle: .show, withAnimation: true) + self.present(viewController: conversationViewController, withStyle: .show, withAnimation: true, withStateable: conversationViewController.viewModel) + } + + private func startOutgoingCall(contactRingId: String, userName: String) { + let callViewController = CallViewController.instantiate(with: self.injectionBag) + callViewController.viewModel.placeCall(with: contactRingId, userName: userName) + self.present(viewController: callViewController, withStyle: .present, withAnimation: false) + } + + private func answerIncomingCall(call: CallModel) { + let callViewController = CallViewController.instantiate(with: self.injectionBag) + callViewController.viewModel.call = call + callViewController.viewModel.answerCall() + self.present(viewController: callViewController, withStyle: .present, withAnimation: false) + } + + private func showCallAlert(call: CallModel) { + let alert = UIAlertController(title: L10n.Alerts.incomingCallAllertTitle + "\(call.displayName)", message: nil, preferredStyle: UIAlertControllerStyle.actionSheet) + alert.addAction(UIAlertAction(title: L10n.Alerts.incomingCallButtonAccept, style: UIAlertActionStyle.default, handler: { (_) in + self.answerIncomingCall(call: call) + alert.dismiss(animated: true, completion: nil)})) + alert.addAction(UIAlertAction(title: L10n.Alerts.incomingCallButtonIgnore, style: UIAlertActionStyle.default, handler: { (_) in + self.injectionBag.callService.refuse(callId: call.callId) + .subscribe({_ in + print("Call ignored") + }).disposed(by: self.disposeBag) + alert.dismiss(animated: true, completion: nil) + })) + + if let controller = self.rootViewController.presentedViewController { + controller.present(alert, animated: false, completion: nil) + } else { + self.present(viewController: alert, withStyle: .present, withAnimation: true) + } } } diff --git a/Ring/Ring/Models/CallModel.swift b/Ring/Ring/Models/CallModel.swift index 86fedfd87ad3a80d6940a1054e86c2b48c702ed4..fbfe7799adc16ff8f41eaf08ac348c37472146c7 100644 --- a/Ring/Ring/Models/CallModel.swift +++ b/Ring/Ring/Models/CallModel.swift @@ -117,11 +117,11 @@ class CallModel { self.dateReceived = Date() - if let displayName = dictionary[CallDetailKey.displayNameKey.rawValue] { + if let displayName = dictionary[CallDetailKey.displayNameKey.rawValue], !displayName.isEmpty { self.displayName = displayName } - if let registeredName = dictionary[CallDetailKey.registeredNameKey.rawValue] { + if let registeredName = dictionary[CallDetailKey.registeredNameKey.rawValue], !registeredName.isEmpty { self.registeredName = registeredName } diff --git a/Ring/Ring/Resources/Images.xcassets/call_button.imageset/Contents.json b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..29b3b86da00e209719f2a852fdb69b32382795fd --- /dev/null +++ b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_call.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_call_2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_call_3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call.png b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call.png new file mode 100644 index 0000000000000000000000000000000000000000..55ed026bcec5bba38bcf91972fd08d4671dc9b84 Binary files /dev/null and b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call.png differ diff --git a/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call_2x.png b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..99f28bbeca97715d85975921dd64fbde691c7ed9 Binary files /dev/null and b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call_2x.png differ diff --git a/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call_3x.png b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call_3x.png new file mode 100644 index 0000000000000000000000000000000000000000..7c9d1b09c55f77d44877d4a354280ae5c2e1f645 Binary files /dev/null and b/Ring/Ring/Resources/Images.xcassets/call_button.imageset/ic_call_3x.png differ diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings index dce149b58b7a1ba644f40c528f62efc39b8baf7f..3600de95413bb23b080fae0ff2a500295ca79703 100644 --- a/Ring/Ring/Resources/en.lproj/Localizable.strings +++ b/Ring/Ring/Resources/en.lproj/Localizable.strings @@ -92,6 +92,16 @@ "actions.blockAction" = "Block"; "actions.deleteAction" = "Delete"; "actions.cancelAction" = "Cancel"; +"alerts.incomingCallAllertTitle" = "Incoming call from "; +"alerts.incomingCallButtonAccept" = "Accept"; +"alerts.incomingCallButtonIgnore" = "Ignore"; + +//Calls +"calls.callItemTitle" = "Call"; +"calls.unknown" = "Unknown"; +"calls.incomingCallInfo" = "wants to talk to you"; +"calls.calling" = "Calling..."; +"calls.callFinished" = "Call finished"; //Account Page "accountPage.devicesListHeader" = "Devices"; diff --git a/Ring/Ring/Services/CallsService.swift b/Ring/Ring/Services/CallsService.swift index 18fc8c44c9cea0c85584fcbefb478e54605e6df2..99d85a08fa3d13a253f887da60dd3c2413e7e5ef 100644 --- a/Ring/Ring/Services/CallsService.swift +++ b/Ring/Ring/Services/CallsService.swift @@ -42,7 +42,8 @@ class CallsService: CallsAdapterDelegate { fileprivate let ringVCardMIMEType = "x-ring/ring.profile.vcard" let currentCall = ReplaySubject<CallModel>.create(bufferSize: 1) - let newcall = Variable<CallModel>(CallModel(withCallId: "", callDetails: [:])) + let newCall = Variable<CallModel>(CallModel(withCallId: "", callDetails: [:])) + init(withCallsAdapter callsAdapter: CallsAdapter) { self.callsAdapter = callsAdapter @@ -109,12 +110,12 @@ class CallsService: CallsAdapterDelegate { }) } - func placeCall(withAccount account: AccountModel, toRingId ringId: String) -> Single<CallModel> { + func placeCall(withAccount account: AccountModel, toRingId ringId: String, userName: String) -> Single<CallModel> { //Create and emit the call let call = CallModel(withCallId: ringId, callDetails: [String: String]()) call.state = .connecting - + call.registeredName = userName return Single<CallModel>.create(subscribe: { single in if let callId = self.callsAdapter.placeCall(withAccountId: account.id, toRingId: "ring:\(ringId)"), @@ -141,6 +142,7 @@ class CallsService: CallsAdapterDelegate { var call = self.calls[callId] if call == nil { call = CallModel(withCallId: callId, callDetails: callDictionary) + self.calls[callId] = call } else { call?.update(withDictionary: callDictionary) } @@ -174,7 +176,7 @@ class CallsService: CallsAdapterDelegate { call?.update(withDictionary: callDictionary) } //Emit the call to the observers - self.newcall.value = call! + self.newCall.value = call! } }