@erkamyaman/capacitor-foldable 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/ErkamyamanCapacitorFoldable.podspec +17 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +28 -0
  4. package/README.md +457 -0
  5. package/android/build.gradle +71 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/kotlin/io/github/erkamyaman/foldable/DisplayModes.kt +115 -0
  8. package/android/src/main/kotlin/io/github/erkamyaman/foldable/Foldable.kt +189 -0
  9. package/android/src/main/kotlin/io/github/erkamyaman/foldable/FoldablePlugin.kt +333 -0
  10. package/android/src/main/res/.gitkeep +0 -0
  11. package/dist/docs.json +584 -0
  12. package/dist/esm/css.d.ts +8 -0
  13. package/dist/esm/css.js +29 -0
  14. package/dist/esm/css.js.map +1 -0
  15. package/dist/esm/definitions.d.ts +267 -0
  16. package/dist/esm/definitions.js +2 -0
  17. package/dist/esm/definitions.js.map +1 -0
  18. package/dist/esm/index.d.ts +5 -0
  19. package/dist/esm/index.js +9 -0
  20. package/dist/esm/index.js.map +1 -0
  21. package/dist/esm/polyfill.d.ts +19 -0
  22. package/dist/esm/polyfill.js +76 -0
  23. package/dist/esm/polyfill.js.map +1 -0
  24. package/dist/esm/segments.d.ts +8 -0
  25. package/dist/esm/segments.js +26 -0
  26. package/dist/esm/segments.js.map +1 -0
  27. package/dist/esm/size-class.d.ts +2 -0
  28. package/dist/esm/size-class.js +33 -0
  29. package/dist/esm/size-class.js.map +1 -0
  30. package/dist/esm/web.d.ts +21 -0
  31. package/dist/esm/web.js +46 -0
  32. package/dist/esm/web.js.map +1 -0
  33. package/dist/plugin.cjs.js +222 -0
  34. package/dist/plugin.cjs.js.map +1 -0
  35. package/dist/plugin.js +225 -0
  36. package/dist/plugin.js.map +1 -0
  37. package/ios/Sources/FoldablePlugin/Foldable.swift +156 -0
  38. package/ios/Sources/FoldablePlugin/FoldablePlugin.swift +194 -0
  39. package/ios/Sources/FoldablePlugin/ReservedRegionFoldProvider.swift +69 -0
  40. package/ios/Sources/FoldablePlugin/UIKitFoldSource.swift +53 -0
  41. package/ios/Tests/FoldablePluginTests/FoldableTests.swift +104 -0
  42. package/ios/Tests/FoldablePluginTests/ReservedRegionFoldProviderTests.swift +143 -0
  43. package/package.json +96 -0
