@momo-kits/native-kits 0.163.1-sp.3-debug → 0.163.1-sp.4-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 (30) hide show
  1. package/.claude/settings.local.json +57 -1
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +5 -5
  5. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +4 -2
  6. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +66 -19
  7. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +15 -3
  8. package/gradle.properties +1 -1
  9. package/ios/Application/Components.swift +14 -14
  10. package/ios/Application/HeaderRight.swift +45 -79
  11. package/ios/Application/Navigation/component/Header.swift +1 -1
  12. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +11 -0
  13. package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
  14. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +196 -0
  15. package/ios/Application/StackScreen.swift +18 -21
  16. package/ios/Badge/BadgeRibbon.swift +4 -1
  17. package/ios/Button/Button.swift +54 -80
  18. package/ios/Icon/Icon.swift +13 -4
  19. package/ios/Layout/GridContext.swift +30 -0
  20. package/ios/Layout/Item.swift +42 -0
  21. package/ios/Layout/Section.swift +134 -0
  22. package/ios/native-kits.podspec +2 -1
  23. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  24. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +8 -8
  25. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +3 -2
  26. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +5 -12
  27. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +104 -0
  28. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +100 -0
  29. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
  30. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,196 @@
1
+ import SwiftUI
2
+ import Foundation
3
+
4
+ /// Keyed (`@Environment(\.applicationEnvironment)`) variant of `HeaderRight`, for hosts
5
+ /// wired through `NavigationContainer`/`KitContainer` (the injection style introduced by
6
+ /// MR 227 / DS-637). Reuses the shared data types (`HeaderRightData`, `Tool`, `ToolGroup`)
7
+ /// and `NavigationButton` from `HeaderRight.swift`. The legacy `HeaderRight` there stays on
8
+ /// `@EnvironmentObject` for hosts that render `Screen` directly with
9
+ /// `.environmentObject(ApplicationEnvironment(...))`. Opt in per screen with
10
+ /// `headerRight: { NavigationHeaderRight() }`.
11
+ public struct NavigationHeaderRight: View {
12
+ public init(headerRight: HeaderRightData? = nil, tintColor: Color? = nil) {
13
+ self.headerRight = headerRight
14
+ self.tintColor = tintColor
15
+ }
16
+ var headerRight: HeaderRightData?
17
+ var tintColor: Color? = nil
18
+
19
+ public var body: some View {
20
+ HStack(alignment: .center) {
21
+ ToolkitNavigationHeaderRight(headerRight: headerRight, tintColor: tintColor)
22
+ }
23
+ }
24
+ }
25
+
26
+ struct ToolkitNavigationHeaderRight: View {
27
+ var headerRight: HeaderRightData?
28
+ var tintColor: Color?
29
+ @State private var isFavorite: Bool = false
30
+ @State private var isLoading: Bool = false
31
+ @Environment(\.applicationEnvironment) private var environment
32
+
33
+ private func apiValue(_ value: Any?) -> Any {
34
+ value ?? NSNull()
35
+ }
36
+
37
+ private var contextMap: [String: Any] {
38
+ let context = environment.applicationContext
39
+ return [
40
+ "appId": apiValue(context?.appId),
41
+ "code": apiValue(context?.appCode),
42
+ "name": apiValue(context?.appName),
43
+ "icon": apiValue(context?.appIcon),
44
+ "description": apiValue(context?.description),
45
+ "support": apiValue(context?.support),
46
+ "toolkitConfig": apiValue(context?.toolkitConfig),
47
+ "providerId": apiValue(context?.providerId),
48
+ "permissions": apiValue(context?.permissions)
49
+ ]
50
+ }
51
+
52
+ private var shouldShowShortcut: Bool {
53
+ headerRight?.useShortcut == true && !(headerRight?.useMore == true && environment.applicationContext == nil)
54
+ }
55
+
56
+ private func parseResponse(_ response: String) -> Any? {
57
+ guard let jsonData = response.data(using: .utf8),
58
+ let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
59
+ return nil
60
+ }
61
+ return json["response"]
62
+ }
63
+
64
+ // MARK: - Actions
65
+ private func loadFavoriteState() {
66
+ environment.composeApi?.request(
67
+ funcName: "isFavoriteApp",
68
+ params: ["code": apiValue(environment.applicationContext?.appCode)]
69
+ ) { response in
70
+ if let isFavorite = parseResponse(response) as? Bool {
71
+ self.isFavorite = isFavorite
72
+ }
73
+ }
74
+ }
75
+
76
+ private func onPressShortcut() {
77
+ environment.composeApi?.request(
78
+ funcName: "onToolAction",
79
+ params: [
80
+ "item": ["key": "onFavorite"],
81
+ "context": contextMap
82
+ ]
83
+ ) { response in
84
+ if let result = parseResponse(response) as? [String: Any],
85
+ result["success"] as? Bool == true {
86
+ isFavorite.toggle()
87
+ }
88
+ }
89
+ }
90
+
91
+ private func onPressHelpCenter() {
92
+ environment.composeApi?.request(
93
+ funcName: "showHelpCenter",
94
+ params: [
95
+ "appId": apiValue(environment.applicationContext?.appId),
96
+ "code": apiValue(environment.applicationContext?.appCode),
97
+ "name": apiValue(environment.applicationContext?.appName),
98
+ "icon": apiValue(environment.applicationContext?.appIcon),
99
+ "description": apiValue(environment.applicationContext?.description)
100
+ ]
101
+ ) { _ in }
102
+ }
103
+
104
+ private func onPressClose() {
105
+ environment.composeApi?.request(
106
+ funcName: "dismissAll",
107
+ params: nil
108
+ ) { _ in }
109
+ }
110
+
111
+ private func onPressMore() {
112
+ let params: [String: Any] = [
113
+ "useSystemTools": headerRight?.useSystemTools ?? true,
114
+ "tools": headerRight?.tools.map { $0.toMap() } ?? [],
115
+ "context": contextMap
116
+ ]
117
+ environment.composeApi?.request(
118
+ funcName: "showTools",
119
+ params: params
120
+ ) { response in
121
+ if let toolResponse = parseResponse(response) as? String {
122
+ headerRight?.toolCallback?(toolResponse)
123
+ }
124
+ }
125
+ }
126
+
127
+ private func getNavigationButtonConfig() -> NavigationButtonConfig {
128
+ let totalTools = headerRight?.tools.reduce(0) { $0 + $1.items.count } ?? 0
129
+ var icon = isFavorite ? "pin_star_checked" : "pin_star"
130
+ var onClickHandler: () -> Void = onPressShortcut
131
+
132
+ if totalTools > 1 || headerRight?.useMore == true {
133
+ icon = "navigation_more_icon"
134
+ onClickHandler = onPressMore
135
+ } else if totalTools == 1, let singleTool = headerRight?.tools.first?.items.first {
136
+ icon = singleTool.icon
137
+ onClickHandler = {
138
+ headerRight?.toolCallback?(singleTool.key)
139
+ }
140
+ }
141
+
142
+ return NavigationButtonConfig(icon: icon, onPress: onClickHandler)
143
+ }
144
+
145
+ var body: some View {
146
+ let backgroundButtonColor: Color = tintColor == Colors.black01 ? Colors.black20.opacity(0.6) : Colors.black01.opacity(0.6)
147
+ let borderColor: Color = tintColor == Colors.black01 ? Colors.black01.opacity(0.2) : Colors.black20.opacity(0.2)
148
+ let navButtonConfig = getNavigationButtonConfig()
149
+ let showBadge = headerRight?.tools.contains { group in
150
+ group.items.contains { $0.showBadge }
151
+ } ?? false
152
+
153
+ HStack(alignment: .center, spacing: 0) {
154
+ if shouldShowShortcut {
155
+ NavigationButton(
156
+ disabled: isLoading,
157
+ icon: navButtonConfig.icon,
158
+ showBadge: showBadge,
159
+ onClick: navButtonConfig.onPress,
160
+ tintColor: tintColor
161
+ )
162
+ }
163
+
164
+ HStack(alignment: .center, spacing: 0) {
165
+ if environment.applicationContext != nil {
166
+ Icon(source: "help_center", size: 20, color: tintColor ?? Colors.black17)
167
+ .padding(4)
168
+ .onTapGesture {
169
+ onPressHelpCenter()
170
+ }
171
+
172
+ Rectangle()
173
+ .fill(tintColor ?? Colors.black20)
174
+ .frame(width: 0.5, height: 12)
175
+ }
176
+
177
+ Icon(source: "16_basic_home", size: 20, color: tintColor ?? Colors.black17)
178
+ .padding(4)
179
+ .onTapGesture {
180
+ onPressClose()
181
+ }
182
+ }
183
+ .frame(width: 65, height: 28)
184
+ .background(backgroundButtonColor)
185
+ .cornerRadius(14)
186
+ .overlay(
187
+ RoundedRectangle(cornerRadius: 14)
188
+ .stroke(borderColor, lineWidth: 0.2)
189
+ )
190
+ .padding(.leading, Spacing.S)
191
+ }
192
+ .onAppear {
193
+ loadFavoriteState()
194
+ }
195
+ }
196
+ }
@@ -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
  //
