@momo-kits/native-kits 0.163.1-beta.5-debug → 0.163.1-beta.6-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.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.163.1-beta.5-debug"
43
+ version = "0.163.1-beta.6-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.163.1-beta.5-debug'
3
+ spec.version = '0.163.1-beta.6-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.163.1-beta.5
21
+ version=0.163.1-beta.6
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -143,6 +143,22 @@ public struct HeaderRightWidthKey: PreferenceKey {
143
143
  }
144
144
  }
145
145
 
146
+ // MARK: - Bottom-tab root preference
147
+
148
+ /// Set by `BottomTab` on appear so its enclosing `StackScreen` can detect that
149
+ /// it's hosting a self-contained bottom-tab surface and skip its own bottom
150
+ /// safe-area reservation — mirrors Compose's `Navigation.markAsBottomTabRoot()`
151
+ /// (`navigation.isBottomTabRoot`, read by `StackScreen.kt`'s `MainContent`).
152
+ /// Without this, `BottomTab`'s own internal safe-area handling gets an extra,
153
+ /// unwanted inset stacked on top of it by the outer `StackScreen`, pushing the
154
+ /// tab bar up higher than the true bottom inset.
155
+ public struct IsBottomTabRootKey: PreferenceKey {
156
+ public static var defaultValue: Bool = false
157
+ public static func reduce(value: inout Bool, nextValue: () -> Bool) {
158
+ value = value || nextValue()
159
+ }
160
+ }
161
+
146
162
  // MARK: - Header View
147
163
 
