@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
@@ -19,7 +19,7 @@ private struct RenderRightIcon: View {
19
19
  // Mirrors Compose's two independent `if` blocks: loading spinner and icon
20
20
  // can both render at once, not exclusively (not an if/else).
21
21
  if loading {
22
- ActivityIndicator(isAnimating: .constant(true), style: .medium)
22
+ ActivityIndicator(isAnimating: .constant(true), style: .medium, color: color)
23
23
  .frame(width: 16, height: 16)
24
24
  }
25
25
  if !icon.isEmpty {
@@ -98,7 +98,7 @@ public struct InputDropDown: View {
98
98
  }
99
99
 
100
100
  public var body: some View {
101
- VStack(alignment: .leading, spacing: 4) {
101
+ VStack(alignment: .leading, spacing: 0) {
102
102
  ZStack(alignment: .topLeading) {
103
103
  // Floating label
104
104
  if !floatingValue.isEmpty || !floatingIcon.isEmpty {
@@ -114,7 +114,7 @@ public struct InputDropDown: View {
114
114
  }
115
115
  .padding(.horizontal, Spacing.S)
116
116
  .background(theme.colors.background.surface)
117
- .offset(x: Spacing.S, y: -8)
117
+ .offset(x: Spacing.S, y: -Spacing.S)
118
118
  .zIndex(10)
119
119
  }
120
120
 
@@ -130,6 +130,7 @@ public struct InputDropDown: View {
130
130
  typography: .bodyDefaultRegular,
131
131
  color: value.isEmpty ? placeholderColor : textColor
132
132
  )
133
+ .fontSize(16, lineHeight: 24)
133
134
  .frame(maxWidth: .infinity, alignment: .leading)
134
135
 
135
136
  RenderRightIcon(
@@ -146,10 +147,6 @@ public struct InputDropDown: View {
146
147
  .stroke(getBorderColor(isFocused: false, error: error, disabled: disabled), lineWidth: 1)
147
148
  )
148
149
  }
149
- .contentShape(Rectangle())
150
- .onTapGesture {
151
- if !disabled { onPress() }
152
- }
153
150
 
154
151
  ErrorView(
155
152
  errorMessage: error,
@@ -157,8 +154,14 @@ public struct InputDropDown: View {
157
154
  hintText: hintText
158
155
  )
159
156
  }
157
+ // Compose makes the whole column clickable, error/hint row included.
158
+ .contentShape(Rectangle())
159
+ .onTapGesture {
160
+ if !disabled { onPress() }
161
+ }
160
162
  .accessibilityElement(children: .combine)
161
163
  .accessibility(label: Text(floatingValue))
164
+ .accessibilityIdentifier(disabled ? "input_\(floatingValue)_disabled" : "input_\(floatingValue)")
162
165
  }
163
166
 
164
167
  // MARK: - Computed colors
@@ -3,9 +3,8 @@
3
3
  // MoMoUIKits
4
4
  //
5
5
  // Native SwiftUI reimplementation of Compose `InputMoney`.
6
- // The bound `text` holds RAW DIGITS only (mirrors Compose, where the
7
- // field stores the numeric string and a VisualTransformation renders the
8
- // thousands-separated currency). Display formatting is applied live.
6
+ // The bound `text` holds RAW DIGITS only, as in Compose; the field shows
7
+ // the thousands-separated currency and edits apply at the caret.
9
8
  //
10
9
 
11
10
  import Foundation
@@ -165,17 +164,18 @@ public struct InputMoney: View {
165
164
  // MARK: - Body
166
165
 
167
166
  public var body: some View {
168
- VStack(alignment: .leading, spacing: 4) {
167
+ VStack(alignment: .leading, spacing: 0) {
169
168
  ZStack(alignment: .topLeading) {
170
169
  // Floating label
171
170
  if !floatingValue.isEmpty || !floatingIcon.isEmpty {
172
- HStack(spacing: Spacing.XS) {
171
+ HStack(spacing: 0) {
173
172
  MomoText(floatingValue, typography: .labelSMedium, color: floatingTitleColor)
174
173
  if required {
175
174
  MomoText("*", typography: .labelSMedium, color: theme.colors.error.primary)
176
175
  }
177
176
  if !floatingIcon.isEmpty {
178
177
  Icon(source: floatingIcon, size: 16, color: floatingIconTintColor)
178
+ .padding(.leading, Spacing.XS)
179
179
  }
180
180
  }
181
181
  .padding(.horizontal, Spacing.S)
@@ -204,17 +204,19 @@ public struct InputMoney: View {
204
204
 
205
205
  // Money field
206
206
  ZStack(alignment: .leading) {
207
- if text.filter({ $0.isNumber }).isEmpty {
207
+ if !hasDigits {
208
208
  MomoText(
209
209
  placeholder,
210
210
  typography: fontWeight == .bold ? .actionSBold : .bodyDefaultRegular,
211
211
  color: placeholderColor
212
212
  )
213
+ .fontSize(20, lineHeight: 24)
213
214
  }
214
215
  MoneyTextField(
215
216
  digits: $text,
216
217
  keyboardType: keyboardType,
217
218
  fontWeight: fontWeight == .bold ? .bold : .regular,
219
+ fontFamily: theme.font,
218
220
  textColor: UIColor(textColor),
219
221
  cursorColor: UIColor(theme.colors.primary),
220
222
  isDisabled: disabled,
@@ -225,7 +227,7 @@ public struct InputMoney: View {
225
227
  .frame(maxWidth: .infinity, alignment: .leading)
226
228
 
227
229
  // Clear button (focused + has value)
228
- if isFocused && !text.filter({ $0.isNumber }).isEmpty {
230
+ if isFocused && hasDigits {
229
231
  SwiftUI.Button(action: {
230
232
  text = ""
231
233
  onChangeText?("")
@@ -239,7 +241,7 @@ public struct InputMoney: View {
239
241
  // Loading indicator and right icon can both render (mirrors
240
242
  // Compose's two independent `if` blocks, not if/else).
241
243
  if loading {
242
- ActivityIndicator(isAnimating: .constant(true), style: .medium)
244
+ ActivityIndicator(isAnimating: .constant(true), style: .medium, color: iconTintColor)
243
245
  .frame(width: 16, height: 16)
244
246
  .padding(.leading, Spacing.S)
245
247
  }
@@ -264,6 +266,9 @@ public struct InputMoney: View {
264
266
 
265
267
  ErrorView(errorMessage: error, errorSpacing: errorSpacing, hintText: "")
266
268
  }
269
+ .accessibilityElement(children: .contain)
270
+ .accessibilityLabel(floatingValue)
271
+ .accessibilityIdentifier(disabled ? "input_\(floatingValue)_disabled" : "input_\(floatingValue)")
267
272
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
268
273
  if isFocused {
269
274
  DispatchQueue.main.async { handleFocusChange(false) }
@@ -273,6 +278,8 @@ public struct InputMoney: View {
273
278
 
274
279
  // MARK: - Helpers
275
280
 
281
+ private var hasDigits: Bool { text.contains(where: isMoneyDigit) }
282
+
276
283
  private func handleFocusChange(_ focused: Bool) {
277
284
  guard isFocused != focused else { return }
278
285
  isFocused = focused
@@ -303,6 +310,7 @@ private struct MoneyTextField: UIViewRepresentable {
303
310
  @Binding var digits: String
304
311
  var keyboardType: UIKeyboardType
305
312
  var fontWeight: UIFont.Weight
313
+ var fontFamily: String
306
314
  var textColor: UIColor
307
315
  var cursorColor: UIColor
308
316
  var isDisabled: Bool
@@ -315,7 +323,7 @@ private struct MoneyTextField: UIViewRepresentable {
315
323
  let field = UITextField()
316
324
  field.delegate = context.coordinator
317
325
  field.keyboardType = keyboardType
318
- field.font = UIFont.systemFont(ofSize: context.environment.momoFontScale(20), weight: fontWeight)
326
+ field.font = font(context)
319
327
  field.textColor = textColor
320
328
  field.isEnabled = !isDisabled
321
329
  field.tintColor = cursorColor
@@ -328,10 +336,14 @@ private struct MoneyTextField: UIViewRepresentable {
328
336
  func updateUIView(_ field: UITextField, context: Context) {
329
337
  context.coordinator.parent = self
330
338
  // Re-applied here, not just in makeUIView, so a font-scale change reaches an existing field.
331
- field.font = UIFont.systemFont(ofSize: context.environment.momoFontScale(20), weight: fontWeight)
339
+ field.font = font(context)
332
340
  field.isEnabled = !isDisabled
333
341
  field.textColor = textColor
334
342
  field.tintColor = cursorColor
343
+ if field.keyboardType != keyboardType {
344
+ field.keyboardType = keyboardType
345
+ field.reloadInputViews()
346
+ }
335
347
  let expected = displayValue(digits)
336
348
  if field.text != expected {
337
349
  field.text = expected
@@ -340,10 +352,20 @@ private struct MoneyTextField: UIViewRepresentable {
340
352
  }
341
353
 
342
354
  fileprivate func displayValue(_ raw: String) -> String {
343
- let numeric = raw.filter { $0.isNumber }
355
+ let numeric = raw.filter(isMoneyDigit)
344
356
  return numeric.isEmpty ? "" : formatNumberToMoney(numeric)
345
357
  }
346
358
 
359
+ // Theme family as Compose `getFontFamily`: Momo Trust Sans only for whitelisted hosts, else system.
360
+ private func font(_ context: Context) -> UIFont {
361
+ let size = context.environment.momoFontScale(20)
362
+ if KitDesignSystem.shared.isWhiteList, fontFamily == "MomoTrustSans",
363
+ let font = UIFont(name: fontWeight == .bold ? "MomoTrustSans-Bold" : "MomoTrustSans-Regular", size: size) {
364
+ return font
365
+ }
366
+ return .systemFont(ofSize: size, weight: fontWeight)
367
+ }
368
+
347
369
  final class Coordinator: NSObject, UITextFieldDelegate {
348
370
  var parent: MoneyTextField
349
371
 
@@ -48,6 +48,7 @@ public struct InputOTP: View {
48
48
 
49
49
  @FocusState private var isFocused: Bool
50
50
  @State private var hasBeenFocused: Bool = false
51
+ @State private var fieldText: String
51
52
 
52
53
  private var maxLength: Int { length ?? 10 }
53
54
 
@@ -64,6 +65,7 @@ public struct InputOTP: View {
64
65
  onBlur: (() -> Void)? = nil
65
66
  ) {
66
67
  self._value = value
68
+ self._fieldText = State(initialValue: value.wrappedValue)
67
69
  self.length = length
68
70
  self.errorMessage = errorMessage
69
71
  self.errorSpacing = errorSpacing
@@ -105,7 +107,7 @@ public struct InputOTP: View {
105
107
  HStack {
106
108
  Spacer()
107
109
  SwiftUI.Button(action: clearText) {
108
- Icon(source: "24_navigation_close_circle_full", size: 12, color: theme.colors.text.hint)
110
+ Icon(source: "24_navigation_close_circle_full", size: 12, color: theme.colors.text.default)
109
111
  .padding(.trailing, 12)
110
112
  }
111
113
  }
@@ -122,17 +124,24 @@ public struct InputOTP: View {
122
124
  // MARK: Subviews
123
125
 
124
126
  private var hiddenField: some View {
125
- TextField("", text: Binding(
126
- get: { value },
127
- set: { handleChangeText($0) }
128
- ))
127
+ TextField("", text: $fieldText)
129
128
  .keyboardType(dataType == "number" ? .numberPad : .asciiCapable)
129
+ .textInputAutocapitalization(.never)
130
+ .autocorrectionDisabled()
130
131
  .focused($isFocused)
131
132
  .foregroundColor(.clear)
132
133
  .accentColor(.clear)
133
134
  .frame(maxWidth: .infinity)
134
135
  .frame(height: 56)
135
136
  .opacity(0.01)
137
+ // Writes a rejected edit back, so the field stays in step with `value` like Compose's
138
+ // controlled field instead of keeping the extra character and swallowing the next key.
139
+ .onChange(of: fieldText) { text in
140
+ if text != value && !handleChangeText(text) { fieldText = value }
141
+ }
142
+ .onChange(of: value) { newValue in
143
+ if fieldText != newValue { fieldText = newValue }
144
+ }
136
145
  .onChange(of: isFocused) { focused in
137
146
  if focused {
138
147
  onFocus?()
@@ -147,7 +156,7 @@ public struct InputOTP: View {
147
156
  private var cells: some View {
148
157
  if let length = length {
149
158
  HStack(spacing: Spacing.L) {
150
- ForEach(0 ..< length, id: \.self) { i in
159
+ ForEach(0 ..< max(length, 0), id: \.self) { i in
151
160
  cell(at: i)
152
161
  }
153
162
  }
@@ -155,7 +164,7 @@ public struct InputOTP: View {
155
164
  } else {
156
165
  HStack(spacing: Spacing.L) {
157
166
  if value.isEmpty && !isFocused && !placeholder.isEmpty {
158
- MomoText(placeholder, typography: .descriptionDefaultRegular, color: theme.colors.text.hint)
167
+ MomoText(placeholder, typography: .bodyDefaultRegular, color: theme.colors.text.hint)
159
168
  } else {
160
169
  ForEach(Array(value.enumerated()), id: \.offset) { _, ch in
161
170
  MomoText(String(ch), typography: .headerDefaultBold, color: theme.colors.text.default)
@@ -186,13 +195,14 @@ public struct InputOTP: View {
186
195
 
187
196
  // MARK: Logic
188
197
 
189
- private func handleChangeText(_ text: String) {
198
+ @discardableResult
199
+ private func handleChangeText(_ text: String) -> Bool {
190
200
  let validData = dataType != "number" || text.allSatisfy { $0.isNumber }
191
201
  let changed = text.count != value.count
192
- if text.count <= maxLength && validData && changed {
193
- value = text
194
- onChangeText?(text)
195
- }
202
+ guard text.count <= maxLength && validData && changed else { return false }
203
+ value = text
204
+ onChangeText?(text)
205
+ return true
196
206
  }
197
207
 
198
208
  private func clearText() {
@@ -0,0 +1,50 @@
1
+ import SwiftUI
2
+
3
+ /// Rounded surface laid out on the kit grid (8pt gutter), inset by the screen margin.
4
+ /// Mirrors `vn.momo.kits.layout.Card`.
5
+ public struct CardView<Content: View>: View {
6
+ @Environment(\.appTheme) private var theme
7
+
8
+ // MARK: Lifecycle
9
+
10
+ public init(
11
+ useShadow: Bool = false,
12
+ backgroundImage: String? = nil,
13
+ backgroundColor: Color? = nil,
14
+ contentPadding: EdgeInsets = EdgeInsets(top: Spacing.M, leading: Spacing.M, bottom: Spacing.M, trailing: Spacing.M),
15
+ @ViewBuilder content: @escaping () -> Content
16
+ ) {
17
+ self.useShadow = useShadow
18
+ self.backgroundImage = backgroundImage
19
+ self.backgroundColor = backgroundColor
20
+ self.contentPadding = contentPadding
21
+ self.content = content
22
+ }
23
+
24
+ // MARK: Public
25
+
26
+ public var body: some View {
27
+ GridFlow(gutter: Spacing.S, content: content)
28
+ .padding(contentPadding)
29
+ .background(background)
30
+ .background(backgroundColor ?? theme.colors.background.surface)
31
+ .clipShape(RoundedRectangle(cornerRadius: Radius.M))
32
+ // Approximates Compose `shadow(6.dp)` elevation.
33
+ .shadow(color: Color.black.opacity(useShadow ? 0.12 : 0), radius: useShadow ? 6 : 0, x: 0, y: useShadow ? 3 : 0)
34
+ .padding(.horizontal, Spacing.M)
35
+ }
36
+
37
+ // MARK: Private
38
+
39
+ private let useShadow: Bool
40
+ private let backgroundImage: String?
41
+ private let backgroundColor: Color?
42
+ private let contentPadding: EdgeInsets
43
+ private let content: () -> Content
44
+
45
+ @ViewBuilder private var background: some View {
46
+ if let backgroundImage {
47
+ ImageView(backgroundImage, contentMode: .fill).clipped()
48
+ }
49
+ }
50
+ }
@@ -1,20 +1,31 @@
1
1
  import SwiftUI
2
2
 
3
- /// Grid metrics published by a layout container (`SectionView`) so span-based children
3
+ /// Grid metrics published by a layout container (`SectionView`, `CardView`) so span-based children
4
4
  /// can size themselves. Mirrors `vn.momo.kits.layout.GridContext`.
5
5
  public struct GridContext: Equatable {
6
6
  public let numberOfColumns: Int
7
7
  public let gutter: CGFloat
8
8
  public let sizePerSpan: CGFloat
9
+ /// Pixels per point; span sizes are floored onto the pixel grid when it is set.
10
+ public let displayScale: CGFloat
9
11
 
10
- public init(numberOfColumns: Int = 12, gutter: CGFloat = Spacing.M, sizePerSpan: CGFloat = 0) {
12
+ public init(
13
+ numberOfColumns: Int = 12,
14
+ gutter: CGFloat = Spacing.M,
15
+ sizePerSpan: CGFloat = 0,
16
+ displayScale: CGFloat = 0
17
+ ) {
11
18
  self.numberOfColumns = numberOfColumns
12
19
  self.gutter = gutter
13
20
  self.sizePerSpan = sizePerSpan
21
+ self.displayScale = displayScale
14
22
  }
15
23
 
16
24
  public func sizeForSpan(_ span: Int) -> CGFloat {
17
- sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
25
+ let size = sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
26
+ guard displayScale > 0 else { return size }
27
+ // Compose floors in pixels so a full row of spans never overflows by a sub-pixel.
28
+ return floor(size * displayScale) / displayScale
18
29
  }
19
30
  }
20
31
 
@@ -28,3 +39,112 @@ public extension EnvironmentValues {
28
39
  set { self[GridContextKey.self] = newValue }
29
40
  }
30
41
  }
42
+
43
+ /// Measures the width it is given, publishes the grid for it and wraps children in a flow row.
44
+ /// Shared body of `SectionView` and `CardView` (Compose `gridColumns()` + `gridContext()` + `FlowRow`).
45
+ struct GridFlow<Content: View>: View {
46
+ let gutter: CGFloat
47
+ let content: () -> Content
48
+
49
+ @Environment(\.screenLayout) private var screenLayout
50
+ @Environment(\.displayScale) private var displayScale
51
+ /// The Compose `BoxWithConstraints` width.
52
+ @State private var availableWidth: CGFloat = 0
53
+
54
+ var body: some View {
55
+ flow
56
+ .environment(\.gridContext, grid)
57
+ .frame(maxWidth: .infinity, alignment: .topLeading)
58
+ .background(widthReader)
59
+ }
60
+
61
+ private var grid: GridContext {
62
+ let columns = screenLayout.contentWidth > 600 ? 24 : 12
63
+ let column = (availableWidth - gutter * CGFloat(columns - 1)) / CGFloat(columns)
64
+ return GridContext(
65
+ numberOfColumns: columns,
66
+ gutter: gutter,
67
+ sizePerSpan: max(0, column),
68
+ displayScale: displayScale
69
+ )
70
+ }
71
+
72
+ @ViewBuilder private var flow: some View {
73
+ if #available(iOS 16.0, *) {
74
+ FlowRowLayout(gutter: gutter) { content() }
75
+ } else {
76
+ // No `Layout` protocol before iOS 16: children stack instead of wrapping.
77
+ // Full-width items — the common case — look identical.
78
+ VStack(alignment: .leading, spacing: gutter) { content() }
79
+ }
80
+ }
81
+
82
+ private var widthReader: some View {
83
+ GeometryReader { proxy in
84
+ Color.clear.preference(key: GridWidthKey.self, value: proxy.size.width)
85
+ }
86
+ .onPreferenceChange(GridWidthKey.self) { availableWidth = $0 }
87
+ }
88
+ }
89
+
90
+ private struct GridWidthKey: PreferenceKey {
91
+ static var defaultValue: CGFloat = 0
92
+
93
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
94
+ value = max(value, nextValue())
95
+ }
96
+ }
97
+
98
+ /// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
99
+ @available(iOS 16.0, *)
100
+ private struct FlowRowLayout: Layout {
101
+ struct Row {
102
+ var items: [(index: Int, size: CGSize)] = []
103
+ var width: CGFloat = 0
104
+ var height: CGFloat = 0
105
+ }
106
+
107
+ let gutter: CGFloat
108
+
109
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
110
+ let maxWidth = proposal.width ?? .infinity
111
+ let rows = rows(for: subviews, maxWidth: maxWidth)
112
+ let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
113
+ return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
114
+ }
115
+
116
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
117
+ var y = bounds.minY
118
+ for row in rows(for: subviews, maxWidth: bounds.width) {
119
+ var x = bounds.minX
120
+ for item in row.items {
121
+ subviews[item.index].place(
122
+ at: CGPoint(x: x, y: y),
123
+ anchor: .topLeading,
124
+ proposal: ProposedViewSize(item.size)
125
+ )
126
+ x += item.size.width + gutter
127
+ }
128
+ y += row.height + gutter
129
+ }
130
+ }
131
+
132
+ private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
133
+ var rows: [Row] = []
134
+ var current = Row()
135
+ for index in subviews.indices {
136
+ let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
137
+ let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
138
+ // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
139
+ if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
140
+ rows.append(current)
141
+ current = Row()
142
+ }
143
+ current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
144
+ current.height = max(current.height, size.height)
145
+ current.items.append((index, size))
146
+ }
147
+ if !current.items.isEmpty { rows.append(current) }
148
+ return rows
149
+ }
150
+ }
@@ -1,12 +1,13 @@
1
1
  import SwiftUI
2
2
 
3
- /// Span-sized cell inside a `SectionView`. Reads the grid published by its container;
3
+ /// Span-sized cell inside a `SectionView` / `CardView`. Reads the grid published by its container;
4
4
  /// without one it just wraps its content. Mirrors `vn.momo.kits.layout.Item`.
5
5
  public struct ItemView<Content: View>: View {
6
6
  // MARK: Lifecycle
7
7
 
8
+ /// `widthSpan` nil spans the full row, whatever the container's column count.
8
9
  public init(
9
- widthSpan: Int = 12,
10
+ widthSpan: Int? = nil,
10
11
  heightSpan: Int = 0,
11
12
  @ViewBuilder content: @escaping () -> Content
12
13
  ) {
@@ -26,14 +27,14 @@ public struct ItemView<Content: View>: View {
26
27
 
27
28
  // MARK: Private
28
29
 
29
- private let widthSpan: Int
30
+ private let widthSpan: Int?
30
31
  private let heightSpan: Int
31
32
  private let content: () -> Content
32
33
 
33
34
  @Environment(\.gridContext) private var grid
34
35
 
35
36
  private var width: CGFloat? {
36
- grid.map { $0.sizeForSpan(widthSpan) }
37
+ grid.map { $0.sizeForSpan(widthSpan ?? $0.numberOfColumns) }
37
38
  }
38
39
 
39
40
  private var height: CGFloat? {