@momo-kits/native-kits 0.167.1-duo.1-debug → 0.167.1-duo.10-debug

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 (177) hide show
  1. package/CLAUDE.md +32 -0
  2. package/compose/build.gradle.kts +15 -1
  3. package/compose/build.gradle.kts.backup +14 -0
  4. package/compose/compose.podspec +1 -1
  5. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Divider.kt +1 -1
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +53 -26
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Card.kt +13 -11
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +21 -9
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +232 -0
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Section.kt +11 -9
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +4 -0
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +143 -31
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +100 -66
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +22 -16
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +98 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +312 -0
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +66 -18
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +23 -14
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +81 -24
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +185 -0
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +13 -0
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +4 -1
  56. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  57. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +86 -0
  58. package/gradle.properties +1 -1
  59. package/ios/Application/ApplicationEnvironment.swift +17 -0
  60. package/ios/Application/ComposeApi.swift +2 -1
  61. package/ios/Application/{Components.swift → LegacyHeader.swift} +7 -65
  62. package/ios/Application/Screen.swift +3 -2
  63. package/ios/Avatar/Avatar.swift +4 -3
  64. package/ios/Badge/Badge.swift +6 -4
  65. package/ios/Badge/BadgeDot.swift +1 -1
  66. package/ios/Badge/BadgeRibbon.swift +0 -1
  67. package/ios/Button/Button.swift +15 -12
  68. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  69. package/ios/Carousel/Carousel.swift +29 -12
  70. package/ios/Checkbox/Checkbox.swift +7 -3
  71. package/ios/Chip/Chip.swift +5 -4
  72. package/ios/Collapse/Collapse.swift +70 -19
  73. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  74. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  75. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  76. package/ios/Divider/Divider.swift +37 -4
  77. package/ios/Extensions/Color++.swift +38 -9
  78. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  79. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  80. package/ios/Icon/Icon.swift +21 -23
  81. package/ios/Icon/Icons.swift +1329 -0
  82. package/ios/IconButton/IconButton.swift +15 -7
  83. package/ios/Image/Image.swift +7 -23
  84. package/ios/Information/Information.swift +27 -9
  85. package/ios/Input/ErrorView.swift +2 -2
  86. package/ios/Input/Input.swift +160 -147
  87. package/ios/Input/InputPhoneNumber.swift +12 -9
  88. package/ios/Input/InputSearch.swift +102 -75
  89. package/ios/Input/InputTextArea.swift +128 -145
  90. package/ios/InputDropDown/InputDropDown.swift +10 -7
  91. package/ios/InputMoney/InputMoney.swift +33 -11
  92. package/ios/InputOTP/InputOTP.swift +22 -12
  93. package/ios/Layout/Card.swift +50 -0
  94. package/ios/Layout/GridContext.swift +123 -3
  95. package/ios/Layout/Item.swift +5 -4
  96. package/ios/Layout/ScreenLayout.swift +312 -0
  97. package/ios/Layout/Section.swift +4 -100
  98. package/ios/Loader/Loader.swift +5 -9
  99. package/ios/Lottie/LottieView.swift +14 -2
  100. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +97 -8
  101. package/ios/Navigation/BottomTab/BottomTab.swift +187 -0
  102. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +15 -7
  103. package/ios/Navigation/BottomTab/DuoBarCompression.swift +105 -0
  104. package/ios/Navigation/BottomTab/DuoTabBar.swift +304 -0
  105. package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
  106. package/ios/Navigation/Component/Header.swift +493 -0
  107. package/ios/Navigation/Component/HeaderBackground.swift +134 -0
  108. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  109. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  110. package/ios/Navigation/Component/HeaderRight.swift +386 -0
  111. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  112. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  113. package/ios/Navigation/Component/SideBar.swift +176 -0
  114. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +5 -5
  115. package/ios/Navigation/HostBackGestureBridge.swift +57 -0
  116. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +5 -1
  117. package/ios/Navigation/Navigation.swift +190 -0
  118. package/ios/{Application/Navigation → Navigation}/NavigationContainer.swift +92 -81
  119. package/ios/{Application/Navigation → Navigation}/Navigator.swift +185 -36
  120. package/ios/Navigation/StackScreen.swift +782 -0
  121. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  122. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  123. package/ios/Pagination/PaginationScroll.swift +2 -2
  124. package/ios/Popup/PopupDisplay.swift +72 -136
  125. package/ios/Popup/PopupInput.swift +10 -13
  126. package/ios/Popup/PopupLayout.swift +140 -0
  127. package/ios/Popup/PopupNotify.swift +56 -73
  128. package/ios/Popup/PopupPromotion.swift +31 -47
  129. package/ios/PopupView/FullscreenPopup.swift +13 -19
  130. package/ios/PopupView/PopupView.swift +1 -1
  131. package/ios/PopupView/Utils++.swift +0 -8
  132. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  133. package/ios/Radio/Radio.swift +2 -1
  134. package/ios/Rating/Rating.swift +9 -1
  135. package/ios/Resources/dot_loading.json +558 -0
  136. package/ios/Resources/lottie_circle_loader.json +1 -0
  137. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  138. package/ios/Skeleton/Skeleton.swift +22 -27
  139. package/ios/Slider/Slider.swift +16 -18
  140. package/ios/Stepper/Stepper.swift +6 -1
  141. package/ios/Steps/Steps.swift +8 -6
  142. package/ios/SuggestAction/SuggestAction.swift +5 -0
  143. package/ios/Swipeable/SwipeCell.swift +10 -6
  144. package/ios/Switch/Switch.swift +22 -20
  145. package/ios/TabView/TabView.swift +102 -21
  146. package/ios/Tag/Tag.swift +15 -4
  147. package/ios/Template/Logo/Logo.swift +2 -1
  148. package/ios/Template/TrustBanner/TrustBanner.swift +106 -54
  149. package/ios/Title/Title.swift +6 -17
  150. package/ios/Tooltip/Tooltip.swift +29 -14
  151. package/ios/Typography/Text.swift +61 -19
  152. package/ios/Uploader/Uploader.swift +3 -1
  153. package/ios/native-kits.podspec +2 -1
  154. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  155. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  156. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  157. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +1 -0
  158. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  159. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  160. package/package.json +1 -1
  161. package/scripts/gen-ios-icons.mjs +36 -0
  162. package/.claude/settings.local.json +0 -185
  163. package/ios/Application/HeaderRight.swift +0 -306
  164. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  165. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  166. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  167. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  168. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  169. package/ios/Application/Navigation/component/Header.swift +0 -178
  170. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  171. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  172. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  173. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  174. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  175. package/ios/Application/StackScreen.swift +0 -492
  176. package/local.properties +0 -8
  177. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -1,12 +1,15 @@
