@momo-kits/native-kits 0.162.21 → 0.162.22

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 (45) hide show
  1. package/ios/Application/ApplicationEnvironment.swift +4 -2
  2. package/ios/Application/MaxApi.swift +18 -0
  3. package/ios/Application/Navigation/NavigationContainer.swift +21 -3
  4. package/ios/Application/Navigation/Navigator.swift +50 -29
  5. package/ios/Application/Navigation/Overplay/ModalScreen.swift +6 -0
  6. package/ios/Application/Navigation/Tracking/ScreenTracker.swift +117 -0
  7. package/ios/Application/StackScreen.swift +41 -0
  8. package/ios/Badge/BadgeRibbon.swift +80 -0
  9. package/ios/Button/Button.swift +67 -11
  10. package/ios/Carousel/Carousel.swift +16 -1
  11. package/ios/Checkbox/Checkbox.swift +14 -20
  12. package/ios/Chip/Chip.swift +35 -26
  13. package/ios/Collapse/Collapse.swift +11 -1
  14. package/ios/DateTimePicker/DateTimePicker.swift +4 -2
  15. package/ios/DateTimePicker/WheelPicker.swift +7 -3
  16. package/ios/Extensions/ActiveOpacityButtonStyle.swift +14 -0
  17. package/ios/Icon/Icon.swift +5 -1
  18. package/ios/IconButton/IconButton.swift +35 -7
  19. package/ios/Image/Image.swift +16 -3
  20. package/ios/Information/Information.swift +3 -1
  21. package/ios/Input/ErrorView.swift +3 -1
  22. package/ios/Input/Input.swift +31 -8
  23. package/ios/Input/InputPhoneNumber.swift +14 -3
  24. package/ios/Input/InputSearch.swift +7 -1
  25. package/ios/Input/InputTextArea.swift +78 -34
  26. package/ios/InputDropDown/InputDropDown.swift +4 -1
  27. package/ios/InputMoney/InputMoney.swift +16 -4
  28. package/ios/Popup/PopupDisplay.swift +3 -4
  29. package/ios/Popup/PopupNotify.swift +3 -4
  30. package/ios/Popup/PopupPromotion.swift +16 -8
  31. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  32. package/ios/Radio/Radio.swift +12 -13
  33. package/ios/Rating/Rating.swift +1 -1
  34. package/ios/Stepper/Stepper.swift +2 -2
  35. package/ios/Steps/Steps.swift +22 -6
  36. package/ios/SuggestAction/SuggestAction.swift +5 -3
  37. package/ios/Swipeable/SwipeCell.swift +5 -3
  38. package/ios/Switch/Switch.swift +53 -28
  39. package/ios/TabView/TabView.swift +18 -14
  40. package/ios/Template/TrustBanner/TrustBanner.swift +6 -7
  41. package/ios/Tooltip/Tooltip.swift +3 -0
  42. package/ios/Uploader/Uploader.swift +0 -9
  43. package/ios/native-kits.podspec +1 -1
  44. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  45. package/package.json +1 -1
