@momo-kits/native-kits 0.300.3-debug → 0.300.4-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 (218) hide show
  1. package/CLAUDE.md +51 -7
  2. package/README.md +2 -2
  3. package/compose/build.gradle.kts +10 -2
  4. package/compose/build.gradle.kts.backup +8 -0
  5. package/compose/compose.podspec +1 -3
  6. package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
  7. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/Platform.android.kt +2 -0
  8. package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +6 -0
  9. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
  10. package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
  11. package/compose/src/commonMain/kotlin/vn/momo/kits/application/LiteScreen.kt +351 -111
  12. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Badge.kt +20 -21
  13. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Button.kt +2 -1
  14. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Carousel.kt +4 -2
  15. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Chip.kt +1 -1
  16. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Icon.kt +1 -1
  17. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Information.kt +1 -1
  18. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Input.kt +9 -6
  19. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputMoney.kt +11 -12
  20. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputOTP.kt +4 -9
  21. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputPhoneNumber.kt +2 -2
  22. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputSearch.kt +10 -9
  23. package/compose/src/commonMain/kotlin/vn/momo/kits/components/InputTextArea.kt +6 -6
  24. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +55 -22
  25. package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +12 -1
  26. package/compose/src/commonMain/kotlin/vn/momo/kits/components/ProgressInfo.kt +7 -9
  27. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Radio.kt +1 -1
  28. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Slider.kt +13 -6
  29. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Swipe.kt +13 -5
  30. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Switch.kt +3 -2
  31. package/compose/src/commonMain/kotlin/vn/momo/kits/components/TabView.kt +14 -6
  32. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tag.kt +19 -21
  33. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Text.kt +2 -2
  34. package/compose/src/commonMain/kotlin/vn/momo/kits/components/Tooltip.kt +5 -2
  35. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/DateTimePickerUtils.kt +3 -1
  36. package/compose/src/commonMain/kotlin/vn/momo/kits/components/datetimepicker/WheelPicker.kt +7 -15
  37. package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
  38. package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +20 -15
  39. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/Item.kt +1 -2
  40. package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +306 -0
  41. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +43 -4
  42. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +8 -0
  43. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigation.kt +10 -0
  44. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +6 -2
  45. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +6 -0
  46. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +197 -60
  47. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTab.kt +132 -83
  48. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/BottomTabBar.kt +54 -62
  49. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +97 -0
  50. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +377 -0
  51. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +122 -24
  52. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +34 -14
  53. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderJourney.kt +3 -3
  54. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderLocation.kt +1 -2
  55. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderRight.kt +113 -26
  56. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
  57. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderUser.kt +1 -2
  58. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HelpCenterRequest.kt +37 -0
  59. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SideBar.kt +192 -0
  60. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/SnackBar.kt +2 -1
  61. package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/ToolkitRequests.kt +8 -0
  62. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/Platform.kt +3 -0
  63. package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +20 -0
  64. package/compose/src/commonMain/kotlin/vn/momo/kits/utils/Utils.kt +13 -0
  65. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/HelpCenterRequestTest.kt +44 -0
  66. package/compose/src/commonTest/kotlin/vn/momo/kits/navigation/component/ToolkitRequestsTest.kt +15 -0
  67. package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
  68. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +4 -0
  69. package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +138 -0
  70. package/gradle/libs.versions.toml +3 -14
  71. package/gradle/wrapper/gradle-wrapper.properties +0 -2
  72. package/gradle.properties +3 -5
  73. package/ios/Application/ApplicationEnvironment.swift +24 -6
  74. package/ios/Application/DesignSystemKits.swift +100 -0
  75. package/ios/Application/DesignSystemKitsPayload.swift +68 -0
  76. package/ios/Application/MaxApi.swift +30 -3
  77. package/ios/Avatar/Avatar.swift +4 -3
  78. package/ios/Badge/Badge.swift +6 -4
  79. package/ios/Badge/BadgeDot.swift +1 -1
  80. package/ios/Badge/BadgeRibbon.swift +8 -2
  81. package/ios/Button/Button.swift +15 -12
  82. package/ios/CalculatorKeyboard/CalculatorKeyboard.swift +64 -18
  83. package/ios/Carousel/Carousel.swift +29 -12
  84. package/ios/Checkbox/Checkbox.swift +7 -3
  85. package/ios/Chip/Chip.swift +5 -4
  86. package/ios/Collapse/Collapse.swift +70 -19
  87. package/ios/Colors+Radius+Spacing/Colors.swift +10 -1
  88. package/ios/Colors+Radius+Spacing/Radius.swift +6 -0
  89. package/ios/DateTimePicker/DateTimePicker.swift +54 -20
  90. package/ios/DateTimePicker/WheelPicker.swift +103 -105
  91. package/ios/Divider/Divider.swift +37 -4
  92. package/ios/Extensions/Color++.swift +38 -9
  93. package/ios/Extensions/OptionalEnvironmentObject.swift +12 -0
  94. package/ios/Extensions/SkeletonUICompat.swift +11 -0
  95. package/ios/Icon/Icon.swift +21 -23
  96. package/ios/Icon/Icons.swift +1329 -0
  97. package/ios/IconButton/IconButton.swift +15 -7
  98. package/ios/Image/Image.swift +7 -23
  99. package/ios/Information/Information.swift +27 -9
  100. package/ios/Input/ErrorView.swift +2 -2
  101. package/ios/Input/Input.swift +171 -148
  102. package/ios/Input/InputPhoneNumber.swift +10 -9
  103. package/ios/Input/InputSearch.swift +107 -78
  104. package/ios/Input/InputTextArea.swift +133 -148
  105. package/ios/InputDropDown/InputDropDown.swift +10 -7
  106. package/ios/InputMoney/InputMoney.swift +32 -11
  107. package/ios/InputOTP/InputOTP.swift +22 -12
  108. package/ios/Layout/Card.swift +50 -0
  109. package/ios/Layout/GridContext.swift +187 -3
  110. package/ios/Layout/Item.swift +5 -4
  111. package/ios/Layout/ScreenLayout.swift +415 -0
  112. package/ios/Layout/Section.swift +4 -100
  113. package/ios/Loader/Loader.swift +5 -9
  114. package/ios/Lottie/LottieView.swift +14 -2
  115. package/ios/{Application/Navigation/Overplay → Navigation}/BottomSheet.swift +169 -29
  116. package/ios/Navigation/BottomTab/BottomTab.swift +332 -0
  117. package/ios/{Application/Navigation → Navigation}/BottomTab/BottomTabBar.swift +18 -7
  118. package/ios/Navigation/BottomTab/DuoBarCompression.swift +104 -0
  119. package/ios/Navigation/BottomTab/DuoTabBar.swift +329 -0
  120. package/ios/{Application → Navigation/Component}/FloatingButton.swift +43 -54
  121. package/ios/Navigation/Component/Header.swift +545 -0
  122. package/ios/Navigation/Component/HeaderBackground.swift +154 -0
  123. package/ios/Navigation/Component/HeaderJourney.swift +73 -0
  124. package/ios/Navigation/Component/HeaderLocation.swift +48 -0
  125. package/ios/Navigation/Component/HeaderRight.swift +425 -0
  126. package/ios/Navigation/Component/HeaderTitle.swift +36 -0
  127. package/ios/{Application/Navigation → Navigation/Component}/HeaderUser.swift +21 -24
  128. package/ios/Navigation/Component/SideBar.swift +183 -0
  129. package/ios/{Application/Navigation/Overplay → Navigation/Component}/SnackBar.swift +8 -8
  130. package/ios/Navigation/HostBackGestureBridge.swift +78 -0
  131. package/ios/{Application/Navigation/Overplay → Navigation}/ModalScreen.swift +9 -2
  132. package/ios/Navigation/Navigation.swift +204 -0
  133. package/ios/Navigation/NavigationContainer.swift +195 -0
  134. package/ios/{Application/Navigation → Navigation}/Navigator.swift +207 -107
  135. package/ios/Navigation/StackScreen.swift +930 -0
  136. package/ios/Navigation/StackTransition.swift +400 -0
  137. package/ios/Navigation/SwipeBackHaptic.swift +33 -0
  138. package/ios/{Application/Navigation → Navigation}/Tracking/ScreenTracker.swift +12 -5
  139. package/ios/OTPKeyboard/OTPKeyboard.swift +8 -11
  140. package/ios/Pagination/PaginationScroll.swift +2 -2
  141. package/ios/Popup/PopupDisplay.swift +72 -135
  142. package/ios/Popup/PopupInput.swift +10 -13
  143. package/ios/Popup/PopupLayout.swift +138 -0
  144. package/ios/Popup/PopupNotify.swift +59 -69
  145. package/ios/Popup/PopupPromotion.swift +31 -47
  146. package/ios/PopupView/FullscreenPopup.swift +13 -19
  147. package/ios/PopupView/PopupView.swift +1 -1
  148. package/ios/PopupView/Utils++.swift +0 -8
  149. package/ios/ProgressInfo/ProgressInfo.swift +4 -2
  150. package/ios/Radio/Radio.swift +2 -1
  151. package/ios/Rating/Rating.swift +9 -1
  152. package/ios/Resources/dot_loading.json +558 -0
  153. package/ios/Resources/lottie_circle_loader.json +1 -0
  154. package/ios/ScrollIndicator/ScrollIndicator.swift +11 -10
  155. package/ios/Skeleton/Skeleton.swift +22 -27
  156. package/ios/Slider/Slider.swift +16 -18
  157. package/ios/Stepper/Stepper.swift +7 -3
  158. package/ios/Steps/Steps.swift +8 -6
  159. package/ios/SuggestAction/SuggestAction.swift +5 -0
  160. package/ios/Swipeable/SwipeCell.swift +10 -6
  161. package/ios/Switch/Switch.swift +22 -20
  162. package/ios/TabView/TabView.swift +102 -21
  163. package/ios/Tag/Tag.swift +15 -4
  164. package/ios/Template/Logo/Logo.swift +2 -1
  165. package/ios/Template/TrustBanner/TrustBanner.swift +109 -55
  166. package/ios/Title/Title.swift +6 -17
  167. package/ios/Tooltip/Tooltip.swift +29 -14
  168. package/ios/Typography/FontScale.swift +17 -14
  169. package/ios/Typography/Text.swift +156 -20
  170. package/ios/Typography/Typography.swift +2 -2
  171. package/ios/Uploader/Uploader.swift +3 -1
  172. package/ios/native-kits.podspec +2 -3
  173. package/ios-demo/MoMoUIKitsDemo.podspec +1 -3
  174. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +1 -1
  175. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DuoTabDemo.swift +80 -0
  176. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +2 -0
  177. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IPhoneDuoDemo.swift +173 -0
  178. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +77 -1
  179. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +1 -0
  180. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +2 -2
  181. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +2 -0
  182. package/package.json +1 -1
  183. package/publish.sh +19 -2
  184. package/scripts/gen-ios-icons.mjs +36 -0
  185. package/compose/src/commonMain/kotlin/vn/momo/kits/application/AnimationSearchInput.kt +0 -52
  186. package/compose/src/commonMain/kotlin/vn/momo/kits/application/FloatingButton.kt +0 -183
  187. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Header.kt +0 -207
  188. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderAnimated.kt +0 -39
  189. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderBackground.kt +0 -86
  190. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderDefault.kt +0 -76
  191. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderExtended.kt +0 -76
  192. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderRight.kt +0 -293
  193. package/compose/src/commonMain/kotlin/vn/momo/kits/application/HeaderTitle.kt +0 -33
  194. package/compose/src/commonMain/kotlin/vn/momo/kits/application/NavigationContainer.kt +0 -110
  195. package/compose/src/commonMain/kotlin/vn/momo/kits/application/Screen.kt +0 -378
  196. package/compose/src/commonMain/kotlin/vn/momo/kits/application/useHeaderSearchAnimation.kt +0 -69
  197. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
  198. package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
  199. package/ios/Application/Components.swift +0 -270
  200. package/ios/Application/ComposeApi.swift +0 -22
  201. package/ios/Application/HeaderRight.swift +0 -306
  202. package/ios/Application/Navigation/BottomTab/BottomTab.swift +0 -104
  203. package/ios/Application/Navigation/HeaderBackProps.swift +0 -66
  204. package/ios/Application/Navigation/HeaderTitle.swift +0 -57
  205. package/ios/Application/Navigation/NavigationContainer.swift +0 -213
  206. package/ios/Application/Navigation/NavigationOptions.swift +0 -109
  207. package/ios/Application/Navigation/NavigatorManager.swift +0 -22
  208. package/ios/Application/Navigation/component/Header.swift +0 -178
  209. package/ios/Application/Navigation/component/HeaderBackground.swift +0 -83
  210. package/ios/Application/Navigation/component/HeaderColor.swift +0 -65
  211. package/ios/Application/Navigation/component/HeaderRightWidthKey.swift +0 -11
  212. package/ios/Application/Navigation/component/NavigationFooter.swift +0 -90
  213. package/ios/Application/Navigation/component/NavigationHeaderRight.swift +0 -196
  214. package/ios/Application/Screen.swift +0 -250
  215. package/ios/Application/StackScreen.swift +0 -492
  216. package/ios/Typography/FontScaleStore.swift +0 -58
  217. package/ios/Typography/KitDesignSystem.swift +0 -17
  218. /package/ios/{Application/Navigation → Navigation}/BottomTab/CurvedContainer.swift +0 -0