1
- import SDWebImageSwiftUI
2
1
  import SwiftUI
3
2
 
4
3
  public typealias PopupActionDirection = PopupButtonDirection
5
4
 
6
- // Portrait width in landscape too, so aspect-ratio images don't push popups past the screen height.
7
- var popupMaxWidth: CGFloat {
8
- let size = UIScreen.main.bounds.size
9
- return min(size.width, size.height)
5
+ public struct PopupAction {
6
+ public let title: String
7
+ public let onPress: (() -> Void)?
8
+
9
+ public init(title: String, onPress: (() -> Void)? = nil) {
10
+ self.title = title
11
+ self.onPress = onPress
12
+ }
10
13
  }
11
14
 
12
15
  public struct PopupNotifyProps {
@@ -44,19 +47,26 @@ public struct PopupNotifyProps {
44
47
  }
45
48
 
46
49
  public struct PopupNotify: View {
50
+ @Environment(\.appTheme) private var theme
47
51
  @Environment(\.localize) private var localize
48
52
  @Environment(\.momoFontScale) private var fontScale
53
+ @Environment(\.screenLayout) private var layout
49
54
 
50
55
  public let props: PopupNotifyProps
51
56
 
52
57
  @State private var descriptionHeight: CGFloat = 0
58
+ @State private var actionsHeight: CGFloat = 0
59
+ @State private var oneLineHeight: CGFloat = 0
60
+ @State private var twoLinesHeight: CGFloat = 0
53
61
 
54
62
  private var errorCodePrefix: String {
55
63
  localize.translate("errorCode")
56
64
  }
57
65
 
66
+ // ~8.5 lines as laid out (Compose measures the real lines); nominal line height until measured
58
67
  private var maxDescriptionHeight: CGFloat {
59
- fontScale(TypographyStyle.bodyDefaultRegular.lineHeight) * 8.5
68
+ popupLinesHeight(8.5, oneLine: oneLineHeight, twoLines: twoLinesHeight)
69
+ ?? fontScale(TypographyStyle.bodyDefaultRegular.lineHeight) * 8.5
60
70
  }
61
71
 
62
72
  private var shouldUseColumnButtons: Bool {
@@ -66,7 +76,9 @@ public struct PopupNotify: View {
66
76
  case .row:
67
77
  return false
68
78
  case .auto:
69
- return (props.secondary?.title.count ?? 0) > 12 || (props.primary?.title.count ?? 0) > 12
79
+ // UTF-16 length, as Kotlin and JS count it
80
+ guard let secondary = props.secondary else { return false }
81
+ return secondary.title.utf16.count > 12 || (props.primary?.title.utf16.count ?? 0) > 12
70
82
  }
71
83
  }
72
84
 
@@ -77,64 +89,60 @@ public struct PopupNotify: View {
77
89
  public var body: some View {
78
90
  ZStack(alignment: .topTrailing) {
79
91
  VStack(spacing: 0) {
80
- if !props.image.isEmpty {
81
- popupImage
82
- }
83
-
84
- VStack(alignment: .leading, spacing: 0) {
85
- MomoText(
86
- props.title,
87
- typography: .headerDefaultBold,
88
- maxLines: 2,
89
- accessibilityId: "title_popup_permission"
90
- )
91
- .padding(.bottom, Spacing.S)
92
-
93
- descriptionContent
94
-
95
- if !props.error.isEmpty {
96
- MomoText(
97
- errorCodePrefix + props.error,
98
- typography: .descriptionXsRegular,
99
- color: Colors.black12,
100
- maxLines: 1
101
- )
102
- .padding(.top, Spacing.S)
92
+ // scrolls when iPhone Duo landscape is too short; actions stay visible
93
+ PopupScrollBody(maxHeight: layout.popupMaxHeight.map { $0 - actionsHeight }) {
94
+ VStack(spacing: 0) {
95
+ if !props.image.isEmpty {
96
+ PopupHeaderImage(url: props.image)
97
+ }
98
+ textContent
103
99
  }
104
100
  }
105
- .frame(maxWidth: .infinity, alignment: .leading)
106
- .padding(Spacing.XL)
107
101
 
108
102
  actionButtons
109
103
  .padding(.horizontal, Spacing.XL)
110
104
  .padding(.bottom, Spacing.XL)
105
+ .popupReadHeight($actionsHeight)
111
106
  }
112
- .background(Colors.black01)
107
+ .background(theme.colors.background.surface)
113
108
  .clipShape(RoundedRectangle(cornerRadius: Radius.L))
114
109
 
115
110
  if props.isShowCloseIcon {
116
- closeButton
111
+ PopupCloseButton(action: props.onIconClose)
117
112
  .offset(x: 11, y: -11)
118
113
  }
119
114
  }
120
- .frame(maxWidth: popupMaxWidth - Spacing.M * 2)
115
+ .frame(maxWidth: layout.popupMaxWidth)
121
116
  .padding(.horizontal, Spacing.M)
122
- .accessibilityElement(children: .ignore)
117
+ .accessibilityElement(children: .contain)
123
118
  .accessibilityIdentifier("popup_notify")
124
119
  }
125
120
 
126
- private var popupImage: some View {
127
- WebImage(url: URL(string: props.image), isAnimating: .constant(true))
128
- .resizable()
129
- .placeholder {
130
- Rectangle()
131
- .fill(Colors.black03)
132
- .aspectRatio(1.777, contentMode: .fit)
133
- .frame(maxWidth: .infinity)
121
+ private var textContent: some View {
122
+ VStack(alignment: .leading, spacing: 0) {
123
+ MomoText(
124
+ props.title,
125
+ typography: .headerDefaultBold,
126
+ maxLines: 2,
127
+ accessibilityId: "title_popup_permission"
128
+ )
129
+ .padding(.bottom, Spacing.S)
130
+
131
+ descriptionContent
132
+
133
+ if !props.error.isEmpty {
134
+ MomoText(
135
+ errorCodePrefix + props.error,
136
+ typography: .descriptionXsRegular,
137
+ color: theme.colors.text.hint,
138
+ maxLines: 1
139
+ )
140
+ .padding(.top, Spacing.S)
134
141
  }
135
- .aspectRatio(1.777, contentMode: .fit)
136
- .frame(maxWidth: .infinity)
137
- .clipShape(RoundedCorner(radius: Radius.L, corners: [.topLeft, .topRight]))
142
+ }
143
+ .frame(maxWidth: .infinity, alignment: .leading)
144
+ .padding(Spacing.XL)
145
+ .measureLineHeights(typography: .bodyDefaultRegular, oneLine: $oneLineHeight, twoLines: $twoLinesHeight)
138
146
  }
139
147
 
140
148
  @ViewBuilder
@@ -156,15 +164,7 @@ public struct PopupNotify: View {
156
164
  accessibilityId: "description_popup_permission"
157
165
  )
158
166
  .frame(maxWidth: .infinity, alignment: .leading)
159
- .background(
160
- GeometryReader { proxy in
161
- Color.clear
162
- .onAppear { descriptionHeight = proxy.size.height }
163
- .onChange(of: props.description) { _ in
164
- descriptionHeight = proxy.size.height
165
- }
166
- }
167
- )
167
+ .popupReadHeight($descriptionHeight)
168
168
  }
169
169
 
170
170
  @ViewBuilder
@@ -206,21 +206,4 @@ public struct PopupNotify: View {
206
206
  )
207
207
  .accessibilityIdentifier("btn_popup_cancel")
208
208
  }
209
-
210
- private var closeButton: some View {
211
- SwiftUI.Button(action: props.onIconClose) {
212
- ZStack {
213
- Circle()
214
- .fill(Colors.black17)
215
- .overlay(
216
- Circle()
217
- .stroke(Colors.black01, lineWidth: 2)
218
- )
219
- .frame(width: 22, height: 22)
220
-
221
- Icon(source: "navigation_close", size: 16, color: Colors.black01)
222
- }
223
- }
224
- .accessibilityIdentifier("ic_popup_close")
225
- }
226
209
  }
@@ -2,12 +2,13 @@ import SwiftUI
2
2
  import SDWebImageSwiftUI
3
3
 
4
4
  public struct PopupPromotion: View {
5
- @Environment(\.appTheme) private var theme
5
+ @Environment(\.screenLayout) private var layout
6
6
 
7
7
  var imageUrl: String
8
8
  var onIconClose: (() -> Void)?
9
9
  var onAction: (() -> Void)?
10
10
 
11
+ @State private var imageFailed = false
11
12
 
12
13
  public init(
13
14
  imageUrl: String,
@@ -19,56 +20,39 @@ public struct PopupPromotion: View {
19
20
  self.onAction = onAction
20
21
  }
21
22
 
22
- public var body: some View {
23
- VStack(spacing: 8) {
24
- if(imageUrl.isEmpty) {
25
- Icon(source: "media_fail", size: popupMaxWidth - Spacing.XL * 2)
26
- }
27
- else {
28
- WebImage(url: URL(string: imageUrl)!)
29
- .resizable()
30
- .placeholder {
31
- Color.gray
32
- .aspectRatio(0.72, contentMode: .fit)
33
- .frame(maxWidth: popupMaxWidth - Spacing.XL * 2)
34
- .clipped()
35
- }
36
- .aspectRatio(0.72, contentMode: .fit)
37
- .frame(maxWidth: popupMaxWidth - Spacing.XL * 2, alignment: .center)
38
- .clipped()
39
- .onTapGesture {
40
- onAction?()
41
- }
42
- }
43
- buildCloseIcon()
44
- .accessibility(identifier: "ic_popup_close")
45
- }
46
- .accessibilityElement(children: .ignore)
47
- .accessibility(identifier: "popup_promotion")
48
- }
23
+ private static let aspectRatio: CGFloat = 0.72
24
+ private static let closeTapSize: CGFloat = 40
25
+ // close button row under the image: top padding + 40pt tap target
26
+ private static let closeRowHeight: CGFloat = Spacing.S + closeTapSize
49
27
 
50
- @ViewBuilder
51
- private func buildCloseIcon() -> some View {
52
- HStack(alignment: .center, spacing: 0) {
53
- SwiftUI.Button(action: {
54
- onIconClose?()
55
- }) {
56
- ZStack {
57
- Circle()
58
- .strokeBorder(theme.colors.background.surface, lineWidth: 2)
59
- .background(Circle().fill(theme.colors.text.default))
60
- .frame(width: 22, height: 22)
28
+ // fit the portrait image by height when iPhone Duo leaves too little room
29
+ private var imageWidth: CGFloat {
30
+ let width = min(layout.width - Spacing.M * 2, layout.popupMaxWidth)
31
+ guard let maxHeight = layout.popupMaxHeight else { return width }
32
+ return min(width, (maxHeight - Self.closeRowHeight) * Self.aspectRatio)
33
+ }
61
34
 
62
- Icon(source: "navigation_close", size: 16, color: theme.colors.background.surface)
35
+ public var body: some View {
36
+ VStack(spacing: 0) {
37
+ // stretched like Compose's ContentScale.FillBounds; an empty url shows the error state, as in Compose
38
+ WebImage(url: URL(string: imageUrl))
39
+ .onSuccess { _, _, _ in imageFailed = false }
40
+ .onFailure { _ in imageFailed = true }
41
+ .resizable()
42
+ .placeholder { PopupImagePlaceholder(failed: imageFailed || URL(string: imageUrl) == nil) }
43
+ .aspectRatio(Self.aspectRatio, contentMode: .fit)
44
+ .frame(maxWidth: imageWidth)
45
+ .contentShape(Rectangle())
46
+ .onTapGesture {
47
+ onAction?()
63
48
  }
64
- .frame(width: 40, height: 40)
65
- .padding(8)
66
- .zIndex(1)
49
+
50
+ PopupCloseButton(tapSize: Self.closeTapSize) {
51
+ onIconClose?()
67
52
  }
53
+ .padding(.top, Spacing.S)
68
54
  }
55
+ .accessibilityElement(children: .contain)
56
+ .accessibilityIdentifier("popup_promotion")
69
57
  }
70
-
71
58
  }
72
-
73
-
74
-
@@ -51,12 +51,9 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: ViewModifier
51
51
  @State private var opacity = 0.0
52
52
 
53
53
  // MARK: - Autohide
54
- /// Class reference for capturing a weak reference later in dispatch work holder.
55
- private var isPresentedRef: ClassReference<Binding<Bool>>?
56
- private var itemRef: ClassReference<Binding<Item?>>?
57
-
58
- /// holder for autohiding dispatch work (to be able to cancel it when needed)
59
- private var dispatchWorkHolder = DispatchWorkHolder()
54
+ /// holder for autohiding dispatch work (to be able to cancel it when needed);
55
+ /// state so it outlives re-renders, which would otherwise orphan the pending work
56
+ @State private var dispatchWorkHolder = DispatchWorkHolder()
60
57
 
61
58
  // MARK: - Internal
62
59
  /// Set dismiss souce to pass to dismiss callback
@@ -85,9 +82,6 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: ViewModifier
85
82
  self.backgroundColor = backgroundColor
86
83
  self.dismissCallback = dismissCallback
87
84
  self.view = view
88
-
89
- self.isPresentedRef = ClassReference(self.$isPresented)
90
- self.itemRef = ClassReference(self.$item)
91
85
  }
92
86
 
93
87
  init(item: Binding<Item?>,
@@ -113,9 +107,6 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: ViewModifier
113
107
  self.backgroundColor = backgroundColor
114
108
  self.dismissCallback = dismissCallback
115
109
  self.view = view
116
-
117
- self.isPresentedRef = ClassReference(self.$isPresented)
118
- self.itemRef = ClassReference(self.$item)
119
110
  }
120
111
 
121
112
  public func body(content: Content) -> some View {
@@ -214,7 +205,8 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: ViewModifier
214
205
  }
215
206
 
216
207
  func onAnimationCompleted() -> () {
217
- if shouldShowContent { // return if this was called on showing animation, only proceed if called on hiding
208
+ // only proceed on hiding, once: both the offset animation and the fallback timer land here
209
+ if shouldShowContent || !showContent {
218
210
  return
219
211
  }
220
212
  showContent = false // unload popup body after hiding animation is done
@@ -229,12 +221,14 @@ public struct FullscreenPopup<Item: Equatable, PopupContent: View>: ViewModifier
229
221
  if let autohideIn = autohideIn {
230
222
  dispatchWorkHolder.work?.cancel()
231
223
 
232
- // Weak reference to avoid the work item capturing the struct,
233
- // which would create a retain cycle with the work holder itself.
234
- dispatchWorkHolder.work = DispatchWorkItem(block: { [weak isPresentedRef, weak itemRef] in
235
- isPresentedRef?.value.wrappedValue = false
236
- itemRef?.value.wrappedValue = nil
237
- dismissSource = .autohide
224
+ // bindings only: capturing the struct would retain the work holder in a cycle
225
+ let isPresented = $isPresented
226
+ let item = $item
227
+ let dismissSource = $dismissSource
228
+ dispatchWorkHolder.work = DispatchWorkItem(block: {
229
+ dismissSource.wrappedValue = .autohide
230
+ isPresented.wrappedValue = false
231
+ item.wrappedValue = nil
238
232
  })
239
233
  if sheetPresented, let work = dispatchWorkHolder.work {
240
234
  DispatchQueue.main.asyncAfter(deadline: .now() + autohideIn, execute: work)
@@ -239,7 +239,7 @@ public struct Popup<Item: Equatable, PopupContent: View>: ViewModifier {
239
239
  .onAnimationCompleted(for: currentOffset) {
240
240
  animationCompletedCallback()
241
241
  }
242
- .animation(animation)
242
+ .animation(animation, value: currentOffset)
243
243
  }
244
244
 
245
245
  #if !os(tvOS)
@@ -5,14 +5,6 @@ final class DispatchWorkHolder {
5
5
  var work: DispatchWorkItem?
6
6
  }
7
7
 
8
- final class ClassReference<T> {
9
- var value: T
10
-
11
- init(_ value: T) {
12
- self.value = value
13
- }
14
- }
15
-
16
8
  public extension View {
17
9
 
18
10
  @ViewBuilder
@@ -112,7 +112,9 @@ private struct ProgressInfoHorizontal: View {
112
112
 
113
113
  var body: some View {
114
114
  HStack(alignment: .top, spacing: 0) {
115
- ForEach(Array(steps.enumerated()), id: \.element.id) { index, item in
115
+ // Keyed by position: `ProgressInfoItem.id` is a fresh UUID per init, so it churns
116
+ // identity whenever the caller rebuilds `steps`.
117
+ ForEach(Array(steps.enumerated()), id: \.offset) { index, item in
116
118
  let isFirst = index == 0
117
119
  let isLast = index == steps.count - 1
118
120
 
@@ -207,7 +209,7 @@ private struct ProgressInfoVertical: View {
207
209
 
208
210
  var body: some View {
209
211
  VStack(alignment: .leading, spacing: 0) {
210
- ForEach(Array(steps.enumerated()), id: \.element.id) { index, item in
212
+ ForEach(Array(steps.enumerated()), id: \.offset) { index, item in
211
213
  let isLast = index == steps.count - 1
212
214
 
213
215
  HStack(alignment: .top, spacing: 0) {
@@ -36,8 +36,9 @@ public struct Radio: View {
36
36
  public var body: some View {
37
37
  SwiftUI.Button(action: onTap) {
38
38
  HStack(spacing: 0) {
39
+ // strokeBorder keeps the ring inside 20pt, like Compose `border`.
39
40
  RoundedRectangle(cornerRadius: Radius.M)
40
- .stroke(borderColor, lineWidth: borderWidth)
41
+ .strokeBorder(borderColor, lineWidth: borderWidth)
41
42
  .frame(width: 20, height: 20)
42
43
  .padding(.trailing, label.isEmpty ? 0 : Spacing.XS)
43
44
 
@@ -35,7 +35,7 @@ public struct Rating: View {
35
35
 
36
36
  public var body: some View {
37
37
  HStack(spacing: 2) {
38
- ForEach(0..<numOfStars, id: \.self) { index in
38
+ ForEach(0..<max(numOfStars, 0), id: \.self) { index in
39
39
  star(at: index)
40
40
  }
41
41
  }
@@ -59,7 +59,14 @@ public struct Rating: View {
59
59
  }
60
60
  }
61
61
 
62
+ // Kotlin's toInt() maps NaN to 0 and saturates; Swift's Int(_:) traps on both.
63
+ private var safeRating: Float {
64
+ guard rating.isFinite else { return 0 }
65
+ return min(max(rating, -Float(Int32.max)), Float(Int32.max))
66
+ }
67
+
62
68
  private func starIcon(at index: Int) -> String {
69
+ let rating = safeRating
63
70
  let intRating = Int(rating)
64
71
  if index < intRating {
65
72
  return "reaction_star_full"
@@ -71,6 +78,7 @@ public struct Rating: View {
71
78
  }
72
79
 
73
80
  private func starColor(at index: Int) -> Color {
81
+ let rating = safeRating
74
82
  let intRating = Int(rating)
75
83
  if index < intRating {
76
84
  return Colors.yellow03