@momo-kits/native-kits 0.163.1-sp.8-debug → 0.163.2-beta.1-debug

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.codegraph/.gitignore +5 -0
  2. package/CLAUDE.md +0 -10
  3. package/compose/build.gradle.kts +1 -1
  4. package/compose/compose.podspec +1 -1
  5. package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Example.xcscheme +32 -0
  6. package/example/ios/Example.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +14 -0
  7. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  8. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/WorkspaceSettings.xcsettings +16 -0
  9. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +6 -0
  10. package/example/ios/Example.xcworkspace/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +5 -0
  11. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/MoMoUIKits.xcscheme +58 -0
  12. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/Pods-Example.xcscheme +58 -0
  13. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImage.xcscheme +58 -0
  14. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SDWebImageSwiftUI.xcscheme +58 -0
  15. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/SkeletonUI.xcscheme +58 -0
  16. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios-LottiePrivacyInfo.xcscheme +58 -0
  17. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/lottie-ios.xcscheme +58 -0
  18. package/example/ios/Pods/Pods.xcodeproj/xcuserdata/sonnguyen.xcuserdatad/xcschemes/xcschememanagement.plist +60 -0
  19. package/gradle.properties +1 -1
  20. package/ios/Application/ApplicationEnvironment.swift +4 -6
  21. package/ios/Application/HeaderRight.swift +17 -1
  22. package/ios/Application/Localize.swift +4 -73
  23. package/ios/Application/Navigation/NavigationContainer.swift +18 -25
  24. package/ios/Application/Navigation/Navigator.swift +29 -91
  25. package/ios/Application/StackScreen.swift +21 -18
  26. package/ios/Badge/BadgeRibbon.swift +1 -4
  27. package/ios/Button/Button.swift +61 -41
  28. package/ios/Icon/Icon.swift +4 -13
  29. package/ios/native-kits.podspec +1 -2
  30. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  31. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +8 -8
  32. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +2 -3
  33. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +12 -5
  34. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +4 -51
  35. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +2 -14
  36. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +1 -3
  37. package/local.properties +2 -2
  38. package/package.json +1 -1
  39. package/.claude/settings.local.json +0 -158
  40. package/ios/Application/LanguageSource.swift +0 -93
  41. package/ios/Application/LocalizeModifier.swift +0 -30
  42. package/ios/Application/Navigation/NavigatorManager.swift +0 -49
  43. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  44. package/ios/Layout/GridContext.swift +0 -30
  45. package/ios/Layout/Item.swift +0 -42
  46. package/ios/Layout/Section.swift +0 -134
  47. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +0 -104
  48. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +0 -100