148
164
  public struct Header: View {
@@ -0,0 +1,277 @@
1
+ //
2
+ // Localize.swift
3
+ // MoMoUIKits
4
+ //
5
+ // SwiftUI mirror of Compose's `application/Localize.kt`, itself a port of
6
+ // momo-kits React Native's `Application/Localize.ts`. Holds the merged
7
+ // dictionary (kit defaults + host overrides) and the active language,
8
+ // switched at runtime via `changeLanguage`. `Localize` is an `ObservableObject`
9
+ // so a language switch republishes to consumers of `translate`/`translateData`,
10
+ // mirroring Compose's snapshot-state-backed `language`.
11
+ //
12
+
13
+ import Foundation
14
+
15
+ /// Translation dictionary keyed by language; both `vi` and `en` are required.
16
+ public struct LocalizationObject {
17
+ public var vi: [String: String]
18
+ public var en: [String: String]
19
+
20
+ public init(vi: [String: String] = [:], en: [String: String] = [:]) {
21
+ self.vi = vi
22
+ self.en = en
23
+ }
24
+ }
25
+
26
+ public final class Localize: ObservableObject {
27
+ public static let VI = "vi"
28
+ public static let EN = "en"
29
+
30
+ private var assets: LocalizationObject
31
+ @Published public private(set) var currentLanguage: String = Localize.VI
32
+
33
+ public init(_ translations: LocalizationObject = LocalizationObject()) {
34
+ self.assets = Localize.merge(base: Localize.defaultLanguage, override: translations)
35
+ }
36
+
37
+ public func changeLanguage(_ language: String?) {
38
+ currentLanguage = (language == Localize.EN) ? Localize.EN : Localize.VI
39
+ }
40
+
41
+ public func translate(_ key: String) -> String {
42
+ let dictionary = currentLanguage == Localize.EN ? assets.en : assets.vi
43
+ if let value = dictionary[key], !value.isEmpty { return value }
44
+ return key
45
+ }
46
+
47
+ public func translateData(_ data: [String: String]) -> String {
48
+ if let value = data[currentLanguage] { return value }
49
+ let body = data.map { "\"\($0.key)\":\"\($0.value)\"" }.joined(separator: ",")
50
+ return "{\(body)}"
51
+ }
52
+
53
+ public func translateData(vi: String, en: String) -> String {
54
+ currentLanguage == Localize.EN ? en : vi
55
+ }
56
+
57
+ /// Existing keys win (mirrors Compose: `merge(translations, assets)`).
58
+ public func addTranslations(_ translations: LocalizationObject) {
59
+ assets = Localize.merge(base: translations, override: assets)
60
+ }
61
+
62
+ private static func merge(base: LocalizationObject, override: LocalizationObject) -> LocalizationObject {
63
+ LocalizationObject(
64
+ vi: base.vi.merging(override.vi) { _, new in new },
65
+ en: base.en.merging(override.en) { _, new in new }
66
+ )
67
+ }
68
+
69
+ private static let defaultLanguage = LocalizationObject(
70
+ vi: [
71
+ "errorCode": "Mã lỗi: ",
72
+ "seeMore": "Xem thêm",
73
+ "cancel": "Huỷ",
74
+ "done": "Xong",
75
+ "confirm": "Xác nhận",
76
+ "month": "tháng",
77
+ "day": "ngày",
78
+ "choose": "Chọn",
79
+ "sent": "Đã gửi",
80
+ "received": "Đã tiếp nhận",
81
+ "processing": "Đang xử lý",
82
+ "processed": "Đã xử lý",
83
+ "addImg": "Thêm hình",
84
+ "chooseBtn": "Chọn button",
85
+ "imgEg": "Hình ảnh tham khảo",
86
+ "filter": "Sắp xếp",
87
+ "shipping": "Đang giao hàng",
88
+ "men": "Nam",
89
+ "women": "Nữ",
90
+ "more": "Khác",
91
+ "expDate": "Ngày hết hạn",
92
+ "exp": "HSD",
93
+ "inActive": "Chưa kích hoạt",
94
+ "voucherRemindHour": "Hết hạn sau {hours} giờ",
95
+ "voucherRemindMinute": "Hết hạn sau {minutes} phút",
96
+ "voucherRemindSecond": "Hết hạn sau {seconds} giây",
97
+ "voucherRemindDay": "Hết hạn sau {days} ngày",
98
+ "chooseRoundtrip": "Chọn vé khứ hồi",
99
+ "depart": "Ngày đi",
100
+ "return": "Ngày về",
101
+ "departing": "Đi",
102
+ "returning": "Về",
103
+ "jan": "Tháng 1",
104
+ "feb": "Tháng 2",
105
+ "mar": "Tháng 3",
106
+ "apr": "Tháng 4",
107
+ "may": "Tháng 5",
108
+ "jun": "Tháng 6",
109
+ "jul": "Tháng 7",
110
+ "aug": "Tháng 8",
111
+ "sep": "Tháng 9",
112
+ "oct": "Tháng 10",
113
+ "nov": "Tháng 11",
114
+ "dec": "Tháng 12",
115
+ "mon": "T2",
116
+ "tue": "T3",
117
+ "wed": "T4",
118
+ "thu": "T5",
119
+ "fri": "T6",
120
+ "sat": "T7",
121
+ "sun": "CN",
122
+ "showLunar": "Hiển thị lịch âm",
123
+ "newYear": "Tết Dương lịch",
124
+ "valentine": "Ngày Lễ Tình nhân",
125
+ "womenDay": "Ngày Quốc tế Phụ nữ",
126
+ "liberationDay": "Ngày giải phóng Miền Nam thống nhất đất nước",
127
+ "laborDay": "Ngày Quốc tế Lao động",
128
+ "childrenDay": "Ngày Quốc tế Thiếu nhi",
129
+ "nationalDay": "Quốc khánh nước CHXHCN Việt Nam",
130
+ "womenDayVN": "Ngày Phụ nữ Việt Nam",
131
+ "teacherDay": "Ngày Nhà giáo Việt Nam",
132
+ "christmasEve": "Ngày Lễ Giáng Sinh",
133
+ "christmas": "Ngày Lễ Giáng Sinh",
134
+ "lunarNewYear": "Tết Nguyên Đán",
135
+ "hungKingDay": "Giỗ Tổ Hùng Vương",
136
+ "balance": "Số dư trong ví",
137
+ "chooseDate": "Chọn ngày tháng năm",
138
+ "year": "năm",
139
+ "hour": "giờ",
140
+ "minute": "phút",
141
+ "from": "Từ",
142
+ "to": "Đến",
143
+ "connected": "Đã kết nối",
144
+ "disconnected": "Mất kết nối",
145
+ "hide": "Ẩn",
146
+ "viewAllTitle": "Xem tất cả",
147
+ "headerTitle": "Lịch sử thanh toán",
148
+ "SHORTEN": "THU GỌN",
149
+ "viewMoreBank": "XEM THÊM (${otherItemCount} ngân hàng)",
150
+ "transaction_success": "Thành công",
151
+ "transaction_fail": "Thất bại",
152
+ "transaction_processing": "Đang xử lý",
153
+ "viewMore": "Xem thêm",
154
+ "shorten": "Thu gọn",
155
+ "Payment": "Thanh toán ${serviceName}",
156
+ "currencyUnit": "đ",
157
+ "Month": "Tháng",
158
+ "save": "Lưu",
159
+ "favoriteIn": "Thêm dịch vụ yêu thích",
160
+ "favoriteOut": "Xóa dịch vụ yêu thích",
161
+ "deviceIn": "Thêm vào thiết bị",
162
+ "setting": "Cài đặt",
163
+ "transaction": "Lịch sử giao dịch",
164
+ "share": "Chia sẽ dịch vụ",
165
+ "information": "Thông tin chung",
166
+ "tutorial": "Hướng dẫn sử dụng",
167
+ "question": "Câu hỏi thường gặp",
168
+ "support": "Trung tâm hỗ trợ",
169
+ "skip": "Bỏ qua",
170
+ "enterPhoneNumber": "Vui lòng nhập số điện thoại",
171
+ "invalidPhoneNumber": "Số điện thoại không đúng",
172
+ ],
173
+ en: [
174
+ "seeMore": "See more",
175
+ "cancel": "Cancel",
176
+ "done": "Done",
177
+ "confirm": "Confirm",
178
+ "month": "month",
179
+ "day": "day",
180
+ "choose": "Choose",
181
+ "sent": "Sent",
182
+ "received": "Received",
183
+ "processing": "Processing",
184
+ "processed": "Done",
185
+ "addImg": "Add image",
186
+ "chooseBtn": "Choose button",
187
+ "imgEg": "Example images",
188
+ "filter": "Filter",
189
+ "shipping": "Shipping",
190
+ "men": "Men",
191
+ "women": "Women",
192
+ "more": "More",
193
+ "expDate": "Expiration date",
194
+ "exp": "EXP",
195
+ "inActive": "Not Activated",
196
+ "voucherRemindHour": "Revoke after {hours} hours",
197
+ "voucherRemindMinute": "Revoke after {minutes} minutes",
198
+ "voucherRemindSecond": "Revoke after {seconds} seconds",
199
+ "voucherRemindDay": "Revoke after {days} days",
200
+ "chooseRoundtrip": "Choose roundtrip ticket",
201
+ "depart": "Departing",
202
+ "return": "Returning",
203
+ "departing": "De",
204
+ "returning": "Re",
205
+ "jan": "January",
206
+ "feb": "February",
207
+ "mar": "March",
208
+ "apr": "April",
209
+ "may": "May",
210
+ "jun": "June",
211
+ "jul": "July",
212
+ "aug": "August",
213
+ "sep": "September",
214
+ "oct": "October",
215
+ "nov": "November",
216
+ "dec": "December",
217
+ "mon": "Mon",
218
+ "tue": "Tue",
219
+ "wed": "Wed",
220
+ "thu": "Thu",
221
+ "fri": "Fri",
222
+ "sat": "Sat",
223
+ "sun": "Sun",
224
+ "showLunar": "Show Lunar Calendar",
225
+ "newYear": "New Year's Day",
226
+ "valentine": "Valentine's Day",
227
+ "womenDay": "International Women's Day",
228
+ "liberationDay": "Liberation Day",
229
+ "laborDay": "Internation Labor's Day",
230
+ "childrenDay": "International Labor's Day",
231
+ "nationalDay": "National Day",
232
+ "womenDayVN": "Vietnamese Women's Day",
233
+ "teacherDay": "Teacher's Day",
234
+ "christmasEve": "Christmas Eve",
235
+ "christmas": "Christmas Day",
236
+ "lunarNewYear": "Lunar New Year",
237
+ "hungKingDay": "Hung Kings' Festival",
238
+ "balance": "Wallet Balance",
239
+ "chooseDate": "Choose date",
240
+ "year": "year",
241
+ "hour": "hour",
242
+ "minute": "minute",
243
+ "from": "From",
244
+ "to": "To",
245
+ "connected": "Connected",
246
+ "disconnected": "Disconnected",
247
+ "hide": "Hide",
248
+ "viewAllTitle": "View all",
249
+ "headerTitle": "Transaction history",
250
+ "SHORTEN": "SHORTEN",
251
+ "viewMoreBank": "VIEW MORE (${otherItemCount} banks)",
252
+ "transaction_success": "Success",
253
+ "transaction_fail": "Fail",
254
+ "transaction_processing": "Processing",
255
+ "viewMore": "View more",
256
+ "shorten": "Shorten",
257
+ "Payment": "${serviceName} payment",
258
+ "currencyUnit": "VND",
259
+ "Month": "Month",
260
+ "save": "Save",
261
+ "favoriteIn": "Add to favorite services",
262
+ "favoriteOut": "Remove to favorite services",
263
+ "deviceIn": "Add to device",
264
+ "setting": "Settings",
265
+ "transaction": "Transaction History",
266
+ "share": "Share",
267
+ "information": "Information",
268
+ "tutorial": "Instruction",
269
+ "question": "FAQ",
270
+ "support": "Support center",
271
+ "errorCode": "Error code: ",
272
+ "skip": "Skip",
273
+ "enterPhoneNumber": "Please enter your phone number",
274
+ "invalidPhoneNumber": "Invalid phone number",
275
+ ]
276
+ )
277
+ }
@@ -56,6 +56,7 @@ public struct BottomTab: View {
56
56
  }
57
57
  .frame(maxWidth: .infinity, maxHeight: .infinity)
58
58
  .ignoresSafeArea(edges: .bottom)
59
+ .preference(key: IsBottomTabRootKey.self, value: true)
59
60
  }
