@momo-kits/native-kits 0.162.30 → 0.162.31

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 (90) hide show
  1. package/CLAUDE.md +19 -0
  2. package/ios/Application/ApplicationEnvironment.swift +14 -6
  3. package/ios/Application/LanguageSource.swift +93 -0
  4. package/ios/Application/Localize.swift +73 -4
  5. package/ios/Application/LocalizeModifier.swift +30 -0
  6. package/ios/Application/Navigation/NavigationContainer.swift +28 -18
  7. package/ios/Application/Navigation/Navigator.swift +121 -53
  8. package/ios/Application/Navigation/NavigatorManager.swift +49 -0
  9. package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
  10. package/ios/Application/StackScreen.swift +19 -28
  11. package/ios/Badge/Badge.swift +2 -1
  12. package/ios/Badge/BadgeRibbon.swift +6 -2
  13. package/ios/Button/Button.swift +41 -61
  14. package/ios/Chip/Chip.swift +6 -5
  15. package/ios/Icon/Icon.swift +13 -4
  16. package/ios/Input/ErrorView.swift +2 -1
  17. package/ios/Input/Input.swift +9 -2
  18. package/ios/Input/InputPhoneNumber.swift +2 -1
  19. package/ios/Input/InputSearch.swift +3 -2
  20. package/ios/Input/InputTextArea.swift +6 -1
  21. package/ios/InputMoney/InputMoney.swift +5 -2
  22. package/ios/InputOTP/InputOTP.swift +2 -1
  23. package/ios/Layout/GridContext.swift +30 -0
  24. package/ios/Layout/Item.swift +42 -0
  25. package/ios/Layout/Section.swift +134 -0
  26. package/ios/Popup/PopupNotify.swift +2 -1
  27. package/ios/ProgressInfo/ProgressInfo.swift +4 -3
  28. package/ios/Rating/Rating.swift +2 -1
  29. package/ios/Stepper/Stepper.swift +2 -1
  30. package/ios/SuggestAction/SuggestAction.swift +3 -2
  31. package/ios/Tag/Tag.swift +2 -1
  32. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  33. package/ios/Title/Title.swift +3 -2
  34. package/ios/Tooltip/Tooltip.swift +2 -1
  35. package/ios/Typography/FontScale.swift +103 -0
  36. package/ios/Typography/FontScaleStore.swift +52 -13
  37. package/ios/Typography/Text.swift +12 -29
  38. package/ios/Typography/Typography.swift +66 -86
  39. package/ios/native-kits.podspec +2 -1
  40. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  41. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
  42. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
  43. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
  47. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
  48. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
  49. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
  50. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
  51. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
  52. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
  53. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
  54. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
  55. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
  56. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
  57. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
  58. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
  59. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
  60. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
  61. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
  62. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
  63. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
  64. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
  65. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
  66. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
  67. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
  68. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
  69. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
  70. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
  71. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
  72. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
  73. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
  74. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
  75. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
  76. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
  77. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
  78. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
  79. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
  80. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
  81. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
  82. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
  83. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
  84. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
  85. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
  86. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
  87. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
  88. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
  89. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
  90. package/package.json +2 -2
