@momo-kits/native-kits 0.162.2-sp.7-debug → 0.162.2-sp.8-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 (160) hide show
  1. package/.claude/settings.local.json +15 -1
  2. package/CLAUDE.md +78 -0
  3. package/compose/build.gradle.kts +12 -31
  4. package/compose/build.gradle.kts.backup +11 -30
  5. package/compose/compose.podspec +1 -1
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/OsFontScale.android.kt +7 -0
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +2 -7
  8. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +8 -11
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +6 -24
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +4 -19
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +1 -10
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +2 -2
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +1 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +1 -1
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +6 -17
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +1 -1
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +6 -101
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Localize.kt +269 -0
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +8 -19
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +8 -33
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +1 -1
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +22 -25
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BadgeRibbon.kt +27 -29
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/BaselineView.kt +3 -26
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +30 -31
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +3 -13
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CheckBox.kt +0 -5
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +3 -11
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Collapse.kt +3 -18
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/CupertinoOverscroll.kt +11 -11
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/IconButton.kt +42 -65
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Image.kt +69 -26
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -5
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +84 -59
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputDropDown.kt +13 -71
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +10 -51
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +6 -1
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +4 -21
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +1 -1
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +16 -75
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/components/LazyColumnWithBouncing.kt +5 -26
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationDot.kt +1 -1
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PaginationScroll.kt +3 -11
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +16 -40
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -9
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +0 -28
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +2 -13
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ScaleSizeScope.kt +4 -0
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Skeleton.kt +4 -10
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +3 -28
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Stepper.kt +8 -47
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Steps.kt +4 -25
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/components/SuggestAction.kt +2 -11
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +1 -17
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +1 -6
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +50 -111
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +1 -5
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +1 -5
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Title.kt +1 -12
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +4 -96
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TrustBanner.kt +5 -7
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Uploader.kt +2 -12
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePicker.kt +9 -21
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerTypes.kt +2 -2
  65. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +9 -14
  66. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +8 -11
  67. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +22 -0
  68. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Theme.kt +1 -1
  69. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +11 -20
  70. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -1
  71. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/AutomationId.kt +0 -5
  72. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Clickable.kt +0 -2
  73. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/DeprecatedModifier.kt +94 -4
  74. package/compose/src/commonMain/kotlin/vn/momo/kits/modifier/Shadow.kt +0 -2
  75. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +2 -2
  76. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +3 -7
  77. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +9 -15
  78. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +36 -24
  79. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +48 -33
  80. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +67 -61
  81. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +6 -13
  82. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +13 -31
  83. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/CurvedContainer.kt +1 -1
  84. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/FloatingButton.kt +6 -24
  85. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +11 -6
  86. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +2 -1
  87. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +9 -6
  88. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +1 -1
  89. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +3 -13
  90. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +3 -2
  91. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +1 -40
  92. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ComposeLottieAnimation.kt +0 -2
  93. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/OsFontScale.kt +9 -0
  94. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +0 -8
  95. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Resources.kt +2 -17
  96. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Tracking.kt +1 -1
  97. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +3 -3
  98. package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +2 -3
  99. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/OsFontScale.ios.kt +66 -0
  100. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +0 -2
  101. package/gradle/libs.versions.toml +2 -0
  102. package/gradle.properties +1 -1
  103. package/ios/Application/ApplicationEnvironment.swift +4 -2
  104. package/ios/Application/Components.swift +16 -0
  105. package/ios/Application/Localize.swift +277 -0
  106. package/ios/Application/MaxApi.swift +18 -0
  107. package/ios/Application/Navigation/BottomTab/BottomTab.swift +1 -0
  108. package/ios/Application/Navigation/NavigationContainer.swift +27 -3
  109. package/ios/Application/Navigation/Navigator.swift +50 -29
  110. package/ios/Application/Navigation/Overplay/BottomSheet.swift +87 -19
  111. package/ios/Application/Navigation/Overplay/ModalScreen.swift +6 -0
  112. package/ios/Application/Navigation/Tracking/ScreenTracker.swift +117 -0
  113. package/ios/Application/Screen.swift +1 -1
  114. package/ios/Application/StackScreen.swift +49 -1
  115. package/ios/Badge/BadgeRibbon.swift +80 -0
  116. package/ios/Button/Button.swift +68 -12
  117. package/ios/Carousel/Carousel.swift +16 -1
  118. package/ios/Checkbox/Checkbox.swift +14 -20
  119. package/ios/Chip/Chip.swift +35 -26
  120. package/ios/Collapse/Collapse.swift +11 -1
  121. package/ios/Colors+Radius+Spacing/Colors.swift +9 -6
  122. package/ios/Colors+Radius+Spacing/Spacing.swift +3 -1
  123. package/ios/DateTimePicker/DateTimePicker.swift +4 -2
  124. package/ios/DateTimePicker/WheelPicker.swift +7 -3
  125. package/ios/Extensions/ActiveOpacityButtonStyle.swift +14 -0
  126. package/ios/Extensions/Color++.swift +5 -2
  127. package/ios/Icon/Icon.swift +5 -1
  128. package/ios/IconButton/IconButton.swift +35 -7
  129. package/ios/Image/Image.swift +16 -3
  130. package/ios/Information/Information.swift +3 -1
  131. package/ios/Input/ErrorView.swift +3 -1
  132. package/ios/Input/Input.swift +31 -8
  133. package/ios/Input/InputPhoneNumber.swift +14 -3
  134. package/ios/Input/InputSearch.swift +7 -1
  135. package/ios/Input/InputTextArea.swift +78 -34
  136. package/ios/InputDropDown/InputDropDown.swift +4 -1
  137. package/ios/InputMoney/InputMoney.swift +16 -4
  138. package/ios/PARITY_AUDIT.md +641 -0
  139. package/ios/Popup/PopupDisplay.swift +3 -4
  140. package/ios/Popup/PopupNotify.swift +3 -4
  141. package/ios/Popup/PopupPromotion.swift +16 -8
  142. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  143. package/ios/Radio/Radio.swift +12 -13
  144. package/ios/Rating/Rating.swift +1 -1
  145. package/ios/Stepper/Stepper.swift +2 -2
  146. package/ios/Steps/Steps.swift +22 -6
  147. package/ios/SuggestAction/SuggestAction.swift +5 -3
  148. package/ios/Swipeable/SwipeCell.swift +5 -3
  149. package/ios/Switch/Switch.swift +53 -28
  150. package/ios/TabView/TabView.swift +18 -14
  151. package/ios/Template/TrustBanner/TrustBanner.swift +6 -7
  152. package/ios/Tooltip/Tooltip.swift +3 -0
  153. package/ios/Typography/FontScaleStore.swift +19 -0
  154. package/ios/Typography/Text.swift +3 -0
  155. package/ios/Typography/Typography.swift +8 -5
  156. package/ios/Uploader/Uploader.swift +0 -9
  157. package/ios/native-kits.podspec +1 -1
  158. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  159. package/package.json +1 -1
  160. package/ios/Colors+Radius+Spacing/MoMoUIKitsResources.swift +0 -2