60
61
 
61
62
  @ViewBuilder
@@ -14,6 +14,7 @@ import SwiftUI
14
14
  public struct NavigationContainer<Initial: View>: View {
15
15
  @StateObject private var navigator: Navigator
16
16
  @StateObject private var applicationEnvironment: ApplicationEnvironment
17
+ @StateObject private var localize: Localize
17
18
 
18
19
  private let setNavigator: ((Navigator) -> Void)?
19
20
  private let composeApi: KitComposeApi?
@@ -25,6 +26,7 @@ public struct NavigationContainer<Initial: View>: View {
25
26
  applicationContext: MiniAppContext? = nil,
26
27
  composeApi: KitComposeApi? = nil,
27
28
  config: KitConfig? = nil,
29
+ localize: Localize? = nil,
28
30
  setNavigator: ((Navigator) -> Void)? = nil
29
31
  ) {
30
32
  let nav = Navigator(
@@ -41,6 +43,7 @@ public struct NavigationContainer<Initial: View>: View {
41
43
  config: config
42
44
  )
43
45
  )
46
+ _localize = StateObject(wrappedValue: localize ?? Localize())
44
47
  self.composeApi = composeApi
45
48
  self.setNavigator = setNavigator
46
49
  }
@@ -55,11 +58,13 @@ public struct NavigationContainer<Initial: View>: View {
55
58
  .environmentObject(navigator)
56
59
  .environmentObject(applicationEnvironment)
57
60
  .environment(\.applicationEnvironment, applicationEnvironment)
61
+ .environmentObject(localize)
58
62
  .fullScreenCover(item: $navigator.presented) { route in
59
63
  screenView(forDialog: route)
60
64
  .environmentObject(navigator)
61
65
  .environmentObject(applicationEnvironment)
62
66
  .environment(\.applicationEnvironment, applicationEnvironment)
67
+ .environmentObject(localize)
63
68
  }
64
69
  .overlay {
65
70
  if let item = navigator.overplay {
@@ -67,6 +72,7 @@ public struct NavigationContainer<Initial: View>: View {
67
72
  .environmentObject(navigator)
68
73
  .environmentObject(applicationEnvironment)
69
74
  .environment(\.applicationEnvironment, applicationEnvironment)
75
+ .environmentObject(localize)
70
76
  }
71
77
  }
72
78
  .onAppear {
@@ -43,42 +43,84 @@ struct BottomSheetView<Content: View>: View {
43
43
  let content: () -> Content
44
44
 
45
45
  @EnvironmentObject private var navigator: Navigator
46
+ @Environment(\.appTheme) private var theme
47
+ @Environment(\.applicationEnvironment) private var appEnv
46
48
 
47
49
  /// Vertical translation of the sheet; starts off-screen and animates to 0.
48
50
  @State private var sheetOffset: CGFloat = UIScreen.main.bounds.height
49
- @State private var scrimAlpha: CGFloat = 0
51
+ /// Mirrors Compose's `backgroundAlpha`: only touched by open()/close(), never by drag.
52
+ @State private var backgroundAlpha: CGFloat = 0
50
53
  @State private var sheetHeight: CGFloat = 0
51
54
  @State private var didAppear = false
55
+ @State private var keyboardHeight: CGFloat = 0
56
+ /// Live drag offset, isolated from `sheetOffset` so a touch-move only invalidates
57
+ /// this gesture-scoped value instead of forcing the whole body (including
58
+ /// `content()`) to reconstruct every frame — mirrors the pattern already used in
59
+ /// `ios/PopupView/PopupView.swift` for smooth drag-to-dismiss.
60
+ @GestureState private var dragTranslation: CGFloat = 0
52
61
 
53
62
  /// Mirrors Compose's `sheetCloseOffset` (100dp drag threshold to dismiss).
54
63
  private let closeThreshold: CGFloat = 100
55
64
 
65
+ /// Mirrors Compose's per-term peak (0.3f). Combined with `backgroundAlpha` the
66
+ /// steady-state (fully open) scrim peaks at 0.6, matching ModalScreen's peak.
67
+ private let maxDynamicAlpha: CGFloat = 0.3
68
+
56
69
  private var screenHeight: CGFloat { UIScreen.main.bounds.height }
57
70
  private var bottomInset: CGFloat { safeAreaBottomInset() }
71
+ /// While the keyboard is up, it already covers the home-indicator area, so it
72
+ /// replaces (rather than adds to) the usual bottom-inset filler.
73
+ private var effectiveBottomInset: CGFloat { keyboardHeight > 0 ? keyboardHeight : bottomInset }
58
74
 
59
75
  private var backgroundColor: Color {
60
- // Compose uses background.surface (white) for `isSurface`, otherwise
61
- // background.default. Both resolve to white in the current iOS palette.
62
- isSurface ? Colors.black01 : Colors.black01
76
+ isSurface ? theme.colors.background.surface : theme.colors.background.default
77
+ }
78
+
79
+ private var effectiveOffset: CGFloat { sheetOffset + dragTranslation }
80
+
81
+ /// Mirrors Compose's `dynamicAlpha`: a plain derived value (not itself animated)
82
+ /// that tracks the current offset live, shrinking toward 0 as the sheet is
83
+ /// dragged down.
84
+ private var dynamicAlpha: CGFloat {
85
+ let denom = sheetHeight > 0 ? sheetHeight : screenHeight
86
+ guard denom > 0 else { return maxDynamicAlpha }
87
+ let progress = min(max(1 - effectiveOffset / denom, 0), 1)
88
+ return progress * maxDynamicAlpha
63
89
  }
64
90
 
91
+ /// Mirrors Compose's `alpha = backgroundAlpha + dynamicAlpha`: additive, so the
92
+ /// scrim peaks at 0.6 at rest and only fades to a 0.3 floor while dragging
93
+ /// (backgroundAlpha holds steady until an explicit close()).
94
+ private var totalScrimAlpha: CGFloat { backgroundAlpha + dynamicAlpha }
95
+
65
96
  var body: some View {
66
97
  ZStack(alignment: .bottom) {
67
- // Dimmed scrim (max 0.3 alpha, mirrors Compose).
68
- Color.black.opacity(scrimAlpha)
69
- .ignoresSafeArea()
98
+ // Dimmed scrim additive backgroundAlpha + dynamicAlpha (see above).
99
+ Color.black.opacity(totalScrimAlpha)
70
100
  .contentShape(Rectangle())
71
101
  .onTapGesture {
72
102
  if barrierDismissible { close() }
73
103
  }
74
104
 
75
105
  sheet
76
- .offset(y: sheetOffset)
106
+ .offset(y: effectiveOffset)
77
107
  }
78
108
  .frame(maxWidth: .infinity, maxHeight: .infinity)
109
+ .ignoresSafeArea()
110
+ .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
111
+ guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
112
+ withAnimation(.easeOut(duration: 0.25)) {
113
+ keyboardHeight = UIScreen.main.bounds.height - endFrame.origin.y
114
+ }
115
+ }
79
116
  .onAppear {
80
117
  guard !didAppear else { return }
81
118
  didAppear = true
119
+ ScreenTracker.trackPopupDisplayed(
120
+ maxApi: appEnv.maxApi,
121
+ context: appEnv.applicationContext,
122
+ parentScreenName: ScreenTracker.getLastScreenName()
123
+ )
82
124
  navigator.registerOverplayDismiss { close() }
83
125
  open()
84
126
  }
@@ -103,16 +145,16 @@ struct BottomSheetView<Content: View>: View {
103
145
  .gesture(dragGesture)
104
146
 
105
147
  Rectangle()
106
- .fill(Colors.black04)
148
+ .fill(theme.colors.border.default)
107
149
  .frame(height: 1)
108
150
 
109
151
  content()
110
152
 
111
153
  backgroundColor
112
- .frame(height: bottomInset)
154
+ .frame(height: effectiveBottomInset)
113
155
  }
114
156
  .frame(maxWidth: .infinity)
115
- .frame(maxHeight: screenHeight - safeAreaTopInset() - 90, alignment: .bottom)
157
+ .frame(alignment: .bottom)
116
158
  .background(
117
159
  GeometryReader { geo in
118
160
  Color.clear.onAppear { sheetHeight = geo.size.height }
@@ -143,13 +185,33 @@ struct BottomSheetView<Content: View>: View {
143
185
  // MARK: - Drag handling (mirrors Compose's detectDragGestures)
144
186
 
145
187
  private var dragGesture: some Gesture {
146
- DragGesture()
147
- .onChanged { value in
148
- let newOffset = max(value.translation.height, 0)
149
- sheetOffset = newOffset
188
+ // `.global` (not the default `.local`) so the gesture tracks against the
189
+ // window rather than the handle's own view, which is itself repositioned
190
+ // every frame by this same gesture's output (`sheet.offset(y: effectiveOffset)`
191
+ // in `body`) — the local coordinate space chasing its own moving reference
192
+ // frame is a plausible source of a continuous drag-time jitter.
193
+ DragGesture(coordinateSpace: .global)
194
+ .onChanged { _ in
195
+ // Touching the handle should immediately settle `sheetOffset` to its
196
+ // resting value (0) with no animation, so a drag that starts while the
197
+ // open animation — or the keyboard-driven resize, which shifts this
198
+ // same bottom-anchored top edge — is still in flight doesn't fight a
199
+ // moving target. Without this, the live drag value chases an animated
200
+ // one, producing a couple of corrective jumps before it settles.
201
+ if sheetOffset != 0 {
202
+ sheetOffset = 0
203
+ }
204
+ }
205
+ .updating($dragTranslation) { value, state, _ in
206
+ state = max(value.translation.height, 0)
150
207
  }
151
- .onEnded { _ in
152
- if sheetOffset > closeThreshold {
208
+ .onEnded { value in
209
+ let finalOffset = max(value.translation.height, 0)
210
+ // Capture into persisted state BEFORE @GestureState resets to 0,
211
+ // avoiding a one-frame snap back to rest before close()'s own
212
+ // slide-out animation starts (mirrors PopupView's `lastDragPosition`).
213
+ sheetOffset = finalOffset
214
+ if finalOffset > closeThreshold {
153
215
  close()
154
216
  } else {
155
217
  withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
@@ -163,17 +225,23 @@ struct BottomSheetView<Content: View>: View {
163
225
 
164
226
  private func open() {
165
227
  DispatchQueue.main.async {
166
- withAnimation(.easeOut(duration: 0.1)) { scrimAlpha = 0.3 }
228
+ withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.1)) { backgroundAlpha = maxDynamicAlpha }
167
229
  withAnimation(.timingCurve(0.05, 0.7, 0.1, 1, duration: 0.35)) { sheetOffset = 0 }
168
230
  }
169
231
  }
170
232
 
233
+ /// Mirrors Compose's `closeEvent`: slide out over 200ms, then fade the scrim
234
+ /// out over 100ms only after the slide finishes, then dismiss.
171
235
  private func close() {
172
236
  withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.2)) {
173
237
  sheetOffset = max(sheetHeight, screenHeight)
174
- scrimAlpha = 0
175
238
  }
176
239
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
240
+ withAnimation(.timingCurve(0.3, 0, 0.8, 0.15, duration: 0.1)) {
241
+ backgroundAlpha = 0
242
+ }
243
+ }
244
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
177
245
  navigator.finishOverplayDismiss()
178
246
  }
179
247
  }
@@ -98,6 +98,7 @@ public struct StackScreen<Content: View>: View {
98
98
  @State private var scrollOffset: CGFloat = 0
99
99
  @State private var keyboardHeight: CGFloat = 0
100
100
  @State private var headerRightWidth: CGFloat = 0
101
+ @State private var isBottomTabRoot: Bool = false
101
102
 
102
103
  /// Header background fade tied to scroll: 0 → transparent (over a hero image
103
104
  /// or transparent header), 1 → opaque surface with the drop shadow.
@@ -135,7 +136,10 @@ public struct StackScreen<Content: View>: View {
135
136
  GeometryReader { geometry in
136
137
  let keyboardSize: CGFloat = useAvoidKeyboard ? max(keyboardHeight, 0) : 0
137
138
  let bottomInset = min(geometry.safeAreaInsets.bottom, 21)
138
- let contentBottomInset = footer == nil ? bottomInset : 0
139
+ // Skip this screen's own bottom-inset reservation when hosting a footer
140
+ // OR a self-contained BottomTab surface (mirrors Compose's
141
+ // `if (options.footerComponent != null || isBottomTab) 0.dp else navigationBar`).
142
+ let contentBottomInset = (footer == nil && !isBottomTabRoot) ? bottomInset : 0
139
143
 
140
144
  ZStack(alignment: .top) {
141
145
  // Background color
@@ -175,6 +179,9 @@ public struct StackScreen<Content: View>: View {
175
179
  .onPreferenceChange(HeaderRightWidthKey.self) { width in
176
180
  if headerRightWidth != width { headerRightWidth = width }
177
181
  }
182
+ .onPreferenceChange(IsBottomTabRootKey.self) { isRoot in
183
+ if isBottomTabRoot != isRoot { isBottomTabRoot = isRoot }
184
+ }
178
185
  .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
179
186
  guard useAvoidKeyboard,
180
187
  let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
@@ -13,7 +13,7 @@ public enum Colors {
13
13
  public static let textSecondary = Color.momoAsset("TextSecondary")
14
14
  public static let card = Color.momoAsset("Card")
15
15
  public static let error = Color.momoAsset("Error")
16
- public static let info = Color(hex: "007aff")
16
+ public static let info = blue03
17
17
  public static let success = Color.momoAsset("Success")
18
18
 
19
19
  public static let black20 = Color(hex: "000000")
@@ -49,15 +49,18 @@ public enum Colors {
49
49
  public static let pink03 = Color(hex: "eb2f96")
50
50
  public static let pink02 = Color(hex: "d42a87")
51
51
  public static let pink01 = Color(hex: "bc2678")
52
+ public static let pinkMoMoBranding = Color(hex: "a50064")
52
53
 
53
54
 
54
- public static let violet10 = Color(hex: "fcf8fe")
55
- public static let violet09 = Color(hex: "faf4fe")
55
+ public static let violet11 = Color(hex: "fcf8fe")
56
+ public static let violet11Stroke = Color(hex: "dfe1e5")
57
+ public static let violet10 = Color(hex: "faf4fe")
58
+ public static let violet09 = Color(hex: "f4e9fd")
56
59
  public static let violet08 = Color(hex: "ead4fc")
57
60
  public static let violet07 = Color(hex: "d5aaf9")
58
- public static let violet06 = Color(hex: "f4e9fd")
59
- public static let violet05 = Color(hex: "c07ff6")
60
- public static let violet04 = Color(hex: "ab55f3")
61
+ public static let violet06 = Color(hex: "c07ff6")
62
+ public static let violet05 = Color(hex: "ab55f3")
63
+ public static let violet04 = Color(hex: "a03ff1")
61
64
  public static let violet03 = Color(hex: "962af0")
62
65
  public static let violet02 = Color(hex: "8726d8")
63
66
  public static let violet01 = Color(hex: "7822c0")
@@ -8,5 +8,7 @@ public enum Spacing {
8
8
  public static let L: CGFloat = 16
9
9
  public static let XL: CGFloat = 24
10
10
  public static let XXL: CGFloat = 32
11
- public static let XXXL: CGFloat = 48
11
+ public static let Size3XL: CGFloat = 48
12
+ public static let Size4XL: CGFloat = 56
13
+ public static let Size5XL: CGFloat = 64
12
14
  }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.163.1-beta.5'
3
+ s.version = '0.163.1-beta.6'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.163.1-beta.5'
3
+ s.version = '0.163.1-beta.6'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -89,6 +89,7 @@ public let ScreenUsages: [ScreenUsage] = [
89
89
  ScreenUsage(title: "Bottom Tab", destination: AnyView(BottomTabDemo()), icon: getIcon(icon: "Layout")),
90
90
  ScreenUsage(title: "Animated Header", destination: AnyView(AnimatedHeaderDemo()), icon: getIcon(icon: "Layout")),
91
91
  ScreenUsage(title: "Theme", destination: AnyView(ThemeDemo()), icon: getIcon(icon: "Typography")),
92
+ ScreenUsage(title: "Localize", destination: AnyView(LocalizeDemo()), icon: getIcon(icon: "Typography")),
92
93
  ]
93
94
 
94
95
  @available(iOS 16.0, *)
@@ -0,0 +1,188 @@
1
+ //
2
+ // LocalizeDemo.swift
3
+ // MoMoUIKitsDemo
4
+ //
5
+ // SwiftUI port of the Compose demo `sample/shared/.../screens/LocalizeUsage.kt`,
6
+ // showcasing the `Localize` i18n port (ios/Application/Localize.swift).
7
+ // Synced from sample/iosApp/iosApp/Demo/LocalizeDemo.swift.
8
+ //
9
+
10
+ import MoMoUIKits
11
+ import SwiftUI
12
+
13
+ // App-specific dictionary. Keys here do NOT exist in the kit defaults, so addTranslations
14
+ // registers them cleanly (addTranslations keeps existing keys on clash, host-default wins).
15
+ private let AppTranslations = LocalizationObject(
16
+ vi: [
17
+ "demo_greeting": "Xin chào MoMo!",
18
+ "demo_cart_title": "Giỏ hàng của bạn",
19
+ ],
20
+ en: [
21
+ "demo_greeting": "Hello MoMo!",
22
+ "demo_cart_title": "Your cart",
23
+ ]
24
+ )
25
+
26
+ struct LocalizeDemo: View {
27
+ @EnvironmentObject private var localize: Localize
28
+
29
+ private var isEnglish: Bool { localize.currentLanguage == Localize.EN }
30
+
31
+ var body: some View {
32
+ ScrollView {
33
+ VStack(alignment: .leading, spacing: 0) {
34
+
35
+ LocalizeSectionBox(title: "Đổi ngôn ngữ (toàn app)") {
36
+ MomoText(
37
+ "localize.changeLanguage(...) đổi ngôn ngữ cho cả NavigationContainer — mọi màn hình đang đọc translate() sẽ cập nhật theo.",
38
+ typography: .descriptionDefaultRegular
39
+ )
40
+ Switch(
41
+ .constant(isEnglish),
42
+ onChange: { _ in localize.changeLanguage(isEnglish ? Localize.VI : Localize.EN) },
43
+ title: "English"
44
+ )
45
+ MomoText("currentLanguage = \(localize.currentLanguage)", typography: .labelDefaultMedium)
46
+ }
47
+
48
+ LocalizeSectionBox(title: "translate(key) — từ điển mặc định của kit") {
49
+ TranslationRow(key: "confirm", value: localize.translate("confirm"))
50
+ TranslationRow(key: "cancel", value: localize.translate("cancel"))
51
+ TranslationRow(key: "done", value: localize.translate("done"))
52
+ TranslationRow(key: "seeMore", value: localize.translate("seeMore"))
53
+ TranslationRow(key: "balance", value: localize.translate("balance"))
54
+ }
55
+
56
+ LocalizeSectionBox(title: "Key thiếu → trả về chính key") {
57
+ MomoText(
58
+ "translate() với key không có (hoặc giá trị rỗng) sẽ trả lại đúng key để dễ phát hiện thiếu bản dịch.",
59
+ typography: .descriptionDefaultRegular
60
+ )
61
+ TranslationRow(key: "not_a_real_key", value: localize.translate("not_a_real_key"))
62
+ }
63
+
64
+ LocalizeSectionBox(title: "Chèn tham số vào bản dịch") {
65
+ MomoText(
66
+ "Bản port không tự nội suy {placeholder} — tự replace ở nơi dùng.",
67
+ typography: .descriptionDefaultRegular
68
+ )
69
+ TranslationRow(
70
+ key: "voucherRemindHour",
71
+ value: localize.translate("voucherRemindHour").replacingOccurrences(of: "{hours}", with: "3")
72
+ )
73
+ }
74
+
75
+ LocalizeSectionBox(title: "translateData(vi:en:) — dịch nội tuyến") {
76
+ MomoText(
77
+ "Cho chuỗi dùng một lần, không cần khai báo key trong từ điển.",
78
+ typography: .descriptionDefaultRegular
79
+ )
80
+ MomoText(localize.translateData(vi: "Thanh toán ngay", en: "Pay now"), typography: .headerDefaultBold)
81
+ }
82
+
83
+ LocalizeSectionBox(title: "translateData(map) — chọn theo ngôn ngữ") {
84
+ MomoText(
85
+ localize.translateData([
86
+ Localize.VI: "Lịch sử giao dịch",
87
+ Localize.EN: "Transaction history",
88
+ ]),
89
+ typography: .headerDefaultBold
90
+ )
91
+ }
92
+
93
+ LocalizeSectionBox(title: "addTranslations — từ điển riêng của app") {
94
+ MomoText(
95
+ "Host đăng ký key riêng (đã add khi màn hình xuất hiện), rồi dùng translate() như key mặc định.",
96
+ typography: .descriptionDefaultRegular
97
+ )
98
+ TranslationRow(key: "demo_greeting", value: localize.translate("demo_greeting"))
99
+ TranslationRow(key: "demo_cart_title", value: localize.translate("demo_cart_title"))
100
+ }
101
+
102
+ LocalizeSectionBox(title: "Ví dụ thực tế") {
103
+ // errorCode already ends with a separator ("Mã lỗi: " / "Error code: ").
104
+ MomoText(localize.translate("errorCode") + "E-4097", typography: .bodyDefaultRegular)
105
+ HStack(spacing: Spacing.M) {
106
+ Button(
107
+ title: localize.translate("cancel"),
108
+ action: {},
109
+ type: .outline,
110
+ size: .medium,
111
+ isFull: false
112
+ )
113
+ Button(
114
+ title: localize.translate("confirm"),
115
+ action: {},
116
+ type: .primary,
117
+ size: .medium,
118
+ isFull: false
119
+ )
120
+ }
121
+ }
122
+
123
+ Spacer(minLength: 32)
124
+ }
125
+ }
126
+ // Registers the app-specific dictionary once per appearance; addTranslations'
127
+ // merge is idempotent, so re-entry (e.g. re-navigating back to this screen) is safe.
128
+ .onAppear {
129
+ localize.addTranslations(AppTranslations)
130
+ }
131
+ }
132
+ }
133
+
134
+ // MARK: - LocalizeSectionBox
135
+
136
+ /// Titled rounded-card section container, mirroring Compose's `BasicColumnBox`
137
+ /// (title label above a `theme.colors.background.surface` card with `Radius.M` corners).
138
+ private struct LocalizeSectionBox<Content: View>: View {
139
+ @Environment(\.appTheme) private var theme
140
+ let title: String
141
+ @ViewBuilder let content: () -> Content
142
+
143
+ var body: some View {
144
+ VStack(alignment: .leading, spacing: 8) {
145
+ if !title.isEmpty {
146
+ MomoText(title, typography: .headerMBold)
147
+ .padding(.top, 8)
148
+ }
149
+
150
+ VStack(alignment: .leading, spacing: 8) {
151
+ content()
152
+ }
153
+ .frame(maxWidth: .infinity, alignment: .leading)
154
+ .padding(12)
155
+ .background(theme.colors.background.surface)
156
+ .clipShape(RoundedRectangle(cornerRadius: Radius.M))
157
+ }
158
+ .padding(12)
159
+ }
160
+ }
161
+
162
+ // MARK: - TranslationRow
163
+
164
+ private struct TranslationRow: View {
165
+ @Environment(\.appTheme) private var theme
166
+ let key: String
167
+ let value: String
168
+
169
+ var body: some View {
170
+ VStack(alignment: .leading, spacing: 2) {
171
+ MomoText(key, typography: .labelDefaultMedium, color: theme.colors.text.secondary)
172
+ MomoText(value, typography: .bodyDefaultRegular)
173
+ }
174
+ }
175
+ }
176
+
177
+ // MARK: - Preview
178
+
179
+ #Preview {
180
+ if #available(iOS 16.0, *) {
181
+ NavigationStack {
182
+ LocalizeDemo()
183
+ .environmentObject(Localize())
184
+ }
185
+ } else {
186
+ // Fallback on earlier versions
187
+ }
188
+ }
@@ -23,6 +23,7 @@ struct NavigationDemo: View {
23
23
  @available(iOS 16.0, *)
24
24
  private struct NavigationDemoContent: View {
25
25
  @EnvironmentObject private var navigator: Navigator
26
+ @State private var textValue1 = ""
26
27
 
27
28
  var body: some View {
28
29
  ScrollView {
@@ -72,6 +73,7 @@ private struct NavigationDemoContent: View {
72
73
  row("Show Bottom Sheet") {
73
74
  navigator.showBottomSheet({
74
75
  VStack(spacing: 12) {
76
+ Input(text: $textValue1, placeholder: "Test Bottom Sheet")
75
77
  ForEach(0..<4, id: \.self) { i in
76
78
  MomoText("Bottom sheet row \(i + 1)")
77
79
  .frame(maxWidth: .infinity, alignment: .leading)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.163.1-beta.5-debug",
3
+ "version": "0.163.1-beta.6-debug",
4
4
  "private": false,
5
5
  "dependencies": {},
6
6
  "devDependencies": {},