@@ -36,33 +36,21 @@ public struct DateTimePicker: View {
36
36
  self.onChange = onChange
37
37
 
38
38
  let calendar = Calendar.current
39
- let effectiveSelected = selectedValue ?? DateTimePickerUtils.getCurrentDateTime()
40
39
  let effectiveMin = minDate ?? DateTimePickerUtils.createRelativeDate(years: -10)
41
40
  let effectiveMax = maxDate ?? DateTimePickerUtils.createRelativeDate(years: 10)
42
41
 
43
42
  // needCheckRange is true only when the caller supplied no bounds.
44
43
  let needCheckRange = (minDate == nil && maxDate == nil)
45
44
 
46
- let initialValue: Date
47
- if !needCheckRange {
48
- initialValue = effectiveSelected
49
- } else if effectiveSelected < effectiveMin {
50
- initialValue = effectiveMin
51
- } else if effectiveSelected > effectiveMax {
52
- initialValue = effectiveMax
53
- } else {
54
- initialValue = effectiveSelected
55
- }
56
-
57
- let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: initialValue)
58
- self._currentData = State(initialValue: DateTimePickerData(
59
- day: comps.day ?? 1,
60
- month: comps.month ?? 1,
61
- year: comps.year ?? 2000,
62
- hour: comps.hour ?? 0,
63
- minute: comps.minute ?? 0
45
+ self._currentData = State(initialValue: Self.pickerData(
46
+ selectedValue: selectedValue,
47
+ effectiveMin: effectiveMin,
48
+ effectiveMax: effectiveMax,
49
+ needCheckRange: needCheckRange
64
50
  ))
65
51
 
52
+ self.effectiveMin = effectiveMin
53
+ self.effectiveMax = effectiveMax
66
54
  self.effectiveMinComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: effectiveMin)
67
55
  self.effectiveMaxComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: effectiveMax)