@@ -43,42 +43,84 @@ struct BottomSheetView<Content: View>: View {
43
43
  let content: () -> Content
44
44
 
45
45
  @EnvironmentObject private var navigator: Navigator
46
+ @Environment(\.appTheme) private var theme
47
+ @Environment(\.applicationEnvironment) private var appEnv
46
48
 
47
49
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
48
50
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
49
- @State private var scrimAlpha: CGFloat = 0
51
+ /// Mirrors Compose's `backgroundAlpha`: only touched by open()/close(), never by drag.
52
+ @State private var backgroundAlpha: CGFloat = 0
50
53
  @State private var sheetHeight: CGFloat = 0
51
54
  @State private var didAppear = false
55
+ @State private var keyboardHeight: CGFloat = 0
56
+ /// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
57
+ /// this gesture-scoped value instead of forcing the whole body (including
58
+ /// `content()`) to reconstruct every frame — mirrors the pattern already used in
59
+ /// `ios/PopupView/PopupView.swift` for smooth drag-to-dismiss.
60
+ @GestureState private var dragTranslation: CGFloat = 0
52
61
 
53
62
  /// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
54
63
  private let closeThreshold: CGFloat = 100
55
64
 
65
+ /// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
66
+ /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
+ private let maxDynamicAlpha: CGFloat = 0.3
68
+
56
69
  private var screenHeight: CGFloat { UIScreen.main.bounds.height }
57
70
  private var bottomInset: CGFloat { safeAreaBottomInset() }
71
+ /// While the keyboard is up, it already covers the home-indicator area, so it
72
+ /// replaces (rather than adds to) the usual bottom-inset filler.
73
+ private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
58
74
 
59
75
  private var backgroundColor: Color {
60
- // Compose uses background.surface (white) for `isSurface`, otherwise
61
- // background.default. Both resolve to white in the current iOS palette.
62
- isSurface ? Colors.black01 : Colors.black01
76
+ isSurface ? theme.colors.background.surface : theme.colors.background.default
77
+ }
78
+
79
+ private var effectiveOffset: CGFloat { sheetOffset + dragTranslation }
80
+
81
+ /// Mirrors Compose's `dynamicAlpha`: a plain derived value (not itself animated)
82
+ /// that tracks the current offset live, shrinking toward 0 as the sheet is
83
+ /// dragged down.
84
+ private var dynamicAlpha: CGFloat {
85
+ let denom = sheetHeight > 0 ? sheetHeight : screenHeight
86
+ guard denom > 0 else { return maxDynamicAlpha }
87
+ let progress = min(max(1 - effectiveOffset / denom, 0), 1)
88
+ return progress * maxDynamicAlpha
63
89
  }
64
90
 
91
+ /// Mirrors Compose's `alpha = backgroundAlpha + dynamicAlpha`: additive, so the
92
+ /// scrim peaks at 0.6 at rest and only fades to a 0.3 floor while dragging
93
+ /// (backgroundAlpha holds steady until an explicit close()).
94
+ private var totalScrimAlpha: CGFloat { backgroundAlpha + dynamicAlpha }
95
+
65
96
  var body: some View {
66
97
  ZStack(alignment: .bottom) {
67
- // Dimmed scrim (max 0.3 alpha, mirrors Compose).
68
- Color.black.opacity(scrimAlpha)
69
- .ignoresSafeArea()
98
+ // Dimmed scrim additive backgroundAlpha + dynamicAlpha (see above).
99
+ Color.black.opacity(totalScrimAlpha)
70
100
  .contentShape(Rectangle())
71
101
  .onTapGesture {
72
102
  if barrierDismissible { close() }
73
103
  }
74
104
 
75
105
  sheet
76
- .offset(y: sheetOffset)
106
+ .offset(y: effectiveOffset)
77
107
  }
78
108
  .frame(maxWidth: .infinity, maxHeight: .infinity)
109
+ .ignoresSafeArea()
110
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
+ guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
+ withAnimation(.easeOut(duration: 0.25)) {
113
+ keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
114
+ }
115
+ }
79
116
  .onAppear {
80
117
  guard !didAppear else { return }
81
118
  didAppear = true
119
+ ScreenTracker.trackPopupDisplayed(
120
+ maxApi: appEnv.maxApi,
121
+ context: appEnv.applicationContext,
122
+ parentScreenName: ScreenTracker.getLastScreenName()
123
+ )
82
124
  navigator.registerOverplayDismiss { close() }
83
125
  open()
84
126
  }
@@ -103,16 +145,16 @@ struct BottomSheetView<Content: View>: View {
103
145
  .gesture(dragGesture)
104
146
 
105
147
  Rectangle()
106
- .fill(Colors.black04)
148
+ .fill(theme.colors.border.default)
107
149
  .frame(height: 1)
108
150
 
109
151
  content()
110
152
 
111
153
  backgroundColor
112
- .frame(height: bottomInset)
154
+ .frame(height: effectiveBottomInset)
113
155
  }
114
156
  .frame(maxWidth: .infinity)
115
- .frame(maxHeight: screenHeight - safeAreaTopInset() - 90, alignment: .bottom)
157
+ .frame(alignment: .bottom)
116
158
  .background(
117
159
  GeometryReader { geo in
118
160
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -143,13 +185,33 @@ struct BottomSheetView<Content: View>: View {
143
185
  // MARK: - Drag handling (mirrors Compose's detectDragGestures)
144
186
 
145
187
  private var dragGesture: some Gesture {
146
- DragGesture()
147
- .onChanged { value in
148
- let newOffset = max(value.translation.height, 0)
149
- sheetOffset = newOffset
188
+ // `.global` (not the default `.local`) so the gesture tracks against the
189
+ // window rather than the handle's own view, which is itself repositioned
190
+ // every frame by this same gesture's output (`sheet.offset(y: effectiveOffset)`
191
+ // in `body`) — the local coordinate space chasing its own moving reference
192
+ // frame is a plausible source of a continuous drag-time jitter.
193
+ DragGesture(coordinateSpace: .global)
194
+ .onChanged { _ in
195
+ // Touching the handle should immediately settle `sheetOffset` to its
196
+ // resting value (0) with no animation, so a drag that starts while the
197
+ // open animation — or the keyboard-driven resize, which shifts this
198
+ // same bottom-anchored top edge — is still in flight doesn't fight a
199
+ // moving target. Without this, the live drag value chases an animated
200
+ // one, producing a couple of corrective jumps before it settles.
201
+ if sheetOffset != 0 {
202
+ sheetOffset = 0
203
+ }
204
+ }
205
+ .updating($dragTranslation) { value, state, _ in
206
+ state = max(value.translation.height, 0)
150
207
  }
151
- .onEnded { _ in
152
- if sheetOffset > closeThreshold {
208
+ .onEnded { value in
209
+ let finalOffset = max(value.translation.height, 0)
210
+ // Capture into persisted state BEFORE @GestureState resets to 0,
211
+ // avoiding a one-frame snap back to rest before close()'s own
212
+ // slide-out animation starts (mirrors PopupView's `lastDragPosition`).
213
+ sheetOffset = finalOffset
214
+ if finalOffset > closeThreshold {
153
215
  close()
154
216
  } else {
155
217
  withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
@@ -163,17 +225,23 @@ struct BottomSheetView<Content: View>: View {
163
225
 
164
226
  private func open() {
165
227
  DispatchQueue.main.async {
166
- withAnimation(.easeOut(duration: 0.1)) { scrimAlpha = 0.3 }
228
+ withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.1)) { backgroundAlpha = maxDynamicAlpha }
167
229
  withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.35)) { sheetOffset = 0 }
168
230
  }
169
231
  }
170
232
 
233
+ /// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
234
+ /// out over 100ms only after the slide finishes, then dismiss.
171
235
  private func close() {
172
236
  withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
173
237
  sheetOffset = max(sheetHeight, screenHeight)
174
- scrimAlpha = 0
175
238
  }
176
239
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
240
+ withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.1)) {
241
+ backgroundAlpha = 0
242
+ }
243
+ }
244
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
177
245
  navigator.finishOverplayDismiss()
178
246
  }
179
247
  }
@@ -15,6 +15,7 @@ struct ModalScreen<Content: View>: View {
15
15
  let content: () -> Content
16
16
 
17
17
  @EnvironmentObject private var navigator: Navigator
18
+ @Environment(\.applicationEnvironment) private var appEnv
18
19
 
19
20
  @State private var scrimOpacity: CGFloat = 0
20
21
  @State private var contentScale: CGFloat = 0.8
@@ -38,6 +39,11 @@ struct ModalScreen<Content: View>: View {
38
39
  .onAppear {
39
40
  guard !didAppear else { return }
40
41
  didAppear = true
42
+ ScreenTracker.trackPopupDisplayed(
43
+ maxApi: appEnv.maxApi,
44
+ context: appEnv.applicationContext,
45
+ parentScreenName: ScreenTracker.getLastScreenName()
46
+ )
41
47
  navigator.registerOverplayDismiss { close() }
42
48
  open()
43
49
  }
@@ -0,0 +1,117 @@
1
+ //
2
+ // ScreenTracker.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/tracking/ScreenTracker.kt`. Pure
6
+ // logic, no platform dependency beyond the `MaxApi` bridge.
7
+ //
8
+
9
+ import Foundation
10
+
11
+ public final class ScreenTrackingState {
12
+ public var time: Double = Date().timeIntervalSince1970 * 1000
13
+ public var releaseNavigated: Bool = false
14
+ public var releaseLoad: Bool = false
15
+ public var releaseInteraction: Bool = false
16
+ public var previousScreenName: String?
17
+ public var action: String = "push"
18
+
19
+ public init() {}
20
+ }
21
+
22
+ public enum ScreenTracker {
23
+ private static var lastScreenName: String?
24
+
25
+ public static func trackScreenNavigated(
26
+ maxApi: MaxApi?,
27
+ context: MiniAppContext?,
28
+ screenName: String,
29
+ action: String,
30
+ state: ScreenTrackingState
31
+ ) {
32
+ guard !state.releaseNavigated else { return }
33
+
34
+ let previousScreen = lastScreenName
35
+ lastScreenName = screenName
36
+
37
+ var params = buildBaseParams(context: context, screenName: screenName)
38
+ params["pre_screen_name"] = previousScreen
39
+ params["state"] = "navigated"
40
+ params["action"] = action
41
+ maxApi?.track(eventName: "auto_screen_navigated", params: params)
42
+
43
+ state.releaseNavigated = true
44
+ state.previousScreenName = previousScreen
45
+ }
46
+
47
+ public static func trackScreenDisplayed(
48
+ maxApi: MaxApi?,
49
+ context: MiniAppContext?,
50
+ screenName: String,
51
+ duration: Double,
52
+ state: ScreenTrackingState
53
+ ) {
54
+ guard !state.releaseLoad else { return }
55
+
56
+ var params = buildBaseParams(context: context, screenName: screenName)
57
+ params["state"] = "load"
58
+ params["duration"] = duration
59
+ maxApi?.track(eventName: "auto_screen_displayed", params: params)
60
+
61
+ state.releaseLoad = true
62
+ }
63
+
64
+ public static func trackScreenInteracted(
65
+ maxApi: MaxApi?,
66
+ context: MiniAppContext?,
67
+ screenName: String,
68
+ duration: Double,
69
+ totalDuration: Double,
70
+ state: ScreenTrackingState
71
+ ) {
72
+ guard !state.releaseInteraction else { return }
73
+
74
+ var params = buildBaseParams(context: context, screenName: screenName)
75
+ params["state"] = "interaction"
76
+ params["duration"] = duration
77
+ params["total_duration"] = totalDuration
78
+ maxApi?.track(eventName: "auto_screen_interacted", params: params)
79
+
80
+ state.releaseInteraction = true
81
+ }
82
+
83
+ public static func trackPopupDisplayed(
84
+ maxApi: MaxApi?,
85
+ context: MiniAppContext?,
86
+ parentScreenName: String?
87
+ ) {
88
+ maxApi?.track(eventName: "auto_popup_displayed", params: [
89
+ "screen_name": parentScreenName,
90
+ "tracking_source": 1,
91
+ "app_id": context?.appId,
92
+ "feature_code": context?.appCode,
93
+ "kits_version": nil,
94
+ "component_name": "Modal"
95
+ ])
96
+ }
97
+
98
+ private static func buildBaseParams(
99
+ context: MiniAppContext?,
100
+ screenName: String,
101
+ componentName: String = "Screen"
102
+ ) -> [String: Any?] {
103
+ [
104
+ "app_id": context?.appId,
105
+ "feature_code": context?.appCode,
106
+ "screen_name": screenName,
107
+ "component_name": componentName,
108
+ "tracking_source": 1
109
+ ]
110
+ }
111
+
112
+ public static func getLastScreenName() -> String? { lastScreenName }
113
+
114
+ public static func reset() {
115
+ lastScreenName = nil
116
+ }
117
+ }
@@ -237,7 +237,7 @@ struct Footer: View {
237
237
  }
238
238
  .padding(.vertical, Spacing.S)
239
239
  .padding(.horizontal, Spacing.M)
240
- .padding(.bottom, keyboardSize + bottomInset)
240
+ .padding(.bottom, keyboardSize - bottomInset)
241
241
  .background(Colors.black01)
242
242
  .overlay(
243
243
  Rectangle()
@@ -39,6 +39,7 @@ public struct StackScreen<Content: View>: View {
39
39
  var layoutOffset: CGFloat
40
40
  var inputSearchProps: InputSearchProps?
41
41
  var fabProps: FabProps?
42
+ var screenName: String = ""
42
43
  var content: () -> Content
43
44
 
44
45
  public init(
@@ -66,6 +67,7 @@ public struct StackScreen<Content: View>: View {
66
67
  layoutOffset: CGFloat = 56,
67
68
  inputSearchProps: InputSearchProps? = nil,
68
69
  fabProps: FabProps? = nil,
70
+ screenName: String = "",
69
71
  @ViewBuilder content: @escaping () -> Content
70
72
  ) {
71
73
  self.backgroundColor = backgroundColor
@@ -92,12 +94,21 @@ public struct StackScreen<Content: View>: View {
92
94
  self.layoutOffset = layoutOffset
93
95
  self.inputSearchProps = inputSearchProps
94
96
  self.fabProps = fabProps
97
+ self.screenName = screenName
95
98
  self.content = content
96
99
  }
97
100
 
98
101
  @State private var scrollOffset: CGFloat = 0
99
102
  @State private var keyboardHeight: CGFloat = 0
100
103
  @State private var headerRightWidth: CGFloat = 0
104
+ @State private var isBottomTabRoot: Bool = false
105
+
106
+ @Environment(\.applicationEnvironment) private var appEnv
107
+
108
+ // Auto-tracking state (mirrors Compose's `remember { ScreenTrackingState() }` +
109
+ // the once-computed push/back `action`).
110
+ @State private var trackingState = ScreenTrackingState()
111
+ @State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
101
112
 
102
113
  /// Header background fade tied to scroll: 0 → transparent (over a hero image
103
114
  /// or transparent header), 1 → opaque surface with the drop shadow.
@@ -135,7 +146,10 @@ public struct StackScreen<Content: View>: View {
135
146
  GeometryReader { geometry in
136
147
  let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
137
148
  let bottomInset = min(geometry.safeAreaInsets.bottom, 21)
138
- let contentBottomInset = footer == nil ? bottomInset : 0
149
+ // Skip this screen's own bottom-inset reservation when hosting a footer
150
+ // OR a self-contained BottomTab surface (mirrors Compose's
151
+ // `if (options.footerComponent != null || isBottomTab) 0.dp else navigationBar`).
152
+ let contentBottomInset = (footer == nil && !isBottomTabRoot) ? bottomInset : 0
139
153
 
140
154
  ZStack(alignment: .top) {
141
155
  // Background color
@@ -175,6 +189,9 @@ public struct StackScreen<Content: View>: View {
175
189
  .onPreferenceChange(HeaderRightWidthKey.self) { width in
176
190
  if headerRightWidth != width { headerRightWidth = width }
177
191
  }
192
+ .onPreferenceChange(IsBottomTabRootKey.self) { isRoot in
193
+ if isBottomTabRoot != isRoot { isBottomTabRoot = isRoot }
194
+ }
178
195
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
179
196
  guard useAvoidKeyboard,
180
197
  let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
@@ -186,6 +203,37 @@ public struct StackScreen<Content: View>: View {
186
203
  // taps still reach buttons/fields while also dismissing the keyboard.
187
204
  .modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
188
205
  }
206
+ .task(id: screenName) {
207
+ ScreenTracker.trackScreenNavigated(
208
+ maxApi: appEnv.maxApi,
209
+ context: appEnv.applicationContext,
210
+ screenName: screenName,
211
+ action: trackingAction,
212
+ state: trackingState
213
+ )
214
+ do {
215
+ try await Task.sleep(nanoseconds: 2_000_000_000)
216
+ } catch {
217
+ return
218
+ }
219
+ let loadTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
220
+ ScreenTracker.trackScreenDisplayed(
221
+ maxApi: appEnv.maxApi,
222
+ context: appEnv.applicationContext,
223
+ screenName: screenName,
224
+ duration: loadTime,
225
+ state: trackingState
226
+ )
227
+ let interactionTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
228
+ ScreenTracker.trackScreenInteracted(
229
+ maxApi: appEnv.maxApi,
230
+ context: appEnv.applicationContext,
231
+ screenName: screenName,
232
+ duration: interactionTime - loadTime,
233
+ totalDuration: interactionTime,
234
+ state: trackingState
235
+ )
236
+ }
189
237
  }
190
238
 
191
239
  // MARK: - Header + content layer
@@ -196,6 +196,86 @@ public struct BadgeRibbon: View {
196
196
  }
197
197
  }
198
198
 
199
+ /// Ported from Compose's `RoundedBadgeRibbon` (BadgeRibbon.kt:164-326): a canvas-drawn
200
+ /// ribbon shape sized to its text, mirrored per corner via scaleX/scaleY (never an actual
201
+ /// rotation) — the label gets the same scale applied a second time so it stays right-reading
202
+ /// while only the ribbon shape flips.
203
+ public struct RoundedBadgeRibbon: View {
204
+ let text: String
205
+ let position: RibbonPosition
206
+
207
+ public init(text: String = "Label", position: RibbonPosition = .topLeft) {
208
+ self.text = text
209
+ self.position = position
210
+ }
211
+
212
+ private var scale: (x: CGFloat, y: CGFloat) {
213
+ switch position {
214
+ case .topLeft: return (-1, 1)
215
+ case .topRight: return (1, 1)
216
+ case .bottomRight: return (1, -1)
217
+ case .bottomLeft: return (-1, -1)
218
+ }
219
+ }
220
+
221
+ private var measuredTextSize: CGSize {
222
+ let fontSize = scaleSize(TypographyStyle.labelXsMedium.fontSize)
223
+ let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
224
+ return text.size(withAttributes: [.font: font])
225
+ }
226
+
227
+ public var body: some View {
228
+ let textSize = measuredTextSize
229
+ let minRibbonHeight = max(textSize.height * 1.2, 16)
230
+ let padding = minRibbonHeight / 4
231
+ let badgeHeight = minRibbonHeight + padding
232
+ let badgeWidth = max(textSize.width + padding * 2, 28)
233
+
234
+ ZStack {
235
+ Canvas { context, size in
236
+ let ribbonHeight = size.height - padding
237
+ let cornerRadius = (badgeHeight - padding) / 2
238
+ let mainColor = Colors.orange03
239
+ let tailColor = Colors.red01
240
+
241
+ let headWidth: CGFloat = 4
242
+ // Kotlin computes `leftSectionWidth` + `middleSectionWidth` here, but those
243
+ // cancel algebraically to `width - headWidth` — kept as the direct result.
244
+ let rightX = size.width - headWidth
245
+
246
+ let leftMiddlePath = RoundedCorners(topLeft: cornerRadius, topRight: 0, bottomLeft: cornerRadius, bottomRight: 0)
247
+ .path(in: CGRect(x: 0, y: 0, width: rightX + 1, height: ribbonHeight))
248
+ context.fill(leftMiddlePath, with: .color(mainColor))
249
+
250
+ let headPath = RoundedCorners(topLeft: 0, topRight: cornerRadius, bottomLeft: 0, bottomRight: 0)
251
+ .path(in: CGRect(x: rightX, y: 0, width: headWidth, height: ribbonHeight))
252
+ context.fill(headPath, with: .color(mainColor))
253
+
254
+ let halfHeadWidth = headWidth / 2
255
+ let tailStartX = rightX + headWidth - halfHeadWidth
256
+ let tailY = ribbonHeight
257
+ let tailHeight = padding
258
+
259
+ context.fill(
260
+ Path(CGRect(x: tailStartX, y: tailY, width: halfHeadWidth, height: halfHeadWidth)),
261
+ with: .color(mainColor)
262
+ )
263
+
264
+ let tailRightPath = RoundedCorners(topLeft: 0, topRight: tailHeight, bottomLeft: 0, bottomRight: tailHeight)
265
+ .path(in: CGRect(x: tailStartX, y: tailY, width: halfHeadWidth, height: tailHeight))
266
+ context.fill(tailRightPath, with: .color(tailColor))
267
+ }
268
+
269
+ MomoText(text, typography: .labelXsMedium, color: Colors.black01, maxLines: 1)
270
+ .padding(.bottom, padding)
271
+ .padding(.horizontal, padding / 2)
272
+ .scaleEffect(x: scale.x, y: scale.y)
273
+ }
274
+ .frame(width: badgeWidth, height: badgeHeight)
275
+ .scaleEffect(x: scale.x, y: scale.y)
276
+ }
277
+ }
278
+
199
279
  // MARK: - Constants
200
280
  private struct RibbonConstants {
201
281
  static let ribbonHeight: CGFloat = 20
@@ -39,7 +39,7 @@ public extension ButtonType {
39
39
  case .disabled: return theme.colors.background.disable.opacity(loading ? 0.75 : 1)
40
40
  case .primary: return theme.colors.primary.opacity(loading ? 0.75 : 1)
41
41
  case .secondary: return theme.colors.background.surface.opacity(loading ? 0.75 : 1)
42
- case .outline: return theme.colors.background.surface.opacity(loading ? 0.75 : 1)
42
+ case .outline: return theme.colors.background.surface.opacity(loading ? 0.75 : 1)
43
43
  case .tonal: return theme.colors.background.tonal.opacity(loading ? 0.75 : 1)
44
44
  case .danger: return theme.colors.error.primary.opacity(loading ? 0.75 : 1)
45
45
  case .text: return .clear
@@ -123,7 +123,6 @@ extension ButtonSize {
123
123
  case .large: return .actionDefaultBold
124
124
  case .medium: return .actionSBold
125
125
  case .small: return .actionXsBold
126
- default : return .actionDefaultBold
127
126
  }
128
127
  }
129
128
  }
@@ -162,6 +161,35 @@ public struct Button: View {
162
161
  self.useTintColor = useTintColor
163
162
  }
164
163
 
164
+ /// Mirrors Kotlin's `Button(iconLeft: String, iconRight: String, ...)`, which resolves
165
+ /// icons by design-system source name through the shared `Icon` component rather than
166
+ /// a caller-supplied view. `iconLeft` is required here (unlike Kotlin's `= ""` default)
167
+ /// solely to keep this initializer unambiguous alongside the `AnyView`-based one above —
168
+ /// pass `""` explicitly for an icon-right-only button.
169
+ public init(
170
+ title: String = "Button",
171
+ action: @escaping () -> Void,
172
+ type: ButtonType = .primary,
173
+ size: ButtonSize = .large,
174
+ iconLeft: String,
175
+ iconRight: String = "",
176
+ isFull: Bool = true,
177
+ loading: Bool = false,
178
+ useTintColor: Bool = true
179
+ ) {
180
+ self.init(
181
+ title: title,
182
+ action: action,
183
+ type: type,
184
+ size: size,
185
+ iconLeft: iconLeft.isEmpty ? nil : AnyView(Icon(source: iconLeft)),
186
+ iconRight: iconRight.isEmpty ? nil : AnyView(Icon(source: iconRight)),
187
+ isFull: isFull,
188
+ loading: loading,
189
+ useTintColor: useTintColor
190
+ )
191
+ }
192
+
165
193
  private func shouldLoadingOnLeft(_ left: AnyView?, _ right: AnyView?) -> Bool {
166
194
  let hasLeft = left != nil
167
195
  let hasRight = right != nil
@@ -214,14 +242,15 @@ public struct Button: View {
214
242
  .frame(maxWidth: isFull ? .infinity : nil)
215
243
  .padding(.horizontal, size.padding)
216
244
  .frame(height: size.height)
217
- .background(bg)
218
- .overlay(
219
- RoundedRectangle(cornerRadius: size.radius)
220
- .strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
221
- )
222
- .clipShape(RoundedRectangle(cornerRadius: size.radius))
223
245
  }
224
- .buttonStyle(ButtonPressFeedbackStyle(isEnabled: isEnabled))
246
+ .buttonStyle(ButtonPressFeedbackStyle(
247
+ isEnabled: isEnabled,
248
+ background: bg,
249
+ border: border,
250
+ borderWidth: borderWidth,
251
+ radius: size.radius,
252
+ height: size.height
253
+ ))
225
254
  .disabled(!isEnabled)
226
255
  }
227
256
 
@@ -238,12 +267,39 @@ public struct Button: View {
238
267
  }
239
268
  }
240
269
 
270
+ /// Mirrors Compose's press feedback: the background shrinks via an animated
271
+ /// horizontal inset while the content fades, rather than a plain opacity dim on
272
+ /// the whole button (matching `animatedPadding`/`alpha` in Button.kt).
241
273
  private struct ButtonPressFeedbackStyle: ButtonStyle {
242
274
  let isEnabled: Bool
275
+ let background: Color
276
+ let border: Color?
277
+ let borderWidth: CGFloat
278
+ let radius: CGFloat
279
+ let height: CGFloat
243
280
 
244
281
  func makeBody(configuration: Configuration) -> some View {
245
- configuration.label
246
- .opacity(configuration.isPressed && isEnabled ? 0.5 : 1)
247
- .animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
282
+ let pressed = configuration.isPressed && isEnabled
283
+
284
+ ZStack {
285
+ RoundedRectangle(cornerRadius: radius)
286
+ .fill(background)
287
+ .overlay(
288
+ RoundedRectangle(cornerRadius: radius)
289
+ .strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
290
+ )
291
+ .padding(.horizontal, pressed ? 2 : 0)
292
+
293
+ configuration.label
294
+ .opacity(pressed ? 0.5 : 1)
295
+ }
296
+ // Pin the whole button to its intended height explicitly: the bare
297
+ // RoundedRectangle background has no intrinsic size of its own, so left
298
+ // unconstrained in a ZStack inside a non-height-constraining container
299
+ // (e.g. a footer VStack) it can report an oversized ideal height and
300
+ // stretch the button — and its container — far taller than intended.
301
+ .frame(height: height)
302
+ .clipShape(RoundedRectangle(cornerRadius: radius))
303
+ .animation(.easeInOut(duration: 0.1), value: pressed)
248
304
  }
249
305
  }
@@ -94,12 +94,27 @@ public struct Carousel<Content: View>: View {
94
94
  // Offset so the current page is centered within the viewport padding.
95
95
  let baseOffset = -CGFloat(currentPage) * step + contentPadding
96
96
 
97
+ // Windows the rendered range around `currentPage` instead of instantiating all
98
+ // `virtualPageCount` slides up front — with `loop=true`, that's `itemCount * 1000`
99
+ // eager views, a real memory/perf regression versus Compose's lazy `HorizontalPager`.
100
+ // Clear spacers of the skipped width keep the HStack's total layout size (and thus
101
+ // the existing `baseOffset` math) unchanged.
102
+ let windowRadius = 3
103
+ let windowStart = max(0, currentPage - windowRadius)
104
+ let windowEnd = min(virtualPageCount - 1, currentPage + windowRadius)
105
+
97
106
  ZStack {
98
107
  HStack(spacing: 0) {
99
- ForEach(0 ..< virtualPageCount, id: \.self) { page in
108
+ if windowStart > 0 {
109
+ Color.clear.frame(width: CGFloat(windowStart) * step)
110
+ }
111
+ ForEach(windowStart...windowEnd, id: \.self) { page in
100
112
  slide(page: page, step: step, viewportWidth: viewportWidth)
101
113
  .frame(width: pageWidth)
102
114
  }
115
+ if windowEnd < virtualPageCount - 1 {
116
+ Color.clear.frame(width: CGFloat(virtualPageCount - 1 - windowEnd) * step)
117
+ }
103
118
  }
104
119
  .offset(x: baseOffset + dragOffset)
105
120
  .animation(isDragging ? nil : .easeInOut(duration: 0.25), value: currentPage)