@@ -165,12 +165,13 @@ public struct StackScreen<Content: View>: View {
165
165
 
166
166
  FloatingContent(
167
167
  fabProps: fabProps,
168
- keyboardOffset: 0,
168
+ keyboardSize: keyboardSize,
169
+ hasFooter: footer != nil,
169
170
  scrollOffset: scrollOffset
170
171
  )
171
172
  }
172
173
 
173
- FooterContent(footer: footer, keyboardSize: keyboardSize, bottomInset: bottomInset)
174
+ NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
174
175
  }
175
176
 
176
177
  // Animated search bar overlay (mirrors Compose's SearchAnimated layer).
@@ -444,17 +445,29 @@ private struct SearchAnimatedHeader: View {
444
445
  /// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
445
446
  private struct FloatingContent: View {
446
447
  let fabProps: FabProps?
447
- let keyboardOffset: CGFloat
448
+ let keyboardSize: CGFloat
449
+ /// With a footer the FAB is anchored to the footer's *layout* top, which does not move
450
+ /// when the band offsets itself over the keyboard — so it rides the same offset.
451
+ let hasFooter: Bool
448
452
  let scrollOffset: CGFloat
449
453
 
454
+ /// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
455
+ /// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
456
+ /// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
457
+ private var verticalOffset: CGFloat {
458
+ if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
459
+ return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
460
+ }
461
+
450
462
  var body: some View {
451
463
  if let fabProps = fabProps {
452
464
  FloatingButton(
453
465
  props: fabProps,
454
- keyboardOffset: keyboardOffset,
466
+ keyboardOffset: 0,
455
467
  scrollOffset: scrollOffset
456
468
  )
457
469
  .padding(.horizontal, Spacing.M)
470
+ .offset(y: verticalOffset)
458
471
  .ignoresSafeArea(.keyboard, edges: .bottom)
459
472
  }
460
473
  }
@@ -483,19 +496,3 @@ private struct HideKeyboardOnTap: ViewModifier {
483
496
  }
484
497
  }
485
498
  }
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
- }
@@ -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
  }
