@momo-kits/native-kits 0.167.1-duo-search.1-debug → 0.167.1-duo-search.2-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 (141) hide show
  1. package/CLAUDE.md +4 -1
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +134 -51
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +1 -1
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +11 -5
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +8 -9
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +2 -1
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  33. package/gradle.properties +1 -1
  34. package/ios/Application/ApplicationEnvironment.swift +11 -0
  35. package/ios/Application/ComposeApi.swift +2 -1
  36. package/ios/Application/{Components.swift → LegacyHeader.swift} +6 -62
  37. package/ios/Application/Screen.swift +3 -2
  38. package/ios/Avatar/Avatar.swift +4 -3
  39. package/ios/Badge/Badge.swift +6 -4
  40. package/ios/Badge/BadgeDot.swift +1 -1
  41. package/ios/Badge/BadgeRibbon.swift +0 -1
  42. package/ios/Button/Button.swift +15 -12
  43. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +12 -20
  44. package/ios/Carousel/Carousel.swift +29 -12
  45. package/ios/Checkbox/Checkbox.swift +7 -3
  46. package/ios/Chip/Chip.swift +5 -4
  47. package/ios/Collapse/Collapse.swift +70 -19
  48. package/ios/Colors+Radius+Spacing/Colors.swift +9 -0
  49. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  50. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  51. package/ios/Divider/Divider.swift +37 -4
  52. package/ios/Extensions/Color++.swift +18 -0
  53. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  54. package/ios/Icon/Icon.swift +7 -1
  55. package/ios/IconButton/IconButton.swift +15 -7
  56. package/ios/Image/Image.swift +7 -34
  57. package/ios/Information/Information.swift +27 -9
  58. package/ios/Input/ErrorView.swift +2 -2
  59. package/ios/Input/Input.swift +160 -147
  60. package/ios/Input/InputPhoneNumber.swift +12 -9
  61. package/ios/Input/InputSearch.swift +102 -75
  62. package/ios/Input/InputTextArea.swift +128 -145
  63. package/ios/InputDropDown/InputDropDown.swift +10 -7
  64. package/ios/InputMoney/InputMoney.swift +33 -11
  65. package/ios/InputOTP/InputOTP.swift +22 -12
  66. package/ios/Layout/Card.swift +50 -0
  67. package/ios/Layout/GridContext.swift +123 -3
  68. package/ios/Layout/Item.swift +5 -4
  69. package/ios/Layout/Section.swift +4 -100
  70. package/ios/Loader/Loader.swift +5 -9
  71. package/ios/Lottie/LottieView.swift +10 -0
  72. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +65 -9
  73. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTab.swift +60 -34
  74. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +11 -6
  75. package/ios/{Application → Navigation/Component}/FloatingButton.swift +14 -3
  76. package/ios/Navigation/Component/Header.swift +493 -0
  77. package/ios/Navigation/Component/HeaderBackground.swift +134 -0
  78. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  79. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  80. package/ios/Navigation/Component/HeaderRight.swift +386 -0
  81. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  82. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +20 -23
  83. package/ios/{Application/Navigation/component → Navigation/Component}/SideBar.swift +49 -28
  84. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +5 -5
  85. package/ios/Navigation/HostBackGestureBridge.swift +57 -0
  86. package/ios/Navigation/Navigation.swift +190 -0
  87. package/ios/{Application/Navigation → Navigation}/NavigationContainer.swift +46 -41
  88. package/ios/{Application/Navigation → Navigation}/Navigator.swift +65 -9
  89. package/ios/{Application → Navigation}/StackScreen.swift +236 -103
  90. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  91. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  92. package/ios/Pagination/PaginationScroll.swift +2 -2
  93. package/ios/Popup/PopupDisplay.swift +79 -159
  94. package/ios/Popup/PopupInput.swift +10 -13
  95. package/ios/Popup/PopupLayout.swift +97 -2
  96. package/ios/Popup/PopupNotify.swift +27 -55
  97. package/ios/Popup/PopupPromotion.swift +22 -50
  98. package/ios/PopupView/FullscreenPopup.swift +13 -19
  99. package/ios/PopupView/PopupView.swift +1 -1
  100. package/ios/PopupView/Utils++.swift +0 -8
  101. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  102. package/ios/Radio/Radio.swift +2 -1
  103. package/ios/Rating/Rating.swift +9 -1
  104. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  105. package/ios/Skeleton/Skeleton.swift +22 -27
  106. package/ios/Slider/Slider.swift +16 -18
  107. package/ios/Stepper/Stepper.swift +6 -1
  108. package/ios/Steps/Steps.swift +8 -6
  109. package/ios/SuggestAction/SuggestAction.swift +5 -0
  110. package/ios/Swipeable/SwipeCell.swift +10 -6
  111. package/ios/Switch/Switch.swift +22 -20
  112. package/ios/TabView/TabView.swift +102 -21
  113. package/ios/Tag/Tag.swift +15 -4
  114. package/ios/Template/Logo/Logo.swift +2 -1
  115. package/ios/Template/TrustBanner/TrustBanner.swift +41 -27
  116. package/ios/Title/Title.swift +6 -17
  117. package/ios/Tooltip/Tooltip.swift +29 -14
  118. package/ios/Typography/Text.swift +61 -19
  119. package/ios/Uploader/Uploader.swift +3 -1
  120. package/ios/native-kits.podspec +2 -1
  121. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  122. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  123. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  124. package/package.json +1 -1
  125. package/ios/Application/HeaderRight.swift +0 -324
  126. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  127. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  128. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  129. package/ios/Application/Navigation/NavigatorManager.swift +0 -29
  130. package/ios/Application/Navigation/component/DuoScrollGradient.swift +0 -54
  131. package/ios/Application/Navigation/component/Header.swift +0 -183
  132. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -84
  133. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  134. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  135. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  136. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -280
  137. /package/ios/{Application/Layout → Layout}/ScreenLayout.swift +0 -0
  138. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
  139. /package/ios/{Application/Navigation → Navigation}/BottomTab/DuoBarCompression.swift +0 -0
  140. /package/ios/{Application/Navigation → Navigation}/BottomTab/DuoTabBar.swift +0 -0
  141. /package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +0 -0
