@momo-kits/native-kits 0.162.30-debug → 0.162.31-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 (93) hide show
  1. package/CLAUDE.md +19 -0
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/gradle.properties +1 -1
  5. package/ios/Application/ApplicationEnvironment.swift +14 -6
  6. package/ios/Application/LanguageSource.swift +93 -0
  7. package/ios/Application/Localize.swift +73 -4
  8. package/ios/Application/LocalizeModifier.swift +30 -0
  9. package/ios/Application/Navigation/NavigationContainer.swift +28 -18
  10. package/ios/Application/Navigation/Navigator.swift +121 -53
  11. package/ios/Application/Navigation/NavigatorManager.swift +49 -0
  12. package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
  13. package/ios/Application/StackScreen.swift +19 -28
  14. package/ios/Badge/Badge.swift +2 -1
  15. package/ios/Badge/BadgeRibbon.swift +6 -2
  16. package/ios/Button/Button.swift +41 -61
  17. package/ios/Chip/Chip.swift +6 -5
  18. package/ios/Icon/Icon.swift +13 -4
  19. package/ios/Input/ErrorView.swift +2 -1
  20. package/ios/Input/Input.swift +9 -2
  21. package/ios/Input/InputPhoneNumber.swift +2 -1
  22. package/ios/Input/InputSearch.swift +3 -2
  23. package/ios/Input/InputTextArea.swift +6 -1
  24. package/ios/InputMoney/InputMoney.swift +5 -2
  25. package/ios/InputOTP/InputOTP.swift +2 -1
  26. package/ios/Layout/GridContext.swift +30 -0
  27. package/ios/Layout/Item.swift +42 -0
  28. package/ios/Layout/Section.swift +134 -0
  29. package/ios/Popup/PopupNotify.swift +2 -1
  30. package/ios/ProgressInfo/ProgressInfo.swift +4 -3
  31. package/ios/Rating/Rating.swift +2 -1
  32. package/ios/Stepper/Stepper.swift +2 -1
  33. package/ios/SuggestAction/SuggestAction.swift +3 -2
  34. package/ios/Tag/Tag.swift +2 -1
  35. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  36. package/ios/Title/Title.swift +3 -2
  37. package/ios/Tooltip/Tooltip.swift +2 -1
  38. package/ios/Typography/FontScale.swift +103 -0
  39. package/ios/Typography/FontScaleStore.swift +52 -13
  40. package/ios/Typography/Text.swift +12 -29
  41. package/ios/Typography/Typography.swift +66 -86
  42. package/ios/native-kits.podspec +2 -1
  43. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
  47. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
  48. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
  49. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
  50. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
  51. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
  52. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
  53. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
  54. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
  55. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
  56. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
  57. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
  58. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
  59. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
  60. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
  61. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
  62. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
  63. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
  64. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
  65. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
  66. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
  67. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
  68. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
  69. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
  70. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
  71. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
  72. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
  73. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
  74. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
  75. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
  76. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
  77. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
  78. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
  79. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
  80. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
  81. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
  82. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
  83. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
  84. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
  85. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
  86. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
  87. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
  88. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
  89. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
  90. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
  91. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
  92. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
  93. package/package.json +2 -2