@@ -281,7 +284,7 @@ private struct RibbonConstants {
281
284
  static let ribbonHeight: CGFloat = 20
282
285
  static let roundHeight: CGFloat = 16
283
286
  static let skewBodyHeight: CGFloat = 16
284
- static let roundRightRadius: CGFloat = 12
287
+ static let roundRightRadius: CGFloat = 8
285
288
  static let roundPaddingEnd: CGFloat = 6
286
289
  static let skewTailWidth: CGFloat = 8
287
290
  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,29 +238,42 @@ 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
- ZStack {
285
- RoundedRectangle(cornerRadius: radius)
286
- .fill(background)
287
- .overlay(
288
- RoundedRectangle(cornerRadius: radius)
289
- .strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
290
- )
291
- .padding(.horizontal, pressed ? 2 : 0)
292
-
293
- configuration.label
294
- .opacity(pressed ? 0.5 : 1)
246
+ configuration.label
247
+ .modifier(HugContentOnLegacyOS(isFull: isFull))
248
+ .opacity(pressed ? 0.5 : 1)
249
+ .background(
250
+ RoundedRectangle(cornerRadius: radius)
251
+ .fill(background)
252
+ .overlay(
253
+ RoundedRectangle(cornerRadius: radius)
254
+ .strokeBorder(border ?? .clear, lineWidth: border != nil ? borderWidth : 0)
255
+ )
256
+ .padding(.horizontal, pressed ? 2 : 0)
257
+ )
258
+ .frame(height: height)
259
+ .clipShape(RoundedRectangle(cornerRadius: radius))
260
+ .animation(.easeInOut(duration: 0.1), value: pressed)
261
+ }
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)
295
277
  }
296
- // Pin the whole button to its intended height explicitly: the bare
297
- // RoundedRectangle background has no intrinsic size of its own, so left
298
- // unconstrained in a ZStack inside a non-height-constraining container
299
- // (e.g. a footer VStack) it can report an oversized ideal height and
300
- // stretch the button — and its container — far taller than intended.
301
- .frame(height: height)
302
- .clipShape(RoundedRectangle(cornerRadius: radius))
303
- .animation(.easeInOut(duration: 0.1), value: pressed)
304
278
  }
305
279
  }
@@ -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"`
@@ -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
+ }