@momo-kits/native-kits 0.168.1-beta.4-debug → 0.168.1-beta.5-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.
- package/CLAUDE.md +4 -4
- package/README.md +2 -10
- package/build.gradle.kts +1 -3
- package/compose/build.gradle.kts +20 -19
- package/compose/build.gradle.kts.backup +19 -18
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/WidgetContainer.kt +14 -23
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +5 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +14 -24
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +1 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +1 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +1 -6
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +30 -44
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/tracking/ScreenTracker.kt +32 -40
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/ScrollToTop.ios.kt +2 -6
- package/gradle/libs.versions.toml +4 -11
- package/gradle/wrapper/gradle-wrapper.properties +1 -3
- package/gradle.properties +3 -3
- package/ios/Layout/ScreenLayout.swift +5 -0
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
- package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
- package/ios/Navigation/Component/HeaderBackground.swift +22 -1
- package/ios/Navigation/StackScreen.swift +3 -7
- package/package.json +1 -1
- package/publish.sh +2 -33
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +0 -37
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +0 -41
- package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +0 -44
- package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +0 -62
package/gradle.properties
CHANGED
|
@@ -18,9 +18,9 @@ kotlin.apple.xcodeCompatibility.nowarn=true
|
|
|
18
18
|
name="ComposeKits"
|
|
19
19
|
group=vn.momo.kits
|
|
20
20
|
artifact.id=kits
|
|
21
|
-
version=0.
|
|
21
|
+
version=0.167.1-duo.15
|
|
22
22
|
|
|
23
23
|
repo=GitLab
|
|
24
24
|
url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
|
|
25
|
-
gitlab.user=
|
|
26
|
-
gitlab.password=
|
|
25
|
+
gitlab.user=upload_packages
|
|
26
|
+
gitlab.password=gldt-CtTSGmzz57sUL9cnjdaQ
|
|
@@ -68,6 +68,11 @@ enum DuoMetrics {
|
|
|
68
68
|
// (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
|
|
69
69
|
static let innerStatusBarCenterY: CGFloat = 47
|
|
70
70
|
static let innerStatusBarWidth: CGFloat = 120
|
|
71
|
+
// Host app's iOS 26 tab bar on the inner display in portrait: a 62pt glass pill sized to its tabs,
|
|
72
|
+
// QR as a 62pt platter beside it, ≥ 20pt from the sides, 13pt into the home indicator inset.
|
|
73
|
+
static let floatingTabBarHeight: CGFloat = 62
|
|
74
|
+
static let floatingTabBarMargin: CGFloat = 20
|
|
75
|
+
static let floatingTabBarInsetOverlap: CGFloat = 13
|
|
71
76
|
// Widest short side of any single-display iPhone.
|
|
72
77
|
static let iPhoneMaxShortSide: CGFloat = 440
|
|
73
78
|
}
|
|
@@ -35,8 +35,6 @@ struct DuoBarCompression {
|
|
|
35
35
|
static let defaultToolbarHeight = DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch
|
|
36
36
|
/// Back, then the single button the toolbar folds into.
|
|
37
37
|
private static let compressedToolbarHeight = 2 * DuoMetrics.controlSize + Spacing.M
|
|
38
|
-
/// Back alone, while a rail opened from the compressed state takes the toolbar slot.
|
|
39
|
-
private static let backOnlyHeight = DuoMetrics.controlSize
|
|
40
38
|
|
|
41
39
|
/// - both fit: both stay whole
|
|
42
40
|
/// - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
@@ -72,7 +70,8 @@ struct DuoBarCompression {
|
|
|
72
70
|
|
|
73
71
|
let expandToolbar = { opened.wrappedValue = .toolbar }
|
|
74
72
|
let expandTabBar = { opened.wrappedValue = .tabBar }
|
|
75
|
-
|
|
73
|
+
// an opened rail always shows, even when only the group gap is short: it closes once a tab is picked
|
|
74
|
+
if opened.wrappedValue == .tabBar {
|
|
76
75
|
return DuoBarCompression(
|
|
77
76
|
toolbarCompressed: true,
|
|
78
77
|
tabBarCompressed: false,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// MoMoUIKits
|
|
4
4
|
//
|
|
5
5
|
// iPhone Duo tab bar, port of RN `BottomTab/DuoTabBar.tsx` (Figma Fold-Design-System MT1/MT2):
|
|
6
|
-
// a
|
|
6
|
+
// a rail in the side bar, or a floating bar when unfolded portrait.
|
|
7
7
|
//
|
|
8
8
|
|
|
9
9
|
import SwiftUI
|
|
@@ -16,12 +16,15 @@ private let railGap = Spacing.XXS
|
|
|
16
16
|
// selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
|
|
17
17
|
private let railBubbleWidth: CGFloat = 44
|
|
18
18
|
private let railBubbleHeight: CGFloat = 58
|
|
19
|
-
let
|
|
19
|
+
private let floatingHeight = DuoMetrics.floatingTabBarHeight
|
|
20
20
|
private let floatingPadding = Spacing.XS
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
private let
|
|
21
|
+
// the system bar sizes its pill to the tabs instead of stretching it across the display
|
|
22
|
+
private let floatingItemWidth: CGFloat = 80
|
|
23
|
+
// while pressed the selection bubble grows into a lens past the pill's edges
|
|
24
|
+
private let lensScaleX: CGFloat = 1.2
|
|
25
|
+
private let lensScaleY: CGFloat = 1.3
|
|
26
|
+
// Figma Home-2026 tab bar "Selection": brand pink at 8%
|
|
27
|
+
private let selectionTintOpacity: Double = 0x14 / 255
|
|
25
28
|
private let tabSpring = Animation.interpolatingSpring(mass: 0.8, stiffness: 220, damping: 18)
|
|
26
29
|
|
|
27
30
|
/// Room tab screens leave for the Duo tab bar, provided by BottomTab to its tab StackScreens.
|
|
@@ -51,47 +54,34 @@ func duoRailHeight(itemCount: Int, hasQr: Bool, compressed: Bool = false) -> CGF
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
extension ScreenLayout {
|
|
57
|
+
/// Overlaps the home indicator inset like the system tab bar (21pt above the bottom with a 34pt inset).
|
|
58
|
+
var duoFloatingTabBarBottom: CGFloat {
|
|
59
|
+
max(insets.bottom - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S)
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
func duoTabBarInsets(itemCount: Int, hasFloatingButton: Bool, tabBarCompressed: Bool = false) -> DuoTabBarInsets {
|
|
55
63
|
guard isIPhoneDuo else { return DuoTabBarInsets() }
|
|
56
64
|
guard let sideBar else {
|
|
57
|
-
|
|
65
|
+
// overlay is measured from the bottom safe area, the bar reaches into it
|
|
66
|
+
return DuoTabBarInsets(overlay: floatingHeight + duoFloatingTabBarBottom - insets.bottom)
|
|
58
67
|
}
|
|
59
68
|
let rail = duoRailHeight(itemCount: itemCount, hasQr: hasFloatingButton, compressed: tabBarCompressed)
|
|
60
69
|
return DuoTabBarInsets(sideBarReserve: sideBar.bottom + rail + DuoBarCompression.groupGap)
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
///
|
|
65
|
-
struct DuoBarMaterial<S:
|
|
73
|
+
/// RN `BarMaterial`: surface with a 1pt border.
|
|
74
|
+
struct DuoBarMaterial<S: InsettableShape>: ViewModifier {
|
|
66
75
|
let shape: S
|
|
67
|
-
var visible: Bool = true
|
|
68
|
-
|
|
69
|
-
func body(content: Content) -> some View {
|
|
70
|
-
content.background(material.allowsHitTesting(false))
|
|
71
|
-
}
|
|
72
76
|
|
|
73
|
-
@
|
|
74
|
-
private var material: some View {
|
|
75
|
-
#if compiler(>=6.2)
|
|
76
|
-
if #available(iOS 26.0, *) {
|
|
77
|
-
// glass materializes by toggling its effect, it does not fade with opacity
|
|
78
|
-
Color.clear
|
|
79
|
-
.glassEffect(visible ? .regular : .identity, in: shape)
|
|
80
|
-
.shadow(color: Color.black.opacity(visible ? 0.16 : 0), radius: 16, x: 0, y: 6)
|
|
81
|
-
.animation(.easeInOut(duration: 0.25), value: visible)
|
|
82
|
-
} else {
|
|
83
|
-
blur
|
|
84
|
-
}
|
|
85
|
-
#else
|
|
86
|
-
blur
|
|
87
|
-
#endif
|
|
88
|
-
}
|
|
77
|
+
@Environment(\.appTheme) private var theme
|
|
89
78
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
79
|
+
func body(content: Content) -> some View {
|
|
80
|
+
content.background(
|
|
81
|
+
shape.fill(theme.colors.background.surface)
|
|
82
|
+
.overlay(shape.strokeBorder(theme.colors.border.default, lineWidth: 1))
|
|
83
|
+
.allowsHitTesting(false)
|
|
84
|
+
)
|
|
95
85
|
}
|
|
96
86
|
}
|
|
97
87
|
|
|
@@ -126,7 +116,7 @@ private struct DuoRailTabBar: View {
|
|
|
126
116
|
let onTabSelected: (Int) -> Void
|
|
127
117
|
let compression: DuoBarCompression
|
|
128
118
|
|
|
129
|
-
@
|
|
119
|
+
@State private var pressedIndex: Int?
|
|
130
120
|
|
|
131
121
|
private var isRight: Bool { sideBar.edge == .right }
|
|
132
122
|
|
|
@@ -157,15 +147,15 @@ private struct DuoRailTabBar: View {
|
|
|
157
147
|
.ignoresSafeArea()
|
|
158
148
|
}
|
|
159
149
|
|
|
160
|
-
///
|
|
150
|
+
/// Column of icon-only tab items with the sliding selection bubble.
|
|
161
151
|
private var rail: some View {
|
|
162
152
|
ZStack(alignment: .top) {
|
|
163
|
-
|
|
164
|
-
.fill(theme.colors.background.selected)
|
|
153
|
+
DuoSelectionMaterial(lens: pressedIndex != nil)
|
|
165
154
|
.frame(width: railBubbleWidth, height: railBubbleHeight)
|
|
166
155
|
.offset(y: railPadding - (railBubbleHeight - itemSize) / 2
|
|
167
|
-
+ CGFloat(selectedIndex) * (itemSize + railGap))
|
|
156
|
+
+ CGFloat(pressedIndex ?? selectedIndex) * (itemSize + railGap))
|
|
168
157
|
.animation(tabSpring, value: selectedIndex)
|
|
158
|
+
.animation(tabSpring, value: pressedIndex)
|
|
169
159
|
|
|
170
160
|
VStack(spacing: railGap) {
|
|
171
161
|
ForEach(items.indices, id: \.self) { index in
|
|
@@ -177,19 +167,19 @@ private struct DuoRailTabBar: View {
|
|
|
177
167
|
.frame(width: itemSize, height: itemSize)
|
|
178
168
|
.contentShape(Rectangle())
|
|
179
169
|
}
|
|
180
|
-
.buttonStyle(
|
|
170
|
+
.buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
|
|
181
171
|
.accessibilityLabel(items[index].label)
|
|
182
172
|
}
|
|
183
173
|
}
|
|
184
174
|
.padding(.vertical, railPadding)
|
|
185
175
|
}
|
|
186
176
|
.frame(width: itemSize)
|
|
187
|
-
.modifier(DuoBarMaterial(shape:
|
|
177
|
+
.modifier(DuoBarMaterial(shape: Capsule()))
|
|
188
178
|
.accessibilityIdentifier("bottom_tab_bar")
|
|
189
179
|
}
|
|
190
180
|
}
|
|
191
181
|
|
|
192
|
-
/// Floating labelled bar for the unfolded portrait display, QR in
|
|
182
|
+
/// Floating labelled bar for the unfolded portrait display, QR pinned in its own platter beside it.
|
|
193
183
|
private struct DuoFloatingTabBar: View {
|
|
194
184
|
let items: [BottomTabItem]
|
|
195
185
|
let floatingButton: BottomTabFloatingButton?
|
|
@@ -198,45 +188,48 @@ private struct DuoFloatingTabBar: View {
|
|
|
198
188
|
|
|
199
189
|
@Environment(\.screenLayout) private var layout
|
|
200
190
|
@Environment(\.appTheme) private var theme
|
|
191
|
+
@State private var pressedIndex: Int?
|
|
201
192
|
|
|
202
193
|
private var barWidth: CGFloat {
|
|
203
|
-
let
|
|
204
|
-
|
|
194
|
+
let margins = layout.insets.leading + layout.insets.trailing + 2 * DuoMetrics.floatingTabBarMargin
|
|
195
|
+
let qr = floatingButton == nil ? 0 : floatingHeight + Spacing.S
|
|
196
|
+
let preferred = CGFloat(items.count) * floatingItemWidth + 2 * floatingPadding
|
|
197
|
+
return max(min(preferred, layout.width - margins - qr), 0)
|
|
205
198
|
}
|
|
206
199
|
|
|
207
|
-
private var
|
|
208
|
-
private var middle: Int { items.count / 2 }
|
|
209
|
-
private var cellCount: Int { items.count + (useQr ? 1 : 0) }
|
|
210
|
-
private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(cellCount, 1)) }
|
|
211
|
-
private var visualIndex: Int { useQr && selectedIndex >= middle ? selectedIndex + 1 : selectedIndex }
|
|
200
|
+
private var cellWidth: CGFloat { (barWidth - floatingPadding * 2) / CGFloat(max(items.count, 1)) }
|
|
212
201
|
|
|
213
202
|
var body: some View {
|
|
203
|
+
HStack(spacing: Spacing.S) {
|
|
204
|
+
bar
|
|
205
|
+
if let floatingButton {
|
|
206
|
+
DuoQrButton(data: floatingButton, size: floatingHeight)
|
|
207
|
+
.shadow(color: Color.black.opacity(0.16), radius: 16, x: 0, y: 6)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
.padding(.bottom, layout.duoFloatingTabBarBottom)
|
|
211
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
212
|
+
.ignoresSafeArea()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private var bar: some View {
|
|
214
216
|
ZStack(alignment: .leading) {
|
|
215
|
-
|
|
216
|
-
.fill(theme.colors.background.selected)
|
|
217
|
+
DuoSelectionMaterial(lens: pressedIndex != nil)
|
|
217
218
|
.frame(width: cellWidth)
|
|
218
|
-
.offset(x: CGFloat(
|
|
219
|
-
.animation(tabSpring, value:
|
|
219
|
+
.offset(x: CGFloat(pressedIndex ?? selectedIndex) * cellWidth)
|
|
220
|
+
.animation(tabSpring, value: selectedIndex)
|
|
221
|
+
.animation(tabSpring, value: pressedIndex)
|
|
220
222
|
|
|
221
223
|
HStack(spacing: 0) {
|
|
222
|
-
ForEach(
|
|
223
|
-
|
|
224
|
-
DuoQrButton(data: floatingButton, size: floatingQrSize)
|
|
225
|
-
.frame(width: cellWidth)
|
|
226
|
-
} else {
|
|
227
|
-
let index = useQr && cell > middle ? cell - 1 : cell
|
|
228
|
-
tabCell(index)
|
|
229
|
-
}
|
|
224
|
+
ForEach(items.indices, id: \.self) { index in
|
|
225
|
+
tabCell(index)
|
|
230
226
|
}
|
|
231
227
|
}
|
|
232
228
|
}
|
|
233
229
|
.padding(floatingPadding)
|
|
234
|
-
.frame(width: barWidth, height:
|
|
230
|
+
.frame(width: barWidth, height: floatingHeight)
|
|
235
231
|
.modifier(DuoBarMaterial(shape: Capsule()))
|
|
236
232
|
.accessibilityIdentifier("bottom_tab_bar")
|
|
237
|
-
.padding(.bottom, layout.insets.bottom + Spacing.M)
|
|
238
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
239
|
-
.ignoresSafeArea()
|
|
240
233
|
}
|
|
241
234
|
|
|
242
235
|
private func tabCell(_ index: Int) -> some View {
|
|
@@ -247,20 +240,52 @@ private struct DuoFloatingTabBar: View {
|
|
|
247
240
|
MomoText(
|
|
248
241
|
items[index].label,
|
|
249
242
|
typography: .labelXsMedium,
|
|
250
|
-
color: selected ? theme.colors.primary : theme.colors.text.
|
|
243
|
+
color: selected ? theme.colors.primary : theme.colors.text.hint,
|
|
251
244
|
textAlign: .center,
|
|
252
245
|
maxLines: 1
|
|
253
246
|
)
|
|
247
|
+
// like the system tab bar the label keeps its design size; a constant snapshot, nothing to observe
|
|
248
|
+
.environment(\.momoFontScale, FontScale(userScaleRate: 1, useOSScaleRate: false))
|
|
254
249
|
}
|
|
255
250
|
.frame(width: cellWidth)
|
|
256
251
|
.frame(maxHeight: .infinity)
|
|
257
252
|
.contentShape(Rectangle())
|
|
258
253
|
}
|
|
259
|
-
.buttonStyle(
|
|
254
|
+
.buttonStyle(DuoLensPressStyle(index: index, pressedIndex: $pressedIndex))
|
|
260
255
|
.accessibilityLabel(items[index].label)
|
|
261
256
|
}
|
|
262
257
|
}
|
|
263
258
|
|
|
259
|
+
/// Pink tinted capsule under the selected tab; while pressed it grows into a lens (80x54 to about 96x70).
|
|
260
|
+
private struct DuoSelectionMaterial: View {
|
|
261
|
+
let lens: Bool
|
|
262
|
+
|
|
263
|
+
@Environment(\.appTheme) private var theme
|
|
264
|
+
|
|
265
|
+
var body: some View {
|
|
266
|
+
Capsule()
|
|
267
|
+
.fill(theme.colors.primary.opacity(selectionTintOpacity))
|
|
268
|
+
.scaleEffect(x: lens ? lensScaleX : 1, y: lens ? lensScaleY : 1)
|
|
269
|
+
.allowsHitTesting(false)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Tracks the finger so the selection lens jumps to the pressed tab before it is selected.
|
|
274
|
+
private struct DuoLensPressStyle: ButtonStyle {
|
|
275
|
+
let index: Int
|
|
276
|
+
@Binding var pressedIndex: Int?
|
|
277
|
+
|
|
278
|
+
func makeBody(configuration: Configuration) -> some View {
|
|
279
|
+
configuration.label.onChange(of: configuration.isPressed) { pressed in
|
|
280
|
+
if pressed {
|
|
281
|
+
pressedIndex = index
|
|
282
|
+
} else if pressedIndex == index {
|
|
283
|
+
pressedIndex = nil
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
private struct DuoTabIcon: View {
|
|
265
290
|
let item: BottomTabItem
|
|
266
291
|
let selected: Bool
|
|
@@ -274,7 +299,7 @@ private struct DuoTabIcon: View {
|
|
|
274
299
|
}
|
|
275
300
|
|
|
276
301
|
var body: some View {
|
|
277
|
-
Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.
|
|
302
|
+
Icon(source: item.icon, size: iconSize, color: selected ? theme.colors.primary : theme.colors.text.hint)
|
|
278
303
|
.overlay(alignment: .topTrailing) {
|
|
279
304
|
if let badgeLabel, !badgeLabel.isEmpty {
|
|
280
305
|
Badge(label: badgeLabel).offset(x: Spacing.S, y: -Spacing.XS)
|
|
@@ -90,6 +90,27 @@ private let duoGradientExtra: CGFloat = 52
|
|
|
90
90
|
// scroll distance over which the extended gradient collapses to the header
|
|
91
91
|
private let duoExtendedCollapseDistance: CGFloat = 100
|
|
92
92
|
|
|
93
|
+
// scroll distance over which the bar fades in
|
|
94
|
+
private let duoBarFadeDistance: CGFloat = 16
|
|
95
|
+
|
|
96
|
+
/// Port of RN `DuoGlassBackground`: translucent surface with a hairline, shown once the content
|
|
97
|
+
/// scrolls `fadeStart` under the header.
|
|
98
|
+
struct DuoHeaderBar: View {
|
|
99
|
+
let scrollOffset: CGFloat
|
|
100
|
+
var fadeStart: CGFloat = 0
|
|
101
|
+
|
|
102
|
+
@Environment(\.appTheme) private var theme
|
|
103
|
+
|
|
104
|
+
var body: some View {
|
|
105
|
+
theme.colors.background.surface.opacity(0.8)
|
|
106
|
+
.overlay(alignment: .bottom) {
|
|
107
|
+
theme.colors.border.default.frame(height: 0.5)
|
|
108
|
+
}
|
|
109
|
+
.opacity(min(max((scrollOffset - fadeStart) / duoBarFadeDistance, 0), 1))
|
|
110
|
+
.allowsHitTesting(false)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
/// Port of RN `DuoScrollGradient`: with the glass header the content scrolls under the bar, so the
|
|
94
115
|
/// header gradient is drawn under it. Compose draws this inside `HeaderBackground`.
|
|
95
116
|
struct DuoScrollGradient: View {
|
|
@@ -114,7 +135,7 @@ struct DuoScrollGradient: View {
|
|
|
114
135
|
let progress = min(scroll / duoExtendedCollapseDistance, 1)
|
|
115
136
|
gradient
|
|
116
137
|
.frame(height: headerHeight + (extended ? duoGradientExtra * (1 - progress) : 0))
|
|
117
|
-
.opacity(1 - min(scroll / duoGradientExtra, 1))
|
|
138
|
+
.opacity(Double(1 - min(scroll / duoGradientExtra, 1)))
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
@@ -403,11 +403,8 @@ public struct StackScreen<Content: View>: View {
|
|
|
403
403
|
.background(alignment: .top) {
|
|
404
404
|
Group {
|
|
405
405
|
if layout.sideBar != nil {
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
shape: Rectangle(),
|
|
409
|
-
visible: scrollOffset > heroGlassStart(animatedHeader)
|
|
410
|
-
))
|
|
406
|
+
// the bar shows once the banner has scrolled under the header
|
|
407
|
+
DuoHeaderBar(scrollOffset: scrollOffset, fadeStart: heroGlassStart(animatedHeader))
|
|
411
408
|
} else {
|
|
412
409
|
HeaderBarBackground(opacity: opacity)
|
|
413
410
|
}
|
|
@@ -457,8 +454,7 @@ public struct StackScreen<Content: View>: View {
|
|
|
457
454
|
|
|
458
455
|
header
|
|
459
456
|
.background(
|
|
460
|
-
|
|
461
|
-
.modifier(DuoBarMaterial(shape: Rectangle(), visible: scrollOffset > 0))
|
|
457
|
+
DuoHeaderBar(scrollOffset: scrollOffset)
|
|
462
458
|
.edgesIgnoringSafeArea([.top, .horizontal])
|
|
463
459
|
)
|
|
464
460
|
.offset(y: -headerHide)
|
package/package.json
CHANGED
package/publish.sh
CHANGED
|
@@ -379,12 +379,6 @@ phase_publish_npm() {
|
|
|
379
379
|
exit 1
|
|
380
380
|
fi
|
|
381
381
|
|
|
382
|
-
# AI-GENERATED START: preserve an existing credential backup owned by another run
|
|
383
|
-
if [ -e gradle.properties.npm-backup ]; then
|
|
384
|
-
echo "❌ Error: gradle.properties.npm-backup already exists; recover it before publishing."
|
|
385
|
-
exit 1
|
|
386
|
-
fi
|
|
387
|
-
# AI-GENERATED END: preserve an existing credential backup owned by another run
|
|
388
382
|
echo "🔧 Preparing debug version with compose kits..."
|
|
389
383
|
|
|
390
384
|
# Create debug version
|
|
@@ -395,9 +389,6 @@ phase_publish_npm() {
|
|
|
395
389
|
restore_npm_workspace() {
|
|
396
390
|
[ -f .npmignore.backup ] && mv .npmignore.backup .npmignore
|
|
397
391
|
[ -f compose/build.gradle.kts.backup ] && mv compose/build.gradle.kts.backup compose/build.gradle.kts
|
|
398
|
-
# AI-GENERATED START: restore private publishing credentials after npm packaging
|
|
399
|
-
[ -f gradle.properties.npm-backup ] && mv gradle.properties.npm-backup gradle.properties
|
|
400
|
-
# AI-GENERATED END: restore private publishing credentials after npm packaging
|
|
401
392
|
local pkg_version
|
|
402
393
|
pkg_version=$(node -p "require('./package.json').version")
|
|
403
394
|
if [ "$pkg_version" != "$VERSION" ]; then
|
|
@@ -409,16 +400,6 @@ phase_publish_npm() {
|
|
|
409
400
|
fi
|
|
410
401
|
}
|
|
411
402
|
trap restore_npm_workspace EXIT
|
|
412
|
-
# AI-GENERATED START: keep private Maven credentials out of the debug npm package
|
|
413
|
-
cp gradle.properties gradle.properties.npm-backup
|
|
414
|
-
node <<'NODE'
|
|
415
|
-
const fs = require('fs');
|
|
416
|
-
const path = 'gradle.properties';
|
|
417
|
-
const sanitized = fs.readFileSync(path, 'utf8')
|
|
418
|
-
.replace(/^(gitlab\.(?:user|password)\s*=).*$/gm, '$1');
|
|
419
|
-
fs.writeFileSync(path, sanitized);
|
|
420
|
-
NODE
|
|
421
|
-
# AI-GENERATED END: keep private Maven credentials out of the debug npm package
|
|
422
403
|
echo "📦 Debug version: $DEBUG_VERSION"
|
|
423
404
|
|
|
424
405
|
# Get current version from package.json (in case it's different from $VERSION)
|
|
@@ -455,18 +436,12 @@ NODE
|
|
|
455
436
|
echo "🔧 Modifying android configuration for debug version..."
|
|
456
437
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
457
438
|
sed -i '' 's/namespace = "\$gitlabGroup\.\$gitlabArtifactId"/namespace = "vn.momo.kits.kits"/' compose/build.gradle.kts
|
|
458
|
-
|
|
459
|
-
sed -i '' \
|
|
460
|
-
's/compileSdk = libs\.versions\.android\.compileSdk\.get()\.toInt()/compileSdk = 36/' compose/build.gradle.kts
|
|
461
|
-
# AI-GENERATED END: keep the debug package on the approved SDK 36 toolchain
|
|
439
|
+
sed -i '' 's/compileSdk = libs\.versions\.android\.compileSdk\.get()\.toInt()/compileSdk = 35/' compose/build.gradle.kts
|
|
462
440
|
sed -i '' 's/minSdk = libs\.versions\.android\.minSdk\.get()\.toInt()/minSdk = 24/' compose/build.gradle.kts
|
|
463
441
|
sed -i '' "s/version = gitlabVersion/version = \"$DEBUG_VERSION\"/" compose/build.gradle.kts
|
|
464
442
|
else
|
|
465
443
|
sed -i 's/namespace = "\$gitlabGroup\.\$gitlabArtifactId"/namespace = "vn.momo.kits.kits"/' compose/build.gradle.kts
|
|
466
|
-
|
|
467
|
-
sed -i \
|
|
468
|
-
's/compileSdk = libs\.versions\.android\.compileSdk\.get()\.toInt()/compileSdk = 36/' compose/build.gradle.kts
|
|
469
|
-
# AI-GENERATED END: keep the debug package on the approved SDK 36 toolchain
|
|
444
|
+
sed -i 's/compileSdk = libs\.versions\.android\.compileSdk\.get()\.toInt()/compileSdk = 35/' compose/build.gradle.kts
|
|
470
445
|
sed -i 's/minSdk = libs\.versions\.android\.minSdk\.get()\.toInt()/minSdk = 24/' compose/build.gradle.kts
|
|
471
446
|
sed -i "s/version = gitlabVersion/version = \"$DEBUG_VERSION\"/" compose/build.gradle.kts
|
|
472
447
|
fi
|
|
@@ -488,9 +463,6 @@ build/
|
|
|
488
463
|
.gitattributes
|
|
489
464
|
.gitlab-ci.yml
|
|
490
465
|
.npmignore.backup
|
|
491
|
-
# AI-GENERATED START: exclude the private credential backup from debug npm packages
|
|
492
|
-
gradle.properties.npm-backup
|
|
493
|
-
# AI-GENERATED END: exclude the private credential backup from debug npm packages
|
|
494
466
|
EOF
|
|
495
467
|
|
|
496
468
|
echo "📤 Publishing debug version (with compose)..."
|
|
@@ -505,9 +477,6 @@ EOF
|
|
|
505
477
|
# Restore compose/build.gradle.kts
|
|
506
478
|
mv compose/build.gradle.kts.backup compose/build.gradle.kts
|
|
507
479
|
echo "✅ compose/build.gradle.kts restored"
|
|
508
|
-
# AI-GENERATED START: restore credentials before leaving the debug packaging phase
|
|
509
|
-
mv gradle.properties.npm-backup gradle.properties
|
|
510
|
-
# AI-GENERATED END: restore credentials before leaving the debug packaging phase
|
|
511
480
|
|
|
512
481
|
# Restore package.json version to original
|
|
513
482
|
# Verify current version is debug version before restoring
|
package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// File: HelpCenterRequest.kt
|
|
2
|
-
// Created At: 2026-10-19 +07:00
|
|
3
|
-
// Created By: AI | AI Agent: Codex | Model: GPT-6
|
|
4
|
-
// AI-GENERATED START: preserve the toolkit Help Center payload in the typed request
|
|
5
|
-
package vn.momo.kits.navigation.component
|
|
6
|
-
|
|
7
|
-
import kotlinx.serialization.json.JsonArray
|
|
8
|
-
import kotlinx.serialization.json.JsonElement
|
|
9
|
-
import kotlinx.serialization.json.JsonNull
|
|
10
|
-
import kotlinx.serialization.json.JsonObject
|
|
11
|
-
import kotlinx.serialization.json.JsonPrimitive
|
|
12
|
-
import vn.momo.kits.application.MiniAppContext
|
|
13
|
-
import vn.momo.maxapi.contract.navigation.model.BottomSheetRequest
|
|
14
|
-
import vn.momo.maxapi.contract.navigation.model.HelpCenterContext
|
|
15
|
-
|
|
16
|
-
internal fun MiniAppContext.helpCenterRequest() = BottomSheetRequest.HelpCenter(
|
|
17
|
-
HelpCenterContext(
|
|
18
|
-
appId = appId,
|
|
19
|
-
code = appCode,
|
|
20
|
-
name = appName?.helpCenterJson(),
|
|
21
|
-
icon = appIcon,
|
|
22
|
-
description = description?.helpCenterJson(),
|
|
23
|
-
),
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
private fun Any?.helpCenterJson(): JsonElement = when (this) {
|
|
27
|
-
null -> JsonNull
|
|
28
|
-
is JsonElement -> this
|
|
29
|
-
is String -> JsonPrimitive(this)
|
|
30
|
-
is Boolean -> JsonPrimitive(this)
|
|
31
|
-
is Number -> JsonPrimitive(this)
|
|
32
|
-
is Map<*, *> -> JsonObject(entries.associate { (key, value) -> key.toString() to value.helpCenterJson() })
|
|
33
|
-
is Iterable<*> -> JsonArray(map { it.helpCenterJson() })
|
|
34
|
-
is Array<*> -> JsonArray(map { it.helpCenterJson() })
|
|
35
|
-
else -> JsonPrimitive(toString())
|
|
36
|
-
}
|
|
37
|
-
// AI-GENERATED END: preserve the toolkit Help Center payload in the typed request
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// File: ToolkitRequests.kt
|
|
2
|
-
// Created At: 2026-10-12 +07:00 | Created By: AI | AI Agent: Codex | Model: GPT-6
|
|
3
|
-
// AI-GENERATED START: cancel only toolkit requests owned by this composition
|
|
4
|
-
package vn.momo.kits.navigation.component
|
|
5
|
-
|
|
6
|
-
import vn.momo.maxapi.runtime.ApiCallback
|
|
7
|
-
import vn.momo.maxapi.runtime.ApiRequestHandle
|
|
8
|
-
|
|
9
|
-
internal class ToolkitRequests {
|
|
10
|
-
private var active = true
|
|
11
|
-
private val handles = mutableSetOf<ApiRequestHandle>()
|
|
12
|
-
|
|
13
|
-
fun <T> launch(request: (ApiCallback<T>) -> ApiRequestHandle, onResult: ApiCallback<T>) {
|
|
14
|
-
if (!active) return
|
|
15
|
-
var completed = false
|
|
16
|
-
var handle: ApiRequestHandle? = null
|
|
17
|
-
val created = request { result ->
|
|
18
|
-
completed = true
|
|
19
|
-
handle?.let(handles::remove)
|
|
20
|
-
if (active) onResult(result)
|
|
21
|
-
}
|
|
22
|
-
handle = created
|
|
23
|
-
if (!active) created.cancel() else if (!completed) handles.add(created)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// AI-GENERATED START: retain subscriptions across nonterminal sheet events
|
|
27
|
-
fun <T> observe(request: (ApiCallback<T>) -> ApiRequestHandle, onResult: ApiCallback<T>) {
|
|
28
|
-
if (!active) return
|
|
29
|
-
val handle = request { result -> if (active) onResult(result) }
|
|
30
|
-
if (active) handles.add(handle) else handle.cancel()
|
|
31
|
-
}
|
|
32
|
-
// AI-GENERATED END: retain subscriptions across nonterminal sheet events
|
|
33
|
-
|
|
34
|
-
fun close() {
|
|
35
|
-
active = false
|
|
36
|
-
val pending = handles.toList()
|
|
37
|
-
handles.clear()
|
|
38
|
-
pending.forEach { it.cancel() }
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
// AI-GENERATED END: cancel only toolkit requests owned by this composition
|
package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// File: HelpCenterRequestTest.kt
|
|
2
|
-
// Created At: 2026-10-19 +07:00
|
|
3
|
-
// Created By: AI | AI Agent: Codex | Model: GPT-6
|
|
4
|
-
// AI-GENERATED START: verify Help Center identity and localized payload preservation
|
|
5
|
-
package vn.momo.kits.navigation.component
|
|
6
|
-
|
|
7
|
-
import kotlinx.serialization.json.JsonObject
|
|
8
|
-
import kotlinx.serialization.json.JsonPrimitive
|
|
9
|
-
import vn.momo.kits.application.MiniAppContext
|
|
10
|
-
import kotlin.test.Test
|
|
11
|
-
import kotlin.test.assertEquals
|
|
12
|
-
import kotlin.test.assertNull
|
|
13
|
-
|
|
14
|
-
class HelpCenterRequestTest {
|
|
15
|
-
@Test
|
|
16
|
-
fun preservesApplicationIdentityAndLocalizedObjects() {
|
|
17
|
-
val context = MiniAppContext(
|
|
18
|
-
appId = "app-id",
|
|
19
|
-
appCode = "app-code",
|
|
20
|
-
appIcon = "app-icon",
|
|
21
|
-
appName = mapOf("vi" to "Tên ứng dụng", "en" to "App name"),
|
|
22
|
-
description = mapOf("vi" to "Mô tả", "en" to "Description"),
|
|
23
|
-
)
|
|
24
|
-
val request = context.helpCenterRequest().context
|
|
25
|
-
assertEquals(context.appId, request.appId)
|
|
26
|
-
assertEquals(context.appCode, request.code)
|
|
27
|
-
assertEquals(context.appIcon, request.icon)
|
|
28
|
-
assertEquals(JsonObject(mapOf("vi" to JsonPrimitive("Tên ứng dụng"),
|
|
29
|
-
"en" to JsonPrimitive("App name"))), request.name)
|
|
30
|
-
assertEquals(JsonObject(mapOf("vi" to JsonPrimitive("Mô tả"),
|
|
31
|
-
"en" to JsonPrimitive("Description"))), request.description)
|
|
32
|
-
assertNull(request.support)
|
|
33
|
-
assertNull(request.toolkitConfig)
|
|
34
|
-
assertNull(request.permissions)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
@Test
|
|
38
|
-
fun preservesPlainStringsAndAbsentValues() {
|
|
39
|
-
val request = MiniAppContext(appName = "App name").helpCenterRequest().context
|
|
40
|
-
assertEquals(JsonPrimitive("App name"), request.name)
|
|
41
|
-
assertNull(request.description)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
// AI-GENERATED END: verify Help Center identity and localized payload preservation
|