@@ -65,6 +65,17 @@ public class ApplicationEnvironment: ObservableObject {
65
65
  }
66
66
  }
67
67
 
68
+ extension ApplicationEnvironment {
69
+ /// Closes the mini app: through `composeApi` as before, else `maxApi` (Compose `maxApi.dismiss`).
70
+ func requestDismiss() {
71
+ if let composeApi {
72
+ composeApi.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
73
+ } else {
74
+ maxApi?.dismiss(completion: nil)
75
+ }
76
+ }
77
+ }
78
+
68
79
  private struct ApplicationEnvironmentKey: EnvironmentKey {
69
80
  static let defaultValue = ApplicationEnvironment()
70
81
  }
@@ -1,11 +1,12 @@
1
1
  import Foundation
2
2
 
3
+ @available(*, deprecated, message: "Use KitComposeApi or MaxApi")
3
4
  public protocol ComposeApi {
4
5
  func request(funcName: String, params: Any, _ completion: ((String) -> Void)?)
5
6
  func request(funcName: String, params: Any) -> String
6
7
  }
7
8
 
8
- // Default implementation
9
+ @available(*, deprecated, message: "Use KitComposeApi or MaxApi")
9
10
  public class ComposeApiImpl: ComposeApi {
10
11
  public static let shared = ComposeApiImpl()
11
12
 
@@ -1,55 +1,13 @@
1
1
  //
2
- // Components.swift
3
- // Pods
2
+ // LegacyHeader.swift
3
+ // MoMoUIKits
4
4
  //
5
- // Created by sophia on 24/1/25.
5
+ // Header pieces of the deprecated `Screen` (Compose `application/Header.kt`,
6
+ // `application/HeaderBackground.kt`). New screens get theirs from `StackScreen`.
6
7
  //
7
8
 
8
9
  import SwiftUI
9
10
 
10
- // MARK: - Enums
11
-
12
- public enum TitlePosition {
13
- case left, center
14
- }
15
-
16
- public enum HeaderType {
17
- case `default`, extended, none
18
- }
19
-
20
- public enum AnimatedHeaderRatio {
21
- case ratio16_9
22
- case ratio1_1
23
- case ratio3_2
24
-
25
- var value: CGFloat {
26
- switch self {
27
- case .ratio16_9: return 16.0 / 9.0
28
- case .ratio1_1: return 1.0
29
- case .ratio3_2: return 3.0 / 2.0
30
- }
31
- }
32
- }
33
-
34
-
35
- // MARK: - Animated Header Config
36
-
37
- public struct AnimatedHeader {
38
- public var aspectRatio: AnimatedHeaderRatio = .ratio16_9
39
- public var isSurface: Bool = true
40
- public var composable: (_ scrollState: CGFloat) -> AnyView = { _ in AnyView(EmptyView()) }
41
-
42
- public init(
43
- aspectRatio: AnimatedHeaderRatio = .ratio16_9,
44
- isSurface: Bool = true,
45
- composable: @escaping (_ scrollState: CGFloat) -> AnyView = { _ in AnyView(EmptyView()) }
46
- ) {
47
- self.aspectRatio = aspectRatio
48
- self.isSurface = isSurface
49
- self.composable = composable
50
- }
51
- }
52
-
53
11
  // MARK: - Utils
54
12
 
55
13
  func safeAreaTopInset() -> CGFloat {
@@ -66,6 +24,7 @@ func safeAreaTopInset() -> CGFloat {
66
24
 
67
25
  // MARK: - Header Background
68
26
 
27
+ @available(*, deprecated, message: "Use NavigationContainer + StackScreen; the header background comes with it")
69
28
  public struct HeaderBackground: View {
70
29
  var headerType: HeaderType = .default
71
30
  var scrollState: CGFloat
@@ -146,24 +105,9 @@ public struct HeaderBackground: View {
146
105
  }
147
106
  }
148
107
 
149
- // MARK: - Bottom-tab root preference
150
-
151
- /// Set by `BottomTab` on appear so its enclosing `StackScreen` can detect that
152
- /// it's hosting a self-contained bottom-tab surface and skip its own bottom
153
- /// safe-area reservation — mirrors Compose's `Navigation.markAsBottomTabRoot()`
154
- /// (`navigation.isBottomTabRoot`, read by `StackScreen.kt`'s `MainContent`).
155
- /// Without this, `BottomTab`'s own internal safe-area handling gets an extra,
156
- /// unwanted inset stacked on top of it by the outer `StackScreen`, pushing the
157
- /// tab bar up higher than the true bottom inset.
158
- public struct IsBottomTabRootKey: PreferenceKey {
159
- public static var defaultValue: Bool = false
160
- public static func reduce(value: inout Bool, nextValue: () -> Bool) {
161
- value = value || nextValue()
162
- }
163
- }
164
-
165
108
  // MARK: - Header View
166
109
 
110
+ @available(*, deprecated, message: "Use NavigationContainer + StackScreen; the header comes with it")
167
111
  public struct Header: View {
168
112
  var headerType: HeaderType = .default
169
113
  var titlePosition: TitlePosition
@@ -1,12 +1,13 @@
1
1
  //
2
2
  // Screen.swift
3
- // Pods
3
+ // MoMoUIKits
4
4
  //
5
- // Created by sophia on 5/2/25.
5
+ // Deprecated standalone screen (Compose `application/Screen.kt`).
6
6
  //
7
7
 
8
8
  import SwiftUI
9
9
 
10
+ @available(*, deprecated, message: "Use NavigationContainer + StackScreen")
10
11
  public struct Screen<Content: View>: View {
11
12
  // MARK: - Properties
12
13
 
@@ -74,12 +74,12 @@ public struct Avatar: View {
74
74
  .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
75
75
  .overlay(
76
76
  RoundedRectangle(cornerRadius: cornerRadius)
77
- .stroke(theme.colors.border.default, lineWidth: 1)
77
+ .strokeBorder(theme.colors.border.default, lineWidth: 1)
78
78
  )
79
79
 
80
80
  // Badge icons — outside clip so they are not cut by border
81
81
  if shouldShowIconTop {
82
- ImageView(LOGO_MOMO_URL)
82
+ ImageView(LOGO_MOMO_URL, contentMode: .fill)
83
83
  .frame(width: badgeSizeDp, height: badgeSizeDp)
84
84
  .clipShape(RoundedRectangle(cornerRadius: badgeCornerRadius))
85
85
  .offset(x: 1, y: -1)
@@ -92,7 +92,7 @@ public struct Avatar: View {
92
92
  }
93
93
 
94
94
  if shouldShowIconBottom {
95
- ImageView(iconSupport)
95
+ ImageView(iconSupport, contentMode: .fill)
96
96
  .frame(width: badgeSizeDp, height: badgeSizeDp)
97
97
  .clipShape(RoundedRectangle(cornerRadius: badgeCornerRadius))
98
98
  .offset(x: 1, y: 1)
@@ -107,6 +107,7 @@ public struct Avatar: View {
107
107
  .frame(width: size.sizeDp, height: size.sizeDp)
108
108
  .accessibilityElement(children: .ignore)
109
109
  .accessibilityLabel(automationId)
110
+ .accessibilityIdentifier(automationId)
110
111
  }
111
112
 
112
113
  // MARK: Internal
@@ -33,7 +33,8 @@ public struct Badge: View {
33
33
  }
34
34
 
35
35
  private func formatTitle(_ text: String) -> String {
36
- if isNumber(text), let number = Int(text), number > 99 {
36
+ // Digits too long for Int are well over 99.
37
+ if isNumber(text), (Int(text) ?? .max) > 99 {
37
38
  return "99+"
38
39
  }
39
40
  return text
@@ -54,14 +55,15 @@ public struct Badge: View {
54
55
  }
55
56
 
56
57
  public var body: some View {
57
- MomoText(formatTitle(label), typography: .actionXxsBold, color: Colors.black01)
58
+ MomoText(formatTitle(label), typography: .actionXxsBold, color: Colors.black01, maxLines: 1)
58
59
  .padding(.horizontal, Spacing.XS)
59
- .frame(minWidth: fontScale(16), minHeight: fontScale(16))
60
+ .frame(minWidth: fontScale(16))
61
+ .frame(height: fontScale(16))
60
62
  .background(badgeColor)
61
63
  .cornerRadius(Radius.M)
62
64
  .overlay(
63
65
  RoundedRectangle(cornerRadius: Radius.M)
64
- .stroke(Colors.black01, lineWidth: 1)
66
+ .strokeBorder(Colors.black01, lineWidth: 1)
65
67
  )
66
68
  }
67
69
  }
@@ -25,7 +25,7 @@ public struct BadgeDot: View {
25
25
  .frame(width: size.size, height: size.size)
26
26
  .overlay(
27
27
  Circle()
28
- .stroke(Colors.black01, lineWidth: 1)
28
+ .strokeBorder(Colors.black01, lineWidth: 1)
29
29
  )
30
30
  }
31
31
  }
@@ -160,7 +160,6 @@ public struct BadgeRibbon: View {
160
160
  MomoText(label, typography: .labelXsMedium, color: Colors.black01, maxLines: 1)
161
161
  .rotationEffect(rotation)
162
162
  .frame(height: RibbonConstants.skewBodyHeight)
163
- .padding(.horizontal, 8)
164
163
  .background(backgroundColor)
165
164
 
166
165
  rightTail
@@ -51,15 +51,17 @@ public extension ButtonType {
51
51
  }
52
52
 
53
53
  func contentColor(loading: Bool, theme: Theme) -> Color {
54
+ let color: Color
54
55
  switch self {
55
- case .disabled: return theme.colors.text.disable.opacity(loading ? 0.75 : 1)
56
- case .primary: return Colors.black01
57
- case .secondary: return theme.colors.text.default
58
- case .outline: return theme.colors.primary
59
- case .tonal: return theme.colors.primary
60
- case .danger: return Colors.black01
61
- case .text: return theme.colors.primary
56
+ case .disabled: color = theme.colors.text.disable
57
+ case .primary: color = Colors.black01
58
+ case .secondary: color = theme.colors.text.default
59
+ case .outline: color = theme.colors.primary
60
+ case .tonal: color = theme.colors.primary
61
+ case .danger: color = Colors.black01
62
+ case .text: color = theme.colors.primary
62
63
  }
64
+ return color.opacity(loading ? 0.75 : 1)
63
65
  }
64
66
 
65
67
  var borderColor: Color? {
@@ -219,9 +221,8 @@ public struct Button: View {
219
221
  let color: Color? = useTintColor ? textColor : nil
220
222
 
221
223
  if forceLoading {
222
- LottieView(name: "lottie_circle_loader", loopMode: .loop)
224
+ LottieView(name: "lottie_circle_loader", loopMode: .loop, tintColor: color.map { UIColor($0) })
223
225
  .frame(width: size.iconSize, height: size.iconSize)
224
- .colorMultiply(color ?? Colors.black01)
225
226
  } else if !icon.isEmpty {
226
227
  Icon(source: icon, size: size.iconSize, color: color)
227
228
  }
@@ -229,8 +230,8 @@ public struct Button: View {
229
230
  }
230
231
 
231
232
  /// Mirrors Compose's press feedback: the background shrinks via an animated
232
- /// horizontal inset while the content fades, rather than a plain opacity dim on
233
- /// the whole button (matching `animatedPadding`/`alpha` in Button.kt).
233
+ /// horizontal inset and the whole button (background included) fades to 0.5,
234
+ /// matching `animatedPadding`/`alpha` in Button.kt.
234
235
  private struct ButtonPressFeedbackStyle: ButtonStyle {
235
236
  let isEnabled: Bool
236
237
  let background: Color
@@ -245,7 +246,6 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
245
246
 
246
247
  configuration.label
247
248
  .modifier(HugContentOnLegacyOS(isFull: isFull))
248
- .opacity(pressed ? 0.5 : 1)
249
249
  .background(
250
250
  RoundedRectangle(cornerRadius: radius)
251
251
  .fill(background)
@@ -257,6 +257,9 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
257
257
  )
258
258
  .frame(height: height)
259
259
  .clipShape(RoundedRectangle(cornerRadius: radius))
260
+ // `.text` has a clear background, which SwiftUI does not hit-test.
261
+ .contentShape(RoundedRectangle(cornerRadius: radius))
262
+ .opacity(pressed ? 0.5 : 1)
260
263
  .animation(.easeInOut(duration: 0.1), value: pressed)
261
264
  }
262
265
  }
@@ -16,42 +16,40 @@ extension String {
16
16
  public struct CalculatorKeyboard: View {
17
17
  // MARK: Lifecycle
18
18
  @Binding var value: String
19
- @State var tempValue: String = ""
20
19
 
21
20
  public init(_ value: Binding<String>) {
22
21
  self._value = value
23
22
  }
23
+
24
+ // Derived from `value` so a host write to the binding (prefill, reset after submit) is honoured.
25
+ private var expression: String {
26
+ value.replacingOccurrences(of: "x", with: "*").replacingOccurrences(of: "÷", with: "/")
27
+ }
24
28
 
25
29
  func onPressBack(){
26
30
  if (value.count > 0 ){
27
31
  value.removeLast()
28
32
  }
29
- // Keys map 1:1 onto `tempValue` characters, so both trims stay aligned.
30
- if (tempValue.count > 0 ){
31
- tempValue.removeLast()
32
- }
33
33
  }
34
34
 
35
35
  func onPressClear(key: String){
36
36
  value = ""
37
- tempValue = ""
38
37
  }
39
38
 
40
39
  func onPressEqual(key: String){
41
- if (tempValue == "") {
40
+ var expression = self.expression
41
+ if (expression == "") {
42
42
  return
43
43
  }
44
- if (tempValue.last?.isNumber == false){
45
- tempValue.removeLast()
44
+ if (expression.last?.isNumber == false){
45
+ expression.removeLast()
46
46
  }
47
47
  // Overflow and division by zero are treated like a negative result: cleared.
48
- if let result = Self.evaluate(tempValue), result >= 0 {
48
+ if let result = Self.evaluate(expression), result >= 0 {
49
49
  value = String(result)
50
50
  } else {
51
51
  value = ""
52
52
  }
53
- // The next keys continue from the shown result, not the old expression.
54
- tempValue = value
55
53
  }
56
54
 
57
55
  /// Integer `+ - * /` with the usual precedence, as NSExpression did, but nil instead of a
@@ -104,21 +102,15 @@ public struct CalculatorKeyboard: View {
104
102
  }
105
103
 
106
104
  func onPressKey(key:String){
107
- if (tempValue.last?.isNumber == false){
105
+ if (value.last?.isNumber == false){
108
106
  if (key.isNumber == false){
109
107
  return
110
108
  }
111
109
  }
112
- var convertedKey = key
113
- if (key == "x") {convertedKey = "*"}
114
- if (key == "÷") {convertedKey = "/"}
115
110
  if (value == "" && (key == "0" || key == "000" || key.isNumber == false)) {
116
111
  return
117
112
  }
118
- else {
119
- value.append(key)
120
- tempValue.append(convertedKey)
121
- }
113
+ value.append(key)
122
114
  }
123
115
 
124
116
  // MARK: Public
@@ -1,4 +1,3 @@
1
- import Combine
2
1
  import SwiftUI
3
2
 
4
3
  /// Native SwiftUI port of the Compose `Carousel` component.
@@ -10,9 +9,9 @@ import SwiftUI
10
9
  /// snap behavior, and scroll enable/disable. Snap / scroll-index callbacks
11
10
  /// report the real item index.
12
11
  ///
13
- /// Available iOS 14+ (uses a custom paging ScrollView so behavior is identical
12
+ /// Uses a custom drag-driven pager (not a ScrollView) so behavior is identical
14
13
  /// across versions and supports the loop virtualization + transform effects
15
- /// that `TabView(.page)` cannot express).
14
+ /// that `TabView(.page)` cannot express.
16
15
  public struct Carousel<Content: View>: View {
17
16
  // MARK: Lifecycle
18
17
 
@@ -82,7 +81,7 @@ public struct Carousel<Content: View>: View {
82
81
  @State private var currentPage: Int
83
82
  @State private var dragOffset: CGFloat = 0
84
83
  @State private var isDragging: Bool = false
85
- @State private var autoplayTick: Int = 0
84
+ @GestureState private var dragActive = false
86
85
 
87
86
  private func realIndex(_ page: Int) -> Int {
88
87
  ((page % itemCount) + itemCount) % itemCount
@@ -124,9 +123,20 @@ public struct Carousel<Content: View>: View {
124
123
  }
125
124
  .frame(width: viewportWidth)
126
125
  .clipped()
127
- .onReceive(autoplayTimer) { _ in
128
- guard autoplay, itemCount > 1, !isDragging else { return }
129
- advance()
126
+ // Restarts on drag like Compose's LaunchedEffect(autoplayInterval, isUserScrolling).
127
+ .task(id: AutoplayKey(enabled: autoplay && itemCount > 1 && !isDragging, interval: autoplayInterval)) { @MainActor in
128
+ guard autoplay, itemCount > 1, !isDragging, autoplayInterval > 0 else { return }
129
+ while !Task.isCancelled {
130
+ try? await Task.sleep(nanoseconds: UInt64(autoplayInterval * 1_000_000_000))
131
+ guard !Task.isCancelled else { return }
132
+ advance()
133
+ }
134
+ }
135
+ // A drag stolen by a parent scroll view never reaches `onEnded`; settle on the current page.
136
+ .onChange(of: dragActive) { active in
137
+ guard !active, isDragging else { return }
138
+ isDragging = false
139
+ withAnimation(.easeInOut(duration: 0.25)) { dragOffset = 0 }
130
140
  }
131
141
  }
132
142
 
@@ -147,9 +157,14 @@ public struct Carousel<Content: View>: View {
147
157
 
148
158
  private func dragGesture(step: CGFloat) -> some Gesture {
149
159
  DragGesture(minimumDistance: 5)
160
+ .updating($dragActive) { _, active, _ in active = true }
150
161
  .onChanged { value in
151
162
  isDragging = true
152
- dragOffset = value.translation.width
163
+ var offset = value.translation.width
164
+ // No slide exists past either end, as with HorizontalPager bounds.
165
+ if currentPage == 0 { offset = min(offset, 0) }
166
+ if currentPage == virtualPageCount - 1 { offset = max(offset, 0) }
167
+ dragOffset = offset
153
168
  }
154
169
  .onEnded { value in
155
170
  isDragging = false
@@ -167,7 +182,8 @@ public struct Carousel<Content: View>: View {
167
182
  let moved = step > 0 ? Int((-dragOffset / step).rounded()) : 0
168
183
  target = min(max(currentPage + moved, 0), virtualPageCount - 1)
169
184
  }
170
- dragOffset = 0
185
+ // Animated so a drag that stays on the same page eases back too.
186
+ withAnimation(.easeInOut(duration: 0.25)) { dragOffset = 0 }
171
187
  setPage(target)
172
188
  }
173
189
  }
@@ -189,8 +205,9 @@ public struct Carousel<Content: View>: View {
189
205
  onScrollIndexChanged(real)
190
206
  }
191
207
  }
208
+ }
192
209
 
193
- private var autoplayTimer: Publishers.Autoconnect<Timer.TimerPublisher> {
194
- Timer.publish(every: autoplayInterval, on: .main, in: .common).autoconnect()
195
- }
210
+ private struct AutoplayKey: Equatable {
211
+ let enabled: Bool
212
+ let interval: TimeInterval
196
213
  }
@@ -21,8 +21,10 @@ public struct Checkbox: View{
21
21
  }
22
22
 
23
23
  func onCheck(){
24
- checked.toggle()
25
- onChange?(checked)
24
+ // Not re-read from the binding: a `.constant` / one-way binding would report the stale value.
25
+ let newValue = !checked
26
+ checked = newValue
27
+ onChange?(newValue)
26
28
  }
27
29
 
28
30
  public var body: some View {
@@ -35,13 +37,15 @@ public struct Checkbox: View{
35
37
  .frame(width: 20, height: 20)
36
38
  .overlay(
37
39
  RoundedRectangle(cornerRadius: Radius.XS)
38
- .stroke(borderColor, lineWidth: Spacing.XXS)
40
+ .strokeBorder(borderColor, lineWidth: Spacing.XXS)
39
41
  )
40
42
 
41
43
  if checked {
42
44
  Icon(source: indeterminate ? "navigation_minus" : "ic_checked", size: 20, color: Colors.black01)
43
45
  }
44
46
  }
47
+ // The unchecked fill is clear, which SwiftUI does not hit-test.
48
+ .contentShape(RoundedRectangle(cornerRadius: Radius.XS))
45
49
  }
46
50
  .buttonStyle(ActiveOpacityButtonStyle(activeOpacity: 0.8))
47
51
  .disabled(disabled)
@@ -89,18 +89,19 @@ public struct Chip: View {
89
89
  .padding(.horizontal, horizontal)
90
90
  .frame(height: height)
91
91
  .background(
92
- RoundedRectangle(cornerRadius: radius, style: .continuous)
92
+ RoundedRectangle(cornerRadius: radius)
93
93
  .fill(bg)
94
94
  )
95
95
  .overlay(
96
96
  Group {
97
97
  if selected {
98
- RoundedRectangle(cornerRadius: radius, style: .continuous)
99
- .stroke(theme.colors.secondary, lineWidth: 2)
98
+ // Inside the bounds, like Compose `border`.
99
+ RoundedRectangle(cornerRadius: radius)
100
+ .strokeBorder(theme.colors.secondary, lineWidth: 2)
100
101
  }
101
102
  }
102
103
  )
103
- .contentShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
104
+ .contentShape(RoundedRectangle(cornerRadius: radius))
104
105
  }
105
106
  .buttonStyle(ActiveOpacityButtonStyle())
106
107
  .modifier(ChipAccessibilityModifier(accessibilityLabel: accessibilityLabel))
@@ -32,7 +32,7 @@ public struct Collapse<Content: View>: View {
32
32
  private let titleColor: Color?
33
33
  private let accessibilityId: String?
34
34
  private let onPress: ((Bool) -> Void)?
35
- private let content: (() -> Content)?
35
+ private var content: (() -> Content)?
36
36
 
37
37
  @State private var expanded: Bool
38
38
 
@@ -87,31 +87,36 @@ public struct Collapse<Content: View>: View {
87
87
  header
88
88
 
89
89
  if let content {
90
- if expanded {
91
- VStack(spacing: 0) {
92
- content()
90
+ // Compose's expandVertically: the clip grows with the panel while it slides in from the top.
91
+ VStack(spacing: 0) {
92
+ if expanded {
93
+ VStack(spacing: 0) {
94
+ content()
95
+ }
96
+ .frame(maxWidth: .infinity)
97
+ .overlay(
98
+ showBorder
99
+ ? Rectangle().strokeBorder(theme.colors.border.default, lineWidth: 1)
100
+ : nil
101
+ )
102
+ .transition(.move(edge: .top))
93
103
  }
94
- .frame(maxWidth: .infinity)
95
- .overlay(
96
- showBorder
97
- ? RoundedRectangle(cornerRadius: 0)
98
- .stroke(theme.colors.border.default, lineWidth: 1)
99
- : nil
100
- )
101
- .transition(.move(edge: .top).combined(with: .opacity))
102
104
  }
105
+ .clipped()
103
106
  }
104
107
  }
105
108
  .frame(maxWidth: .infinity)
106
109
  .background(resolvedBackgroundColor)
107
110
  .clipShape(RoundedRectangle(cornerRadius: Radius.S, style: .continuous))
108
- .accessibilityIdentifier(automationId)
109
111
  .overlay(
110
112
  showBorder
111
113
  ? RoundedRectangle(cornerRadius: Radius.S, style: .continuous)
112
- .stroke(theme.colors.border.default, lineWidth: 1)
114
+ .strokeBorder(theme.colors.border.default, lineWidth: 1)
113
115
  : nil
114
116
  )
117
+ // `.contain` keeps the children's own identifiers instead of overriding them.
118
+ .accessibilityElement(children: .contain)
119
+ .accessibilityIdentifier(automationId)
115
120
  }
116
121
 
117
122
  private var header: some View {
@@ -126,6 +131,7 @@ public struct Collapse<Content: View>: View {
126
131
  ImageView(image, contentMode: .fill)
127
132
  .frame(width: imageSize, height: imageSize)
128
133
  .clipShape(RoundedRectangle(cornerRadius: Radius.XS, style: .continuous))
134
+ .accessibilityIdentifier("\(automationId)|img")
129
135
  Spacer().frame(width: Spacing.S)
130
136
  }
131
137
 
@@ -133,15 +139,17 @@ public struct Collapse<Content: View>: View {
133
139
  MomoText(
134
140
  title,
135
141
  typography: titleSize.typography,
136
- color: titleColor ?? theme.colors.text.default
142
+ color: titleColor ?? theme.colors.text.default,
143
+ accessibilityId: "\(automationId)|text"
137
144
  )
138
145
  if let description {
139
146
  MomoText(
140
147
  description,
141
148
  typography: .bodyDefaultRegular,
142
- color: theme.colors.text.secondary
149
+ color: theme.colors.text.secondary,
150
+ maxLines: 2,
151
+ accessibilityId: "\(automationId)|description-text"
143
152
  )
144
- .lineLimit(2)
145
153
  }
146
154
  }
147
155
  .frame(maxWidth: .infinity, alignment: .leading)
@@ -152,9 +160,10 @@ public struct Collapse<Content: View>: View {
152
160
  MomoText(
153
161
  subTitle,
154
162
  typography: .bodyDefaultRegular,
155
- color: theme.colors.text.secondary
163
+ color: theme.colors.text.secondary,
164
+ maxLines: 1,
165
+ accessibilityId: "\(automationId)|sub-title-text"
156
166
  )
157
- .lineLimit(1)
158
167
  } else if let tagLabel {
159
168
  Tag(label: tagLabel, color: tagColor)
160
169
  }
@@ -187,6 +196,48 @@ public struct Collapse<Content: View>: View {
187
196
  .rotationEffect(.degrees(expanded ? 180 : 0))
188
197
  }
189
198
  }
199
+ .accessibilityIdentifier("\(automationId)|icon")
190
200
  .animation(.easeInOut(duration: 0.3), value: expanded)
191
201
  }
192
202
  }
203
+
204
+ public extension Collapse where Content == EmptyView {
205
+ /// Header-only collapse, mirroring Compose's `content = null`.
206
+ init(
207
+ title: String,
208
+ description: String? = nil,
209
+ image: String? = nil,
210
+ imageSize: CGFloat = 24,
211
+ subTitle: String? = nil,
212
+ tagLabel: String? = nil,
213
+ tagColor: Tag.TagColor = .default,
214
+ showBorder: Bool = false,
215
+ expandDefault: Bool = false,
216
+ titleSize: CollapseTitleSize = .medium,
217
+ useBackgroundCollapseButton: Bool = false,
218
+ backgroundColor: Color? = nil,
219
+ titleColor: Color? = nil,
220
+ accessibilityId: String? = nil,
221
+ onPress: ((Bool) -> Void)? = nil
222
+ ) {
223
+ self.init(
224
+ title: title,
225
+ description: description,
226
+ image: image,
227
+ imageSize: imageSize,
228
+ subTitle: subTitle,
229
+ tagLabel: tagLabel,
230
+ tagColor: tagColor,
231
+ showBorder: showBorder,
232
+ expandDefault: expandDefault,
233
+ titleSize: titleSize,
234
+ useBackgroundCollapseButton: useBackgroundCollapseButton,
235
+ backgroundColor: backgroundColor,
236
+ titleColor: titleColor,
237
+ accessibilityId: accessibilityId,
238
+ onPress: onPress,
239
+ content: { EmptyView() }
240
+ )
241
+ self.content = nil
242
+ }
243
+ }
@@ -172,4 +172,13 @@ public enum Colors {
172
172
  public static let red03 = Color(hex: "f5222d")
173
173
  public static let red02 = Color(hex: "dd1f29")
174
174
  public static let red01 = Color(hex: "c41b24")
175
+
176
+ public static let opacityBlack20 = Color(hex: "00000033")
177
+ public static let opacityBlack40 = Color(hex: "00000066")
178
+ public static let opacityBlack60 = Color(hex: "00000099")
179
+ public static let opacityBlack80 = Color(hex: "000000cc")
180
+ public static let opacityWhite20 = Color(hex: "ffffff33")
181
+ public static let opacityWhite40 = Color(hex: "ffffff66")
182
+ public static let opacityWhite60 = Color(hex: "ffffff99")
183
+ public static let opacityWhite80 = Color(hex: "ffffffcc")
175
184
  }