@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.
- package/ios/Application/ApplicationEnvironment.swift +4 -2
- package/ios/Application/MaxApi.swift +18 -0
- package/ios/Application/Navigation/NavigationContainer.swift +21 -3
- package/ios/Application/Navigation/Navigator.swift +50 -29
- package/ios/Application/Navigation/Overplay/ModalScreen.swift +6 -0
- package/ios/Application/Navigation/Tracking/ScreenTracker.swift +117 -0
- package/ios/Application/StackScreen.swift +41 -0
- package/ios/Badge/BadgeRibbon.swift +80 -0
- package/ios/Button/Button.swift +67 -11
- package/ios/Carousel/Carousel.swift +16 -1
- package/ios/Checkbox/Checkbox.swift +14 -20
- package/ios/Chip/Chip.swift +35 -26
- package/ios/Collapse/Collapse.swift +11 -1
- package/ios/DateTimePicker/DateTimePicker.swift +4 -2
- package/ios/DateTimePicker/WheelPicker.swift +7 -3
- package/ios/Extensions/ActiveOpacityButtonStyle.swift +14 -0
- package/ios/Icon/Icon.swift +5 -1
- package/ios/IconButton/IconButton.swift +35 -7
- package/ios/Image/Image.swift +16 -3
- package/ios/Information/Information.swift +3 -1
- package/ios/Input/ErrorView.swift +3 -1
- package/ios/Input/Input.swift +31 -8
- package/ios/Input/InputPhoneNumber.swift +14 -3
- package/ios/Input/InputSearch.swift +7 -1
- package/ios/Input/InputTextArea.swift +78 -34
- package/ios/InputDropDown/InputDropDown.swift +4 -1
- package/ios/InputMoney/InputMoney.swift +16 -4
- package/ios/Popup/PopupDisplay.swift +3 -4
- package/ios/Popup/PopupNotify.swift +3 -4
- package/ios/Popup/PopupPromotion.swift +16 -8
- package/ios/ProgressInfo/ProgressInfo.swift +4 -2
- package/ios/Radio/Radio.swift +12 -13
- package/ios/Rating/Rating.swift +1 -1
- package/ios/Stepper/Stepper.swift +2 -2
- package/ios/Steps/Steps.swift +22 -6
- package/ios/SuggestAction/SuggestAction.swift +5 -3
- package/ios/Swipeable/SwipeCell.swift +5 -3
- package/ios/Switch/Switch.swift +53 -28
- package/ios/TabView/TabView.swift +18 -14
- package/ios/Template/TrustBanner/TrustBanner.swift +6 -7
- package/ios/Tooltip/Tooltip.swift +3 -0
- package/ios/Uploader/Uploader.swift +0 -9
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- 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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
165
|
-
|
|
167
|
+
for route in path { registry.unregister(id: route.id) }
|
|
168
|
+
registry.unregister(id: rootRoute.id)
|
|
166
169
|
path.removeAll()
|
|
167
|
-
let route =
|
|
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 {
|
|
181
|
-
|
|
183
|
+
for route in path { registry.unregister(id: route.id) }
|
|
184
|
+
registry.unregister(id: rootRoute.id)
|
|
182
185
|
path.removeAll()
|
|
183
|
-
let route =
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|