@@ -28,69 +28,16 @@ public final class Localize: ObservableObject {
28
28
  public static let VI = "vi"
29
29
  public static let EN = "en"
30
30
 
31
- // AI-GENERATED START: follow the host app language and publish dictionary changes
32
- /// App-wide instance bound to the host language; the environment default uses it too,
33
- /// so every screen agrees on one language.
34
- public static let shared = Localize()
35
- // AI-GENERATED END: follow the host app language and publish dictionary changes
36
-
37
31
  private var assets: LocalizationObject
38
32
  @Published public private(set) var currentLanguage: String = Localize.VI
39
33
 
40
- // AI-GENERATED START: follow the host app language and publish dictionary changes
41
- /// Bumped by `addTranslations` — `assets` is not published, so without this the dictionary
42
- /// can change without any consumer re-rendering.
43
- @Published public private(set) var revision: Int = 0
44
-
45
- private let languageSource: LanguageSource?
46
-
47
- /// `NavigationContainer` uses this to skip its legacy one-shot `MaxApi` pull.
48
- var observesAppLanguage: Bool { languageSource != nil }
49
-
50
- /// Pass `languageSource: nil` to opt out and keep the old "starts at vi" behavior.
51
- public init(
52
- _ translations: LocalizationObject = LocalizationObject(),
53
- languageSource: LanguageSource? = UserDefaultsLanguageSource()
54
- ) {
34
+ public init(_ translations: LocalizationObject = LocalizationObject()) {
55
35
  self.assets = Localize.merge(base: Localize.defaultLanguage, override: translations)
56
- self.languageSource = languageSource
57
- guard let languageSource else { return }
58
- // Seed synchronously so the first frame is already correct (no vi -> en flash).
59
- currentLanguage = languageSource.currentLanguage
60
- languageSource.startObserving { [weak self] language in self?.apply(language) }
61
- }
62
-
63
- deinit {
64
- languageSource?.stopObserving()
65
- }
66
-
67
- /// Host values are sometimes legacy JSON-quoted (`"\"vi\""`); anything that is not `en` is `vi`.
68
- public static func normalize(_ raw: String?) -> String {
69
- let cleaned = (raw ?? "")
70
- .replacingOccurrences(of: "\"", with: "")
71
- .trimmingCharacters(in: .whitespacesAndNewlines)
72
- .lowercased()
73
- return cleaned == Localize.EN ? Localize.EN : Localize.VI
74
- }
75
-
76
- private func apply(_ language: String) {
77
- // KVO and didChangeNotification both fire for one write; publish once, on main.
78
- onMain {
79
- guard self.currentLanguage != language else { return }
80
- self.currentLanguage = language
81
- }
82
- }
83
-
84
- /// Synchronous when already on main — an async hop would defer the switch by a frame.
85
- private func onMain(_ work: @escaping () -> Void) {
86
- if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
87
36
  }
88
37
 
89
- /// A local override: the next host language change supersedes it.
90
38
  public func changeLanguage(_ language: String?) {
91
- apply(Localize.normalize(language))
39
+ currentLanguage = (language == Localize.EN) ? Localize.EN : Localize.VI
92
40
  }
93
- // AI-GENERATED END: follow the host app language and publish dictionary changes
94
41
 
95
42
  public func translate(_ key: String) -> String {
96
43
  let dictionary = currentLanguage == Localize.EN ? assets.en : assets.vi
@@ -111,9 +58,6 @@ public final class Localize: ObservableObject {
111
58
  /// Existing keys win (mirrors Compose: `merge(translations, assets)`).
112
59
  public func addTranslations(_ translations: LocalizationObject) {
113
60
  assets = Localize.merge(base: translations, override: assets)
114
- // AI-GENERATED START: publish dictionary changes so consumers re-render
115
- onMain { self.revision &+= 1 }
116
- // AI-GENERATED END: publish dictionary changes so consumers re-render
117
61
  }
118
62
 
119
63
  private static func merge(base: LocalizationObject, override: LocalizationObject) -> LocalizationObject {
@@ -339,22 +283,14 @@ public final class Localize: ObservableObject {
339
283
  private struct LocalizeBox: Equatable {
340
284
  let localize: Localize
341
285
  let language: String
342
- // AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
343
- let revision: Int
344
- // AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
345
286
 
346
287
  init(_ localize: Localize) {
347
288
  self.localize = localize
348
289
  self.language = localize.currentLanguage
349
- // AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
350
- self.revision = localize.revision
351
- // AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
352
290
  }
353
291
 
354
292
  static func == (lhs: LocalizeBox, rhs: LocalizeBox) -> Bool {
355
- // AI-GENERATED START: invalidate consumers when the dictionary changes, not just the language
356
- lhs.localize === rhs.localize && lhs.language == rhs.language && lhs.revision == rhs.revision
357
- // AI-GENERATED END: invalidate consumers when the dictionary changes, not just the language
293
+ lhs.localize === rhs.localize && lhs.language == rhs.language
358
294
  }
359
295
  }
360
296
 
@@ -362,12 +298,7 @@ private struct LocalizeKey: EnvironmentKey {
362
298
  /// A default-constructed `Localize` already carries the full kit dictionary, so a
363
299
  /// consumer that never injects one renders untranslated-but-correct text instead of
364
300
  /// trapping the way `@EnvironmentObject` does.
365
- // AI-GENERATED START: default to the app-language-observing shared instance
366
- /// `defaultValue` is a `static let`, so this box is never rebuilt: without a provider the
367
- /// language is right on first render but does not live-update. Wrap the root in
368
- /// `.momoLocalize()` (or use `NavigationContainer`) to get updates.
369
- static let defaultValue = LocalizeBox(Localize.shared)
370
- // AI-GENERATED END: default to the app-language-observing shared instance
301
+ static let defaultValue = LocalizeBox(Localize())
371
302
  }
372
303
 
373
304
  public extension EnvironmentValues {
@@ -46,9 +46,7 @@ public struct NavigationContainer<Initial: View>: View {
46
46
  maxApi: maxApi
47
47
  )
48
48
  )
49
- // AI-GENERATED START: default to the app-language-observing shared Localize
50
- _localize = StateObject(wrappedValue: localize ?? Localize.shared)
51
- // AI-GENERATED END: default to the app-language-observing shared Localize
49
+ _localize = StateObject(wrappedValue: localize ?? Localize())
52
50
  self.composeApi = composeApi
53
51
  self.maxApi = maxApi
54
52
  self.setNavigator = setNavigator
@@ -64,17 +62,15 @@ public struct NavigationContainer<Initial: View>: View {
64
62
  .environmentObject(navigator)
65
63
  .environmentObject(applicationEnvironment)
66
64
  .environment(\.applicationEnvironment, applicationEnvironment)
67
- // AI-GENERATED START: route Localize injection through the observing modifier
68
- .momoLocalize(localize)
69
- // AI-GENERATED END: route Localize injection through the observing modifier
65
+ .environmentObject(localize)
66
+ .environment(\.localize, localize)
70
67
  .fullScreenCover(item: $navigator.presented) { route in
71
68
  screenView(forDialog: route)
72
69
  .environmentObject(navigator)
73
70
  .environmentObject(applicationEnvironment)
74
71
  .environment(\.applicationEnvironment, applicationEnvironment)
75
- // AI-GENERATED START: route Localize injection through the observing modifier
76
- .momoLocalize(localize)
77
- // AI-GENERATED END: route Localize injection through the observing modifier
72
+ .environmentObject(localize)
73
+ .environment(\.localize, localize)
78
74
  }
79
75
  .overlay {
80
76
  if let item = navigator.overplay {
@@ -82,9 +78,8 @@ public struct NavigationContainer<Initial: View>: View {
82
78
  .environmentObject(navigator)
83
79
  .environmentObject(applicationEnvironment)
84
80
  .environment(\.applicationEnvironment, applicationEnvironment)
85
- // AI-GENERATED START: route Localize injection through the observing modifier
86
- .momoLocalize(localize)
87
- // AI-GENERATED END: route Localize injection through the observing modifier
81
+ .environmentObject(localize)
82
+ .environment(\.localize, localize)
88
83
  }
89
84
  }
90
85
  .onAppear {
@@ -92,21 +87,13 @@ public struct NavigationContainer<Initial: View>: View {
92
87
  navigator.composeApi = composeApi
93
88
  }
94
89
  setNavigator?(navigator)
95
- // Register with the host swipe-back bridge (mirrors ComposeAppContainer's
96
- // ComposeNavigatorManager.push). Idempotent, so a re-appear won't double-count.
97
- NavigatorManager.shared.push(navigator)
98
- // AI-GENERATED START: demote the MaxApi language pull to a seed of last resort
99
- // Only a seed: a Localize with a language source already tracks the host in both
100
- // directions, so asking maxApi would just race it. `changeLanguage` normalizes the
101
- // raw response and hops to main itself.
102
- guard !localize.observesAppLanguage else { return }
90
+ // Mirrors Compose's LaunchedEffect(maxApi, resolvedLocalize): host-reported
91
+ // language wins over the default "vi" only when it resolves to English.
103
92
  maxApi?.getLanguage { data in
104
- localize.changeLanguage(data?["response"] as? String)
93
+ if parseLanguage(data) == Localize.EN {
94
+ localize.changeLanguage(Localize.EN)
95
+ }
105
96
  }
106
- // AI-GENERATED END: demote the MaxApi language pull to a seed of last resort
107
- }
108
- .onDisappear {
109
- NavigatorManager.shared.remove(navigator)
110
97
  }
111
98
  }
112
99
 
@@ -208,3 +195,9 @@ private func safeAreaBottomInset() -> CGFloat {
208
195
  .compactMap { ($0 as? UIWindowScene)?.keyWindow?.safeAreaInsets.bottom }
209
196
  .first ?? 0
210
197
  }
198
+
199
+ private func parseLanguage(_ raw: [String: Any?]?) -> String? {
200
+ guard let response = (raw?["response"] as? String)?.trimmingCharacters(in: .whitespaces).lowercased(),
201
+ !response.isEmpty else { return nil }
202
+ return response.hasPrefix(Localize.EN) ? Localize.EN : Localize.VI
203
+ }
@@ -89,11 +89,7 @@ public struct OverplayItem: Identifiable {
89
89
 
90
90
  @available(iOS 16.0, *)
91
91
  public final class Navigator: ObservableObject {
92
- @Published public var path: [DynamicScreenRoute] = [] {
93
- // Disposal hangs off the path so every route that leaves the stack — pop,
94
- // replace, reset — is released the same way, after its exit transition.
95
- didSet { disposeScreens(droppedFrom: oldValue) }
96
- }
92
+ @Published public var path: [DynamicScreenRoute] = []
97
93
  @Published public var presented: DynamicDialogRoute?
98
94
  @Published public var overplay: OverplayItem?
99
95
 
@@ -151,7 +147,8 @@ public final class Navigator: ObservableObject {
151
147
 
152
148
  public func replace<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
153
149
  guard !path.isEmpty else { return }
154
- path.removeLast()
150
+ let removed = path.removeLast()
151
+ registry.unregister(id: removed.id)
155
152
  push(content, options: options)
156
153
  }
157
154
 
@@ -161,12 +158,14 @@ public final class Navigator: ObservableObject {
161
158
  options: NavigationOptions? = nil
162
159
  ) {
163
160
  guard !path.isEmpty else { return }
164
- path.removeLast()
161
+ let removed = path.removeLast()
162
+ registry.unregister(id: removed.id)
165
163
  push(screenName: screenName, content, options: options)
166
164
  }
167
165
 
168
166
  public func reset<V: View>(@ViewBuilder _ content: @escaping () -> V, options: NavigationOptions? = nil) {
169
- let previousRoot = rootRoute.id
167
+ for route in path { registry.unregister(id: route.id) }
168
+ registry.unregister(id: rootRoute.id)
170
169
  path.removeAll()
171
170
  let route = registry.register(
172
171
  screenName: options?.screenName ?? "",
@@ -174,7 +173,6 @@ public final class Navigator: ObservableObject {
174
173
  options: options
175
174
  )
176
175
  rootRoute = route
177
- disposeScreens([previousRoot])
178
176
  }
179
177
 
180
178
  public func reset<V: View>(
@@ -182,7 +180,8 @@ public final class Navigator: ObservableObject {
182
180
  @ViewBuilder _ content: @escaping () -> V,
183
181
  options: NavigationOptions? = nil
184
182
  ) {
185
- let previousRoot = rootRoute.id
183
+ for route in path { registry.unregister(id: route.id) }
184
+ registry.unregister(id: rootRoute.id)
186
185
  path.removeAll()
187
186
  let route = registry.register(
188
187
  screenName: screenName,
@@ -190,50 +189,26 @@ public final class Navigator: ObservableObject {
190
189
  options: options
191
190
  )
192
191
  rootRoute = route
193
- disposeScreens([previousRoot])
194
192
  }
195
193
 
196
194
  public func pop(_ count: Int = 1, callback: (() -> Void)? = nil) {
197
- popStep(remaining: count, callback: callback)
198
- }
199
-
200
- /// One `pop` iteration. Compose runs the loop in a coroutine and suspends over the
201
- /// overplay's exit animation before the next step; the same wait is needed here, or
202
- /// a `pop(2)` (prevent-back: close the popup, then leave) would spend both steps on
203
- /// the overplay that is still animating out and never pop the screen.
204
- private func popStep(remaining: Int, callback: (() -> Void)?) {
205
- guard remaining > 0 else {
206
- callback?()
207
- return
208
- }
209
-
210
- if overplay?.type == .snackBar {
211
- // Mirrors Kotlin: a snackbar doesn't block back-navigation, so popping
212
- // while one is showing pops the screen underneath and clears the
213
- // snackbar immediately, without playing its dismiss animation.
214
- overplayDismissHandler = nil
215
- overplay = nil
216
- guard popScreen() else {
217
- callback?()
218
- return
219
- }
220
- popStep(remaining: remaining - 1, callback: callback)
221
- } else if overplay != nil {
222
- hideOverplay()
223
- DispatchQueue.main.asyncAfter(deadline: .now() + Self.overplayDismissDelay) { [weak self] in
224
- guard let self else { return }
225
- // The component reports back through `finishOverplayDismiss` once its exit
226
- // animation ends; clear it here if it never did, so the step is not wasted.
227
- if self.overplay != nil { self.finishOverplayDismiss() }
228
- self.popStep(remaining: remaining - 1, callback: callback)
195
+ var remaining = count
196
+ while remaining > 0 {
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 {
205
+ hideOverplay()
206
+ } else if !popScreen() {
207
+ break
229
208
  }
230
- } else {
231
- guard popScreen() else {
232
- callback?()
233
- return
234
- }
235
- popStep(remaining: remaining - 1, callback: callback)
209
+ remaining -= 1
236
210
  }
211
+ callback?()
237
212
  }
238
213
 
239
214
  /// Pops the topmost presented screen or stack entry. Returns `false` (and requests
@@ -243,10 +218,11 @@ public final class Navigator: ObservableObject {
243
218
  if presented != nil {
244
219
  let dismissedId = presented?.id
245
220
  presented = nil
246
- if let id = dismissedId { disposeScreens([id]) }
221
+ if let id = dismissedId { registry.unregister(id: id) }
247
222
  return true
248
223
  } else if !path.isEmpty {
249
- path.removeLast()
224
+ let removed = path.removeLast()
225
+ registry.unregister(id: removed.id)
250
226
  return true
251
227
  } else {
252
228
  composeApi?.requestCallback(funcName: "dismiss", params: nil, onResponse: nil)
@@ -255,18 +231,12 @@ public final class Navigator: ObservableObject {
255
231
  }
256
232
 
257
233
  public func popToRoot() {
234
+ for route in path { registry.unregister(id: route.id) }
258
235
  path.removeAll()
259
236
  }
260
237
 
261
- /// The entry a back action applies to — mirrors Compose's `currentScreen()`
262
- /// (`navController.currentBackStackEntry`): a presented screen sits on top of
263
- /// the stack, otherwise it is the last pushed screen, otherwise the root.
264
- private func currentScreen() -> DynamicScreen? {
265
- registry.getScreen(id: presented?.id ?? path.last?.id ?? rootRoute.id)
266
- }
267
-
268
238
  public func onBackSafe(callback: (() -> Void)? = nil) {
269
- let latest = currentScreen()
239
+ let latest = registry.latest()
270
240
  if let onBack = latest?.options?.onBackHandler {
271
241
  onBack()
272
242
  return
@@ -412,36 +382,4 @@ public final class Navigator: ObservableObject {
412
382
  public func hideSnackBar() {
413
383
  if overplay?.type == .snackBar { hideOverplay() }
414
384
  }
415
-
416
- // MARK: Screen disposal
417
-
418
- /// Matches the `delay(300)` Compose waits in `dismissScreen` before unregistering,
419
- /// with a little headroom for the pop transition.
420
- private static let screenDisposeDelay: TimeInterval = 0.35
421
-
422
- /// Longest overplay exit animation (the bottom sheet's), so a queued pop step runs
423
- /// after the component has taken itself down.
424
- private static let overplayDismissDelay: TimeInterval = 0.3
425
-
426
- private func disposeScreens(droppedFrom previous: [DynamicScreenRoute]) {
427
- let live = Set(path.map(\.id))
428
- disposeScreens(previous.map(\.id).filter { !live.contains($0) })
429
- }
430
-
431
- /// Screens are released only once the pop transition has played out: the outgoing
432
- /// view is still on screen while it animates and the registry is what renders it —
433
- /// dropping it right away blanks the screen mid-transition. Liveness is re-checked
434
- /// when the timer fires, so a route pushed back before then keeps its screen.
435
- private func disposeScreens(_ ids: [Int]) {
436
- guard !ids.isEmpty else { return }
437
- DispatchQueue.main.asyncAfter(deadline: .now() + Self.screenDisposeDelay) { [weak self] in
438
- guard let self else { return }
439
- var live = Set(self.path.map(\.id))
440
- live.insert(self.rootRoute.id)
441
- if let presented = self.presented { live.insert(presented.id) }
442
- for id in ids where !live.contains(id) {
443
- self.registry.unregister(id: id)
444
- }
445
- }
446
- }
447
385
  }
@@ -5,7 +5,7 @@
5
5
  // SwiftUI mirror of Compose's `StackScreen`. This is a refactor of `Screen`:
6
6
  // the body is decomposed into the same focused pieces Compose uses — header
7
7
  // layer, scrollable content (`StackScreenContent`), floating button
8
- // (`FloatingContent`) and footer (`NavigationFooter`) — instead of one
8
+ // (`FloatingContent`) and footer (`FooterContent`) — instead of one
9
9
  // monolithic, partially-duplicated view. The public init matches `Screen`
10
10
  // so call sites (e.g. NavigationContainer's ScreenHost) can swap over.
11
11
  //
@@ -165,13 +165,12 @@ public struct StackScreen<Content: View>: View {
165
165
 
166
166
  FloatingContent(
167
167
  fabProps: fabProps,
168
- keyboardSize: keyboardSize,
169
- hasFooter: footer != nil,
168
+ keyboardOffset: 0,
170
169
  scrollOffset: scrollOffset
171
170
  )
172
171
  }
173
172
 
174
- NavigationFooter(footerComponent: footer, keyboardSize: keyboardSize)
173
+ FooterContent(footer: footer, keyboardSize: keyboardSize, bottomInset: bottomInset)
175
174
  }
176
175
 
177
176
  // Animated search bar overlay (mirrors Compose's SearchAnimated layer).
@@ -445,29 +444,17 @@ private struct SearchAnimatedHeader: View {
445
444
  /// Mirrors Compose's `FloatingContent`: no FAB props means nothing renders.
446
445
  private struct FloatingContent: View {
447
446
  let fabProps: FabProps?
448
- let keyboardSize: CGFloat
449
- /// With a footer the FAB is anchored to the footer's *layout* top, which does not move
450
- /// when the band offsets itself over the keyboard — so it rides the same offset.
451
- let hasFooter: Bool
447
+ let keyboardOffset: CGFloat
452
448
  let scrollOffset: CGFloat
453
449
 
454
- /// Compose keeps the FAB `Spacing.M + AppNavigationBar` above the bottom of the screen
455
- /// and lifts it with `imePadding()`; here it sits `Spacing.M` above the safe area and
456
- /// nothing lifts it, so the difference is applied as an offset (see NavigationFooter).
457
- private var verticalOffset: CGFloat {
458
- if hasFooter { return NavigationFooter.verticalOffset(keyboardSize: keyboardSize) }
459
- return NavigationFooter.safeAreaBottom - NavigationFooter.navigationBarInset - keyboardSize
460
- }
461
-
462
450
  var body: some View {
463
451
  if let fabProps = fabProps {
464
452
  FloatingButton(
465
453
  props: fabProps,
466
- keyboardOffset: 0,
454
+ keyboardOffset: keyboardOffset,
467
455
  scrollOffset: scrollOffset
468
456
  )
469
457
  .padding(.horizontal, Spacing.M)
470
- .offset(y: verticalOffset)
471
458
  .ignoresSafeArea(.keyboard, edges: .bottom)
472
459
  }
473
460
  }
@@ -496,3 +483,19 @@ private struct HideKeyboardOnTap: ViewModifier {
496
483
  }
497
484
  }
498
485
  }
486
+
487
+ // MARK: - Footer content
488
+
489
+ /// Mirrors Compose's `FooterContent`: renders the footer chrome only when a
490
+ /// footer component is supplied. Reuses the existing `Footer` view from Screen.swift.
491
+ private struct FooterContent: View {
492
+ let footer: (() -> AnyView)?
493
+ let keyboardSize: CGFloat
494
+ let bottomInset: CGFloat
495
+
496
+ var body: some View {
497
+ if let footerView = footer?() {
498
+ Footer(footerView: footerView, keyboardSize: keyboardSize, bottomInset: bottomInset)
499
+ }
500
+ }
501
+ }
@@ -174,7 +174,6 @@ public struct BadgeRibbon: View {
174
174
  )
175
175
  .frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
176
176
  .rotationEffect(.degrees(180))
177
- .offset(x: 1)
178
177
  .clipped()
179
178
  }
180
179
 
@@ -184,7 +183,6 @@ public struct BadgeRibbon: View {
184
183
  placeholder: AnyView(Color.clear)
185
184
  )
186
185
  .frame(width: RibbonConstants.headTailWidth, height: RibbonConstants.headTailHeight)
187
- .offset(x: 1)
188
186
  .clipped()
189
187
  }
190
188
 
@@ -194,7 +192,6 @@ public struct BadgeRibbon: View {
194
192
  placeholder: AnyView(Color.clear)
195
193
  )
196
194
  .frame(width: RibbonConstants.skewTailWidth, height: RibbonConstants.skewTailHeight)
197
- .offset(x: -2)
198
195
  .clipped()
199
196
  }
200
197
  }
@@ -284,7 +281,7 @@ private struct RibbonConstants {
284
281
  static let ribbonHeight: CGFloat = 20
285
282
  static let roundHeight: CGFloat = 16
286
283
  static let skewBodyHeight: CGFloat = 16
287
- static let roundRightRadius: CGFloat = 8
284
+ static let roundRightRadius: CGFloat = 12
288
285
  static let roundPaddingEnd: CGFloat = 6
289
286
  static let skewTailWidth: CGFloat = 8
290
287
  static let skewTailHeight: CGFloat = 16
@@ -133,19 +133,19 @@ public struct Button: View {
133
133
  var action: () -> Void
134
134
  var type: ButtonType
135
135
  var size: ButtonSize
136
- var iconLeft: String
137
- var iconRight: String
136
+ var iconLeft: AnyView?
137
+ var iconRight: AnyView?
138
138
  var isFull: Bool
139
139
  var loading: Bool
140
140
  var useTintColor: Bool
141
141
 
142
142
  public init(
143
- title: String = "Button",
143
+ title: String = "",
144
144
  action: @escaping () -> Void,
145
145
  type: ButtonType = .primary,
146
146
  size: ButtonSize = .large,
147
- iconLeft: String = "",
148
- iconRight: String = "",
147
+ iconLeft: AnyView? = nil,
148
+ iconRight: AnyView? = nil,
149
149
  isFull: Bool = true,
150
150
  loading: Bool = false,
151
151
  useTintColor: Bool = true
@@ -161,9 +161,38 @@ public struct Button: View {
161
161
  self.useTintColor = useTintColor
162
162
  }
163
163
 
164
- private func shouldLoadingOnLeft(_ left: String, _ right: String) -> Bool {
165
- let hasLeft = !left.isEmpty
166
- let hasRight = !right.isEmpty
164
+ /// Mirrors Kotlin's `Button(iconLeft: String, iconRight: String, ...)`, which resolves
165
+ /// icons by design-system source name through the shared `Icon` component rather than
166
+ /// a caller-supplied view. `iconLeft` is required here (unlike Kotlin's `= ""` default)
167
+ /// solely to keep this initializer unambiguous alongside the `AnyView`-based one above —
168
+ /// pass `""` explicitly for an icon-right-only button.
169
+ public init(
170
+ title: String = "Button",
171
+ action: @escaping () -> Void,
172
+ type: ButtonType = .primary,
173
+ size: ButtonSize = .large,
174
+ iconLeft: String,
175
+ iconRight: String = "",
176
+ isFull: Bool = true,
177
+ loading: Bool = false,
178
+ useTintColor: Bool = true
179
+ ) {
180
+ self.init(
181
+ title: title,
182
+ action: action,
183
+ type: type,
184
+ size: size,
185
+ iconLeft: iconLeft.isEmpty ? nil : AnyView(Icon(source: iconLeft)),
186
+ iconRight: iconRight.isEmpty ? nil : AnyView(Icon(source: iconRight)),
187
+ isFull: isFull,
188
+ loading: loading,
189
+ useTintColor: useTintColor
190
+ )
191
+ }
192
+
193
+ private func shouldLoadingOnLeft(_ left: AnyView?, _ right: AnyView?) -> Bool {
194
+ let hasLeft = left != nil
195
+ let hasRight = right != nil
167
196
 
168
197
  switch (hasLeft, hasRight) {
169
198
  case (false, false): return true
@@ -188,13 +217,27 @@ public struct Button: View {
188
217
  }
189
218
  }) {
190
219
  HStack(spacing: size.iconSpacing) {
191
- renderIcon(icon: iconLeft, forceLoading: loading && loadingOnLeft, textColor: fg)
220
+ if loading && loadingOnLeft {
221
+ LottieView(name: "lottie_circle_loader", loopMode: .loop)
222
+ .frame(width: size.iconSize, height: size.iconSize)
223
+ .colorMultiply(fg)
224
+
225
+ } else if let iconLeft = iconLeft {
226
+ tintedIcon(iconLeft, color: fg)
227
+ }
192
228
 
193
229
  MomoText(title, typography: size.typographyStyle, color: fg)
194
230
  .lineLimit(1)
195
231
  .truncationMode(.tail)
196
232
 
197
- renderIcon(icon: iconRight, forceLoading: loading && !loadingOnLeft, textColor: fg)
233
+ if loading && !loadingOnLeft {
234
+ LottieView(name: "lottie_circle_loader", loopMode: .loop)
235
+ .frame(width: size.iconSize, height: size.iconSize)
236
+ .colorMultiply(fg)
237
+
238
+ } else if let iconRight = iconRight {
239
+ tintedIcon(iconRight, color: fg)
240
+ }
198
241
  }
199
242
  .frame(maxWidth: isFull ? .infinity : nil)
200
243
  .padding(.horizontal, size.padding)
@@ -206,24 +249,20 @@ public struct Button: View {
206
249
  border: border,
207
250
  borderWidth: borderWidth,
208
251
  radius: size.radius,
209
- height: size.height,
210
- isFull: isFull
252
+ height: size.height
211
253
  ))
212
254
  .disabled(!isEnabled)
213
255
  }
214
256
 
215
- /// Mirrors Kotlin `RenderIcon`: the loader replaces the icon slot entirely, and
216
- /// `useTintColor == false` leaves the icon at its own colors.
217
257
  @ViewBuilder
218
- private func renderIcon(icon: String, forceLoading: Bool, textColor: Color) -> some View {
219
- let color: Color? = useTintColor ? textColor : nil
220
-
221
- if forceLoading {
222
- LottieView(name: "lottie_circle_loader", loopMode: .loop)
258
+ private func tintedIcon(_ icon: AnyView, color: Color) -> some View {
259
+ if useTintColor {
260
+ icon
261
+ .frame(width: size.iconSize, height: size.iconSize)
262
+ .colorMultiply(color)
263
+ } else {
264
+ icon
223
265
  .frame(width: size.iconSize, height: size.iconSize)
224
- .colorMultiply(color ?? Colors.black01)
225
- } else if !icon.isEmpty {
226
- Icon(source: icon, size: size.iconSize, color: color)
227
266
  }
228
267
  }
229
268
  }
@@ -238,13 +277,11 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
238
277
  let borderWidth: CGFloat
239
278
  let radius: CGFloat
240
279
  let height: CGFloat
241
- let isFull: Bool
242
280
 
243
281
  func makeBody(configuration: Configuration) -> some View {
244
282
  let pressed = configuration.isPressed && isEnabled
245
283
 
246
284
  configuration.label
247
- .modifier(HugContentOnLegacyOS(isFull: isFull))
248
285
  .opacity(pressed ? 0.5 : 1)
249
286
  .background(
250
287
  RoundedRectangle(cornerRadius: radius)
@@ -260,20 +297,3 @@ private struct ButtonPressFeedbackStyle: ButtonStyle {
260
297
  .animation(.easeInOut(duration: 0.1), value: pressed)
261
298
  }
262
299
  }
263
-
264
- /// `ButtonStyleConfiguration.Label` is greedy on iOS 16 and earlier — it accepts
265
- /// the full proposed width, so the background drawn in `makeBody` stretches past
266
- /// the content even when `isFull == false`. iOS 17 fixed the label to hug its
267
- /// content, so only legacy OSes need the `fixedSize` clamp (kept off on 17+ to
268
- /// preserve tail truncation of long titles).
269
- private struct HugContentOnLegacyOS: ViewModifier {
270
- let isFull: Bool
271
-
272
- func body(content: Content) -> some View {
273
- if #available(iOS 17.0, *) {
274
- content
275
- } else {
276
- content.fixedSize(horizontal: !isFull, vertical: false)
277
- }
278
- }
279
- }