diff --git a/SUPLA.xcodeproj/project.pbxproj b/SUPLA.xcodeproj/project.pbxproj index cd343bff..c83095ec 100644 --- a/SUPLA.xcodeproj/project.pbxproj +++ b/SUPLA.xcodeproj/project.pbxproj @@ -704,6 +704,12 @@ A58316FC2B64510A006113F8 /* ThermometerValueStringProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58316FB2B64510A006113F8 /* ThermometerValueStringProvider.swift */; }; A58316FE2B6452AB006113F8 /* ThermometerAndHumidityValueProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58316FD2B6452AB006113F8 /* ThermometerAndHumidityValueProvider.swift */; }; A58317002B645814006113F8 /* ThermometerAndHumidityValueStringProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58316FF2B645814006113F8 /* ThermometerAndHumidityValueStringProvider.swift */; }; + A58A63052C0715E500A9D02D /* VerticalBlindsVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A63042C0715E500A9D02D /* VerticalBlindsVM.swift */; }; + A58A63072C07163E00A9D02D /* VerticalBlindWindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A63062C07163E00A9D02D /* VerticalBlindWindowState.swift */; }; + A58A63092C07168400A9D02D /* VerticalBlindMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A63082C07168400A9D02D /* VerticalBlindMarker.swift */; }; + A58A630B2C071ACB00A9D02D /* VerticalBlindsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A630A2C071ACB00A9D02D /* VerticalBlindsVC.swift */; }; + A58A630E2C071B0600A9D02D /* VerticalBlindsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A630D2C071B0600A9D02D /* VerticalBlindsView.swift */; }; + A58A63132C09B56000A9D02D /* VerticalBlindsVMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A63122C09B56000A9D02D /* VerticalBlindsVMTests.swift */; }; A58A9BFA2A9F77BB00D28848 /* BaseCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A9BF92A9F77BB00D28848 /* BaseCell.swift */; }; A58A9BFE2AA0A4BD00D28848 /* UIView+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A9BFD2AA0A4BD00D28848 /* UIView+Ext.swift */; }; A58A9C012AA0AD0E00D28848 /* ThermostatCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58A9C002AA0AD0E00D28848 /* ThermostatCell.swift */; }; @@ -2033,6 +2039,12 @@ A58316FB2B64510A006113F8 /* ThermometerValueStringProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermometerValueStringProvider.swift; sourceTree = ""; }; A58316FD2B6452AB006113F8 /* ThermometerAndHumidityValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermometerAndHumidityValueProvider.swift; sourceTree = ""; }; A58316FF2B645814006113F8 /* ThermometerAndHumidityValueStringProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermometerAndHumidityValueStringProvider.swift; sourceTree = ""; }; + A58A63042C0715E500A9D02D /* VerticalBlindsVM.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = VerticalBlindsVM.swift; path = SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVM.swift; sourceTree = SOURCE_ROOT; }; + A58A63062C07163E00A9D02D /* VerticalBlindWindowState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalBlindWindowState.swift; sourceTree = ""; }; + A58A63082C07168400A9D02D /* VerticalBlindMarker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalBlindMarker.swift; sourceTree = ""; }; + A58A630A2C071ACB00A9D02D /* VerticalBlindsVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalBlindsVC.swift; sourceTree = ""; }; + A58A630D2C071B0600A9D02D /* VerticalBlindsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalBlindsView.swift; sourceTree = ""; }; + A58A63122C09B56000A9D02D /* VerticalBlindsVMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalBlindsVMTests.swift; sourceTree = ""; }; A58A9BF92A9F77BB00D28848 /* BaseCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCell.swift; sourceTree = ""; }; A58A9BFD2AA0A4BD00D28848 /* UIView+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Ext.swift"; sourceTree = ""; }; A58A9C002AA0AD0E00D28848 /* ThermostatCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermostatCell.swift; sourceTree = ""; }; @@ -3088,6 +3100,7 @@ A5074BBB2BCE5CBF0081B6B1 /* UI */ = { isa = PBXGroup; children = ( + A58A630C2C071AF400A9D02D /* VerticalBlind */, A5AD70192C00B1C300A36318 /* Controls */, A50E5D782BFF551B00303BAE /* Curtain */, A50B5D3B2BFB6E9100918D18 /* ProjectorScreen */, @@ -3203,6 +3216,7 @@ A50B5D102BECC58800918D18 /* WindowDetail */ = { isa = PBXGroup; children = ( + A58A63112C09B54B00A9D02D /* VerticalBlind */, A5AD702A2C04771500A36318 /* Curtain */, A50B5D312BF54FE600918D18 /* TerraceAwning */, A50B5D1E2BECE8B100918D18 /* FacadeBlinds */, @@ -3943,6 +3957,7 @@ A55A8D6C2BA831AD00C540D4 /* WindowDetail */ = { isa = PBXGroup; children = ( + A58A63032C07154700A9D02D /* VerticalBlind */, A50E5D712BFF540700303BAE /* Curtain */, A50B5D342BFB6BDB00918D18 /* ProjectorScreen */, A50B5D272BF49F0D00918D18 /* TerraceAwning */, @@ -4012,7 +4027,9 @@ A55A8D9A2BAB802B00C540D4 /* RollerShutterAction.swift */, A50B5D052BEA4DA700918D18 /* RollerShutterWindowState.swift */, A50B5D072BEA4E2A00918D18 /* FacadeBlindWindowState.swift */, + A58A63062C07163E00A9D02D /* VerticalBlindWindowState.swift */, A50B5D092BEA4EC700918D18 /* FacadeBlindMarker.swift */, + A58A63082C07168400A9D02D /* VerticalBlindMarker.swift */, A50B5D2C2BF49F7700918D18 /* TerraceAwningWindowState.swift */, A50B5D372BFB6D1400918D18 /* ProjectorScreenState.swift */, A50E5D762BFF548200303BAE /* CurtainWindowState.swift */, @@ -4326,6 +4343,31 @@ path = Model; sourceTree = ""; }; + A58A63032C07154700A9D02D /* VerticalBlind */ = { + isa = PBXGroup; + children = ( + A58A630A2C071ACB00A9D02D /* VerticalBlindsVC.swift */, + A58A63042C0715E500A9D02D /* VerticalBlindsVM.swift */, + ); + path = VerticalBlind; + sourceTree = ""; + }; + A58A630C2C071AF400A9D02D /* VerticalBlind */ = { + isa = PBXGroup; + children = ( + A58A630D2C071B0600A9D02D /* VerticalBlindsView.swift */, + ); + path = VerticalBlind; + sourceTree = ""; + }; + A58A63112C09B54B00A9D02D /* VerticalBlind */ = { + isa = PBXGroup; + children = ( + A58A63122C09B56000A9D02D /* VerticalBlindsVMTests.swift */, + ); + path = VerticalBlind; + sourceTree = ""; + }; A58A9BF82A9F77A400D28848 /* Cells */ = { isa = PBXGroup; children = ( @@ -5832,6 +5874,7 @@ A55501FC2B83F28800FD3296 /* InsertNotificationUseCase.swift in Sources */, 01C1719922C7F55B005983E1 /* SAMeasurementItem+CoreDataClass.m in Sources */, A55A8D722BA8406900C540D4 /* WindowDetailVC.swift in Sources */, + A58A630E2C071B0600A9D02D /* VerticalBlindsView.swift in Sources */, A5F29BE62A27739000ED700A /* MoveableCell.swift in Sources */, A5AE7A8F2A3AE0290097FA8B /* TitleArrowButtonCell.swift in Sources */, A5CE73292B4607AE003F882C /* EspConfigResult.swift in Sources */, @@ -5893,6 +5936,7 @@ A55501F52B83842000FD3296 /* NotificationsLogVC.swift in Sources */, A55A8DAC2BAC60A200C540D4 /* SAAuthorizationDialogVM.swift in Sources */, 0148933425AA12DB00B9974E /* SADiwCalibrationTool.m in Sources */, + A58A630B2C071ACB00A9D02D /* VerticalBlindsVC.swift in Sources */, 01C1719222C7F3A2005983E1 /* SAElectricityMeasurementItem+CoreDataProperties.m in Sources */, A5D837DD2AF12B5B002A420D /* BaseLoadMeasurementsUseCase.swift in Sources */, 40AB8BDE1DCB32EB0030F3DE /* SARateApp.m in Sources */, @@ -6266,6 +6310,7 @@ A530EE082A557EE400F8DAEE /* CircleControlButtonView.swift in Sources */, A57C88302ACAA445000B0F10 /* ValuesFormatter.swift in Sources */, A52BFEB92B065BF800A2F64C /* SuplaConfigResult.swift in Sources */, + A58A63052C0715E500A9D02D /* VerticalBlindsVM.swift in Sources */, 40A6757F1BA1A5B6004A51C4 /* SuplaApp.m in Sources */, A50B5D502BFCBDFB00918D18 /* OpenedClosedGroupActivePercentageProvider.swift in Sources */, 018CFD2023281A5900888CB7 /* SAElectricityMeterExtendedValue.m in Sources */, @@ -6274,6 +6319,7 @@ A54149272B63031800B44BD6 /* GpmDetailVM.swift in Sources */, A51BE8FC2AA7300A00718F2F /* ThermometerValues.swift in Sources */, A5F29BD82A26240E00ED700A /* ThermostatMeasurementItemRepository.swift in Sources */, + A58A63072C07163E00A9D02D /* VerticalBlindWindowState.swift in Sources */, A5F29BC42A24E35C00ED700A /* ChangeGroupsVisibilityUseCase.swift in Sources */, A5B3CBEF2B625D9000F95AC3 /* WeightValueProvider.swift in Sources */, A50B5D4E2BFCBDAE00918D18 /* HeatpolThermostatGroupActivePercentageProvider.swift in Sources */, @@ -6415,6 +6461,7 @@ A5A14A312B60F76A004B1598 /* IconValueCell.swift in Sources */, A5AE7A852A3AC7020097FA8B /* BaseSettingsCell.swift in Sources */, A52BFEEC2B173F7100A2F64C /* ReadProfileByIdUseCase.swift in Sources */, + A58A63092C07168400A9D02D /* VerticalBlindMarker.swift in Sources */, A530EE242A57006A00F8DAEE /* LiquidSensorIconNameProducer.swift in Sources */, A55A8D912BAB13B300C540D4 /* RollerShutterValue.swift in Sources */, A530EDFD2A54153000F8DAEE /* SwitchDetailNavigationCoordinator.swift in Sources */, @@ -6516,6 +6563,7 @@ A530EE392A57FCE200F8DAEE /* GetChannelBaseStateUseCaseTests.swift in Sources */, A5E40B662B86249E00DB6ABE /* GpmHistoryDetailVMTests.swift in Sources */, A5ABE5B72ABC278700FFA50B /* ChannelConfigEventsManagerMock.swift in Sources */, + A58A63132C09B56000A9D02D /* VerticalBlindsVMTests.swift in Sources */, A59AB8D12A3091EE00D91F1F /* SceneRepositoryMock.swift in Sources */, A59AB8B32A3078FB00D91F1F /* SceneListVMTests.swift in Sources */, A54A06882AF9252C00C03DBC /* MultiAccountProfileManagerTests.swift in Sources */, diff --git a/SUPLA/ChannelCell.m b/SUPLA/ChannelCell.m index 83e13ffc..aee167ff 100644 --- a/SUPLA/ChannelCell.m +++ b/SUPLA/ChannelCell.m @@ -301,6 +301,7 @@ -(void) updateCellView { case SUPLA_CHANNELFNC_TERRACE_AWNING: case SUPLA_CHANNELFNC_PROJECTOR_SCREEN: case SUPLA_CHANNELFNC_CURTAIN: + case SUPLA_CHANNELFNC_VERTICAL_BLIND: self.left_OnlineStatus.hidden = YES; self.right_OnlineStatus.hidden = NO; break; @@ -392,6 +393,7 @@ -(void) updateCellView { case SUPLA_CHANNELFNC_TERRACE_AWNING: case SUPLA_CHANNELFNC_PROJECTOR_SCREEN: case SUPLA_CHANNELFNC_CURTAIN: + case SUPLA_CHANNELFNC_VERTICAL_BLIND: br = [MGSwipeButton buttonWithTitle:NSLocalizedString(@"Open", nil) icon:nil backgroundColor:[UIColor blackColor]]; bl = [MGSwipeButton buttonWithTitle:NSLocalizedString(@"Close", nil) icon:nil backgroundColor:[UIColor blackColor]]; break; diff --git a/SUPLA/Core/UI/Details/StandardDetailVC.swift b/SUPLA/Core/UI/Details/StandardDetailVC.swift index 20d34574..d72bcd7e 100644 --- a/SUPLA/Core/UI/Details/StandardDetailVC.swift +++ b/SUPLA/Core/UI/Details/StandardDetailVC.swift @@ -95,7 +95,8 @@ class StandardDetailVC viewControllers.append(projectorScreenDetail()) case .curtain: viewControllers.append(curtainDetail()) - + case .verticalBlind: + viewControllers.append(verticalBlindDetail()) } } @@ -277,6 +278,17 @@ class StandardDetailVC ) return vc } + + private func verticalBlindDetail() -> VerticalBlindsVC { + let vc = VerticalBlindsVC(itemBundle: item) + vc.navigationCoordinator = navigationCoordinator + vc.tabBarItem = UITabBarItem( + title: settings.showBottomLabels ? Strings.StandardDetail.tabGeneral : nil, + image: .iconGeneral, + tag: DetailTabTag.Window.rawValue + ) + return vc + } } protocol NavigationItemProvider: AnyObject { diff --git a/SUPLA/Core/UI/TableView/BaseTableViewModel.swift b/SUPLA/Core/UI/TableView/BaseTableViewModel.swift index 495cb159..21821355 100644 --- a/SUPLA/Core/UI/TableView/BaseTableViewModel.swift +++ b/SUPLA/Core/UI/TableView/BaseTableViewModel.swift @@ -63,7 +63,8 @@ class BaseTableViewModel: BaseViewModel { SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND, SUPLA_CHANNELFNC_TERRACE_AWNING, SUPLA_CHANNELFNC_PROJECTOR_SCREEN, - SUPLA_CHANNELFNC_CURTAIN: + SUPLA_CHANNELFNC_CURTAIN, + SUPLA_CHANNELFNC_VERTICAL_BLIND: return true case SUPLA_CHANNELFNC_LIGHTSWITCH, SUPLA_CHANNELFNC_POWERSWITCH, diff --git a/SUPLA/Core/UI/TableView/ChannelBaseTableViewController.swift b/SUPLA/Core/UI/TableView/ChannelBaseTableViewController.swift index 59872405..635b70c2 100644 --- a/SUPLA/Core/UI/TableView/ChannelBaseTableViewController.swift +++ b/SUPLA/Core/UI/TableView/ChannelBaseTableViewController.swift @@ -196,7 +196,8 @@ class ChannelBaseTableViewController { SUPLA_CHANNELFNC_CONTROLLINGTHEROLLERSHUTTER, SUPLA_CHANNELFNC_TERRACE_AWNING, SUPLA_CHANNELFNC_PROJECTOR_SCREEN, - SUPLA_CHANNELFNC_CURTAIN: true + SUPLA_CHANNELFNC_CURTAIN, + SUPLA_CHANNELFNC_VERTICAL_BLIND: true default: false } } @@ -118,7 +119,8 @@ final class IconCell: BaseCell { SUPLA_CHANNELFNC_CONTROLLINGTHEROLLERSHUTTER, SUPLA_CHANNELFNC_TERRACE_AWNING, SUPLA_CHANNELFNC_PROJECTOR_SCREEN, - SUPLA_CHANNELFNC_CURTAIN: true + SUPLA_CHANNELFNC_CURTAIN, + SUPLA_CHANNELFNC_VERTICAL_BLIND: true default: false } } diff --git a/SUPLA/Features/Details/WindowDetail/Base/Model/VerticalBlindMarker.swift b/SUPLA/Features/Details/WindowDetail/Base/Model/VerticalBlindMarker.swift new file mode 100644 index 00000000..a0026485 --- /dev/null +++ b/SUPLA/Features/Details/WindowDetail/Base/Model/VerticalBlindMarker.swift @@ -0,0 +1,22 @@ +/* + Copyright (C) AC SOFTWARE SP. Z O.O. + + 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 2 + 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +struct VerticalBlindMarker: Equatable { + let position: CGFloat + let tilt: CGFloat +} diff --git a/SUPLA/Features/Details/WindowDetail/Base/Model/VerticalBlindWindowState.swift b/SUPLA/Features/Details/WindowDetail/Base/Model/VerticalBlindWindowState.swift new file mode 100644 index 00000000..df24d3c7 --- /dev/null +++ b/SUPLA/Features/Details/WindowDetail/Base/Model/VerticalBlindWindowState.swift @@ -0,0 +1,51 @@ +/* + Copyright (C) AC SOFTWARE SP. Z O.O. + + 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 2 + 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +struct VerticalBlindWindowState: WindowState, Equatable, Changeable { + /** + * The blind roller position in percentage + * 0 - open + * 100 - closed + */ + var position: WindowGroupedValue + + var positionTextFormat: WindowGroupedValueFormat = .percentage + + /** + * Slat tilt as percentage - 0 up to 100 + */ + var slatTilt: WindowGroupedValue? = nil + + var tiltTextFormat: WindowGroupedValueFormat = .degree + + var tilt0Angle: CGFloat? = nil + + var tilt100Angle: CGFloat? = nil + + var markers: [VerticalBlindMarker] = [] + + var slatTiltDegrees: CGFloat? { + guard let tilt = slatTilt else { return nil } + return tilt.asAngle(tilt0Angle ?? DEFAULT_TILT_0_ANGLE, tilt100Angle ?? DEFAULT_TILT_100_ANGLE) + } + + var slatTiltText: String { + guard let tilt = slatTilt else { return Strings.FacadeBlindsDetail.noTilt } + return tilt.asString(tiltTextFormat, value0: tilt0Angle, value100: tilt100Angle) + } +} diff --git a/SUPLA/Features/Details/WindowDetail/Base/UI/Controls/WindowHorizontalControls.swift b/SUPLA/Features/Details/WindowDetail/Base/UI/Controls/WindowHorizontalControls.swift index bfc65a2b..81f7635b 100644 --- a/SUPLA/Features/Details/WindowDetail/Base/UI/Controls/WindowHorizontalControls.swift +++ b/SUPLA/Features/Details/WindowDetail/Base/UI/Controls/WindowHorizontalControls.swift @@ -20,7 +20,9 @@ import RxSwift class WindowHorizontalControls: WindowControls { override var intrinsicContentSize: CGSize { - return CGSize(width: ControlButtonType.left.width * 2, height: ControlButtonType.up.height * 2) + let touchTimeIconHeight: CGFloat = 25 + let height = ControlButtonType.left.height * 2 + Dimens.distanceTiny + Dimens.distanceDefault + touchTimeIconHeight + return CGSize(width: ControlButtonType.left.width * 2, height: height) } override var isEnabled: Bool { @@ -90,16 +92,17 @@ class WindowHorizontalControls: WindowControls { private func setupLayout() { NSLayoutConstraint.activate([ - holdToMoveButton.centerYAnchor.constraint(equalTo: centerYAnchor), + moveTimeView.topAnchor.constraint(equalTo: topAnchor), + moveTimeView.centerXAnchor.constraint(equalTo: centerXAnchor), + holdToMoveButton.leftAnchor.constraint(equalTo: leftAnchor), holdToMoveButton.rightAnchor.constraint(equalTo: rightAnchor), + holdToMoveButton.topAnchor.constraint(equalTo: moveTimeView.bottomAnchor, constant: Dimens.distanceTiny), - pressToMoveButton.topAnchor.constraint(equalTo: holdToMoveButton.bottomAnchor, constant: Dimens.distanceSmall), + pressToMoveButton.topAnchor.constraint(equalTo: holdToMoveButton.bottomAnchor, constant: Dimens.distanceDefault), pressToMoveButton.leftAnchor.constraint(equalTo: leftAnchor), pressToMoveButton.rightAnchor.constraint(equalTo: rightAnchor), - - moveTimeView.centerXAnchor.constraint(equalTo: centerXAnchor), - moveTimeView.bottomAnchor.constraint(equalTo: holdToMoveButton.topAnchor, constant: -Dimens.distanceSmall) + pressToMoveButton.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } } diff --git a/SUPLA/Features/Details/WindowDetail/Base/UI/VerticalBlind/VerticalBlindsView.swift b/SUPLA/Features/Details/WindowDetail/Base/UI/VerticalBlind/VerticalBlindsView.swift new file mode 100644 index 00000000..e1d19216 --- /dev/null +++ b/SUPLA/Features/Details/WindowDetail/Base/UI/VerticalBlind/VerticalBlindsView.swift @@ -0,0 +1,307 @@ +/* + Copyright (C) AC SOFTWARE SP. Z O.O. + + 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 2 + 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +import RxRelay +import RxSwift + +// MARK: - RollerShutterView + +class VerticalBlindsView: BaseWallWindowView { + override var windowState: VerticalBlindWindowState? { + didSet { + setNeedsLayout() + } + } + + // Configuration + private let maxTilt: CGFloat = 8 + private let minTilt: CGFloat = 1 + private let tiltRangeDegrees: CGFloat = 180 + private let tiltHalfRangeDegrees: CGFloat = 90 + + // Events + fileprivate let moveRelay: PublishRelay = PublishRelay() + fileprivate let moveFinishedRelay: PublishRelay = PublishRelay() + + private var tiltChangeAllowed = false + private var startTilt: CGFloat? = nil + + private let markerPath: UIBezierPath = .init() + + init() { + super.init(VerticalBlindRuntimeDimens()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawShadowingElements(_ context: CGContext, _ dimens: VerticalBlindRuntimeDimens) { + guard let position = windowState?.position.value else { return } + let tiltDegrees = windowState?.slatTiltDegrees ?? 0 + + let correctedTilt = tiltDegrees <= tiltHalfRangeDegrees ? tiltDegrees : tiltRangeDegrees - tiltDegrees + let verticalSlatCorrection = ((maxTilt * correctedTilt / tiltHalfRangeDegrees / 2) + minTilt) + .also { tiltDegrees <= tiltHalfRangeDegrees ? $0 : -$0 } + + let leftCorrection = position * dimens.movementLimit / 100 + + drawSlats(context, dimens.leftSlats, leftCorrection - dimens.movementLimit, correctedTilt, verticalSlatCorrection) + drawSlats(context, dimens.rightSlats, dimens.movementLimit - leftCorrection, correctedTilt, verticalSlatCorrection) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + guard let point = event?.allTouches?.first?.location(in: self) else { return } + + if (isEnabled && touchRect.contains(point)) { + startTilt = windowState?.slatTilt?.value + tiltChangeAllowed = false + } + } + + override func handleMovement( + _ positionRelay: PublishRelay, + _ startPosition: CGPoint, + _ startPercentage: CGFloat, + _ currentPosition: CGPoint + ) { + let positionDiffAsPercentage = (currentPosition.x - startPosition.x) + .divideToPercentage(value: touchRect.width / 2) + .run { startPosition.x < touchRect.midX ? $0 : -$0 } + + + let position = (startPercentage + positionDiffAsPercentage).limit(max: 100) + windowState?.position = .similar(position) + + guard let startTilt = startTilt else { return } + + let minHorizontalDistance: CGFloat = 15 + tiltChangeAllowed = tiltChangeAllowed || abs(currentPosition.y - startPosition.y) > minHorizontalDistance + + let tiltDiffAsPercentage = (currentPosition.y - startPosition.y) + .divideToPercentage(value: touchRect.width / 2) + let tilt = (startTilt + tiltDiffAsPercentage).limit(max: 100) + + if (tiltChangeAllowed) { + moveRelay.accept(CGPoint(x: tilt, y: position)) + } else { + moveRelay.accept(CGPoint(x: startTilt, y: position)) + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + if (startTilt != nil), let tiltPercentage = windowState?.slatTilt?.value { + let position = windowState?.position.value ?? 0 + moveFinishedRelay.accept(CGPoint(x: tiltPercentage, y: position)) + } + startTilt = nil + } + + override func drawMarkers(_ context: CGContext, _ dimens: VerticalBlindRuntimeDimens) { + if (isMoving) { + return // No markers when user interacts with view + } + + let markers = windowState?.markers ?? [] + let markerDiameter = dimens.markerInfoRadius * 2 + + let tilt0 = windowState?.tilt0Angle ?? DEFAULT_TILT_0_ANGLE + let tilt100 = windowState?.tilt100Angle ?? DEFAULT_TILT_100_ANGLE + + for marker in markers { + markerPath.removeAllPoints() + let leftCorrection = marker.position * dimens.movementLimit / 100 + let markerRect = CGRect( + x: dimens.topLineRect.minX + leftCorrection + dimens.slatWidth - dimens.markerInfoRadius, + y: dimens.topLineRect.maxY - dimens.markerInfoRadius, + width: markerDiameter, + height: markerDiameter + ) + markerPath.append(UIBezierPath(ovalIn: markerRect)) + + let degrees = tilt0 + (tilt100 - tilt0) * marker.tilt / 100 + let correctedDegree = SlatTiltSlider.trimAngle(Float(degrees)) + let angle = CGFloat(correctedDegree) * .pi / 180 + + let translationX = -markerRect.minX - dimens.markerInfoRadius + let translationY = -markerRect.minY - dimens.markerInfoRadius + let startPoint = CGPoint(x: -dimens.markerInfoRadius / 2, y: 0) + let endPoint = CGPoint(x: dimens.markerInfoRadius / 2, y: 0) + + markerPath.apply(CGAffineTransform(translationX: translationX, y: translationY)) + markerPath.apply(CGAffineTransform(rotationAngle: -angle)) + markerPath.move(to: startPoint) + markerPath.addLine(to: endPoint) + markerPath.apply(CGAffineTransform(rotationAngle: angle)) + markerPath.apply(CGAffineTransform(translationX: -translationX, y: -translationY)) + + markerPath.apply(CGAffineTransform(translationX: translationX, y: translationY - 3)) + markerPath.apply(CGAffineTransform(rotationAngle: -angle)) + markerPath.move(to: startPoint) + markerPath.addLine(to: endPoint) + markerPath.apply(CGAffineTransform(rotationAngle: angle)) + markerPath.apply(CGAffineTransform(translationX: -translationX, y: -translationY + 3)) + + markerPath.apply(CGAffineTransform(translationX: translationX, y: translationY + 3)) + markerPath.apply(CGAffineTransform(rotationAngle: -angle)) + markerPath.move(to: startPoint) + markerPath.addLine(to: endPoint) + markerPath.apply(CGAffineTransform(rotationAngle: angle)) + markerPath.apply(CGAffineTransform(translationX: -translationX, y: -translationY - 3)) + + drawPath(context, fillColor: colors.slatBackground) { markerPath.cgPath } + drawPath(context, strokeColor: colors.markerBorder) { markerPath.cgPath } + } + } + + private func drawSlats(_ context: CGContext, _ slats: [CGRect], _ correction: CGFloat, _ correctedTilt: CGFloat, _ verticalSlatCorrection: CGFloat) { + for slat in slats { + let frame = slat.offsetBy(dx: correction, dy: 0) + + let maxSlatCorrection = frame.width - 4 + let horizontalSlatCorrection = maxSlatCorrection * correctedTilt / tiltHalfRangeDegrees / 2 + + let rect = if (frame.minX < dimens.topLineRect.minX) { + CGRect(x: dimens.topLineRect.minX, y: frame.minY, width: frame.width, height: frame.height) + } else if (frame.maxX > dimens.topLineRect.maxX) { + CGRect(x: dimens.topLineRect.maxX - frame.width, y: frame.minY, width: frame.width, height: frame.height) + } else { + frame + } + + if (rect.maxY > dimens.topLineRect.maxY) { + let path = getSlatPath(rect, verticalSlatCorrection, horizontalSlatCorrection) + + drawPath(context, fillColor: colors.slatBackground) { path } + drawPath(context, strokeColor: colors.slatBorder) { path } + } + } + } + + private func getSlatPath( + _ rect: CGRect, + _ verticalSlatCorrection: CGFloat, + _ horizontalSlatCorrection: CGFloat + ) -> CGPath { + dimens.slatPath.removeAllPoints() + dimens.slatPath.move( + to: CGPoint( + x: rect.minX + horizontalSlatCorrection, + y: rect.minY + ) + ) + dimens.slatPath.addLine( + to: CGPoint( + x: rect.maxX - horizontalSlatCorrection, + y: rect.minY + ) + ) + dimens.slatPath.addLine( + to: CGPoint( + x: rect.maxX - horizontalSlatCorrection, + y: rect.maxY + verticalSlatCorrection + ) + ) + dimens.slatPath.addLine( + to: CGPoint( + x: rect.minX + horizontalSlatCorrection, + y: rect.maxY - verticalSlatCorrection + ) + ) + dimens.slatPath.close() + return dimens.slatPath.cgPath + } +} + +extension Reactive where Base: VerticalBlindsView { + var positionAndTilt: Observable { + base.moveRelay.asObservable() + } + + var positionAndTiltSet: Observable { + base.moveFinishedRelay.asObservable() + } +} + +// MARK: - Runtime dimensions + +class VerticalBlindRuntimeDimens: BaseWallWindowDimens { + static let markerInfoRadius: CGFloat = 14 + static let slatsCount = 5 + static let slatDistance: CGFloat = 4 + static let slatWidth: CGFloat = 24 + static let slatHeight: CGFloat = 308 + + var leftSlats: [CGRect] = .init(repeating: .zero, count: slatsCount) + var rightSlats: [CGRect] = .init(repeating: .zero, count: slatsCount) + var markerInfoRadius: CGFloat = 0 + var slatPath: UIBezierPath = .init() + var movementLimit: CGFloat = 0 + + var slatWidth: CGFloat = 0 + var slatHeight: CGFloat = 0 + var slatDistance: CGFloat = 0 + + override func update(_ frame: CGRect) { + super.update(frame) + + slatDistance = VerticalBlindRuntimeDimens.slatDistance * scale + slatWidth = VerticalBlindRuntimeDimens.slatWidth * scale + slatHeight = VerticalBlindRuntimeDimens.slatHeight * scale + + createLeftSlatRects() + createRightSlatRects() + + let slatWidth = VerticalBlindRuntimeDimens.slatWidth * scale + let slatDistance = VerticalBlindRuntimeDimens.slatDistance * scale + movementLimit = topLineRect.width / 2 - slatWidth - slatDistance + + markerInfoRadius = VerticalBlindRuntimeDimens.markerInfoRadius * scale + } + + private func createLeftSlatRects() { + let slatSize = CGSize(width: slatWidth, height: slatHeight) + let slatSpace = slatSize.width + slatDistance + let top = topLineRect.maxY + + for i in 0 ..< VerticalBlindRuntimeDimens.slatsCount { + leftSlats[i] = CGRect( + origin: CGPoint(x: topLineRect.minX + slatSpace * CGFloat(i), y: top), + size: slatSize + ) + } + } + + private func createRightSlatRects() { + let slatSize = CGSize(width: slatWidth, height: slatHeight) + let slatSpace = slatSize.width + slatDistance + let top = topLineRect.maxY + let left = topLineRect.maxX - slatSize.width + + for i in 0 ..< VerticalBlindRuntimeDimens.slatsCount { + rightSlats[i] = CGRect( + origin: CGPoint(x: left - slatSpace * CGFloat(i), y: top), + size: slatSize + ) + } + } +} diff --git a/SUPLA/Features/Details/WindowDetail/Base/UI/WindowView/BaseWallWindowView.swift b/SUPLA/Features/Details/WindowDetail/Base/UI/WindowView/BaseWallWindowView.swift index a4349f4e..2d792302 100644 --- a/SUPLA/Features/Details/WindowDetail/Base/UI/WindowView/BaseWallWindowView.swift +++ b/SUPLA/Features/Details/WindowDetail/Base/UI/WindowView/BaseWallWindowView.swift @@ -86,13 +86,13 @@ class BaseWallWindowView: BaseWindowVie drawShadowingElements(context, dimens) context.restoreGState() - // markers - for groups - drawMarkers(context, dimens) - // top line rect drawPath(context, fillColor: colors.window, withShadow: true) { UIBezierPath(rect: dimens.topLineRect).cgPath } + + // markers - for groups + drawMarkers(context, dimens) } func drawShadowingElements(_ context: CGContext, _ dimens: D) { diff --git a/SUPLA/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVM.swift b/SUPLA/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVM.swift index d3ba726d..8f4a5e5d 100644 --- a/SUPLA/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVM.swift +++ b/SUPLA/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVM.swift @@ -259,8 +259,8 @@ struct FacadeBlindsViewState: BaseWindowViewState { } private extension SAChannelGroup { - func getFacadeBlindPositions() -> [FacadeBlindGroupValue] { + func getFacadeBlindPositions() -> [ShadowingBlindGroupValue] { guard let totalValue = total_value as? GroupTotalValue else { return [] } - return totalValue.values.compactMap { $0 as? FacadeBlindGroupValue } + return totalValue.values.compactMap { $0 as? ShadowingBlindGroupValue } } } diff --git a/SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVC.swift b/SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVC.swift new file mode 100644 index 00000000..bd6383c0 --- /dev/null +++ b/SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVC.swift @@ -0,0 +1,69 @@ +/* + Copyright (C) AC SOFTWARE SP. Z O.O. + + 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 2 + 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +final class VerticalBlindsVC: BaseWindowVC { + init(itemBundle: ItemBundle) { + super.init(itemBundle: itemBundle, viewModel: VerticalBlindsVM(), windowControls: WindowHorizontalControls()) + } + + override func getWindowView() -> VerticalBlindsView { VerticalBlindsView() } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.observeConfig(itemBundle.remoteId, itemBundle.subjectType) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.loadConfig(itemBundle.remoteId, itemBundle.subjectType) + } + + override func handle(state: VerticalBlindsViewState) { + windowView.windowState = state.verticalBlindWindowState + + topView.valueBottom = state.verticalBlindWindowState.slatTiltText + + slatTiltSlider.isHidden = false + slatTiltSlider.isEnabled = !state.offline && state.verticalBlindWindowState.slatTilt != nil + slatTiltSlider.value = Float(state.verticalBlindWindowState.slatTilt?.value ?? 0) + slatTiltSlider.minDegree = state.verticalBlindWindowState.tilt0Angle?.float ?? SlatTiltSlider.defaultMinDegrees + slatTiltSlider.maxDegree = state.verticalBlindWindowState.tilt100Angle?.float ?? SlatTiltSlider.defaultMaxDegrees + + super.handle(state: state) + } + + override func setupWindowGesturesObservers() { + viewModel.bind(windowView.rx.positionAndTilt) { [weak self] point in + guard let bundle = self?.itemBundle else { return } + self?.viewModel.handleAction( + .moveAndTiltTo(position: point.y, tilt: point.x), + remoteId: bundle.remoteId, + type: bundle.subjectType + ) + } + + viewModel.bind(windowView.rx.positionAndTiltSet) { [weak self] point in + guard let bundle = self?.itemBundle else { return } + self?.viewModel.handleAction( + .moveAndTiltSetTo(position: point.y, tilt: point.x), + remoteId: bundle.remoteId, + type: bundle.subjectType + ) + } + } +} diff --git a/SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVM.swift b/SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVM.swift new file mode 100644 index 00000000..6058bc5d --- /dev/null +++ b/SUPLA/Features/Details/WindowDetail/VerticalBlind/VerticalBlindsVM.swift @@ -0,0 +1,266 @@ +/* + Copyright (C) AC SOFTWARE SP. Z O.O. + + 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 2 + 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +import RxSwift + +final class VerticalBlindsVM: BaseWindowVM { + @Singleton private var getChannelConfigUseCase + @Singleton private var channelConfigEventsManager + @Singleton private var executeFacadeBlindActionUseCase + + override func defaultViewState() -> VerticalBlindsViewState { VerticalBlindsViewState() } + + override func handleAction(_ action: RollerShutterAction, remoteId: Int32, type: SubjectType) { + switch (action) { + case .tiltTo(let tilt): + updateView { + if ($0.calibrating || $0.remoteId == nil) { + // Check for remote id is add to prevent calling the logic at the initialization time + // When setting value observer to the slider initial 0 is emitted + return $0 + } + + let markers: [VerticalBlindMarker] = if ($0.tiltControlType == .tiltsOnlyWhenFullyClosed) { + [] + } else if ($0.windowState.position.isDifferent()) { + $0.verticalBlindWindowState.markers.map { marker in VerticalBlindMarker(position: marker.position, tilt: tilt) } + } else { + [] + } + let position: WindowGroupedValue = $0.tiltControlType == .tiltsOnlyWhenFullyClosed ? .similar(100) : $0.windowState.position + + let windowState = $0.verticalBlindWindowState + .changing(path: \.position, to: position) + .changing(path: \.slatTilt, to: .similar(tilt)) + .changing(path: \.markers, to: markers) + return $0.changing(path: \.verticalBlindWindowState, to: windowState) + .changing(path: \.manualMoving, to: true) + .changing(path: \.positionUnknown, to: false) + } + case .tiltSetTo(let tilt): + updateView { + if ($0.calibrating) { + return $0 + } + executeFacadeBlindActionUseCase + .invoke(action: .shutPartially, type: type, remoteId: remoteId, position: CGFloat(VALUE_IGNORE), tilt: tilt) + .run(self) + + return $0.changing(path: \.moveStartTime, to: nil) + .changing(path: \.manualMoving, to: false) + .changing(path: \.touchTime, to: nil) + } + case .moveAndTiltTo(let position, let tilt): + updateView { + if ($0.calibrating) { + return $0 + } + let tilt: CGFloat? = if ($0.verticalBlindWindowState.slatTilt == nil) { + nil + } else if ($0.tiltControlType == .changesPositionWhileTilting) { + limitTilt(tilt: tilt, position: position, state: $0) + } else if ($0.tiltControlType != .tiltsOnlyWhenFullyClosed || position == 100) { + tilt + } else { + 0 + } + let windowState = $0.verticalBlindWindowState + .changing(path: \.position, to: .similar(position)) + .changing(path: \.slatTilt, to: tilt?.run { .similar($0) }) + .changing(path: \.markers, to: []) + return $0.changing(path: \.verticalBlindWindowState, to: windowState) + .changing(path: \.manualMoving, to: true) + .changing(path: \.positionUnknown, to: false) + } + case .moveAndTiltSetTo(let position, let tilt): + updateView { + if ($0.calibrating) { + return $0 + } + let tilt: CGFloat = if ($0.verticalBlindWindowState.slatTilt == nil) { + CGFloat(VALUE_IGNORE) + } else if ($0.tiltControlType == .changesPositionWhileTilting) { + limitTilt(tilt: tilt, position: position, state: $0) + } else if ($0.tiltControlType != .tiltsOnlyWhenFullyClosed || position == 100) { + tilt + } else { + 0 + } + + executeFacadeBlindActionUseCase + .invoke(action: .shutPartially, type: type, remoteId: remoteId, position: position, tilt: tilt) + .run(self) + + return $0.changing(path: \.moveStartTime, to: nil) + .changing(path: \.manualMoving, to: false) + .changing(path: \.touchTime, to: nil) + } + default: super.handleAction(action, remoteId: remoteId, type: type) + } + } + + func observeConfig(_ remoteId: Int32, _ type: SubjectType) { + if (type == .channel) { + channelConfigEventsManager.observeConfig(id: remoteId) + .filter { $0.config is SuplaChannelFacadeBlindConfig } + .asDriverWithoutError() + .drive(onNext: { [weak self] in self?.handleConfig($0) }) + .disposed(by: self) + } + } + + func loadConfig(_ remoteId: Int32, _ type: SubjectType) { + if (type == .channel) { + getChannelConfigUseCase.invoke(remoteId: remoteId, type: .defaultConfig).subscribe().disposed(by: self) + } + } + + override func handleChannel(_ channel: SAChannel) { + guard let value = channel.value?.asFacadeBlindValue() else { return } + + updateView { + if ($0.manualMoving) { + return $0 + } + + let position = value.hasValidPosition ? value.position : 0 + let tilt = value.hasValidTilt && value.flags.contains(.tiltIsSet) ? CGFloat(value.tilt) : nil + let windowState = $0.verticalBlindWindowState + .changing(path: \.position, to: .similar(value.online ? CGFloat(position) : 25)) + .changing(path: \.slatTilt, to: tilt?.run { .similar(value.online ? $0 : 50) }) + + return updateChannel($0, channel, value) { + $0.changing(path: \.verticalBlindWindowState, to: windowState) + .changing(path: \.lastPosition, to: CGFloat(position)) + } + } + } + + override func handleGroup(_ group: SAChannelGroup, _ onlineSummary: GroupOnlineSummary) { + updateView { + if ($0.manualMoving) { + return $0 + } + + let positions = group.getFacadeBlindPositions() + let overallPosition = getGroupPercentage(positions, !$0.verticalBlindWindowState.markers.isEmpty) { CGFloat($0.position) } + let overallTilt = getGroupPercentage(positions, !$0.verticalBlindWindowState.markers.isEmpty) { CGFloat($0.tilt) } + let markers = (overallPosition.isDifferent() ? positions : []) + .map { VerticalBlindMarker(position: CGFloat($0.position), tilt: CGFloat($0.tilt)) } + let windowState = $0.verticalBlindWindowState + .changing(path: \.position, to: group.isOnline() ? overallPosition : .similar(25)) + .changing(path: \.slatTilt, to: group.isOnline() ? overallTilt : .similar(50)) + .changing(path: \.markers, to: group.isOnline() ? markers : []) + .changing(path: \.positionTextFormat, to: positionTextFormat) + + return updateGroup($0, group, onlineSummary) { + $0.changing(path: \.verticalBlindWindowState, to: windowState) + .changing(path: \.positionUnknown, to: overallPosition == .invalid) + .changing(path: \.lastPosition, to: overallPosition.value) + } + } + } + + override func canShowMoveTime(_ state: VerticalBlindsViewState) -> Bool { + state.positionUnknown || state.verticalBlindWindowState.slatTilt == nil + } + + private func handleConfig(_ config: ChannelConfigEvent) { + guard let facadeConfig = config.config as? SuplaChannelFacadeBlindConfig else { return } + + let (tilt0, tilt100) = if (facadeConfig.tilt0Angle == facadeConfig.tilt100Angle) { + (DEFAULT_TILT_0_ANGLE, DEFAULT_TILT_100_ANGLE) + } else { + (CGFloat(facadeConfig.tilt0Angle), CGFloat(facadeConfig.tilt100Angle)) + } + + updateView { + let windowState = $0.verticalBlindWindowState + .changing(path: \.tilt0Angle, to: tilt0) + .changing(path: \.tilt100Angle, to: tilt100) + return $0.changing(path: \.verticalBlindWindowState, to: windowState) + .changing(path: \.tiltingTime, to: CGFloat(facadeConfig.tiltingTimeMs)) + .changing(path: \.openingTime, to: CGFloat(facadeConfig.openingTimeMs)) + .changing(path: \.closingTime, to: CGFloat(facadeConfig.closingTimeMs)) + .changing(path: \.tiltControlType, to: facadeConfig.type) + } + } + + private func limitTilt(tilt: CGFloat, position: CGFloat, state: VerticalBlindsViewState) -> CGFloat { + guard let tiltingTime = state.tiltingTime, + let openingTime = state.openingTime, + let closingTime = state.closingTime, + let lastPosition = state.lastPosition + else { + return tilt + } + + let time = position > lastPosition ? closingTime : openingTime + let positionTime = time * position / 100 + + if (positionTime < tiltingTime) { + return min(tilt, 100 * positionTime / tiltingTime) + } + if (positionTime > time - tiltingTime) { + return max(tilt, 100 - (100 * (time - positionTime) / tiltingTime)) + } + + return tilt + } +} + +private extension Completable { + func run(_ viewModel: VerticalBlindsVM) { + asDriverWithoutError() + .drive() + .disposed(by: viewModel) + } +} + +struct VerticalBlindsViewState: BaseWindowViewState { + var tiltControlType: SuplaTiltControlType? = nil + var tiltingTime: CGFloat? = nil + var openingTime: CGFloat? = nil + var closingTime: CGFloat? = nil + var lastPosition: CGFloat? = nil + + var remoteId: Int32? = nil + var verticalBlindWindowState: VerticalBlindWindowState = .init(position: .similar(0)) + var issues: [ChannelIssueItem] = [] + var offline: Bool = true + var showClosingPercentage: Bool = false + var calibrating: Bool = false + var calibrationPossible: Bool = false + var positionUnknown: Bool = false + var touchTime: CGFloat? = nil + var isGroup: Bool = false + var onlineStatusString: String? = nil + var moveStartTime: TimeInterval? = nil + var manualMoving: Bool = false + + var windowState: any WindowState { + verticalBlindWindowState + } +} + +private extension SAChannelGroup { + func getFacadeBlindPositions() -> [ShadowingBlindGroupValue] { + guard let totalValue = total_value as? GroupTotalValue else { return [] } + return totalValue.values.compactMap { $0 as? ShadowingBlindGroupValue } + } +} diff --git a/SUPLA/Model/CoreData/Extensions/SAChannelBase+Ext.swift b/SUPLA/Model/CoreData/Extensions/SAChannelBase+Ext.swift index b04898fa..2af6239b 100644 --- a/SUPLA/Model/CoreData/Extensions/SAChannelBase+Ext.swift +++ b/SUPLA/Model/CoreData/Extensions/SAChannelBase+Ext.swift @@ -55,7 +55,8 @@ extension SAChannelBase { self.func == SUPLA_CHANNELFNC_CONTROLLINGTHEROOFWINDOW || self.func == SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND || self.func == SUPLA_CHANNELFNC_TERRACE_AWNING || - self.func == SUPLA_CHANNELFNC_CURTAIN + self.func == SUPLA_CHANNELFNC_CURTAIN || + self.func == SUPLA_CHANNELFNC_VERTICAL_BLIND } func hasMeasurements() -> Bool { diff --git a/SUPLA/Model/CoreData/Extensions/SAChannelGroup+Ext.swift b/SUPLA/Model/CoreData/Extensions/SAChannelGroup+Ext.swift index 504acb78..800ece62 100644 --- a/SUPLA/Model/CoreData/Extensions/SAChannelGroup+Ext.swift +++ b/SUPLA/Model/CoreData/Extensions/SAChannelGroup+Ext.swift @@ -27,7 +27,7 @@ extension SAChannelGroup { return totalValue.values .map { - if let value = $0 as? FacadeBlindGroupValue { + if let value = $0 as? ShadowingBlindGroupValue { return value.position } if let value = $0 as? RollerShutterGroupValue { diff --git a/SUPLA/Resources/Default.strings b/SUPLA/Resources/Default.strings index 49303c3b..34b16406 100644 --- a/SUPLA/Resources/Default.strings +++ b/SUPLA/Resources/Default.strings @@ -69,6 +69,7 @@ "channel_caption_terrace_awning" = "Terrace awning"; "channel_caption_projector_screen" = "Projector screen"; "channel_caption_curtain" = "Curtain"; +"channel_caption_vertical_blind" = "Vertical blind"; /* Main */ "dialog_new_gesture_info_text" = "Swipe gesture to open details was removed, tap on particular channel to open it."; diff --git a/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/Contents.json b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/Contents.json new file mode 100644 index 00000000..db8050cc --- /dev/null +++ b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "vertical blinds.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "vertical blinds - left - dark mode.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/vertical blinds - left - dark mode.svg b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/vertical blinds - left - dark mode.svg new file mode 100644 index 00000000..f9a662f3 --- /dev/null +++ b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/vertical blinds - left - dark mode.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/vertical blinds.svg b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/vertical blinds.svg new file mode 100644 index 00000000..43550cae --- /dev/null +++ b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-closed.imageset/vertical blinds.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/Contents.json b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/Contents.json new file mode 100644 index 00000000..de6faac9 --- /dev/null +++ b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "curtain - short (2).svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "vertical blinds - dark mode.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/curtain - short (2).svg b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/curtain - short (2).svg new file mode 100644 index 00000000..40bcdc99 --- /dev/null +++ b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/curtain - short (2).svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/vertical blinds - dark mode.svg b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/vertical blinds - dark mode.svg new file mode 100644 index 00000000..43cc6788 --- /dev/null +++ b/SUPLA/Resources/Resources.xcassets/Images/FunctionIcons/fnc_vertical_blind-open.imageset/vertical blinds - dark mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SUPLA/Resources/Strings.swift b/SUPLA/Resources/Strings.swift index c150e06e..c41b5e0d 100644 --- a/SUPLA/Resources/Strings.swift +++ b/SUPLA/Resources/Strings.swift @@ -306,6 +306,7 @@ struct Strings { static let captionTerraceAwning = "channel_caption_terrace_awning".toLocalized() static let captionProjectorScreen = "channel_caption_projector_screen".toLocalized() static let captionCurtain = "channel_caption_curtain".toLocalized() + static let captionVerticalBlind = "channel_caption_vertical_blind".toLocalized() } } diff --git a/SUPLA/Resources/de.lproj/Localizable.strings b/SUPLA/Resources/de.lproj/Localizable.strings index ae4fc8b7..82bff2a0 100644 --- a/SUPLA/Resources/de.lproj/Localizable.strings +++ b/SUPLA/Resources/de.lproj/Localizable.strings @@ -326,6 +326,7 @@ "channel_caption_terrace_awning" = "Terrassenmarkise"; "channel_caption_projector_screen" = "Leinwand"; "channel_caption_curtain" = "Vorhang"; +"channel_caption_vertical_blind" = "Vertikaljalousien"; /* Main */ "dialog_new_gesture_info_text" = "Wischgeste zum Öffnen der Kanaldetails wurde gelöscht, tippe auf dem Kanal, um sie zu sehen."; diff --git a/SUPLA/Resources/pl.lproj/Localizable.strings b/SUPLA/Resources/pl.lproj/Localizable.strings index 82eb2f58..bc9dc86e 100644 --- a/SUPLA/Resources/pl.lproj/Localizable.strings +++ b/SUPLA/Resources/pl.lproj/Localizable.strings @@ -351,6 +351,7 @@ "channel_caption_terrace_awning" = "Markiza tarasowa"; "channel_caption_projector_screen" = "Ekran projekcyjny"; "channel_caption_curtain" = "Zasłona"; +"channel_caption_vertical_blind" = "Żaluzja pionowa"; /* Main */ "dialog_new_gesture_info_text" = "Usunęliśmy gest przesunięcia otwierający szczegóły, aby je zobaczyć dotknij wybrany kanał."; diff --git a/SUPLA/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCase.swift b/SUPLA/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCase.swift index 7f7dfd4b..3c029e62 100644 --- a/SUPLA/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCase.swift +++ b/SUPLA/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCase.swift @@ -119,6 +119,8 @@ final class GetChannelBaseDefaultCaptionUseCaseImpl: GetChannelBaseDefaultCaptio return Strings.General.Channel.captionProjectorScreen case SUPLA_CHANNELFNC_CURTAIN: return Strings.General.Channel.captionCurtain + case SUPLA_CHANNELFNC_VERTICAL_BLIND: + return Strings.General.Channel.captionVerticalBlind default: return NSLocalizedString("Not supported function", comment: "") } diff --git a/SUPLA/UseCase/ChannelBase/GetChannelBaseStateUseCase.swift b/SUPLA/UseCase/ChannelBase/GetChannelBaseStateUseCase.swift index c5ce9495..0ef8173a 100644 --- a/SUPLA/UseCase/ChannelBase/GetChannelBaseStateUseCase.swift +++ b/SUPLA/UseCase/ChannelBase/GetChannelBaseStateUseCase.swift @@ -48,7 +48,8 @@ final class GetChannelBaseStateUseCaseImpl: GetChannelBaseStateUseCase { SUPLA_CHANNELFNC_CONTROLLINGTHEROOFWINDOW, SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND, SUPLA_CHANNELFNC_TERRACE_AWNING, - SUPLA_CHANNELFNC_CURTAIN: + SUPLA_CHANNELFNC_CURTAIN, + SUPLA_CHANNELFNC_VERTICAL_BLIND: return valueWrapper.rollerShutterClosed ? .closed : .opened case SUPLA_CHANNELFNC_PROJECTOR_SCREEN: return valueWrapper.projectorScreenClosed ? .closed : .opened @@ -104,6 +105,7 @@ final class GetChannelBaseStateUseCaseImpl: GetChannelBaseStateUseCase { SUPLA_CHANNELFNC_OPENINGSENSOR_ROOFWINDOW, SUPLA_CHANNELFNC_TERRACE_AWNING, SUPLA_CHANNELFNC_CURTAIN, + SUPLA_CHANNELFNC_VERTICAL_BLIND, SUPLA_CHANNELFNC_VALVE_OPENCLOSE, SUPLA_CHANNELFNC_VALVE_PERCENTAGE: .opened case SUPLA_CHANNELFNC_PROJECTOR_SCREEN: .closed diff --git a/SUPLA/UseCase/Detail/ProvideDetailTypeUseCase.swift b/SUPLA/UseCase/Detail/ProvideDetailTypeUseCase.swift index 172f9f8b..f3d06241 100644 --- a/SUPLA/UseCase/Detail/ProvideDetailTypeUseCase.swift +++ b/SUPLA/UseCase/Detail/ProvideDetailTypeUseCase.swift @@ -42,6 +42,8 @@ final class ProvideDetailTypeUseCaseImpl: ProvideDetailTypeUseCase { return .windowDetail(pages: [.projectorScreen]) case SUPLA_CHANNELFNC_CURTAIN: return .windowDetail(pages: [.curtain]) + case SUPLA_CHANNELFNC_VERTICAL_BLIND: + return .windowDetail(pages: [.verticalBlind]) case SUPLA_CHANNELFNC_LIGHTSWITCH, SUPLA_CHANNELFNC_POWERSWITCH, @@ -142,4 +144,5 @@ enum DetailPage { case terraceAwning case projectorScreen case curtain + case verticalBlind } diff --git a/SUPLA/UseCase/Group/ActivePercentage/FacadeBlindGroupActivePercentageProvider.swift b/SUPLA/UseCase/Group/ActivePercentage/FacadeBlindGroupActivePercentageProvider.swift index fd5874aa..17440cfe 100644 --- a/SUPLA/UseCase/Group/ActivePercentage/FacadeBlindGroupActivePercentageProvider.swift +++ b/SUPLA/UseCase/Group/ActivePercentage/FacadeBlindGroupActivePercentageProvider.swift @@ -16,18 +16,20 @@ Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. */ -final class FacadeBlindGroupActivePercentageProvider: GroupActivePercentageProvider { +final class BlindsGroupActivePercentageProvider: GroupActivePercentageProvider { func handleFunction(_ function: Int32) -> Bool { - function == SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND + switch (function) { + case SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND, + SUPLA_CHANNELFNC_VERTICAL_BLIND: true + default: false + } } - + func getActivePercentage(_ valueIndex: Int, _ totalValue: GroupTotalValue) -> Int { return totalValue.values - .map { $0 as! FacadeBlindGroupValue } + .map { $0 as! ShadowingBlindGroupValue } .reduce(0) { result, value in value.position >= 100 ? result + 1 : result } * 100 / totalValue.values.count } - - } diff --git a/SUPLA/UseCase/Group/GetGroupActivePercentageUseCase.swift b/SUPLA/UseCase/Group/GetGroupActivePercentageUseCase.swift index 7cd64c51..3eb13666 100644 --- a/SUPLA/UseCase/Group/GetGroupActivePercentageUseCase.swift +++ b/SUPLA/UseCase/Group/GetGroupActivePercentageUseCase.swift @@ -35,7 +35,7 @@ final class GetGroupActivePercentageUseCaseImpl: GetGroupActivePercentageUseCase let providers: [GroupActivePercentageProvider] = [ DimmerAndRgbGroupActivePercentageProvider(), DimmerGroupActivePercentageProvider(), - FacadeBlindGroupActivePercentageProvider(), + BlindsGroupActivePercentageProvider(), HeatpolThermostatGroupActivePercentageProvider(), OpenedClosedGroupActivePercentageProvider(), PercentageChannelActivePercentageProvider(), diff --git a/SUPLA/UseCase/Group/TotalValue/GroupTotalValue.swift b/SUPLA/UseCase/Group/TotalValue/GroupTotalValue.swift index bf1a8ecb..88e92768 100644 --- a/SUPLA/UseCase/Group/TotalValue/GroupTotalValue.swift +++ b/SUPLA/UseCase/Group/TotalValue/GroupTotalValue.swift @@ -36,7 +36,7 @@ class GroupTotalValue: NSObject, NSSecureCoding { of: [ NSArray.self, RollerShutterGroupValue.self, - FacadeBlindGroupValue.self, + ShadowingBlindGroupValue.self, IntegerGroupValue.self, BoolGroupValue.self, RgbLightingGroupValue.self, @@ -96,7 +96,7 @@ class GroupTotalValue: NSObject, NSSecureCoding { } } -@objc class FacadeBlindGroupValue: BaseGroupValue, NSSecureCoding { +@objc class ShadowingBlindGroupValue: BaseGroupValue, NSSecureCoding { static var supportsSecureCoding: Bool = true @objc let position: Int @@ -119,7 +119,7 @@ class GroupTotalValue: NSObject, NSSecureCoding { } override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? FacadeBlindGroupValue else { return false } + guard let other = object as? ShadowingBlindGroupValue else { return false } return other.position == position && other.tilt == tilt } diff --git a/SUPLA/UseCase/Group/UpdateChannelGroupTotalValueUseCase.swift b/SUPLA/UseCase/Group/UpdateChannelGroupTotalValueUseCase.swift index acca6caf..960c3b76 100644 --- a/SUPLA/UseCase/Group/UpdateChannelGroupTotalValueUseCase.swift +++ b/SUPLA/UseCase/Group/UpdateChannelGroupTotalValueUseCase.swift @@ -129,9 +129,10 @@ private extension SAChannelGroup { position: value.asRollerShutterValue().alwaysValidPosition, closedSensorActive: value.hiSubValue() == 1 ) - case SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND: + case SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND, + SUPLA_CHANNELFNC_VERTICAL_BLIND: let facadeBlindValue = value.asFacadeBlindValue() - return FacadeBlindGroupValue( + return ShadowingBlindGroupValue( position: facadeBlindValue.alwaysValidPosition, tilt: facadeBlindValue.tilt ) diff --git a/SUPLA/UseCase/Icon/GetDefaultIconNameUseCase.swift b/SUPLA/UseCase/Icon/GetDefaultIconNameUseCase.swift index 6f87c6ed..03cb9425 100644 --- a/SUPLA/UseCase/Icon/GetDefaultIconNameUseCase.swift +++ b/SUPLA/UseCase/Icon/GetDefaultIconNameUseCase.swift @@ -79,6 +79,7 @@ final class GetDefaultIconNameUseCaseImpl: GetDefaultIconNameUseCase { StaticIconNameProducer(function: SUPLA_CHANNELFNC_TERRACE_AWNING, name: "fnc_terrace_awning"), StaticIconNameProducer(function: SUPLA_CHANNELFNC_PROJECTOR_SCREEN, name: "fnc_projector_screen"), StaticIconNameProducer(function: SUPLA_CHANNELFNC_CURTAIN, name: "fnc_curtain"), + StaticIconNameProducer(function: SUPLA_CHANNELFNC_VERTICAL_BLIND, name: "fnc_vertical_blind"), PowerSwitchIconNameProducer(), LightSwitchIconNameProducer(), StaircaseTimerIconNameProducer(), diff --git a/SUPLATests/Tests/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVMTests.swift b/SUPLATests/Tests/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVMTests.swift index 454bf2dd..6a7d8dd5 100644 --- a/SUPLATests/Tests/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVMTests.swift +++ b/SUPLATests/Tests/Features/Details/WindowDetail/FacadeBlinds/FacadeBlindsVMTests.swift @@ -103,8 +103,8 @@ final class FacadeBlindsVMTests: ViewModelTest { + private lazy var readChannelByRemoteIdUseCase: ReadChannelByRemoteIdUseCaseMock! = ReadChannelByRemoteIdUseCaseMock() + + private lazy var readGroupByRemoteIdUseCase: ReadGroupByRemoteIdUseCaseMock! = ReadGroupByRemoteIdUseCaseMock() + + private lazy var getGroupOnlineSummaryUseCase: GetGroupOnlineSummaryUseCaseMock! = GetGroupOnlineSummaryUseCaseMock() + + private lazy var channelConfigEventsManager: ChannelConfigEventsManagerMock! = ChannelConfigEventsManagerMock() + + private lazy var executeFacadeBlindActionUseCase: ExecuteFacadeBlindActionUseCaseMock! = ExecuteFacadeBlindActionUseCaseMock() + + private lazy var settings: GlobalSettingsMock! = GlobalSettingsMock() + + private lazy var viewModel: VerticalBlindsVM! = VerticalBlindsVM() + + override func setUp() { + DiContainer.register(ReadChannelByRemoteIdUseCase.self, readChannelByRemoteIdUseCase!) + DiContainer.register(ReadGroupByRemoteIdUseCase.self, readGroupByRemoteIdUseCase!) + DiContainer.register(GetGroupOnlineSummaryUseCase.self, getGroupOnlineSummaryUseCase!) + DiContainer.register(ChannelConfigEventsManager.self, channelConfigEventsManager!) + DiContainer.register(ExecuteFacadeBlindActionUseCase.self, executeFacadeBlindActionUseCase!) + DiContainer.register(GlobalSettings.self, settings!) + } + + override func tearDown() { + super.tearDown() + + readChannelByRemoteIdUseCase = nil + readGroupByRemoteIdUseCase = nil + getGroupOnlineSummaryUseCase = nil + channelConfigEventsManager = nil + executeFacadeBlindActionUseCase = nil + settings = nil + viewModel = nil + } + + func test_shouldLoadChannel() { + // given + let channel = SAChannel(testContext: nil) + channel.remote_id = 123 + channel.flags = Int64(SUPLA_CHANNEL_FLAG_CALCFG_RECALIBRATE) + channel.value = SAChannelValue(testContext: nil) + channel.value?.value = NSData(data: FacadeBlindValue.mockData(position: 50, tilt: 70, flags: SuplaRollerShutterFlag.motorProblem.rawValue)) + channel.value?.online = true + + settings.showOpeningPercentReturns = false + readChannelByRemoteIdUseCase.returns = .just(channel) + + // when + observe(viewModel) + viewModel.loadData(remoteId: 123, type: .channel) + + // then + assertStates(expected: [ + VerticalBlindsViewState(), + VerticalBlindsViewState( + lastPosition: 50, + remoteId: 123, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(50), + slatTilt: .similar(70) + ), + issues: [ + ChannelIssueItem( + issueIconType: .warning, + description: Strings.RollerShutterDetail.calibrationFailed + ) + ], + offline: false, + showClosingPercentage: true, + calibrating: false, + calibrationPossible: true + ) + ]) + assertEvents(expected: []) + } + + func test_shouldLoadGroup() { + // given + let groupOnlineSummary = GroupOnlineSummary(onlineCount: 2, count: 3) + let group = SAChannelGroup(testContext: nil) + group.remote_id = 234 + group.online = 1 + group.total_value = GroupTotalValue(values: [ + ShadowingBlindGroupValue(position: 50, tilt: 50), + ShadowingBlindGroupValue(position: 80, tilt: 20) + ]) + + settings.showOpeningPercentReturns = true + readGroupByRemoteIdUseCase.returns = .just(group) + getGroupOnlineSummaryUseCase.returns = .just(groupOnlineSummary) + + // when + observe(viewModel) + viewModel.loadData(remoteId: 234, type: .group) + + // then + assertStates(expected: [ + VerticalBlindsViewState(), + VerticalBlindsViewState( + lastPosition: 0, + remoteId: 234, + verticalBlindWindowState: VerticalBlindWindowState( + position: .different(min: 50, max: 80), + positionTextFormat: .openingPercentage, + slatTilt: .different(min: 20, max: 50), + markers: [ + VerticalBlindMarker(position: 50, tilt: 50), + VerticalBlindMarker(position: 80, tilt: 20) + ] + ), + offline: false, + isGroup: true, + onlineStatusString: "2/3" + ) + ]) + } + + func test_shouldLoadConfig() { + // given + let configEvent = ChannelConfigEvent( + result: .resultTrue, + config: SuplaChannelFacadeBlindConfig( + remoteId: 123, + channelFunc: SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND, + crc32: 0, + closingTimeMs: 10000, + openingTimeMs: 10500, + motorUpsideDown: false, + buttonUpsideDown: true, + timeMargin: 20, + tiltingTimeMs: 1500, + tilt0Angle: 10, + tilt100Angle: 80, + type: .tiltsOnlyWhenFullyClosed + ) + ) + channelConfigEventsManager.observeConfigReturns = [.just(configEvent)] + + // when + observe(viewModel) + viewModel.observeConfig(123, .channel) + + // then + assertStates(expected: [ + VerticalBlindsViewState(), + VerticalBlindsViewState( + tiltControlType: .tiltsOnlyWhenFullyClosed, + tiltingTime: 1500, + openingTime: 10500, + closingTime: 10000, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(0), + tilt0Angle: 10, + tilt100Angle: 80 + ) + ) + ]) + } + + func test_shouldTiltToDefinedValue() { + // when + viewModel.updateView { $0.changing(path: \.remoteId, to: 111) } + observe(viewModel) + viewModel.handleAction(.tiltTo(tilt: 10), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + VerticalBlindsViewState(remoteId: 111), + VerticalBlindsViewState( + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState(position: .similar(0), slatTilt: .similar(10)), + manualMoving: true + ) + ]) + } + + func test_shouldTiltToDefinedValue_andUpdateMarkers() { + // given + let initialState = VerticalBlindsViewState( + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .different(min: 10, max: 20), + slatTilt: .different(min: 40, max: 60), + markers: [ + VerticalBlindMarker(position: 10, tilt: 40), + VerticalBlindMarker(position: 20, tilt: 60) + ] + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.tiltTo(tilt: 50), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState + .changing( + path: \.verticalBlindWindowState, + to: initialState.verticalBlindWindowState + .changing(path: \.slatTilt, to: .similar(50)) + .changing(path: \.markers, to: [ + VerticalBlindMarker(position: 10, tilt: 50), + VerticalBlindMarker(position: 20, tilt: 50) + ]) + ) + .changing(path: \.manualMoving, to: true) + ]) + } + + func test_shouldTiltToDefinedValue_andSetPositionToClosedWhenTiltsOnlyWhenFullyClosed() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .tiltsOnlyWhenFullyClosed, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(50), + slatTilt: .similar(50) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.tiltTo(tilt: 60), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState + .changing( + path: \.verticalBlindWindowState, + to: initialState.verticalBlindWindowState + .changing(path: \.position, to: .similar(100)) + .changing(path: \.slatTilt, to: .similar(60)) + ) + .changing(path: \.manualMoving, to: true) + ]) + } + + func test_shouldSetTilt() { + let initialState = VerticalBlindsViewState( + manualMoving: true + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.tiltSetTo(tilt: 80), remoteId: 222, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState.changing(path: \.manualMoving, to: false) + ]) + XCTAssertTuples(executeFacadeBlindActionUseCase.parameters, [ + (Action.shutPartially, SubjectType.channel, Int32(222), CGFloat(VALUE_IGNORE), 80) + ]) + } + + func test_shouldMoveAndTiltToDefinedValue() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .standsInPositionWhileTilting, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10), + slatTilt: .similar(50) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltTo(position: 20, tilt: 90), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState + .changing( + path: \.verticalBlindWindowState, + to: initialState.verticalBlindWindowState + .changing(path: \.position, to: .similar(20)) + .changing(path: \.slatTilt, to: .similar(90)) + ) + .changing(path: \.manualMoving, to: true) + ]) + } + + func test_shouldMoveAndTiltToDefinedValue_whenTiltNotSet() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .standsInPositionWhileTilting, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltTo(position: 20, tilt: 90), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState + .changing( + path: \.verticalBlindWindowState, + to: initialState.verticalBlindWindowState + .changing(path: \.position, to: .similar(20)) + ) + .changing(path: \.manualMoving, to: true) + ]) + } + + func test_shouldMoveAndTiltToDefinedValue_andLimitTiltWhenChangesPositionWhileTilting_onTop() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .changesPositionWhileTilting, + tiltingTime: 2000, + openingTime: 20000, + closingTime: 20000, + lastPosition: 10, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10), + slatTilt: .similar(50) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltTo(position: 2.5, tilt: 80), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState + .changing( + path: \.verticalBlindWindowState, + to: initialState.verticalBlindWindowState + .changing(path: \.position, to: .similar(2.5)) + .changing(path: \.slatTilt, to: .similar(25)) + ) + .changing(path: \.manualMoving, to: true) + ]) + } + + func test_shouldMoveAndTiltToDefinedValue_andLimitTiltWhenChangesPositionWhileTilting_onBottom() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .changesPositionWhileTilting, + tiltingTime: 2000, + openingTime: 20000, + closingTime: 20000, + lastPosition: 10, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10), + slatTilt: .similar(50) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltTo(position: 95, tilt: 0), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState + .changing( + path: \.verticalBlindWindowState, + to: initialState.verticalBlindWindowState + .changing(path: \.position, to: .similar(95)) + .changing(path: \.slatTilt, to: .similar(50)) + ) + .changing(path: \.manualMoving, to: true) + ]) + } + + func test_shouldSetPositionAndTiltToDefinedValue() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .standsInPositionWhileTilting, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10), + slatTilt: .similar(50) + ), + manualMoving: true + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltSetTo(position: 20, tilt: 90), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState.changing(path: \.manualMoving, to: false) + ]) + XCTAssertTuples(executeFacadeBlindActionUseCase.parameters, [ + (Action.shutPartially, SubjectType.channel, Int32(111), 20, 90) + ]) + } + + func test_shouldSetPositionAndTiltToDefinedValue_whenTiltNotSet() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .standsInPositionWhileTilting, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltSetTo(position: 20, tilt: 90), remoteId: 111, type: .channel) + + // then + // then + assertStates(expected: [ + initialState, + initialState.changing(path: \.manualMoving, to: false) + ]) + XCTAssertTuples(executeFacadeBlindActionUseCase.parameters, [ + (Action.shutPartially, SubjectType.channel, Int32(111), 20, CGFloat(VALUE_IGNORE)) + ]) + } + + func test_shouldSetPositionAndTiltToDefinedValue_andLimitTiltWhenChangesPositionWhileTilting_onTop() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .changesPositionWhileTilting, + tiltingTime: 2000, + openingTime: 20000, + closingTime: 20000, + lastPosition: 10, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10), + slatTilt: .similar(50) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltSetTo(position: 2.5, tilt: 80), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState.changing(path: \.manualMoving, to: false) + ]) + XCTAssertTuples(executeFacadeBlindActionUseCase.parameters, [ + (Action.shutPartially, SubjectType.channel, Int32(111), 2.5, 25) + ]) + } + + func test_shouldSetPositionAndTiltToDefinedValue_andLimitTiltWhenChangesPositionWhileTilting_onBottom() { + // given + let initialState = VerticalBlindsViewState( + tiltControlType: .changesPositionWhileTilting, + tiltingTime: 2000, + openingTime: 20000, + closingTime: 20000, + lastPosition: 10, + remoteId: 111, + verticalBlindWindowState: VerticalBlindWindowState( + position: .similar(10), + slatTilt: .similar(50) + ) + ) + viewModel.updateView { _ in initialState } + + // when + observe(viewModel) + viewModel.handleAction(.moveAndTiltSetTo(position: 95, tilt: 0), remoteId: 111, type: .channel) + + // then + assertStates(expected: [ + initialState, + initialState.changing(path: \.manualMoving, to: false) + ]) + XCTAssertTuples(executeFacadeBlindActionUseCase.parameters, [ + (Action.shutPartially, SubjectType.channel, Int32(111), 95, 50) + ]) + } +} diff --git a/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCaseTests.swift b/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCaseTests.swift index 9080aece..6912fd69 100644 --- a/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCaseTests.swift +++ b/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseDefaultCaptionUseCaseTests.swift @@ -86,6 +86,7 @@ final class GetChannelBaseDefaultCaptionUseCaseTests: XCTestCase { doTest(function: SUPLA_CHANNELFNC_TERRACE_AWNING, Strings.General.Channel.captionTerraceAwning) doTest(function: SUPLA_CHANNELFNC_PROJECTOR_SCREEN, Strings.General.Channel.captionProjectorScreen) doTest(function: SUPLA_CHANNELFNC_CURTAIN, Strings.General.Channel.captionCurtain) + doTest(function: SUPLA_CHANNELFNC_VERTICAL_BLIND, Strings.General.Channel.captionVerticalBlind) doTest(function: -1, "Not supported function") } diff --git a/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseStateUseCaseTests.swift b/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseStateUseCaseTests.swift index e04a4298..605f5a72 100644 --- a/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseStateUseCaseTests.swift +++ b/SUPLATests/Tests/UseCase/ChannelBase/GetChannelBaseStateUseCaseTests.swift @@ -488,4 +488,46 @@ final class GetChannelBaseStateUseCaseTests: XCTestCase { XCTAssertEqual(state, .opened) XCTAssertFalse(state.isActive()) } + + func test_verticalBlindClosedState() { + // given + let channel = SAChannel(testContext: nil) + channel.func = SUPLA_CHANNELFNC_VERTICAL_BLIND + channel.value = mockChannelValue(byte0: 100) + + // when + let state = useCase.invoke(channelBase: channel) + + // then + XCTAssertEqual(state, .closed) + XCTAssertTrue(state.isActive()) + } + + func test_verticalBlindOpenedState() { + // given + let channel = SAChannel(testContext: nil) + channel.func = SUPLA_CHANNELFNC_VERTICAL_BLIND + channel.value = mockChannelValue() + + // when + let state = useCase.invoke(channelBase: channel) + + // then + XCTAssertEqual(state, .opened) + XCTAssertFalse(state.isActive()) + } + + func test_verticalBlindOfflineState() { + // given + let channel = SAChannel(testContext: nil) + channel.func = SUPLA_CHANNELFNC_VERTICAL_BLIND + channel.value = mockChannelValue(online: false) + + // when + let state = useCase.invoke(channelBase: channel) + + // then + XCTAssertEqual(state, .opened) + XCTAssertFalse(state.isActive()) + } } diff --git a/SUPLATests/Tests/UseCase/Detail/ProvideDetailTypeUseCaseTests.swift b/SUPLATests/Tests/UseCase/Detail/ProvideDetailTypeUseCaseTests.swift index 842e83fc..105f4ec0 100644 --- a/SUPLATests/Tests/UseCase/Detail/ProvideDetailTypeUseCaseTests.swift +++ b/SUPLATests/Tests/UseCase/Detail/ProvideDetailTypeUseCaseTests.swift @@ -109,6 +109,15 @@ final class ProvideDetailTypeUseCaseTests: XCTestCase { } } + func test_shouldProvideRs_forVerticalBlindFunction() { + doTest(expectedResult: .windowDetail(pages: [.verticalBlind])) { + let channel = SAChannel(testContext: nil) + channel.func = SUPLA_CHANNELFNC_VERTICAL_BLIND + + return channel + } + } + func test_shouldProvideEm_forEmFunction() { doTest(expectedResult: .legacy(type: .em)) { let channel = SAChannel(testContext: nil) diff --git a/SUPLATests/Tests/UseCase/Group/GetGroupActivePercentageUseCaseTests.swift b/SUPLATests/Tests/UseCase/Group/GetGroupActivePercentageUseCaseTests.swift index 11bf7765..d13af166 100644 --- a/SUPLATests/Tests/UseCase/Group/GetGroupActivePercentageUseCaseTests.swift +++ b/SUPLATests/Tests/UseCase/Group/GetGroupActivePercentageUseCaseTests.swift @@ -64,10 +64,10 @@ final class GroupActivePercentageUseCaseTests: XCTestCase { let group = SAChannelGroup(testContext: nil) group.func = SUPLA_CHANNELFNC_CONTROLLINGTHEFACADEBLIND group.total_value = GroupTotalValue(values: [ - FacadeBlindGroupValue(position: 100, tilt: 10), - FacadeBlindGroupValue(position: 0, tilt: 30), - FacadeBlindGroupValue(position: 20, tilt: 0), - FacadeBlindGroupValue(position: 80, tilt: 90) + ShadowingBlindGroupValue(position: 100, tilt: 10), + ShadowingBlindGroupValue(position: 0, tilt: 30), + ShadowingBlindGroupValue(position: 20, tilt: 0), + ShadowingBlindGroupValue(position: 80, tilt: 90) ]) // when diff --git a/SUPLATests/Tests/UseCase/Group/TotalValue/GroupTotalValueTest.swift b/SUPLATests/Tests/UseCase/Group/TotalValue/GroupTotalValueTest.swift index 6b8c6be2..6810cd2d 100644 --- a/SUPLATests/Tests/UseCase/Group/TotalValue/GroupTotalValueTest.swift +++ b/SUPLATests/Tests/UseCase/Group/TotalValue/GroupTotalValueTest.swift @@ -27,7 +27,7 @@ class GroupTotalValueTest: XCTestCase { let totalValue = GroupTotalValue(values: [ RollerShutterGroupValue(position: 10, closedSensorActive: false), RollerShutterGroupValue(position: 30, closedSensorActive: true), - FacadeBlindGroupValue(position: 10, tilt: 20), + ShadowingBlindGroupValue(position: 10, tilt: 20), IntegerGroupValue(value: 33), RgbLightingGroupValue(color: .background, brightness: 88), DimmerAndRgbLightingGroupValue(color: .channelCell, colorBrightness: 32, brightness: 92), @@ -51,10 +51,10 @@ class GroupTotalValueTest: XCTestCase { } func testShouldArchiveFacadeBlindGroupValue() { - let value = FacadeBlindGroupValue(position: 10, tilt: 55) + let value = ShadowingBlindGroupValue(position: 10, tilt: 55) let archive = try! NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: false) - let result = try! NSKeyedUnarchiver.unarchivedObject(ofClass: FacadeBlindGroupValue.self, from: archive) + let result = try! NSKeyedUnarchiver.unarchivedObject(ofClass: ShadowingBlindGroupValue.self, from: archive) XCTAssertEqual(value.position, result?.position) XCTAssertEqual(value.tilt, result?.tilt) diff --git a/SUPLATests/Tests/UseCase/Group/UpdateChannelGroupTotalValueUseCaseTests.swift b/SUPLATests/Tests/UseCase/Group/UpdateChannelGroupTotalValueUseCaseTests.swift index 07b57138..75dbbb09 100644 --- a/SUPLATests/Tests/UseCase/Group/UpdateChannelGroupTotalValueUseCaseTests.swift +++ b/SUPLATests/Tests/UseCase/Group/UpdateChannelGroupTotalValueUseCaseTests.swift @@ -106,7 +106,7 @@ final class UpdateChannelGroupTotalValueUseCaseTests: UseCaseTest<[Int32]> { XCTAssertEqual(secondGroup.online, 100) XCTAssertTrue(secondGroup.total_value is GroupTotalValue) if let groupTotalValue = secondGroup.total_value as? GroupTotalValue, - let firstRelationValue = groupTotalValue.values[0] as? FacadeBlindGroupValue + let firstRelationValue = groupTotalValue.values[0] as? ShadowingBlindGroupValue { XCTAssertEqual(groupTotalValue.values.count, 1) XCTAssertEqual(firstRelationValue.position, 10) diff --git a/SUPLATests/Tests/UseCase/Icon/GetDefaultIconNameUseCaseTests.swift b/SUPLATests/Tests/UseCase/Icon/GetDefaultIconNameUseCaseTests.swift index 17ec6fee..8eb8d78d 100644 --- a/SUPLATests/Tests/UseCase/Icon/GetDefaultIconNameUseCaseTests.swift +++ b/SUPLATests/Tests/UseCase/Icon/GetDefaultIconNameUseCaseTests.swift @@ -1693,4 +1693,42 @@ final class GetDefaultIconNameUseCaseTests: XCTestCase { // then XCTAssertEqual(iconName, "fnc_curtain-closed") } + + func test_verticalBlindOpened() { + // given + let function = SUPLA_CHANNELFNC_VERTICAL_BLIND + + // when + let iconName = useCase.invoke( + iconData: IconData( + function: function, + altIcon: 0, + state: .opened, + type: .single, + subfunction: .notSet + ) + ) + + // then + XCTAssertEqual(iconName, "fnc_vertical_blind-open") + } + + func test_verticalBlindClose() { + // given + let function = SUPLA_CHANNELFNC_VERTICAL_BLIND + + // when + let iconName = useCase.invoke( + iconData: IconData( + function: function, + altIcon: 0, + state: .closed, + type: .single, + subfunction: .notSet + ) + ) + + // then + XCTAssertEqual(iconName, "fnc_vertical_blind-closed") + } }