68
56
  self.needCheckRange = needCheckRange
@@ -102,9 +90,10 @@ public struct DateTimePicker: View {
102
90
  }
103
91
  .frame(maxWidth: .infinity)
104
92
  .frame(height: pickerHeight)
105
- .background(theme.colors.background.surface)
106
93
  .padding(.horizontal, Spacing.M)
94
+ .background(theme.colors.background.surface)
107
95
  .onAppear { emitIfValid() }
96
+ .onChange(of: selectedValue) { value in resetSelection(to: value) }
108
97
  }
109
98
 
110
99
  // MARK: Internal
@@ -119,10 +108,55 @@ public struct DateTimePicker: View {
119
108
 
120
109
  @State private var currentData: DateTimePickerData
121
110
 
111
+ private let effectiveMin: Date
112
+ private let effectiveMax: Date
122
113
  private let effectiveMinComponents: DateComponents
123
114
  private let effectiveMaxComponents: DateComponents
124
115
  private let needCheckRange: Bool
125
116
 
117
+ /// Mirrors Compose `initialValue`: the selection is clamped only when no bounds were supplied.
118
+ private static func pickerData(
119
+ selectedValue: Date?,
120
+ effectiveMin: Date,
121
+ effectiveMax: Date,
122
+ needCheckRange: Bool
123
+ ) -> DateTimePickerData {
124
+ let effectiveSelected = selectedValue ?? DateTimePickerUtils.getCurrentDateTime()
125
+ let initialValue: Date
126
+ if !needCheckRange {
127
+ initialValue = effectiveSelected
128
+ } else if effectiveSelected < effectiveMin {
129
+ initialValue = effectiveMin
130
+ } else if effectiveSelected > effectiveMax {
131
+ initialValue = effectiveMax
132
+ } else {
133
+ initialValue = effectiveSelected
134
+ }
135
+
136
+ let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: initialValue)
137
+ return DateTimePickerData(
138
+ day: comps.day ?? 1,
139
+ month: comps.month ?? 1,
140
+ year: comps.year ?? 2000,
141
+ hour: comps.hour ?? 0,
142
+ minute: comps.minute ?? 0
143
+ )
144
+ }
145
+
146
+ /// Compose re-seeds the wheels when `selectedValue` changes (`remember(initialValue)`).
147
+ /// A caller echoing `onChange` back lands on the current selection and is a no-op.
148
+ private func resetSelection(to value: Date?) {
149
+ var next = Self.pickerData(
150
+ selectedValue: value,
151
+ effectiveMin: effectiveMin,
152
+ effectiveMax: effectiveMax,
153
+ needCheckRange: needCheckRange
154
+ )
155
+ // The date carries no AM/PM, so keep the wheel's choice.
156
+ next.timeMode = currentData.timeMode
157
+ if next != currentData { currentData = next }
158
+ }
159
+
126
160
  private func selectedDataFor(_ name: String) -> String {
127
161
  switch name {
128
162
  case "day": return DateTimePickerUtils.paddingNum(currentData.day)
@@ -10,9 +10,8 @@ private let wheelPickerBorderWidth: CGFloat = 1
10
10
 
11
11
  /// A reusable single-column wheel picker, mirroring Compose `WheelPicker.kt`.
12
12
  ///
13
- /// The data is padded with two empty rows at the top and bottom so the first/last
14
- /// real value can be centered. The currently centered row is reported back via
15
- /// `onChange(name, value)`.
13
+ /// Five rows tall with the selection in the middle row. A drag always settles on a row
14
+ /// (Compose snaps once the scroll stops) and then reports it via `onChange(name, value)`.
16
15
  public struct WheelPicker: View {
17
16
  @Environment(\.appTheme) private var theme
18
17
 
@@ -28,68 +27,50 @@ public struct WheelPicker: View {
28
27
  self.data = data
29
28
  self.selectedData = selectedData
30
29
  self.onChange = onChange
30
+ _restIndex = State(initialValue: Self.index(of: selectedData, in: data))
31
31
  }
32
32
 
33
33
  // MARK: Public
34
34
 
35
35
  public var body: some View {
36
- let paddedData = ["", ""] + data + ["", ""]
37
-
38
- return GeometryReader { geo in
39
- let height = geo.size.height
40
- ScrollViewReader { proxy in
41
- ScrollView(.vertical, showsIndicators: false) {
42
- LazyVStack(spacing: 0) {
43
- ForEach(Array(paddedData.enumerated()), id: \.offset) { index, text in
44
- WheelPickerItem(
45
- text: text,
46
- isSelected: index == selectedIndex,
47
- index: index,
48
- scrollOffset: scrollOffset,
49
- viewportHeight: height
50
- )
51
- .frame(height: wheelItemHeight)
52
- .id(index)
53
- }
54
- }
55
- .background(
56
- GeometryReader { inner in
57
- Color.clear.preference(
58
- key: WheelOffsetKey.self,
59
- value: inner.frame(in: .named(scrollSpace)).minY
60
- )
61
- }
62
- )
63
- }
64
- .coordinateSpace(name: scrollSpace)
65
- .onPreferenceChange(WheelOffsetKey.self) { value in
66
- scrollOffset = value
67
- updateSelection(paddedData: paddedData, viewportHeight: height)
68
- }
69
- .onAppear {
70
- let initial = max(paddedData.firstIndex(of: selectedData) ?? 2, 2)
71
- selectedIndex = initial
72
- DispatchQueue.main.async {
73
- proxy.scrollTo(initial, anchor: .center)
36
+ let position = self.position
37
+ let centerSlot = CGFloat(wheelVisibleCount / 2)
38
+
39
+ return Rectangle()
40
+ .fill(theme.colors.background.surface)
41
+ .overlay(
42
+ // Compose draws the selection band behind the rows.
43
+ Rectangle()
44
+ .fill(Colors.blue10)
45
+ .opacity(0.2)
46
+ .frame(height: wheelItemHeight)
47
+ )
48
+ .overlay(
49
+ VStack(spacing: 0) {
50
+ ForEach(Array(data.enumerated()), id: \.offset) { index, text in
51
+ WheelPickerItem(text: text, distance: abs(CGFloat(index) - position))
74
52
  }
75
53
  }
54
+ .offset(y: (centerSlot - position) * wheelItemHeight),
55
+ alignment: .top
56
+ )
57
+ .frame(maxWidth: .infinity)
58
+ .frame(height: wheelItemHeight * CGFloat(wheelVisibleCount))
59
+ .clipShape(RoundedRectangle(cornerRadius: Radius.S))
60
+ .overlay(
61
+ RoundedRectangle(cornerRadius: Radius.S)
62
+ .strokeBorder(theme.colors.border.default, lineWidth: wheelPickerBorderWidth)
63
+ )
64
+ .contentShape(Rectangle())
65
+ .gesture(dragGesture)
66
+ .onAppear { reportIfUnlisted() }
67
+ .onChange(of: selectedData) { _ in syncToSelection() }
68
+ .onChange(of: data) { _ in syncToSelection() }
69
+ .accessibilityElement()
70
+ .accessibilityValue(data.indices.contains(restIndex) ? data[restIndex] : "")
71
+ .accessibilityAdjustableAction { direction in
72
+ settle(at: restIndex + (direction == .increment ? 1 : -1))
76
73
  }
77
- }
78
- .frame(height: wheelItemHeight * CGFloat(wheelVisibleCount))
79
- .overlay(
80
- RoundedRectangle(cornerRadius: Radius.S)
81
- .stroke(theme.colors.border.default, lineWidth: wheelPickerBorderWidth)
82
- )
83
- .background(theme.colors.background.surface.clipShape(RoundedRectangle(cornerRadius: Radius.S)))
84
- .overlay(
85
- // Centered translucent selection band.
86
- Rectangle()
87
- .fill(Colors.blue10)
88
- .opacity(0.2)
89
- .frame(height: wheelItemHeight)
90
- .allowsHitTesting(false)
91
- )
92
- .clipShape(RoundedRectangle(cornerRadius: Radius.S))
93
74
  }
94
75
 
95
76
  // MARK: Internal
@@ -99,24 +80,60 @@ public struct WheelPicker: View {
99
80
  let selectedData: String
100
81
  let onChange: (String, String) -> Void
101
82
 
102
- @State private var selectedIndex: Int = 0
103
- @State private var scrollOffset: CGFloat = 0
104
-
105
- private let scrollSpace = "wheelScroll"
106
-
107
- /// Map the current scroll offset to the centered index and report changes.
108
- private func updateSelection(paddedData: [String], viewportHeight: CGFloat) {
109
- // `scrollOffset` is the top of the content relative to the viewport (<= 0 when scrolled).
110
- let centerY = viewportHeight / 2
111
- let contentCenter = -scrollOffset + centerY
112
- let rawIndex = Int((contentCenter / wheelItemHeight).rounded()) - 1
113
- let clamped = max(2, min(rawIndex, paddedData.count - 3))
114
- guard clamped != selectedIndex else { return }
115
- selectedIndex = clamped
116
- let value = paddedData[clamped]
117
- if !value.isEmpty {
118
- onChange(name, value)
83
+ // MARK: Private
84
+
85
+ @State private var restIndex: Int
86
+ @State private var dragTranslation: CGFloat = 0
87
+
88
+ /// Fractional row index under the band; past either end the drag meets resistance.
89
+ private var position: CGFloat {
90
+ let raw = CGFloat(restIndex) - dragTranslation / wheelItemHeight
91
+ let last = CGFloat(max(0, data.count - 1))
92
+ if raw < 0 { return raw * 0.3 }
93
+ if raw > last { return last + (raw - last) * 0.3 }
94
+ return raw
95
+ }
96
+
97
+ private var dragGesture: some Gesture {
98
+ DragGesture(minimumDistance: 4)
99
+ .onChanged { value in
100
+ dragTranslation = value.translation.height
101
+ }
102
+ .onEnded { value in
103
+ let predicted = CGFloat(restIndex) - value.predictedEndTranslation.height / wheelItemHeight
104
+ settle(at: Int(predicted.rounded()))
105
+ }
106
+ }
107
+
108
+ /// Like Compose `paddedData.indexOf(selectedData).coerceAtLeast(2)`: an unknown value shows the first row.
109
+ private static func index(of value: String, in data: [String]) -> Int {
110
+ data.firstIndex(of: value) ?? 0
111
+ }
112
+
113
+ private func settle(at index: Int) {
114
+ let target = min(max(index, 0), max(0, data.count - 1))
115
+ let rows = Double(abs(CGFloat(target) - position))
116
+ withAnimation(.easeOut(duration: min(0.5, 0.2 + 0.03 * rows))) {
117
+ restIndex = target
118
+ dragTranslation = 0
119
119
  }
120
+ guard data.indices.contains(target), data[target] != selectedData else { return }
121
+ onChange(name, data[target])
122
+ }
123
+
124
+ /// The parent may coerce the value (e.g. day 31 → 28) or swap the list; jump without animation
125
+ /// like Compose `scrollToItem`.
126
+ private func syncToSelection() {
127
+ restIndex = Self.index(of: selectedData, in: data)
128
+ dragTranslation = 0
129
+ reportIfUnlisted()
130
+ }
131
+
132
+ /// Compose reports the row the wheel lands on, so a value missing from `data` (minute 07 with a
133
+ /// 5-minute interval, an unset AM/PM) takes the shown row's value instead of drifting from it.
134
+ private func reportIfUnlisted() {
135
+ guard data.indices.contains(restIndex), !data.contains(selectedData) else { return }
136
+ onChange(name, data[restIndex])
120
137
  }
121
138
  }
122
139
 
@@ -126,40 +143,21 @@ private struct WheelPickerItem: View {
126
143
  @Environment(\.appTheme) private var theme
127
144
 
128
145
  let text: String
129
- let isSelected: Bool
130
- let index: Int
131
- let scrollOffset: CGFloat
132
- let viewportHeight: CGFloat
146
+ /// Rows between this one and the band.
147
+ let distance: CGFloat
133
148
 
134
149
  var body: some View {
135
- let itemCenterInContent = CGFloat(index) * wheelItemHeight + wheelItemHeight / 2
136
- let itemCenterInViewport = itemCenterInContent + scrollOffset
137
- let distanceFromCenter = abs(itemCenterInViewport - viewportHeight / 2) / wheelItemHeight
138
-
139
- let alpha: Double
140
- switch distanceFromCenter {
141
- case ..<1: alpha = 1.0
142
- case ..<2: alpha = 0.8
143
- default: alpha = 0.4
144
- }
145
-
146
- let scale: CGFloat = isSelected ? 1.0 : 0.87
147
-
148
- return ZStack {
149
- MomoText(text, typography: .actionSBold, color: theme.colors.text.default)
150
- .scaleEffect(scale)
151
- }
152
- .frame(maxWidth: .infinity)
153
- .frame(height: wheelItemHeight)
154
- .opacity(alpha)
150
+ MomoText(text, typography: .actionSBold, color: theme.colors.text.default)
151
+ .scaleEffect(1 - 0.13 * min(distance, 1))
152
+ .frame(maxWidth: .infinity)
153
+ .frame(height: wheelItemHeight)
154
+ .opacity(Self.opacity(distance))
155
155
  }
156
- }
157
-
158
- // MARK: - WheelOffsetKey
159
156
 
160
- private struct WheelOffsetKey: PreferenceKey {
161
- static var defaultValue: CGFloat = 0
162
- static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
163
- value = nextValue()
157
+ // RN parity: 1 on the band, 0.8 one row away, 0.4 from two rows on.
158
+ private static func opacity(_ distance: CGFloat) -> Double {
159
+ if distance <= 1 { return Double(1 - 0.2 * distance) }
160
+ if distance <= 2 { return Double(0.8 - 0.4 * (distance - 1)) }
161
+ return 0.4
164
162
  }
165
163
  }
@@ -1,16 +1,49 @@
1
1
  import Foundation
2
2
  import SwiftUI
3
3
 
4
+ public enum DividerType {
5
+ case `default`
6
+ case dash
7
+ }
8
+
4
9
  public struct MomoDivider: View {
5
10
  @Environment(\.appTheme) private var theme
6
11
 
7
- public init() {}
12
+ private let useMargin: Bool
13
+ private let type: DividerType
14
+
15
+ public init(useMargin: Bool = false, type: DividerType = .default) {
16
+ self.useMargin = useMargin
17
+ self.type = type
18
+ }
8
19
 
9
20
  public var body: some View {
10
- Rectangle()
11
- .fill(theme.colors.border.default)
21
+ line
12
22
  .frame(maxWidth: .infinity)
13
23
  .frame(height: 1)
14
- .padding(.vertical, Spacing.XS)
24
+ .padding(.vertical, useMargin ? Spacing.XS : 0)
25
+ }
26
+
27
+ @ViewBuilder
28
+ private var line: some View {
29
+ switch type {
30
+ case .default:
31
+ Rectangle().fill(theme.colors.border.default)
32
+ case .dash:
33
+ DividerDashLine()
34
+ .stroke(
35
+ theme.colors.border.default,
36
+ style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [4, 4])
37
+ )
38
+ }
39
+ }
40
+ }
41
+
42
+ private struct DividerDashLine: Shape {
43
+ func path(in rect: CGRect) -> Path {
44
+ var path = Path()
45
+ path.move(to: CGPoint(x: rect.minX, y: rect.midY))
46
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
47
+ return path
15
48
  }
16
49
  }
@@ -43,15 +43,44 @@ public extension Color {
43
43
  )
44
44
  }
45
45
 
46
+ /// CSS-style `RGB`, `RGBA`, `RRGGBB` or `RRGGBBAA`, `#` optional; anything else is black.
46
47
  init(hex: String) {
47
- let scanner = Scanner(string: hex)
48
- var rgbValue: UInt64 = 0
49
- scanner.scanHexInt64(&rgbValue)
50
-
51
- let r = (rgbValue & 0xff0000) >> 16
52
- let g = (rgbValue & 0xff00) >> 8
53
- let b = rgbValue & 0xff
54
-
55
- self.init(red: Double(r) / 0xff, green: Double(g) / 0xff, blue: Double(b) / 0xff)
48
+ var digits = hex.trimmingCharacters(in: .whitespacesAndNewlines)
49
+ if digits.hasPrefix("#") { digits.removeFirst() }
50
+ if digits.count == 3 || digits.count == 4 {
51
+ digits = String(digits.flatMap { [$0, $0] })
52
+ }
53
+ guard digits.count == 6 || digits.count == 8,
54
+ digits.allSatisfy(\.isHexDigit),
55
+ let value = UInt64(digits, radix: 16) else {
56
+ self.init(red: 0, green: 0, blue: 0)
57
+ return
58
+ }
59
+ let rgba = digits.count == 8 ? value : value << 8 | 0xFF
60
+ self.init(
61
+ .sRGB,
62
+ red: Double((rgba >> 24) & 0xFF) / 255,
63
+ green: Double((rgba >> 16) & 0xFF) / 255,
64
+ blue: Double((rgba >> 8) & 0xFF) / 255,
65
+ opacity: Double(rgba & 0xFF) / 255
66
+ )
67
+ }
68
+ }
69
+
70
+ extension Color {
71
+ /// Compose's `lerp(start, stop, fraction)`, in sRGB; `Color.mix` needs iOS 18.
72
+ func lerp(to other: Color, _ fraction: CGFloat) -> Color {
73
+ let t = min(max(fraction, 0), 1)
74
+ var (r1, g1, b1, a1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0)
75
+ var (r2, g2, b2, a2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0)
76
+ UIColor(self).getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
77
+ UIColor(other).getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
78
+ return Color(
79
+ .sRGB,
80
+ red: r1 + (r2 - r1) * t,
81
+ green: g1 + (g2 - g1) * t,
82
+ blue: b1 + (b2 - b1) * t,
83
+ opacity: a1 + (a2 - a1) * t
84
+ )
56
85
  }
57
86
  }
@@ -0,0 +1,12 @@
1
+ import SwiftUI
2
+
3
+ /// `@EnvironmentObject` that reads nil instead of trapping when nothing was injected.
4
+ /// `_store` is the backing field of the `@frozen` `EnvironmentObject`, so its layout is ABI-stable.
5
+ @propertyWrapper
6
+ struct OptionalEnvironmentObject<Object: ObservableObject>: DynamicProperty {
7
+ @EnvironmentObject private var object: Object
8
+
9
+ var wrappedValue: Object? {
10
+ Mirror(reflecting: _object).descendant("_store") as? Object
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ // File: ios/Extensions/SkeletonUICompat.swift
2
+ // Created At: 2026-10-07 12:03:28 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5-5
6
+
7
+ // AI-GENERATED START: keep SkeletonUI extensions visible to MoMoUIKits clients
8
+ // Unused here on purpose: Swift 5 exposes extension members of every module a dependency imports, so clients
9
+ // that only `import MoMoUIKits` rely on SkeletonUI's (e.g. `Text(String?)` in NativeWidget). Removing it breaks them.
10
+ import SkeletonUI
11
+ // AI-GENERATED END: keep SkeletonUI extensions visible to MoMoUIKits clients
@@ -1,30 +1,20 @@
1
+ import SDWebImageSwiftUI
1
2
  import SwiftUI
2
3
 
3
4
  struct IconSource: Codable {
4
5
  let uri: String
5
6
  }
6
7
 
7
- func loadIconSources() -> [String: IconSource] {
8
- guard let url = Bundle.main.url(forResource: "icon", withExtension: "json") else {
9
- print("Error: icon.json not found")
10
- return [:]
8
+ // The compiled-in map (Icons.swift) wins; a host `icon.json` in the main bundle only adds keys
9
+ // the kit doesn't know, so hosts that shipped one keep their extra icons.
10
+ let iconSources: [String: String] = {
11
+ guard let url = Bundle.main.url(forResource: "icon", withExtension: "json"),
12
+ let data = try? Data(contentsOf: url),
13
+ let hostIcons = try? JSONDecoder().decode([String: IconSource].self, from: data) else {
14
+ return kitIcons
11
15
  }
12
-
13
- do {
14
- let data = try Data(contentsOf: url)
15
- let decoded = try JSONDecoder().decode([String: IconSource].self, from: data)
16
- return decoded
17
- } catch {
18
- print("Error decoding icon.json: \(error)")
19
- return [:]
20
- }
21
- }
22
-
23
- // Load the JSON data into a global variable
24
- let iconSources: [String: IconSource] = loadIconSources()
25
-
26
- // Icons that must never be tinted (mirror Compose `noThemeIcons`)
27
- let noThemeIcons: [String] = ["ic_round_momo_tiny"]
16
+ return kitIcons.merging(hostIcons.mapValues(\.uri)) { kit, _ in kit }
17
+ }()
28
18
 
29
19
  // Compose defaults `color` to the theme text color while `color = null` means "no tint".
30
20
  // Swift can't tell an omitted argument from an explicit `nil`, so the default is this
@@ -53,16 +43,24 @@ public struct Icon: View {
53
43
  }
54
44
 
55
45
  public var body: some View {
56
- // Mirror Compose: an https source is used as-is, anything else is a key into icon.json
57
- if let uri = source.contains("https") ? source : iconSources[source]?.uri {
46
+ // Mirror Compose: an https source is used as-is, anything else is a key into the icon map
47
+ if let uri = source.contains("https") ? source : iconSources[source] {
58
48
  let requestedColor = color == iconThemeColor ? theme.colors.text.default : color
59
49
  // Mirror Compose: certain icons (e.g. branded glyphs) are never tinted
60
50
  let resolvedColor = noThemeIcons.contains(source) ? nil : requestedColor
61
- ImageView(uri, color: resolvedColor)
51
+ // Bare WebImage like Compose's AsyncImage: no skeleton while loading, nothing on failure.
52
+ WebImage(url: URL(string: uri), isAnimating: .constant(true))
53
+ .resizable()
54
+ .renderingMode(resolvedColor != nil ? .template : .original)
55
+ .scaledToFit()
56
+ .foregroundColor(resolvedColor)
62
57
  .frame(width: size, height: size)
63
58
  // Mirrors Compose's auto-generated `contentDescription = "img|$iconUrl"`
64
59
  // when no explicit label is provided.
65
60
  .accessibilityLabel(accessibilityLabel ?? "img|\(uri)")
61
+ } else {
62
+ // Compose keeps the sized box for an unknown key, so layouts don't collapse.
63
+ Color.clear.frame(width: size, height: size)
66
64
  }
67
65
  }
68
66
  }