@@ -0,0 +1,49 @@
1
+ //
2
+ // NavigatorManager.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Host bridge for the native iOS edge-swipe back — the SwiftUI mirror of Compose's
6
+ // `ComposeNavigatorManager` (momo-app `vn.momo.compose.uikits.ComposeNavigatorManager`).
7
+ //
8
+ // The kit does NOT own the swipe gesture. In the host app a kit screen lives inside a
9
+ // `UIHostingController` hosted by the mini-app container VC, and that VC owns the
10
+ // `UIScreenEdgePanGestureRecognizer`. When the swipe fires the host asks this manager
11
+ // whether a kit stack is mounted (`checkCanPop`) and, if so, routes the back into it
12
+ // (`pop` → `Navigator.onBackSafe`) instead of dismissing the whole mini-app. Standalone
13
+ // (no such host, e.g. the demo app) there is no edge swipe — same as Compose on iOS,
14
+ // whose `BackHandler` is a no-op and which relies entirely on the host.
15
+ //
16
+
17
+ import Foundation
18
+
19
+ @available(iOS 16.0, *)
20
+ public final class NavigatorManager {
21
+ public static let shared = NavigatorManager()
22
+ private init() {}
23
+
24
+ /// Stack of mounted navigators; the last one is the visible top (nested mini-apps
25
+ /// push more than one). Held strongly, mirroring `ComposeNavigatorManager` — entries
26
+ /// are released by `NavigationContainer` on disappear, not by ARC.
27
+ private var navigators: [Navigator] = []
28
+
29
+ /// Idempotent: SwiftUI can re-run a view's `onAppear`, and a double push would need a
30
+ /// matching double remove to unwind.
31
+ public func push(_ navigator: Navigator) {
32
+ guard !navigators.contains(where: { $0 === navigator }) else { return }
33
+ navigators.append(navigator)
34
+ }
35
+
36
+ /// Removes by identity rather than `removeLast` so a spurious disappear of a covered
37
+ /// (non-top) container can't drop the wrong navigator.
38
+ public func remove(_ navigator: Navigator) {
39
+ navigators.removeAll { $0 === navigator }
40
+ }
41
+
42
+ /// True while any kit stack is mounted — tells the host to route the back into the kit
43
+ /// instead of dismissing the mini-app. Matches `ComposeNavigatorManager.checkCanPop`:
44
+ /// it does not inspect stack depth; `onBackSafe` handles the at-root case itself by
45
+ /// asking the host to dismiss (via `composeApi`).
46
+ public func checkCanPop() -> Bool { !navigators.isEmpty }
47
+
48
+ public func pop() { navigators.last?.onBackSafe() }
49
+ }
@@ -0,0 +1,90 @@
1
+ //
2
+ // NavigationFooter.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `Footer` (navigation/StackScreen.kt): the band
6
+ // pinned under the screen content — surface background, S/M padding, the
7
+ // navigation-bar reserve, and the 6pt gradient shadow cast up onto the content.
8
+ //
9
+ // Named `NavigationFooter` to coexist with the legacy `Footer` in `Screen.swift`
10
+ // (kept as-is for the old `Screen` path). Used by `StackScreen`.
11
+ //
12
+
13
+ import SwiftUI
14
+
15
+ public struct NavigationFooter: View {
16
+ /// Compose's `footerComponent`: nothing renders when nil.
17
+ var footerComponent: (() -> AnyView)?
18
+ /// Current keyboard height, measured from the bottom of the screen.
19
+ var keyboardSize: CGFloat
20
+
21
+ public init(
22
+ footerComponent: (() -> AnyView)?,
23
+ keyboardSize: CGFloat = 0
24
+ ) {
25
+ self.footerComponent = footerComponent
26
+ self.keyboardSize = keyboardSize
27
+ }
28
+
29
+ @Environment(\.appTheme) private var theme
30
+
31
+ /// Read from the window, not from an enclosing `GeometryProxy`: the proxy's bottom
32
+ /// inset grows to the keyboard height while the keyboard is up.
33
+ static var safeAreaBottom: CGFloat {
34
+ UIApplication.shared.connectedScenes
35
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
36
+ .first ?? 0
37
+ }
38
+
39
+ /// Compose's `AppNavigationBar.current` (`getNavigationBarHeight` clamps iOS to 21pt).
40
+ static var navigationBarInset: CGFloat { min(safeAreaBottom, 21) }
41
+
42
+ /// Compose's `bottomPadding + Spacing.S` with the keyboard down. The footer is laid
43
+ /// out inside the safe area, which already covers part of that gap.
44
+ static var bottomPadding: CGFloat {
45
+ max(navigationBarInset + Spacing.S - safeAreaBottom, 0)
46
+ }
47
+
48
+ /// Deliberately an offset and not padding: growing the footer feeds back into SwiftUI's
49
+ /// keyboard avoidance, so a padding-based lift never settles at the right place.
50
+ /// Negative lifts the band over the keyboard; positive shaves the few points by which
51
+ /// the safe area over-reserves compared to Compose. `FloatingContent` rides the same
52
+ /// amount so the FAB keeps its distance above the band.
53
+ ///
54
+ /// Natural position is `safeAreaBottom + bottomPadding` above the bottom of the screen;
55
+ /// Compose puts it at `(AppNavigationBar - keyboard).coerceAtLeast(0) + Spacing.S` on
56
+ /// top of the `imePadding()` lift — i.e. `max(navigationBar, keyboard) + Spacing.S`.
57
+ static func verticalOffset(keyboardSize: CGFloat) -> CGFloat {
58
+ (safeAreaBottom + bottomPadding) - (max(navigationBarInset, keyboardSize) + Spacing.S)
59
+ }
60
+
61
+ public var body: some View {
62
+ if let footerComponent = footerComponent {
63
+ VStack(alignment: .leading, spacing: 0) {
64
+ footerComponent()
65
+ }
66
+ .frame(maxWidth: .infinity, alignment: .leading)
67
+ .padding(.top, Spacing.S)
68
+ .padding(.horizontal, Spacing.M)
69
+ .padding(.bottom, Self.bottomPadding)
70
+ // Compose paints the surface all the way to the bottom of the screen; the
71
+ // footer's own frame stops at the safe area, so only the fill bleeds down.
72
+ .background(theme.colors.background.surface.ignoresSafeArea(.container, edges: .bottom))
73
+ .overlay(alignment: .top) { shadow }
74
+ .offset(y: Self.verticalOffset(keyboardSize: keyboardSize))
75
+ }
76
+ }
77
+
78
+ /// Compose offsets the shadow box by -6dp, so it lands on the content above the
79
+ /// footer rather than on the footer surface itself.
80
+ private var shadow: some View {
81
+ LinearGradient(
82
+ colors: [Color.clear, Color.black.opacity(0.05)],
83
+ startPoint: .top,
84
+ endPoint: .bottom
85
+ )
86
+ .frame(height: 6)
87
+ .offset(y: -6)
88
+ .allowsHitTesting(false)
89
+ }
90
+ }
@@ -5,7 +5,7 @@
5
5
  // SwiftUI mirror of Compose's `StackScreen`. This is a refactor of `Screen`:
6
6
  // the body is decomposed into the same focused pieces Compose uses — header
7
7
  // layer, scrollable content (`StackScreenContent`), floating button
8
- // (`FloatingContent`) and footer (`FooterContent`) — instead of one
8
+ // (`FloatingContent`) and footer (`NavigationFooter`) — instead of one
9
9
  // monolithic, partially-duplicated view. The public init matches `Screen`
10
10
  // so call sites (e.g. NavigationContainer's ScreenHost) can swap over.
11
11
  //
@@ -143,13 +143,8 @@ public struct StackScreen<Content: View>: View {
143
143
  // MARK: - Body
144
144
 
145
145
  public var body: some View {
146
- GeometryReader { geometry in
146
+ GeometryReader { _ in
147
147
  let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
148
- let bottomInset = min(geometry.safeAreaInsets.bottom, 21)
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
153
148
 
154
149
  ZStack(alignment: .top) {
155
150
  // Background color
@@ -161,16 +156,16 @@ public struct StackScreen<Content: View>: View {
161
156
  VStack(alignment: horizontalAlignment, spacing: 0) {
162
157
  headerAndContent
163
158
  }
164
- .padding(.bottom, contentBottomInset)
165
159
 
166
160
  FloatingContent(
167
161
  fabProps: fabProps,
168
- keyboardOffset: 0,
162
+ keyboardSize: keyboardSize,
163
+ hasFooter: footer != nil,
169
164
  scrollOffset: scrollOffset
170
165
  )
171
166
  }
172
167
 
173
- FooterContent(footer: footer, keyboardSize: keyboardSize, bottomInset: bottomInset)
168
+ NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
174
169
  }
175
170
 
176
171
  // Animated search bar overlay (mirrors Compose's SearchAnimated layer).
@@ -444,17 +439,29 @@ private struct SearchAnimatedHeader: View {
444
439
  /// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
445
440
  private struct FloatingContent: View {
446
441
  let fabProps: FabProps?
447
- let keyboardOffset: CGFloat
442
+ let keyboardSize: CGFloat
443
+ /// With a footer the FAB is anchored to the footer's *layout* top, which does not move
444
+ /// when the band offsets itself over the keyboard — so it rides the same offset.
445
+ let hasFooter: Bool
448
446
  let scrollOffset: CGFloat
449
447
 
448
+ /// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
449
+ /// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
450
+ /// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
451
+ private var verticalOffset: CGFloat {
452
+ if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
453
+ return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
454
+ }
455
+
450
456
  var body: some View {
451
457
  if let fabProps = fabProps {
452
458
  FloatingButton(
453
459
  props: fabProps,
454
- keyboardOffset: keyboardOffset,
460
+ keyboardOffset: 0,
455
461
  scrollOffset: scrollOffset
456
462
  )
457
463
  .padding(.horizontal, Spacing.M)
464
+ .offset(y: verticalOffset)
458
465
  .ignoresSafeArea(.keyboard, edges: .bottom)
459
466
  }
460
467
  }
@@ -483,19 +490,3 @@ private struct HideKeyboardOnTap: ViewModifier {
483
490
  }
484
491
  }
485
492
  }
486
-
487
- // MARK: - Footer content
488
-
489
- /// Mirrors Compose's `FooterContent`: renders the footer chrome only when a
490
- /// footer component is supplied. Reuses the existing `Footer` view from Screen.swift.
491
- private struct FooterContent: View {
492
- let footer: (() -> AnyView)?
493
- let keyboardSize: CGFloat
494
- let bottomInset: CGFloat
495
-
496
- var body: some View {
497
- if let footerView = footer?() {
498
- Footer(footerView: footerView, keyboardSize: keyboardSize, bottomInset: bottomInset)
499
- }
500
- }
501
- }
@@ -2,6 +2,7 @@ import SwiftUI
2
2
 
3
3
  public struct Badge: View {
4
4
  @Environment(\.appTheme) private var theme
5
+ @Environment(\.momoFontScale) private var fontScale
5
6
  let label: String
6
7
  let backgroundColor: Color?
7
8
 
@@ -55,7 +56,7 @@ public struct Badge: View {
55
56
  public var body: some View {
56
57
  MomoText(formatTitle(label), typography: .actionXxsBold, color: Colors.black01)
57
58
  .padding(.horizontal, Spacing.XS)
58
- .frame(minWidth: scaleSize(16), minHeight: scaleSize(16))
59
+ .frame(minWidth: fontScale(16), minHeight: fontScale(16))
59
60
  .background(badgeColor)
60
61
  .cornerRadius(Radius.M)
61
62
  .overlay(
@@ -174,6 +174,7 @@ public struct BadgeRibbon: View {
174
174
  )
175
175
  .frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
176
176
  .rotationEffect(.degrees(180))
177
+ .offset(x: 1)
177
178
  .clipped()
178
179
  }
179
180
 
@@ -183,6 +184,7 @@ public struct BadgeRibbon: View {
183
184
  placeholder: AnyView(Color.clear)
184
185
  )
185
186
  .frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
187
+ .offset(x: 1)
186
188
  .clipped()
187
189
  }
188
190
 
@@ -192,6 +194,7 @@ public struct BadgeRibbon: View {
192
194
  placeholder: AnyView(Color.clear)
193
195
  )
194
196
  .frame(width: RibbonConstants.skewTailWidth, height: RibbonConstants.skewTailHeight)
197
+ .offset(x: -2)
195
198
  .clipped()
196
199
  }
197
200
  }
@@ -201,6 +204,7 @@ public struct BadgeRibbon: View {
201
204
  /// rotation) — the label gets the same scale applied a second time so it stays right-reading
202
205
  /// while only the ribbon shape flips.
203
206
  public struct RoundedBadgeRibbon: View {
207
+ @Environment(\.momoFontScale) private var fontScale
204
208
  let text: String
205
209
  let position: RibbonPosition
206
210
 
@@ -219,7 +223,7 @@ public struct RoundedBadgeRibbon: View {
219
223
  }
220
224
 
221
225
  private var measuredTextSize: CGSize {
222
- let fontSize = scaleSize(TypographyStyle.labelXsMedium.fontSize)
226
+ let fontSize = fontScale(TypographyStyle.labelXsMedium.fontSize)
223
227
  let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
224
228
  return text.size(withAttributes: [.font: font])
225
229
  }
@@ -281,7 +285,7 @@ private struct RibbonConstants {
281
285
  static let ribbonHeight: CGFloat = 20
282
286
  static let roundHeight: CGFloat = 16
283
287
  static let skewBodyHeight: CGFloat = 16
284
- static let roundRightRadius: CGFloat = 12
288
+ static let roundRightRadius: CGFloat = 8
285
289
  static let roundPaddingEnd: CGFloat = 6
286
290
  static let skewTailWidth: CGFloat = 8
287
291
  static let skewTailHeight: CGFloat = 16
@@ -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