@@ -0,0 +1,156 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ struct NativeFold {
5
+ var state: String
6
+ var isSeparating: Bool
7
+ var hingeOrientation: String?
8
+ var hingeBounds: CGRect?
9
+ var occludedBounds: CGRect?
10
+ var activeDisplay: String?
11
+ var cameraBounds: [CGRect] = []
12
+ }
13
+
14
+ protocol FoldProvider {
15
+ var isFoldable: Bool { get }
16
+ var supportsTabletop: Bool { get }
17
+ func fold(in view: UIView?) -> NativeFold?
18
+ func hingeAngle() -> Double?
19
+ func observe(_ view: UIView, onChange: @escaping () -> Void)
20
+ }
21
+
22
+ struct UnsupportedFoldProvider: FoldProvider {
23
+ var isFoldable: Bool { false }
24
+ var supportsTabletop: Bool { false }
25
+
26
+ func fold(in view: UIView?) -> NativeFold? {
27
+ return nil
28
+ }
29
+
30
+ func hingeAngle() -> Double? {
31
+ return nil
32
+ }
33
+
34
+ func observe(_ view: UIView, onChange: @escaping () -> Void) {}
35
+ }
36
+
37
+ @objc public class Foldable: NSObject {
38
+ private let provider: FoldProvider
39
+
40
+ init(provider: FoldProvider) {
41
+ self.provider = provider
42
+ super.init()
43
+ }
44
+
45
+ override public convenience init() {
46
+ self.init(provider: Foldable.defaultProvider())
47
+ }
48
+
49
+ private static func defaultProvider() -> FoldProvider {
50
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
51
+ if #available(iOS 27.1, *) {
52
+ return ReservedRegionFoldProvider(source: UIKitFoldSource())
53
+ }
54
+ #endif
55
+ return UnsupportedFoldProvider()
56
+ }
57
+
58
+ @objc public func isDeviceFoldable() -> Bool {
59
+ return provider.isFoldable
60
+ }
61
+
62
+ @objc public func supportsTabletop() -> Bool {
63
+ return provider.supportsTabletop
64
+ }
65
+
66
+ public func getFoldState(in view: UIView? = nil) -> [String: Any] {
67
+ guard let fold = provider.fold(in: view) else {
68
+ return ["state": "flat", "isSeparating": false, "posture": "flat"]
69
+ }
70
+
71
+ var result: [String: Any] = [
72
+ "state": fold.state,
73
+ "isSeparating": fold.isSeparating,
74
+ "posture": posture(of: fold)
75
+ ]
76
+ result["hingeOrientation"] = fold.hingeOrientation
77
+ result["hingeBounds"] = fold.hingeBounds.map(bounds(of:))
78
+ result["occludedBounds"] = fold.occludedBounds.map(bounds(of:))
79
+ result["activeDisplay"] = fold.activeDisplay
80
+ if !fold.cameraBounds.isEmpty {
81
+ result["cameraBounds"] = fold.cameraBounds.map(bounds(of:))
82
+ }
83
+ return result
84
+ }
85
+
86
+ @objc public func getHingeAngle() -> [String: Any] {
87
+ return ["angle": provider.hingeAngle().map { $0 as Any } ?? NSNull()]
88
+ }
89
+
90
+ @objc public func barPlacement(in traits: UITraitCollection) -> [String: Any] {
91
+ return ["verticalBarEdge": verticalBarEdge(of: traits).map { $0 as Any } ?? NSNull()]
92
+ }
93
+
94
+ private func verticalBarEdge(of traits: UITraitCollection) -> String? {
95
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
96
+ if #available(iOS 27.1, *) {
97
+ switch traits.verticalBarEdge {
98
+ case .leading: return "leading"
99
+ case .trailing: return "trailing"
100
+ default: return nil
101
+ }
102
+ }
103
+ #endif
104
+ return nil
105
+ }
106
+
107
+ public func observeChanges(in view: UIView, onChange: @escaping () -> Void) {
108
+ provider.observe(view, onChange: onChange)
109
+ }
110
+
111
+ @objc public func sizeClass(
112
+ horizontal: UIUserInterfaceSizeClass,
113
+ vertical: UIUserInterfaceSizeClass,
114
+ width: CGFloat,
115
+ height: CGFloat
116
+ ) -> [String: String] {
117
+ return [
118
+ "horizontal": name(of: horizontal),
119
+ "vertical": name(of: vertical),
120
+ "widthClass": widthClass(of: width),
121
+ "heightClass": heightClass(of: height)
122
+ ]
123
+ }
124
+
125
+ private func name(of sizeClass: UIUserInterfaceSizeClass) -> String {
126
+ return sizeClass == .regular ? "regular" : "compact"
127
+ }
128
+
129
+ private func widthClass(of width: CGFloat) -> String {
130
+ if width >= 1600 { return "extraLarge" }
131
+ if width >= 1200 { return "large" }
132
+ if width >= 840 { return "expanded" }
133
+ if width >= 600 { return "medium" }
134
+ return "compact"
135
+ }
136
+
137
+ private func heightClass(of height: CGFloat) -> String {
138
+ if height >= 900 { return "expanded" }
139
+ if height >= 480 { return "medium" }
140
+ return "compact"
141
+ }
142
+
143
+ private func posture(of fold: NativeFold) -> String {
144
+ guard fold.state == "half-opened" else { return "flat" }
145
+ return fold.hingeOrientation == "horizontal" ? "tabletop" : "book"
146
+ }
147
+
148
+ private func bounds(of rect: CGRect) -> [String: Int] {
149
+ return [
150
+ "x": Int(rect.origin.x.rounded()),
151
+ "y": Int(rect.origin.y.rounded()),
152
+ "width": Int(rect.size.width.rounded()),
153
+ "height": Int(rect.size.height.rounded())
154
+ ]
155
+ }
156
+ }
@@ -0,0 +1,194 @@
1
+ import Foundation
2
+ import UIKit
3
+ import Capacitor
4
+
5
+ /**
6
+ * Please read the Capacitor iOS Plugin Development Guide
7
+ * here: https://capacitorjs.com/docs/plugins/ios
8
+ */
9
+ @objc(FoldablePlugin)
10
+ public class FoldablePlugin: CAPPlugin, CAPBridgedPlugin {
11
+ public let identifier = "FoldablePlugin"
12
+ public let jsName = "Foldable"
13
+ public let pluginMethods: [CAPPluginMethod] = [
14
+ CAPPluginMethod(name: "isDeviceFoldable", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "getFoldState", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "getHingeAngle", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "getSizeClass", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "getDisplayModes", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "getBarPlacement", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "startRearDisplay", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "stopRearDisplay", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "startDualScreen", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "stopDualScreen", returnType: CAPPluginReturnPromise)
24
+ ]
25
+ private let implementation = Foldable()
26
+ private var lastSizeClass: [String: String]?
27
+ private var lastFoldState: [String: Any]?
28
+ private var lastBarPlacement: [String: Any]?
29
+ private var lastHingeAngle: Double?
30
+ private var orientationObserver: NSObjectProtocol?
31
+
32
+ @objc override public func load() {
33
+ DispatchQueue.main.async { [weak self] in
34
+ self?.observeChanges()
35
+ }
36
+ }
37
+
38
+ deinit {
39
+ if let orientationObserver = orientationObserver {
40
+ NotificationCenter.default.removeObserver(orientationObserver)
41
+ UIDevice.current.endGeneratingDeviceOrientationNotifications()
42
+ }
43
+ }
44
+
45
+ @objc func isDeviceFoldable(_ call: CAPPluginCall) {
46
+ DispatchQueue.main.async { [weak self] in
47
+ guard let self = self else { return }
48
+ call.resolve([
49
+ "foldable": self.implementation.isDeviceFoldable(),
50
+ "supportsTabletop": self.implementation.supportsTabletop()
51
+ ])
52
+ }
53
+ }
54
+
55
+ @objc func getFoldState(_ call: CAPPluginCall) {
56
+ DispatchQueue.main.async { [weak self] in
57
+ guard let self = self else { return }
58
+ call.resolve(self.currentFoldState())
59
+ }
60
+ }
61
+
62
+ @objc func getHingeAngle(_ call: CAPPluginCall) {
63
+ DispatchQueue.main.async { [weak self] in
64
+ guard let self = self else { return }
65
+ call.resolve(self.implementation.getHingeAngle())
66
+ }
67
+ }
68
+
69
+ @objc func getSizeClass(_ call: CAPPluginCall) {
70
+ DispatchQueue.main.async { [weak self] in
71
+ guard let self = self else { return }
72
+ call.resolve(self.currentSizeClass())
73
+ }
74
+ }
75
+
76
+ @objc func getDisplayModes(_ call: CAPPluginCall) {
77
+ call.resolve(["rearDisplay": "unsupported", "dualScreen": "unsupported"])
78
+ }
79
+
80
+ @objc func getBarPlacement(_ call: CAPPluginCall) {
81
+ DispatchQueue.main.async { [weak self] in
82
+ guard let self = self else { return }
83
+ call.resolve(self.currentBarPlacement())
84
+ }
85
+ }
86
+
87
+ @objc func startRearDisplay(_ call: CAPPluginCall) {
88
+ call.unavailable("Rear display mode is only available on Android.")
89
+ }
90
+
91
+ @objc func stopRearDisplay(_ call: CAPPluginCall) {
92
+ call.resolve()
93
+ }
94
+
95
+ @objc func startDualScreen(_ call: CAPPluginCall) {
96
+ call.unavailable("Dual-screen mode is only available on Android.")
97
+ }
98
+
99
+ @objc func stopDualScreen(_ call: CAPPluginCall) {
100
+ call.resolve()
101
+ }
102
+
103
+ private func currentFoldState() -> [String: Any] {
104
+ return implementation.getFoldState(in: bridge?.webView)
105
+ }
106
+
107
+ private func currentBarPlacement() -> [String: Any] {
108
+ return implementation.barPlacement(in: bridge?.viewController?.traitCollection ?? UITraitCollection.current)
109
+ }
110
+
111
+ private func currentSizeClass() -> [String: String] {
112
+ let traits = bridge?.viewController?.traitCollection ?? UITraitCollection.current
113
+ let size = bridge?.viewController?.view.bounds.size ?? .zero
114
+ return implementation.sizeClass(
115
+ horizontal: traits.horizontalSizeClass,
116
+ vertical: traits.verticalSizeClass,
117
+ width: size.width,
118
+ height: size.height
119
+ )
120
+ }
121
+
122
+ private func observeChanges() {
123
+ guard let view = bridge?.viewController?.view else { return }
124
+
125
+ lastFoldState = currentFoldState()
126
+ lastBarPlacement = currentBarPlacement()
127
+ implementation.observeChanges(in: view) { [weak self] in
128
+ self?.notifyFoldStateIfChanged()
129
+ self?.notifyHingeAngleIfChanged()
130
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
131
+ self?.notifyFoldStateIfChanged()
132
+ }
133
+ }
134
+
135
+ UIDevice.current.beginGeneratingDeviceOrientationNotifications()
136
+ orientationObserver = NotificationCenter.default.addObserver(
137
+ forName: UIDevice.orientationDidChangeNotification,
138
+ object: nil,
139
+ queue: .main
140
+ ) { [weak self] _ in
141
+ self?.notifyFoldStateIfChanged()
142
+ self?.notifySizeClassIfChanged()
143
+ self?.notifyBarPlacementIfChanged()
144
+ }
145
+
146
+ guard #available(iOS 17.0, *) else { return }
147
+
148
+ lastSizeClass = currentSizeClass()
149
+ MainActor.assumeIsolated {
150
+ var traits: [UITrait] = [UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]
151
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
152
+ if #available(iOS 27.1, *) {
153
+ traits += UITraitCollection.systemTraitsAffectingVerticalBarEdge
154
+ }
155
+ #endif
156
+ _ = view.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in
157
+ self?.notifySizeClassIfChanged()
158
+ self?.notifyFoldStateIfChanged()
159
+ self?.notifyBarPlacementIfChanged()
160
+ }
161
+ }
162
+ }
163
+
164
+ private func notifyHingeAngleIfChanged() {
165
+ guard let angle = implementation.getHingeAngle()["angle"] as? Double, angle != lastHingeAngle else { return }
166
+
167
+ lastHingeAngle = angle
168
+ notifyListeners("hingeAngleChange", data: ["angle": angle])
169
+ }
170
+
171
+ private func notifyBarPlacementIfChanged() {
172
+ let placement = currentBarPlacement()
173
+ if let last = lastBarPlacement, (last as NSDictionary).isEqual(to: placement) { return }
174
+
175
+ lastBarPlacement = placement
176
+ notifyListeners("barPlacementChange", data: placement)
177
+ }
178
+
179
+ private func notifyFoldStateIfChanged() {
180
+ let foldState = currentFoldState()
181
+ if let last = lastFoldState, (last as NSDictionary).isEqual(to: foldState) { return }
182
+
183
+ lastFoldState = foldState
184
+ notifyListeners("foldStateChange", data: foldState)
185
+ }
186
+
187
+ private func notifySizeClassIfChanged() {
188
+ let sizeClass = currentSizeClass()
189
+ guard sizeClass != lastSizeClass else { return }
190
+
191
+ lastSizeClass = sizeClass
192
+ notifyListeners("sizeClassChange", data: sizeClass)
193
+ }
194
+ }
@@ -0,0 +1,69 @@
1
+ import UIKit
2
+
3
+ struct ReservedRegion {
4
+ enum Kind {
5
+ case division
6
+ case occlusion
7
+ }
8
+
9
+ var kind: Kind
10
+ var frame: CGRect
11
+ var isActive: Bool
12
+ }
13
+
14
+ enum HingeStatus {
15
+ case closed
16
+ case partiallyOpen
17
+ case fullyOpen
18
+ }
19
+
20
+ protocol FoldSource {
21
+ var hasFold: Bool { get }
22
+ var hingeStatus: HingeStatus? { get }
23
+ var hingeAngle: Double? { get }
24
+ func reservedRegions(in view: UIView) -> [ReservedRegion]
25
+ func observe(_ view: UIView, onChange: @escaping () -> Void)
26
+ }
27
+
28
+ struct ReservedRegionFoldProvider: FoldProvider {
29
+ let source: FoldSource
30
+
31
+ var isFoldable: Bool { source.hasFold }
32
+ var supportsTabletop: Bool { source.hasFold }
33
+
34
+ func fold(in view: UIView?) -> NativeFold? {
35
+ guard source.hasFold, let view = view else { return nil }
36
+ return nativeFoldOf(regions: source.reservedRegions(in: view), hingeStatus: source.hingeStatus)
37
+ }
38
+
39
+ func hingeAngle() -> Double? {
40
+ return source.hingeAngle
41
+ }
42
+
43
+ func observe(_ view: UIView, onChange: @escaping () -> Void) {
44
+ source.observe(view, onChange: onChange)
45
+ }
46
+ }
47
+
48
+ func nativeFoldOf(regions: [ReservedRegion], hingeStatus: HingeStatus?) -> NativeFold {
49
+ let divisions = hingeStatus == .closed ? [] : regions.filter { $0.kind == .division }
50
+ let division = divisions.first { $0.isActive } ?? divisions.first
51
+ let isFolded: Bool
52
+ switch hingeStatus {
53
+ case .partiallyOpen?:
54
+ isFolded = division != nil
55
+ case .fullyOpen?:
56
+ isFolded = false
57
+ default:
58
+ isFolded = division?.isActive == true
59
+ }
60
+
61
+ var fold = NativeFold(state: isFolded ? "half-opened" : "flat", isSeparating: isFolded)
62
+ if let division = division {
63
+ fold.hingeBounds = division.frame
64
+ fold.hingeOrientation = division.frame.height >= division.frame.width ? "vertical" : "horizontal"
65
+ }
66
+ fold.cameraBounds = regions.filter { $0.kind == .occlusion && $0.isActive }.map { $0.frame }
67
+ fold.activeDisplay = hingeStatus.map { $0 == .closed ? "outer" : "inner" }
68
+ return fold
69
+ }
@@ -0,0 +1,53 @@
1
+ import UIKit
2
+
3
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
4
+ @available(iOS 27.1, *)
5
+ @MainActor
6
+ final class UIKitFoldSource: @preconcurrency FoldSource {
7
+ private weak var view: UIView?
8
+ private var interaction: UIHingeInteraction?
9
+ private var hinge: (status: UIHinge.Status, angle: CGFloat)?
10
+
11
+ nonisolated init() {}
12
+
13
+ var hasFold: Bool {
14
+ if let status = hinge?.status, status != .unknown { return true }
15
+ guard let view = view else { return false }
16
+ return !view.reservedRegions(kind: .division, options: .includeInactive).isEmpty
17
+ }
18
+
19
+ var hingeStatus: HingeStatus? {
20
+ switch hinge?.status {
21
+ case .closed: return .closed
22
+ case .partiallyOpen: return .partiallyOpen
23
+ case .fullyOpen: return .fullyOpen
24
+ default: return nil
25
+ }
26
+ }
27
+
28
+ var hingeAngle: Double? {
29
+ guard let hinge = hinge, hinge.status != .unknown else { return nil }
30
+ return Double(hinge.angle) * 180 / .pi
31
+ }
32
+
33
+ func reservedRegions(in view: UIView) -> [ReservedRegion] {
34
+ let divisions = view.reservedRegions(kind: .division, options: .includeInactive).map {
35
+ ReservedRegion(kind: .division, frame: $0.frame, isActive: $0.isActive)
36
+ }
37
+ let occlusions = view.reservedRegions(kind: .occlusion).map {
38
+ ReservedRegion(kind: .occlusion, frame: $0.frame, isActive: $0.isActive)
39
+ }
40
+ return divisions + occlusions
41
+ }
42
+
43
+ func observe(_ view: UIView, onChange: @escaping () -> Void) {
44
+ self.view = view
45
+ let interaction = UIHingeInteraction { [weak self] _, update in
46
+ self?.hinge = update.hinge.map { ($0.status, $0.angle) }
47
+ onChange()
48
+ }
49
+ view.addInteraction(interaction)
50
+ self.interaction = interaction
51
+ }
52
+ }
53
+ #endif
@@ -0,0 +1,104 @@
1
+ import UIKit
2
+ import XCTest
3
+ @testable import FoldablePlugin
4
+
5
+ private final class FakeFoldProvider: FoldProvider {
6
+ var isFoldable = true
7
+ var supportsTabletop = true
8
+ var reportedFold: NativeFold?
9
+ var angle: Double?
10
+
11
+ func fold(in view: UIView?) -> NativeFold? {
12
+ return reportedFold
13
+ }
14
+
15
+ func hingeAngle() -> Double? {
16
+ return angle
17
+ }
18
+
19
+ func observe(_ view: UIView, onChange: @escaping () -> Void) {}
20
+ }
21
+
22
+ class FoldableTests: XCTestCase {
23
+ func testIsDeviceFoldableReportsFalseStub() {
24
+ let implementation = Foldable()
25
+
26
+ XCTAssertFalse(implementation.isDeviceFoldable())
27
+ XCTAssertFalse(implementation.supportsTabletop())
28
+ }
29
+
30
+ func testGetFoldStateReportsFlatStub() {
31
+ let result = Foldable().getFoldState()
32
+
33
+ XCTAssertEqual(result["state"] as? String, "flat")
34
+ XCTAssertEqual(result["isSeparating"] as? Bool, false)
35
+ XCTAssertEqual(result["posture"] as? String, "flat")
36
+ XCTAssertNil(result["hingeOrientation"])
37
+ XCTAssertNil(result["occludedBounds"])
38
+ }
39
+
40
+ func testSizeClassNamesUIKitSizeClassesAndWindowClasses() {
41
+ let result = Foldable().sizeClass(horizontal: .regular, vertical: .regular, width: 626, height: 890)
42
+
43
+ XCTAssertEqual(result["horizontal"], "regular")
44
+ XCTAssertEqual(result["vertical"], "regular")
45
+ XCTAssertEqual(result["widthClass"], "medium")
46
+ XCTAssertEqual(result["heightClass"], "medium")
47
+ }
48
+
49
+ func testBarPlacementIsHorizontalWithoutVerticalBars() {
50
+ XCTAssertTrue(Foldable().barPlacement(in: UITraitCollection())["verticalBarEdge"] is NSNull)
51
+ }
52
+
53
+ func testGetHingeAngleReportsNullStub() {
54
+ XCTAssertTrue(Foldable().getHingeAngle()["angle"] is NSNull)
55
+ }
56
+
57
+ func testGetFoldStateMapsProviderFold() {
58
+ let provider = FakeFoldProvider()
59
+ provider.reportedFold = NativeFold(
60
+ state: "half-opened",
61
+ isSeparating: true,
62
+ hingeOrientation: "vertical",
63
+ hingeBounds: CGRect(x: 400.4, y: 0, width: 20, height: 700),
64
+ activeDisplay: "inner",
65
+ cameraBounds: [CGRect(x: 10, y: 10, width: 40, height: 40)]
66
+ )
67
+
68
+ let result = Foldable(provider: provider).getFoldState()
69
+
70
+ XCTAssertEqual(result["state"] as? String, "half-opened")
71
+ XCTAssertEqual(result["isSeparating"] as? Bool, true)
72
+ XCTAssertEqual(result["posture"] as? String, "book")
73
+ XCTAssertEqual(result["hingeOrientation"] as? String, "vertical")
74
+ XCTAssertEqual(result["hingeBounds"] as? [String: Int], ["x": 400, "y": 0, "width": 20, "height": 700])
75
+ XCTAssertNil(result["occludedBounds"])
76
+ XCTAssertEqual(result["activeDisplay"] as? String, "inner")
77
+ XCTAssertEqual(result["cameraBounds"] as? [[String: Int]], [["x": 10, "y": 10, "width": 40, "height": 40]])
78
+ }
79
+
80
+ func testHorizontalHalfOpenedFoldIsTabletop() {
81
+ let provider = FakeFoldProvider()
82
+ provider.reportedFold = NativeFold(state: "half-opened", isSeparating: true, hingeOrientation: "horizontal")
83
+
84
+ XCTAssertEqual(Foldable(provider: provider).getFoldState()["posture"] as? String, "tabletop")
85
+ }
86
+
87
+ func testFoldStateOmitsCameraBoundsWhenThereAreNone() {
88
+ let provider = FakeFoldProvider()
89
+ provider.reportedFold = NativeFold(state: "flat", isSeparating: false)
90
+
91
+ XCTAssertNil(Foldable(provider: provider).getFoldState()["cameraBounds"])
92
+ }
93
+
94
+ func testIsDeviceFoldableAndHingeAngleReadProvider() {
95
+ let provider = FakeFoldProvider()
96
+ provider.angle = 90
97
+
98
+ let implementation = Foldable(provider: provider)
99
+
100
+ XCTAssertTrue(implementation.isDeviceFoldable())
101
+ XCTAssertTrue(implementation.supportsTabletop())
102
+ XCTAssertEqual(implementation.getHingeAngle()["angle"] as? Double, 90)
103
+ }
104
+ }