@@ -41,11 +41,13 @@ public class ApplicationEnvironment: ObservableObject {
41
41
  let applicationContext: MiniAppContext?
42
42
  let composeApi: KitComposeApi?
43
43
  let config: KitConfig?
44
-
45
- public init(applicationContext: MiniAppContext? = nil, composeApi: KitComposeApi? = nil, config: KitConfig? = nil) {
44
+ let maxApi: MaxApi?
45
+
46
+ public init(applicationContext: MiniAppContext? = nil, composeApi: KitComposeApi? = nil, config: KitConfig? = nil, maxApi: MaxApi? = nil) {
46
47
  self.applicationContext = applicationContext
47
48
  self.composeApi = composeApi
48
49
  self.config = config
50
+ self.maxApi = maxApi
49
51
  }
50
52
  }
51
53
 
@@ -0,0 +1,18 @@
1
+ //
2
+ // MaxApi.swift
3
+ // MoMoUIKits
4
+ //
5
+ // Swift mirror of the host-app bridge exposed on Kotlin as `vn.momo.maxapi.IMaxApi`
6
+ // (`LocalMaxApi`). `IMaxApi` itself is an external compiled dependency with no
7
+ // source in this repo, so this protocol intentionally exposes only the methods
8
+ // this kit's Kotlin source actually calls (`track`, `dismiss`, `getLanguage`) —
9
+ // not a full guess at IMaxApi's real surface.
10
+ //
11
+
12
+ import Foundation
13
+
14
+ public protocol MaxApi {
15
+ func track(eventName: String, params: [String: Any?])
16
+ func dismiss(completion: (() -> Void)?)
17
+ func getLanguage(callback: @escaping ([String: Any?]?) -> Void)
18
+ }
@@ -18,6 +18,7 @@ public struct NavigationContainer<Initial: View>: View {
18
18
 
19
19
  private let setNavigator: ((Navigator) -> Void)?
20
20
  private let composeApi: KitComposeApi?
21
+ private let maxApi: MaxApi?
21
22
 
22
23
  public init(
23
24
  initialScreenName: String = "",
@@ -26,6 +27,7 @@ public struct NavigationContainer<Initial: View>: View {
26
27
  applicationContext: MiniAppContext? = nil,
27
28
  composeApi: KitComposeApi? = nil,
28
29
  config: KitConfig? = nil,
30
+ maxApi: MaxApi? = nil,
29
31
  localize: Localize? = nil,
30
32
  setNavigator: ((Navigator) -> Void)? = nil
31
33
  ) {
@@ -40,11 +42,13 @@ public struct NavigationContainer<Initial: View>: View {
40
42
  wrappedValue: ApplicationEnvironment(
41
43
  applicationContext: applicationContext,
42
44
  composeApi: composeApi,
43
- config: config
45
+ config: config,
46
+ maxApi: maxApi
44
47
  )
45
48
  )
46
49
  _localize = StateObject(wrappedValue: localize ?? Localize())
47
50
  self.composeApi = composeApi
51
+ self.maxApi = maxApi
48
52
  self.setNavigator = setNavigator
49
53
  }
50
54
 
@@ -80,12 +84,19 @@ public struct NavigationContainer<Initial: View>: View {
80
84
  navigator.composeApi = composeApi
81
85
  }
82
86
  setNavigator?(navigator)
87
+ // Mirrors Compose's LaunchedEffect(maxApi, resolvedLocalize): host-reported
88
+ // language wins over the default "vi" only when it resolves to English.
89
+ maxApi?.getLanguage { data in
90
+ if parseLanguage(data) == Localize.EN {
91
+ localize.changeLanguage(Localize.EN)
92
+ }
93
+ }
83
94
  }
84
95
  }
85
96
 
86
97
  @ViewBuilder
87
98
  private func screenView(for route: DynamicScreenRoute) -> some View {
88
- if let screen = DynamicScreenRegistry.shared.getScreen(id: route.id) {
99
+ if let screen = navigator.registry.getScreen(id: route.id) {
89
100
  ScreenHost(screen: screen, navigator: navigator)
90
101
  .navigationBarBackButtonHidden(true)
91
102
  } else {
@@ -95,7 +106,7 @@ public struct NavigationContainer<Initial: View>: View {
95
106
 
96
107
  @ViewBuilder
97
108
  private func screenView(forDialog route: DynamicDialogRoute) -> some View {
98
- if let screen = DynamicScreenRegistry.shared.getScreen(id: route.id) {
109
+ if let screen = navigator.registry.getScreen(id: route.id) {
99
110
  ScreenHost(screen: screen, navigator: navigator)
100
111
  } else {
101
112
  EmptyView()
@@ -134,6 +145,7 @@ private struct ScreenHost: View {
134
145
  layoutOffset: options.layoutOffset,
135
146
  inputSearchProps: options.inputSearchProps,
136
147
  fabProps: options.fabProps,
148
+ screenName: screen.name,
137
149
  content: { screen.content() }
138
150
  )
139
151
  }
@@ -180,3 +192,9 @@ private func safeAreaBottomInset() -> CGFloat {
180
192
  .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
181
193
  .first ?? 0
182
194
  }
195
+
196
+ private func parseLanguage(_ raw: [String: Any?]?) -> String? {
197
+ guard let response = (raw?["response"] as? String)?.trimmingCharacters(in: .whitespaces).lowercased(),
198
+ !response.isEmpty else { return nil }
199
+ return response.hasPrefix(Localize.EN) ? Localize.EN : Localize.VI
200
+ }
@@ -30,12 +30,10 @@ public struct DynamicScreen {
30
30
  }
31
31
 
32
32
  public final class DynamicScreenRegistry {
33
- public static let shared = DynamicScreenRegistry()
34
-
35
33
  private var screens: [Int: DynamicScreen] = [:]
36
34
  private var idCounter: Int = 1
37
35
 
38
- private init() {}
36
+ public init() {}
39
37
 
40
38
  @discardableResult
41
39
  public func register(
@@ -95,6 +93,11 @@ public final class Navigator: ObservableObject {
95
93
  @Published public var presented: DynamicDialogRoute?
96
94
  @Published public var overplay: OverplayItem?
97
95
 
96
+ /// Owned per-Navigator (mirrors Compose's `remember { DynamicScreenRegistry() }` +
97
+ /// disposal): screens registered by this navigator are released via ARC when the
98
+ /// navigator itself deallocates, instead of accumulating in a process-wide singleton.
99
+ public let registry = DynamicScreenRegistry()
100
+
98
101
  /// Animated-close handler registered by the active overplay component
99
102
  /// (mirrors Compose's `OverplayComponentRegistry.bindClose`). When set, a
100
103
  /// dismiss request runs the component's exit animation before the item is
@@ -111,7 +114,7 @@ public final class Navigator: ObservableObject {
111
114
  composeApi: KitComposeApi? = nil
112
115
  ) {
113
116
  self.composeApi = composeApi
114
- self.rootRoute = DynamicScreenRegistry.shared.register(
117
+ self.rootRoute = registry.register(
115
118
  screenName: initialScreenName,
116
119
  content: initialContent,
117
120
  options: options
@@ -121,7 +124,7 @@ public final class Navigator: ObservableObject {
121
124
  // MARK: Stack
122
125
 
123
126
  public func push<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
124
- let route = DynamicScreenRegistry.shared.register(
127
+ let route = registry.register(
125
128
  screenName: options?.screenName ?? "",
126
129
  content: { AnyView(content()) },
127
130
  options: options
@@ -134,7 +137,7 @@ public final class Navigator: ObservableObject {
134
137
  @ViewBuilder _ content: @escaping () -> V,
135
138
  options: NavigationOptions? = nil
136
139
  ) {
137
- let route = DynamicScreenRegistry.shared.register(
140
+ let route = registry.register(
138
141
  screenName: screenName,
139
142
  content: { AnyView(content()) },
140
143
  options: options
@@ -145,7 +148,7 @@ public final class Navigator: ObservableObject {
145
148
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
146
149
  guard !path.isEmpty else { return }
147
150
  let removed = path.removeLast()
148
- DynamicScreenRegistry.shared.unregister(id: removed.id)
151
+ registry.unregister(id: removed.id)
149
152
  push(content, options: options)
150
153
  }
151
154
 
@@ -156,15 +159,15 @@ public final class Navigator: ObservableObject {
156
159
  ) {
157
160
  guard !path.isEmpty else { return }
158
161
  let removed = path.removeLast()
159
- DynamicScreenRegistry.shared.unregister(id: removed.id)
162
+ registry.unregister(id: removed.id)
160
163
  push(screenName: screenName, content, options: options)
161
164
  }
162
165
 
163
166
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
164
- for route in path { DynamicScreenRegistry.shared.unregister(id: route.id) }
165
- DynamicScreenRegistry.shared.unregister(id: rootRoute.id)
167
+ for route in path { registry.unregister(id: route.id) }
168
+ registry.unregister(id: rootRoute.id)
166
169
  path.removeAll()
167
- let route = DynamicScreenRegistry.shared.register(
170
+ let route = registry.register(
168
171
  screenName: options?.screenName ?? "",
169
172
  content: { AnyView(content()) },
170
173
  options: options
@@ -177,10 +180,10 @@ public final class Navigator: ObservableObject {
177
180
  @ViewBuilder _ content: @escaping () -> V,
178
181
  options: NavigationOptions? = nil
179
182
  ) {
180
- for route in path { DynamicScreenRegistry.shared.unregister(id: route.id) }
181
- DynamicScreenRegistry.shared.unregister(id: rootRoute.id)
183
+ for route in path { registry.unregister(id: route.id) }
184
+ registry.unregister(id: rootRoute.id)
182
185
  path.removeAll()
183
- let route = DynamicScreenRegistry.shared.register(
186
+ let route = registry.register(
184
187
  screenName: screenName,
185
188
  content: { AnyView(content()) },
186
189
  options: options
@@ -191,17 +194,16 @@ public final class Navigator: ObservableObject {
191
194
  public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
192
195
  var remaining = count
193
196
  while remaining > 0 {
194
- if overplay != nil {
197
+ if overplay?.type == .snackBar {
198
+ // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
199
+ // while one is showing pops the screen underneath and clears the
200
+ // snackbar immediately, without playing its dismiss animation.
201
+ overplayDismissHandler = nil
202
+ overplay = nil
203
+ if !popScreen() { break }
204
+ } else if overplay != nil {
195
205
  hideOverplay()
196
- } else if presented != nil {
197
- let dismissedId = presented?.id
198
- presented = nil
199
- if let id = dismissedId { DynamicScreenRegistry.shared.unregister(id: id) }
200
- } else if !path.isEmpty {
201
- let removed = path.removeLast()
202
- DynamicScreenRegistry.shared.unregister(id: removed.id)
203
- } else {
204
- composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
206
+ } else if !popScreen() {
205
207
  break
206
208
  }
207
209
  remaining -= 1
@@ -209,13 +211,32 @@ public final class Navigator: ObservableObject {
209
211
  callback?()
210
212
  }
211
213
 
214
+ /// Pops the topmost presented screen or stack entry. Returns `false` (and requests
215
+ /// a host dismiss) when there is nothing left to pop.
216
+ @discardableResult
217
+ private func popScreen() -> Bool {
218
+ if presented != nil {
219
+ let dismissedId = presented?.id
220
+ presented = nil
221
+ if let id = dismissedId { registry.unregister(id: id) }
222
+ return true
223
+ } else if !path.isEmpty {
224
+ let removed = path.removeLast()
225
+ registry.unregister(id: removed.id)
226
+ return true
227
+ } else {
228
+ composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
229
+ return false
230
+ }
231
+ }
232
+
212
233
  public func popToRoot() {
213
- for route in path { DynamicScreenRegistry.shared.unregister(id: route.id) }
234
+ for route in path { registry.unregister(id: route.id) }
214
235
  path.removeAll()
215
236
  }
216
237
 
217
238
  public func onBackSafe(callback: (() -> Void)? = nil) {
218
- let latest = DynamicScreenRegistry.shared.latest()
239
+ let latest = registry.latest()
219
240
  if let onBack = latest?.options?.onBackHandler {
220
241
  onBack()
221
242
  return
@@ -256,7 +277,7 @@ public final class Navigator: ObservableObject {
256
277
  // MARK: Modal-style screen (vertical slide)
257
278
 
258
279
  public func present<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
259
- let route = DynamicScreenRegistry.shared.register(
280
+ let route = registry.register(
260
281
  screenName: options?.screenName ?? "",
261
282
  content: { AnyView(content()) },
262
283
  options: options
@@ -269,7 +290,7 @@ public final class Navigator: ObservableObject {
269
290
  @ViewBuilder _ content: @escaping () -> V,
270
291
  options: NavigationOptions? = nil
271
292
  ) {
272
- let route = DynamicScreenRegistry.shared.register(
293
+ let route = registry.register(
273
294
  screenName: screenName,
274
295
  content: { AnyView(content()) },
275
296
  options: options
@@ -279,7 +300,7 @@ public final class Navigator: ObservableObject {
279
300
 
280
301
  public func dismiss() {
281
302
  if let id = presented?.id {
282
- DynamicScreenRegistry.shared.unregister(id: id)
303
+ registry.unregister(id: id)
283
304
  }
284
305
  presented = nil
285
306
  }
@@ -15,6 +15,7 @@ struct ModalScreen<Content: View>: View {
15
15
  let content: () -> Content
16
16
 
17
17
  @EnvironmentObject private var navigator: Navigator
18
+ @Environment(\.applicationEnvironment) private var appEnv
18
19
 
19
20
  @State private var scrimOpacity: CGFloat = 0
20
21
  @State private var contentScale: CGFloat = 0.8
@@ -38,6 +39,11 @@ struct ModalScreen<Content: View>: View {
38
39
  .onAppear {
39
40
  guard !didAppear else { return }
40
41
  didAppear = true
42
+ ScreenTracker.trackPopupDisplayed(
43
+ maxApi: appEnv.maxApi,
44
+ context: appEnv.applicationContext,
45
+ parentScreenName: ScreenTracker.getLastScreenName()
46
+ )
41
47
  navigator.registerOverplayDismiss { close() }
42
48
  open()
43
49
  }
@@ -0,0 +1,117 @@
1
+ //
2
+ // ScreenTracker.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `navigation/tracking/ScreenTracker.kt`. Pure
6
+ // logic, no platform dependency beyond the `MaxApi` bridge.
7
+ //
8
+
9
+ import Foundation
10
+
11
+ public final class ScreenTrackingState {
12
+ public var time: Double = Date().timeIntervalSince1970 * 1000
13
+ public var releaseNavigated: Bool = false
14
+ public var releaseLoad: Bool = false
15
+ public var releaseInteraction: Bool = false
16
+ public var previousScreenName: String?
17
+ public var action: String = "push"
18
+
19
+ public init() {}
20
+ }
21
+
22
+ public enum ScreenTracker {
23
+ private static var lastScreenName: String?
24
+
25
+ public static func trackScreenNavigated(
26
+ maxApi: MaxApi?,
27
+ context: MiniAppContext?,
28
+ screenName: String,
29
+ action: String,
30
+ state: ScreenTrackingState
31
+ ) {
32
+ guard !state.releaseNavigated else { return }
33
+
34
+ let previousScreen = lastScreenName
35
+ lastScreenName = screenName
36
+
37
+ var params = buildBaseParams(context: context, screenName: screenName)
38
+ params["pre_screen_name"] = previousScreen
39
+ params["state"] = "navigated"
40
+ params["action"] = action
41
+ maxApi?.track(eventName: "auto_screen_navigated", params: params)
42
+
43
+ state.releaseNavigated = true
44
+ state.previousScreenName = previousScreen
45
+ }
46
+
47
+ public static func trackScreenDisplayed(
48
+ maxApi: MaxApi?,
49
+ context: MiniAppContext?,
50
+ screenName: String,
51
+ duration: Double,
52
+ state: ScreenTrackingState
53
+ ) {
54
+ guard !state.releaseLoad else { return }
55
+
56
+ var params = buildBaseParams(context: context, screenName: screenName)
57
+ params["state"] = "load"
58
+ params["duration"] = duration
59
+ maxApi?.track(eventName: "auto_screen_displayed", params: params)
60
+
61
+ state.releaseLoad = true
62
+ }
63
+
64
+ public static func trackScreenInteracted(
65
+ maxApi: MaxApi?,
66
+ context: MiniAppContext?,
67
+ screenName: String,
68
+ duration: Double,
69
+ totalDuration: Double,
70
+ state: ScreenTrackingState
71
+ ) {
72
+ guard !state.releaseInteraction else { return }
73
+
74
+ var params = buildBaseParams(context: context, screenName: screenName)
75
+ params["state"] = "interaction"
76
+ params["duration"] = duration
77
+ params["total_duration"] = totalDuration
78
+ maxApi?.track(eventName: "auto_screen_interacted", params: params)
79
+
80
+ state.releaseInteraction = true
81
+ }
82
+
83
+ public static func trackPopupDisplayed(
84
+ maxApi: MaxApi?,
85
+ context: MiniAppContext?,
86
+ parentScreenName: String?
87
+ ) {
88
+ maxApi?.track(eventName: "auto_popup_displayed", params: [
89
+ "screen_name": parentScreenName,
90
+ "tracking_source": 1,
91
+ "app_id": context?.appId,
92
+ "feature_code": context?.appCode,
93
+ "kits_version": nil,
94
+ "component_name": "Modal"
95
+ ])
96
+ }
97
+
98
+ private static func buildBaseParams(
99
+ context: MiniAppContext?,
100
+ screenName: String,
101
+ componentName: String = "Screen"
102
+ ) -> [String: Any?] {
103
+ [
104
+ "app_id": context?.appId,
105
+ "feature_code": context?.appCode,
106
+ "screen_name": screenName,
107
+ "component_name": componentName,
108
+ "tracking_source": 1
109
+ ]
110
+ }
111
+
112
+ public static func getLastScreenName() -> String? { lastScreenName }
113
+
114
+ public static func reset() {
115
+ lastScreenName = nil
116
+ }
117
+ }
@@ -39,6 +39,7 @@ public struct StackScreen<Content: View>: View {
39
39
  var layoutOffset: CGFloat
40
40
  var inputSearchProps: InputSearchProps?
41
41
  var fabProps: FabProps?
42
+ var screenName: String = ""
42
43
  var content: () -> Content
43
44
 
44
45
  public init(
@@ -66,6 +67,7 @@ public struct StackScreen<Content: View>: View {
66
67
  layoutOffset: CGFloat = 56,
67
68
  inputSearchProps: InputSearchProps? = nil,
68
69
  fabProps: FabProps? = nil,
70
+ screenName: String = "",
69
71
  @ViewBuilder content: @escaping () -> Content
70
72
  ) {
71
73
  self.backgroundColor = backgroundColor
@@ -92,6 +94,7 @@ public struct StackScreen<Content: View>: View {
92
94
  self.layoutOffset = layoutOffset
93
95
  self.inputSearchProps = inputSearchProps
94
96
  self.fabProps = fabProps
97
+ self.screenName = screenName
95
98
  self.content = content
96
99
  }
97
100
 
@@ -100,6 +103,13 @@ public struct StackScreen<Content: View>: View {
100
103
  @State private var headerRightWidth: CGFloat = 0
101
104
  @State private var isBottomTabRoot: Bool = false
102
105
 
106
+ @Environment(\.applicationEnvironment) private var appEnv
107
+
108
+ // Auto-tracking state (mirrors Compose's `remember { ScreenTrackingState() }` +
109
+ // the once-computed push/back `action`).
110
+ @State private var trackingState = ScreenTrackingState()
111
+ @State private var trackingAction: String = ScreenTracker.getLastScreenName() != nil ? "push" : "back"
112
+
103
113
  /// Header background fade tied to scroll: 0 → transparent (over a hero image
104
114
  /// or transparent header), 1 → opaque surface with the drop shadow.
105
115
  private var scrollOpacity: CGFloat { min(scrollOffset / 114, 1) }
@@ -193,6 +203,37 @@ public struct StackScreen<Content: View>: View {
193
203
  // taps still reach buttons/fields while also dismissing the keyboard.
194
204
  .modifier(HideKeyboardOnTap(enabled: keyboardShouldPersistTaps))
195
205
  }
206
+ .task(id: screenName) {
207
+ ScreenTracker.trackScreenNavigated(
208
+ maxApi: appEnv.maxApi,
209
+ context: appEnv.applicationContext,
210
+ screenName: screenName,
211
+ action: trackingAction,
212
+ state: trackingState
213
+ )
214
+ do {
215
+ try await Task.sleep(nanoseconds: 2_000_000_000)
216
+ } catch {
217
+ return
218
+ }
219
+ let loadTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
220
+ ScreenTracker.trackScreenDisplayed(
221
+ maxApi: appEnv.maxApi,
222
+ context: appEnv.applicationContext,
223
+ screenName: screenName,
224
+ duration: loadTime,
225
+ state: trackingState
226
+ )
227
+ let interactionTime = Date().timeIntervalSince1970 * 1000 - trackingState.time
228
+ ScreenTracker.trackScreenInteracted(
229
+ maxApi: appEnv.maxApi,
230
+ context: appEnv.applicationContext,
231
+ screenName: screenName,
232
+ duration: interactionTime - loadTime,
233
+ totalDuration: interactionTime,
234
+ state: trackingState
235
+ )
236
+ }
196
237
  }
197
238
 
198
239
  // MARK: - Header + content layer
@@ -196,6 +196,86 @@ public struct BadgeRibbon: View {
196
196
  }
197
197
  }
198
198
 
199
+ /// Ported from Compose's `RoundedBadgeRibbon` (BadgeRibbon.kt:164-326): a canvas-drawn
200
+ /// ribbon shape sized to its text, mirrored per corner via scaleX/scaleY (never an actual
201
+ /// rotation) — the label gets the same scale applied a second time so it stays right-reading
202
+ /// while only the ribbon shape flips.
203
+ public struct RoundedBadgeRibbon: View {
204
+ let text: String
205
+ let position: RibbonPosition
206
+
207
+ public init(text: String = "Label", position: RibbonPosition = .topLeft) {
208
+ self.text = text
209
+ self.position = position
210
+ }
211
+
212
+ private var scale: (x: CGFloat, y: CGFloat) {
213
+ switch position {
214
+ case .topLeft: return (-1, 1)
215
+ case .topRight: return (1, 1)
216
+ case .bottomRight: return (1, -1)
217
+ case .bottomLeft: return (-1, -1)
218
+ }
219
+ }
220
+
221
+ private var measuredTextSize: CGSize {
222
+ let fontSize = scaleSize(TypographyStyle.labelXsMedium.fontSize)
223
+ let font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
224
+ return text.size(withAttributes: [.font: font])
225
+ }
226
+
227
+ public var body: some View {
228
+ let textSize = measuredTextSize
229
+ let minRibbonHeight = max(textSize.height * 1.2, 16)
230
+ let padding = minRibbonHeight / 4
231
+ let badgeHeight = minRibbonHeight + padding
232
+ let badgeWidth = max(textSize.width + padding * 2, 28)
233
+
234
+ ZStack {
235
+ Canvas { context, size in
236
+ let ribbonHeight = size.height - padding
237
+ let cornerRadius = (badgeHeight - padding) / 2
238
+ let mainColor = Colors.orange03
239
+ let tailColor = Colors.red01
240
+
241
+ let headWidth: CGFloat = 4
242
+ // Kotlin computes `leftSectionWidth` + `middleSectionWidth` here, but those
243
+ // cancel algebraically to `width - headWidth` — kept as the direct result.
244
+ let rightX = size.width - headWidth
245
+
246
+ let leftMiddlePath = RoundedCorners(topLeft: cornerRadius, topRight: 0, bottomLeft: cornerRadius, bottomRight: 0)
247
+ .path(in: CGRect(x: 0, y: 0, width: rightX + 1, height: ribbonHeight))
248
+ context.fill(leftMiddlePath, with: .color(mainColor))
249
+
250
+ let headPath = RoundedCorners(topLeft: 0, topRight: cornerRadius, bottomLeft: 0, bottomRight: 0)
251
+ .path(in: CGRect(x: rightX, y: 0, width: headWidth, height: ribbonHeight))
252
+ context.fill(headPath, with: .color(mainColor))
253
+
254
+ let halfHeadWidth = headWidth / 2
255
+ let tailStartX = rightX + headWidth - halfHeadWidth
256
+ let tailY = ribbonHeight
257
+ let tailHeight = padding
258
+
259
+ context.fill(
260
+ Path(CGRect(x: tailStartX, y: tailY, width: halfHeadWidth, height: halfHeadWidth)),
261
+ with: .color(mainColor)
262
+ )
263
+
264
+ let tailRightPath = RoundedCorners(topLeft: 0, topRight: tailHeight, bottomLeft: 0, bottomRight: tailHeight)
265
+ .path(in: CGRect(x: tailStartX, y: tailY, width: halfHeadWidth, height: tailHeight))
266
+ context.fill(tailRightPath, with: .color(tailColor))
267
+ }
268
+
269
+ MomoText(text, typography: .labelXsMedium, color: Colors.black01, maxLines: 1)
270
+ .padding(.bottom, padding)
271
+ .padding(.horizontal, padding / 2)
272
+ .scaleEffect(x: scale.x, y: scale.y)
273
+ }
274
+ .frame(width: badgeWidth, height: badgeHeight)
275
+ .scaleEffect(x: scale.x, y: scale.y)
276
+ }
277
+ }
278
+
199
279
  // MARK: - Constants
200
280
  private struct RibbonConstants {
201
281
  static let ribbonHeight: CGFloat = 20