@@ -133,19 +133,19 @@ public struct Button: View {
133
133
  var action: () -> Void
134
134
  var type: ButtonType
135
135
  var size: ButtonSize
136
- var iconLeft: AnyView?
137
- var iconRight: AnyView?
136
+ var iconLeft: String
137
+ var iconRight: String
138
138
  var isFull: Bool
139
139
  var loading: Bool
140
140
  var useTintColor: Bool
141
141
 
142
142
  public init(
143
- title: String = "",
143
+ title: String = "Button",
144
144
  action: @escaping () -> Void,
145
145
  type: ButtonType = .primary,
146
146
  size: ButtonSize = .large,
147
- iconLeft: AnyView? = nil,
148
- iconRight: AnyView? = nil,
147
+ iconLeft: String = "",
148
+ iconRight: String = "",
149
149
  isFull: Bool = true,
150
150
  loading: Bool = false,
151
151
  useTintColor: Bool = true
@@ -161,38 +161,9 @@ public struct Button: View {
161
161
  self.useTintColor = useTintColor
162
162
  }
163
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
-
193
- private func shouldLoadingOnLeft(_ left: AnyView?, _ right: AnyView?) -> Bool {
194
- let hasLeft = left != nil
195
- let hasRight = right != nil
164
+ private func shouldLoadingOnLeft(_ left: String, _ right: String) -> Bool {
165
+ let hasLeft = !left.isEmpty
166
+ let hasRight = !right.isEmpty
196
167
 
197
168
  switch (hasLeft, hasRight) {
198
169
  case (false, false): return true
@@ -217,27 +188,13 @@ public struct Button: View {
217
188
  }
218
189
  }) {
219
190
  HStack(spacing: size.iconSpacing) {
220
- if loading && loadingOnLeft {
221
- LottieView(name: "lottie_circle_loader", loopMode: .loop)
222
- .frame(width: size.iconSize, height: size.iconSize)
223
- .colorMultiply(fg)
224
-
225
- } else if let iconLeft = iconLeft {
226
- tintedIcon(iconLeft, color: fg)
227
- }
191
+ renderIcon(icon: iconLeft, forceLoading: loading && loadingOnLeft, textColor: fg)
228
192
 
229
193
  MomoText(title, typography: size.typographyStyle, color: fg)
230
194
  .lineLimit(1)
231
195
  .truncationMode(.tail)
232
196
 
233
- if loading && !loadingOnLeft {
234
- LottieView(name: "lottie_circle_loader", loopMode: .loop)
235
- .frame(width: size.iconSize, height: size.iconSize)
236
- .colorMultiply(fg)
237
-
238
- } else if let iconRight = iconRight {
239
- tintedIcon(iconRight, color: fg)
240
- }
197
+ renderIcon(icon: iconRight, forceLoading: loading && !loadingOnLeft, textColor: fg)
241
198
  }
242
199
  .frame(maxWidth: isFull ? .infinity : nil)
243
200
  .padding(.horizontal, size.padding)
@@ -249,20 +206,24 @@ public struct Button: View {
249
206
  border: border,
250
207
  borderWidth: borderWidth,
251
208
  radius: size.radius,
252
- height: size.height
209
+ height: size.height,
210
+ isFull: isFull
253
211
  ))
254
212
  .disabled(!isEnabled)
255
213
  }
256
214
 
215
+ /// Mirrors Kotlin `RenderIcon`: the loader replaces the icon slot entirely, and
216
+ /// `useTintColor == false` leaves the icon at its own colors.
257
217
  @ViewBuilder
258
- private func tintedIcon(_ icon: AnyView, color: Color) -> some View {
259
- if useTintColor {
260
- icon
261
- .frame(width: size.iconSize, height: size.iconSize)
262
- .colorMultiply(color)
263
- } else {
264
- icon
218
+ private func renderIcon(icon: String, forceLoading: Bool, textColor: Color) -> some View {
219
+ let color: Color? = useTintColor ? textColor : nil
220
+
221
+ if forceLoading {
222
+ LottieView(name: "lottie_circle_loader", loopMode: .loop)
265
223
  .frame(width: size.iconSize, height: size.iconSize)
224
+ .colorMultiply(color ?? Colors.black01)
225
+ } else if !icon.isEmpty {
226
+ Icon(source: icon, size: size.iconSize, color: color)
266
227
  }
267
228
  }
268
229
  }
@@ -277,11 +238,13 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
277
238
  let borderWidth: CGFloat
278
239
  let radius: CGFloat
279
240
  let height: CGFloat
241
+ let isFull: Bool
280
242
 
281
243
  func makeBody(configuration: Configuration) -> some View {
282
244
  let pressed = configuration.isPressed && isEnabled
283
245
 
284
246
  configuration.label
247
+ .modifier(HugContentOnLegacyOS(isFull: isFull))
285
248
  .opacity(pressed ? 0.5 : 1)
286
249
  .background(
287
250
  RoundedRectangle(cornerRadius: radius)
@@ -297,3 +260,20 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
297
260
  .animation(.easeInOut(duration: 0.1), value: pressed)
298
261
  }
299
262
  }
263
+
264
+ /// `ButtonStyleConfiguration.Label` is greedy on iOS 16 and earlier — it accepts
265
+ /// the full proposed width, so the background drawn in `makeBody` stretches past
266
+ /// the content even when `isFull == false`. iOS 17 fixed the label to hug its
267
+ /// content, so only legacy OSes need the `fixedSize` clamp (kept off on 17+ to
268
+ /// preserve tail truncation of long titles).
269
+ private struct HugContentOnLegacyOS: ViewModifier {
270
+ let isFull: Bool
271
+
272
+ func body(content: Content) -> some View {
273
+ if #available(iOS 17.0, *) {
274
+ content
275
+ } else {
276
+ content.fixedSize(horizontal: !isFull, vertical: false)
277
+ }
278
+ }
279
+ }
@@ -3,6 +3,7 @@ import Foundation
3
3
 
4
4
  public struct Chip: View {
5
5
  @Environment(\.appTheme) private var theme
6
+ @Environment(\.momoFontScale) private var fontScale
6
7
  public enum ChipSize { case small, large }
7
8
 
8
9
  public struct Dimensions {
@@ -54,11 +55,11 @@ public struct Chip: View {
54
55
  let dims = (size == .small) ? Self.Small : Self.Large
55
56
  // Mirrors Compose's `scaleSize(...)` on every chip dimension so it responds
56
57
  // to Dynamic Type scaling the same way the rest of the kit does.
57
- let height = scaleSize(dims.height)
58
- let horizontal = scaleSize(dims.horizontal)
59
- let iconSize = scaleSize(dims.iconSize)
60
- let iconSpacing = scaleSize(dims.iconSpacing)
61
- let radius = scaleSize(Radius.L)
58
+ let height = fontScale(dims.height)
59
+ let horizontal = fontScale(dims.horizontal)
60
+ let iconSize = fontScale(dims.iconSize)
61
+ let iconSpacing = fontScale(dims.iconSpacing)
62
+ let radius = fontScale(Radius.L)
62
63
 
63
64
  let bg: Color = {
64
65
  if selected { return theme.colors.background.selected }
@@ -26,17 +26,24 @@ let iconSources: [String: IconSource] = loadIconSources()
26
26
  // Icons that must never be tinted (mirror Compose `noThemeIcons`)
27
27
  let noThemeIcons: [String] = ["ic_round_momo_tiny"]
28
28
 
29
+ // Compose defaults `color` to the theme text color while `color = null` means "no tint".
30
+ // Swift can't tell an omitted argument from an explicit `nil`, so the default is this
31
+ // unrepresentable sentinel: omitted -> theme text color, `nil` -> untinted.
32
+ public let iconThemeColor = Color(.sRGB, red: -1, green: -1, blue: -1, opacity: -1)
33
+
29
34
  // Icon ViewModel
30
35
  public struct Icon: View {
36
+ @Environment(\.appTheme) private var theme
37
+
31
38
  var source: String
32
39
  var size: CGFloat
33
40
  var color: Color?
34
41
  var accessibilityLabel: String?
35
-
42
+
36
43
  public init(
37
44
  source: String,
38
45
  size: CGFloat = 24,
39
- color: Color? = nil,
46
+ color: Color? = iconThemeColor,
40
47
  accessibilityLabel: String? = nil
41
48
  ) {
42
49
  self.source = source
@@ -46,9 +53,11 @@ public struct Icon: View {
46
53
  }
47
54
 
48
55
  public var body: some View {
49
- if let uri = iconSources[source]?.uri {
56
+ // Mirror Compose: an https source is used as-is, anything else is a key into icon.json
57
+ if let uri = source.contains("https") ? source : iconSources[source]?.uri {
58
+ let requestedColor = color == iconThemeColor ? theme.colors.text.default : color
50
59
  // Mirror Compose: certain icons (e.g. branded glyphs) are never tinted
51
- let resolvedColor = noThemeIcons.contains(source) ? nil : color
60
+ let resolvedColor = noThemeIcons.contains(source) ? nil : requestedColor
52
61
  ImageView(uri, color: resolvedColor)
53
62
  .frame(width: size, height: size)
54
63
  // Mirrors Compose's auto-generated `contentDescription = "img|$iconUrl"`
@@ -9,6 +9,7 @@ import SwiftUI
9
9
 
10
10
  public struct ErrorView: View {
11
11
  @Environment(\.appTheme) private var theme
12
+ @Environment(\.momoFontScale) private var fontScale
12
13
 
13
14
  public var errorMessage: String
14
15
  public var errorSpacing: Bool
@@ -27,7 +28,7 @@ public struct ErrorView: View {
27
28
  public var body: some View {
28
29
  if !errorMessage.isEmpty || !hintText.isEmpty {
29
30
  HStack(alignment: .top, spacing: 4) {
30
- Icon(source: "ic_error", size: scaleSize(16), color: displayColor())
31
+ Icon(source: "ic_error", size: fontScale(16), color: displayColor())
31
32
  MomoText(
32
33
  displayText(),
33
34
  typography: .descriptionDefaultRegular,
@@ -4,6 +4,7 @@ import Combine
4
4
 
5
5
  public struct Input: View {
6
6
  @Environment(\.appTheme) private var theme
7
+ @Environment(\.momoFontScale) private var fontScale
7
8
  @Binding public var text: String
8
9
 
9
10
  public var placeholder: String
@@ -163,7 +164,7 @@ public struct Input: View {
163
164
  SecureInputField(
164
165
  text: $text,
165
166
  keyboardType: keyboardType,
166
- fontSize: scaleSize(14),
167
+ fontSize: fontScale(14),
167
168
  fontWeight: fontWeight == .bold ? .bold : .regular,
168
169
  textColor: UIColor(getTextColor()),
169
170
  cursorColor: UIColor(theme.colors.primary),
@@ -189,6 +190,12 @@ public struct Input: View {
189
190
  .onAppear {
190
191
  if autofocus { isFieldFocused = true }
191
192
  }
193
+ // SwiftUI reads a TextField's UIKit input traits only when it
194
+ // builds the backing UITextField, so a later `keyboardType`
195
+ // change is silently dropped. The secure path can call
196
+ // `reloadInputViews()` in `updateUIView`; here the only lever
197
+ // is to force a rebuild.
198
+ .id(keyboardType.rawValue)
192
199
  }
193
200
  }
194
201
 
@@ -230,7 +237,7 @@ public struct Input: View {
230
237
  }
231
238
  }
232
239
  .padding(.horizontal, Spacing.M)
233
- .frame(height: scaleSize(size == .small ? 48 : 56, 1.1))
240
+ .frame(height: fontScale(size == .small ? 48 : 56))
234
241
  .background(
235
242
  RoundedRectangle(cornerRadius: Radius.S)
236
243
  .fill(theme.colors.background.surface)
@@ -8,6 +8,7 @@
8
8
  import SwiftUI
9
9
  public struct InputPhoneNumber: View {
10
10
  @Environment(\.appTheme) private var theme
11
+ @Environment(\.momoFontScale) private var fontScale
11
12
  @Binding public var text: String
12
13
 
13
14
  public var placeholder: String
@@ -143,7 +144,7 @@ public struct InputPhoneNumber: View {
143
144
  }
144
145
  }
145
146
  .padding(.horizontal, Spacing.M)
146
- .frame(height: scaleSize(size == .small ? 48 : 56, 1.1))
147
+ .frame(height: fontScale(size == .small ? 48 : 56))
147
148
  .background(
148
149
  RoundedRectangle(cornerRadius: Radius.S)
149
150
  .fill(theme.colors.background.surface)
@@ -104,6 +104,7 @@ public struct InputSearchProps {
104
104
 
105
105
  public struct InputSearch: View {
106
106
  @Environment(\.appTheme) private var theme
107
+ @Environment(\.momoFontScale) private var fontScale
107
108
  @Binding var text: String
108
109
  var buttonText: String = ""
109
110
  var showButtonText: Bool = false
@@ -214,7 +215,7 @@ public struct InputSearch: View {
214
215
  } else {
215
216
  Text(placeholder)
216
217
  .foregroundColor(disabled ? theme.colors.text.disable : theme.colors.text.hint)
217
- .font(.system(size: scaleSize(14), weight: fontWeight))
218
+ .font(.system(size: fontScale(14), weight: fontWeight))
218
219
  .ignoreBoldTextSetting()
219
220
  }
220
221
  }
@@ -231,7 +232,7 @@ public struct InputSearch: View {
231
232
  }, onCommit: {})
232
233
  .disabled(disabled)
233
234
  .foregroundColor(disabled ? theme.colors.text.disable : theme.colors.text.default)
234
- .font(.system(size: scaleSize(14), weight: fontWeight))
235
+ .font(.system(size: fontScale(14), weight: fontWeight))
235
236
  .ignoreBoldTextSetting()
236
237
  .keyboardType(keyboardType)
237
238
  .applyCursorColor(theme.colors.primary)
@@ -37,7 +37,12 @@ struct UITextViewWrapper: UIViewRepresentable {
37
37
  }
38
38
  uiView.textColor = UIColor(textColor)
39
39
  uiView.tintColor = UIColor(cursorColor)
40
- uiView.font = UIFont.systemFont(ofSize: scaleSize(16), weight: fontWeight == .bold ? .bold : .regular)
40
+ // Read through `context.environment` rather than the global `scaleSize`: that is what makes
41
+ // SwiftUI re-run updateUIView when the font scale changes.
42
+ uiView.font = UIFont.systemFont(
43
+ ofSize: context.environment.momoFontScale(16),
44
+ weight: fontWeight == .bold ? .bold : .regular
45
+ )
41
46
  uiView.isEditable = !disabled
42
47
  }
43
48
 
@@ -50,6 +50,7 @@ public func formatNumberToMoney(_ digits: String, currency: String = "đ") -> St
50
50
 
51
51
  public struct InputMoney: View {
52
52
  @Environment(\.appTheme) private var theme
53
+ @Environment(\.momoFontScale) private var fontScale
53
54
  @Binding public var text: String
54
55
 
55
56
  public var floatingValue: String
@@ -214,7 +215,7 @@ public struct InputMoney: View {
214
215
  }
215
216
  }
216
217
  .padding(.horizontal, Spacing.M)
217
- .frame(height: scaleSize(size == .small ? 48 : 56, 1.1))
218
+ .frame(height: fontScale(size == .small ? 48 : 56))
218
219
  .background(
219
220
  RoundedRectangle(cornerRadius: Radius.S)
220
221
  .fill(theme.colors.background.surface)
@@ -278,7 +279,7 @@ private struct MoneyTextField: UIViewRepresentable {
278
279
  let field = UITextField()
279
280
  field.delegate = context.coordinator
280
281
  field.keyboardType = keyboardType
281
- field.font = UIFont.systemFont(ofSize: scaleSize(20), weight: fontWeight)
282
+ field.font = UIFont.systemFont(ofSize: context.environment.momoFontScale(20), weight: fontWeight)
282
283
  field.textColor = textColor
283
284
  field.isEnabled = !isDisabled
284
285
  field.tintColor = cursorColor
@@ -295,6 +296,8 @@ private struct MoneyTextField: UIViewRepresentable {
295
296
 
296
297
  func updateUIView(_ field: UITextField, context: Context) {
297
298
  context.coordinator.parent = self
299
+ // Re-applied here, not just in makeUIView, so a font-scale change reaches an existing field.
300
+ field.font = UIFont.systemFont(ofSize: context.environment.momoFontScale(20), weight: fontWeight)
298
301
  field.isEnabled = !isDisabled
299
302
  field.textColor = textColor
300
303
  field.tintColor = cursorColor
@@ -7,12 +7,13 @@ import SwiftUI
7
7
  private struct OTPCaret: View {
8
8
  @Environment(\.appTheme) private var theme
9
9
  @State private var visible: Bool = true
10
+ @Environment(\.momoFontScale) private var fontScale
10
11
 
11
12
  var body: some View {
12
13
  HStack(spacing: 0) {
13
14
  Rectangle()
14
15
  .fill(theme.colors.primary)
15
- .frame(width: 1, height: scaleSize(12))
16
+ .frame(width: 1, height: fontScale(12))
16
17
  .opacity(visible ? 1 : 0)
17
18
  .animation(
18
19
  .linear(duration: 0.5).repeatForever(autoreverses: true),
@@ -0,0 +1,30 @@
1
+ import SwiftUI
2
+
3
+ /// Grid metrics published by a layout container (`SectionView`) so span-based children
4
+ /// can size themselves. Mirrors `vn.momo.kits.layout.GridContext`.
5
+ public struct GridContext: Equatable {
6
+ public let numberOfColumns: Int
7
+ public let gutter: CGFloat
8
+ public let sizePerSpan: CGFloat
9
+
10
+ public init(numberOfColumns: Int = 12, gutter: CGFloat = Spacing.M, sizePerSpan: CGFloat = 0) {
11
+ self.numberOfColumns = numberOfColumns
12
+ self.gutter = gutter
13
+ self.sizePerSpan = sizePerSpan
14
+ }
15
+
16
+ public func sizeForSpan(_ span: Int) -> CGFloat {
17
+ sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
18
+ }
19
+ }
20
+
21
+ private struct GridContextKey: EnvironmentKey {
22
+ static let defaultValue: GridContext? = nil
23
+ }
24
+
25
+ public extension EnvironmentValues {
26
+ var gridContext: GridContext? {
27
+ get { self[GridContextKey.self] }
28
+ set { self[GridContextKey.self] = newValue }
29
+ }
30
+ }
@@ -0,0 +1,42 @@
1
+ import SwiftUI
2
+
3
+ /// Span-sized cell inside a `SectionView`. Reads the grid published by its container;
4
+ /// without one it just wraps its content. Mirrors `vn.momo.kits.layout.Item`.
5
+ public struct ItemView<Content: View>: View {
6
+ // MARK: Lifecycle
7
+
8
+ public init(
9
+ widthSpan: Int = 12,
10
+ heightSpan: Int = 0,
11
+ @ViewBuilder content: @escaping () -> Content
12
+ ) {
13
+ self.widthSpan = widthSpan
14
+ self.heightSpan = heightSpan
15
+ self.content = content
16
+ }
17
+
18
+ // MARK: Public
19
+
20
+ public var body: some View {
21
+ // A VStack (not a ZStack) so multiple children flow VERTICALLY instead of stacking
22
+ // on top of each other — matches the React Item's flex-column content.
23
+ VStack(alignment: .leading, spacing: 0) { content() }
24
+ .frame(width: width, height: height, alignment: .topLeading)
25
+ }
26
+
27
+ // MARK: Private
28
+
29
+ private let widthSpan: Int
30
+ private let heightSpan: Int
31
+ private let content: () -> Content
32
+
33
+ @Environment(\.gridContext) private var grid
34
+
35
+ private var width: CGFloat? {
36
+ grid.map { $0.sizeForSpan(widthSpan) }
37
+ }
38
+
39
+ private var height: CGFloat? {
40
+ heightSpan > 0 ? grid.map { $0.sizeForSpan(heightSpan) } : nil
41
+ }
42
+ }
@@ -0,0 +1,134 @@
1
+ import SwiftUI
2
+
3
+ /// 12-column section container: horizontal margin, optional background image, and a
4
+ /// wrapping row of children that read their span size from `\.gridContext`.
5
+ /// Mirrors `vn.momo.kits.layout.Section`.
6
+ public struct SectionView<Content: View>: View {
7
+ // MARK: Lifecycle
8
+
9
+ public init(
10
+ useMargin: Bool = true,
11
+ backgroundImage: String? = nil,
12
+ @ViewBuilder content: @escaping () -> Content
13
+ ) {
14
+ self.useMargin = useMargin
15
+ self.backgroundImage = backgroundImage
16
+ self.content = content
17
+ }
18
+
19
+ // MARK: Public
20
+
21
+ public var body: some View {
22
+ flow
23
+ .environment(\.gridContext, grid)
24
+ .frame(maxWidth: .infinity, alignment: .topLeading)
25
+ .background(widthReader)
26
+ .background(background)
27
+ .padding(.horizontal, useMargin ? Spacing.M : 0)
28
+ }
29
+
30
+ // MARK: Private
31
+
32
+ private static var columns: Int { 12 }
33
+ private static var gutter: CGFloat { Spacing.M }
34
+
35
+ private let useMargin: Bool
36
+ private let backgroundImage: String?
37
+ private let content: () -> Content
38
+
39
+ /// Width available *inside* the horizontal margin — the Compose `BoxWithConstraints` equivalent.
40
+ @State private var availableWidth: CGFloat = 0
41
+
42
+ private var grid: GridContext {
43
+ let columns = CGFloat(Self.columns)
44
+ // The trailing `- 1 / columns` mirrors Compose: it absorbs rounding so 12 spans
45
+ // plus their gutters never overflow the row by a sub-point.
46
+ let sizePerSpan = (availableWidth - Self.gutter * (columns - 1)) / columns - 1 / columns
47
+ return GridContext(numberOfColumns: Self.columns, gutter: Self.gutter, sizePerSpan: max(0, sizePerSpan))
48
+ }
49
+
50
+ @ViewBuilder private var flow: some View {
51
+ if #available(iOS 16.0, *) {
52
+ FlowRowLayout(gutter: Self.gutter) { content() }
53
+ } else {
54
+ // No `Layout` protocol before iOS 16: children stack instead of wrapping.
55
+ // Full-width (span 12) items — the common case — look identical.
56
+ VStack(alignment: .leading, spacing: Self.gutter) { content() }
57
+ }
58
+ }
59
+
60
+ @ViewBuilder private var background: some View {
61
+ if let backgroundImage {
62
+ ImageView(backgroundImage, contentMode: .fill).clipped()
63
+ }
64
+ }
65
+
66
+ private var widthReader: some View {
67
+ GeometryReader { proxy in
68
+ Color.clear.preference(key: SectionWidthKey.self, value: proxy.size.width)
69
+ }
70
+ .onPreferenceChange(SectionWidthKey.self) { availableWidth = $0 }
71
+ }
72
+ }
73
+
74
+ private struct SectionWidthKey: PreferenceKey {
75
+ static var defaultValue: CGFloat = 0
76
+
77
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
78
+ value = max(value, nextValue())
79
+ }
80
+ }
81
+
82
+ /// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
83
+ @available(iOS 16.0, *)
84
+ private struct FlowRowLayout: Layout {
85
+ struct Row {
86
+ var items: [(index: Int, size: CGSize)] = []
87
+ var width: CGFloat = 0
88
+ var height: CGFloat = 0
89
+ }
90
+
91
+ let gutter: CGFloat
92
+
93
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
94
+ let maxWidth = proposal.width ?? .infinity
95
+ let rows = rows(for: subviews, maxWidth: maxWidth)
96
+ let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
97
+ return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
98
+ }
99
+
100
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
101
+ var y = bounds.minY
102
+ for row in rows(for: subviews, maxWidth: bounds.width) {
103
+ var x = bounds.minX
104
+ for item in row.items {
105
+ subviews[item.index].place(
106
+ at: CGPoint(x: x, y: y),
107
+ anchor: .topLeading,
108
+ proposal: ProposedViewSize(item.size)
109
+ )
110
+ x += item.size.width + gutter
111
+ }
112
+ y += row.height + gutter
113
+ }
114
+ }
115
+
116
+ private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
117
+ var rows: [Row] = []
118
+ var current = Row()
119
+ for index in subviews.indices {
120
+ let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
121
+ let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
122
+ // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
123
+ if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
124
+ rows.append(current)
125
+ current = Row()
126
+ }
127
+ current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
128
+ current.height = max(current.height, size.height)
129
+ current.items.append((index, size))
130
+ }
131
+ if !current.items.isEmpty { rows.append(current) }
132
+ return rows
133
+ }
134
+ }
@@ -39,6 +39,7 @@ public struct PopupNotifyProps {
39
39
 
40
40
  public struct PopupNotify: View {
41
41
  @Environment(\.localize) private var localize
42
+ @Environment(\.momoFontScale) private var fontScale
42
43
 
43
44
  public let props: PopupNotifyProps
44
45
 
@@ -49,7 +50,7 @@ public struct PopupNotify: View {
49
50
  }
50
51
 
51
52
  private var maxDescriptionHeight: CGFloat {
52
- scaleSize(TypographyStyle.bodyDefaultRegular.lineHeight) * 8.5
53
+ fontScale(TypographyStyle.bodyDefaultRegular.lineHeight) * 8.5
53
54
  }
54
55
 
55
56
  private var shouldUseColumnButtons: Bool {
@@ -254,15 +254,16 @@ private struct ProgressInfoVertical: View {
254
254
  // MARK: - Step icon
255
255
 
256
256
  private struct ProgressInfoStepIcon: View {
257
+ @Environment(\.momoFontScale) private var fontScale
257
258
  let index: Int
258
259
  let size: ProgressInfoSize
259
260
  let useNumber: Bool
260
261
  let customIcon: String?
261
262
 
262
263
  var body: some View {
263
- let containerSize = scaleSize(size == .small ? 16 : 24)
264
- let dotSize = scaleSize(size == .small ? 6 : 8)
265
- let iconSize = scaleSize(size == .small ? 12 : 16)
264
+ let containerSize = fontScale(size == .small ? 16 : 24)
265
+ let dotSize = fontScale(size == .small ? 6 : 8)
266
+ let iconSize = fontScale(size == .small ? 12 : 16)
266
267
 
267
268
  ZStack {
268
269
  Circle()
@@ -15,6 +15,7 @@ public enum RatingSize {
15
15
  }
16
16
 
17
17
  public struct Rating: View {
18
+ @Environment(\.momoFontScale) private var fontScale
18
19
  private let numOfStars: Int
19
20
  private let rating: Float
20
21
  private let onRatingChange: ((Int) -> Void)?
@@ -44,7 +45,7 @@ public struct Rating: View {
44
45
  private func star(at index: Int) -> some View {
45
46
  let icon = Icon(
46
47
  source: starIcon(at: index),
47
- size: scaleSize(size.iconSize),
48
+ size: fontScale(size.iconSize),
48
49
  color: starColor(at: index)
49
50
  )
50
51