@momo-kits/native-kits 0.162.2-debug → 0.162.2-sp.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 (149) hide show
  1. package/.claude/settings.local.json +55 -0
  2. package/compose/build.gradle.kts +1 -1
  3. package/compose/compose.podspec +1 -1
  4. package/gradle.properties +1 -1
  5. package/ios/Application/Components.swift +34 -43
  6. package/ios/Application/FloatingButton.swift +10 -8
  7. package/ios/Application/Navigation/BottomTab/BottomTab.swift +102 -0
  8. package/ios/Application/Navigation/BottomTab/BottomTabBar.swift +215 -0
  9. package/ios/Application/Navigation/BottomTab/CurvedContainer.swift +65 -0
  10. package/ios/Application/Navigation/HeaderBackProps.swift +66 -0
  11. package/ios/Application/Navigation/HeaderTitle.swift +57 -0
  12. package/ios/Application/Navigation/HeaderUser.swift +209 -0
  13. package/ios/Application/Navigation/NavigationContainer.swift +151 -0
  14. package/ios/Application/Navigation/NavigationOptions.swift +109 -0
  15. package/ios/Application/Navigation/Navigator.swift +360 -0
  16. package/ios/Application/Navigation/Overplay/BottomSheet.swift +176 -0
  17. package/ios/Application/Navigation/Overplay/ModalScreen.swift +68 -0
  18. package/ios/Application/Navigation/Overplay/SnackBar.swift +144 -0
  19. package/ios/Application/Navigation/component/Header.swift +178 -0
  20. package/ios/Application/Navigation/component/HeaderBackground.swift +83 -0
  21. package/ios/Application/Navigation/component/HeaderColor.swift +65 -0
  22. package/ios/Application/StackScreen.swift +449 -0
  23. package/ios/Avatar/Avatar.swift +193 -0
  24. package/ios/Badge/Badge.swift +11 -13
  25. package/ios/Badge/BadgeRibbon.swift +12 -19
  26. package/ios/BaselineView/BaselineView.swift +163 -0
  27. package/ios/Button/Button.swift +62 -23
  28. package/ios/Carousel/Carousel.swift +181 -0
  29. package/ios/Checkbox/Checkbox.swift +14 -12
  30. package/ios/Chip/Chip.swift +23 -9
  31. package/ios/Collapse/Collapse.swift +182 -0
  32. package/ios/Colors+Radius+Spacing/Colors.swift +1 -1
  33. package/ios/Colors+Radius+Spacing/Theme.swift +295 -0
  34. package/ios/DateTimePicker/DateTimePicker.swift +210 -0
  35. package/ios/DateTimePicker/DateTimePickerTypes.swift +55 -0
  36. package/ios/DateTimePicker/DateTimePickerUtils.swift +167 -0
  37. package/ios/DateTimePicker/WheelPicker.swift +161 -0
  38. package/ios/Divider/Divider.swift +16 -0
  39. package/ios/Icon/Icon.swift +6 -1
  40. package/ios/IconButton/IconButton.swift +112 -0
  41. package/ios/Information/Information.swift +151 -0
  42. package/ios/Input/Input.swift +42 -25
  43. package/ios/Input/InputPhoneNumber.swift +21 -19
  44. package/ios/Input/InputSearch.swift +101 -45
  45. package/ios/Input/InputTextArea.swift +67 -40
  46. package/ios/InputDropDown/InputDropDown.swift +198 -0
  47. package/ios/InputMoney/InputMoney.swift +335 -0
  48. package/ios/InputOTP/InputOTP.swift +210 -0
  49. package/ios/Loader/Loader.swift +113 -0
  50. package/ios/MoMoUIKits.podspec +5 -0
  51. package/ios/OTPKeyboard/KeyboardButton.swift +1 -1
  52. package/ios/Pagination/PaginationDot.swift +61 -0
  53. package/ios/Pagination/PaginationNumber.swift +35 -0
  54. package/ios/Pagination/PaginationScroll.swift +101 -0
  55. package/ios/Pagination/PaginationWhiteDot.swift +37 -0
  56. package/ios/Popup/PopupDisplay.swift +10 -12
  57. package/ios/Popup/PopupInput.swift +2 -6
  58. package/ios/Popup/PopupNotify.swift +219 -0
  59. package/ios/ProgressInfo/ProgressInfo.swift +294 -0
  60. package/ios/Radio/Radio.swift +70 -0
  61. package/ios/Rating/Rating.swift +82 -0
  62. package/ios/Skeleton/Skeleton.swift +99 -0
  63. package/ios/Slider/Slider.swift +237 -0
  64. package/ios/Stepper/Stepper.swift +220 -0
  65. package/ios/Steps/Steps.swift +451 -0
  66. package/ios/SuggestAction/SuggestAction.swift +97 -0
  67. package/ios/Swipeable/SwipeCell.swift +185 -0
  68. package/ios/Swipeable/SwipeCellModel.swift +21 -0
  69. package/ios/TabView/TabView.swift +530 -0
  70. package/ios/Tag/Tag.swift +98 -0
  71. package/ios/Template/TrustBanner/TrustBanner.swift +2 -0
  72. package/ios/Title/Title.swift +269 -0
  73. package/ios/Tooltip/Tooltip.swift +482 -0
  74. package/ios/Typography/Text.swift +77 -5
  75. package/ios/Typography/Typography.swift +9 -0
  76. package/ios/Uploader/Uploader.swift +183 -0
  77. package/ios/native-kits.podspec +58 -8
  78. package/ios-demo/MoMoUIKitsDemo.podspec +18 -0
  79. package/ios-demo/README.md +120 -0
  80. package/ios-demo/Resources/dot_loading.json +558 -0
  81. package/ios-demo/Resources/icon.json +4052 -0
  82. package/ios-demo/Resources/lottie_circle_loader.json +1 -0
  83. package/ios-demo/Sources/MoMoUIKitsDemo/DemoScaffold.swift +48 -0
  84. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsBadgeDemoView.swift +25 -0
  85. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsButtonDemoView.swift +26 -0
  86. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsCheckboxDemoView.swift +19 -0
  87. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsChipDemoView.swift +33 -0
  88. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsInputDemoView.swift +48 -0
  89. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsPopupDemoView.swift +21 -0
  90. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsRadioDemoView.swift +17 -0
  91. package/ios-demo/Sources/MoMoUIKitsDemo/Demos/UIKitsTypographyDemoView.swift +28 -0
  92. package/ios-demo/Sources/MoMoUIKitsDemo/MoMoUIKitsDemoContext.swift +31 -0
  93. package/ios-demo/Sources/MoMoUIKitsDemo/MoMoUIKitsDemoFactory.swift +72 -0
  94. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AnimatedHeaderDemo.swift +110 -0
  95. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +93 -0
  96. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +269 -0
  97. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BaselineView.swift +117 -0
  98. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BottomTabDemo.swift +89 -0
  99. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +100 -0
  100. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +126 -0
  101. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +63 -0
  102. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +20 -0
  103. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +93 -0
  104. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ContentView.swift +107 -0
  105. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +124 -0
  106. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +14 -0
  107. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/HeaderUserDemo.swift +49 -0
  108. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +261 -0
  109. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +58 -0
  110. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +78 -0
  111. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +138 -0
  112. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +111 -0
  113. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +136 -0
  114. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +107 -0
  115. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +73 -0
  116. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +100 -0
  117. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +32 -0
  118. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +57 -0
  119. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +70 -0
  120. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +227 -0
  121. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +72 -0
  122. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupDisplayDemo.swift +175 -0
  123. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +121 -0
  124. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +76 -0
  125. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +89 -0
  126. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +32 -0
  127. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +56 -0
  128. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +32 -0
  129. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +150 -0
  130. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +138 -0
  131. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +103 -0
  132. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +94 -0
  133. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +79 -0
  134. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +152 -0
  135. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +33 -0
  136. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +190 -0
  137. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +34 -0
  138. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +182 -0
  139. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +109 -0
  140. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +187 -0
  141. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +87 -0
  142. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +131 -0
  143. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +50 -0
  144. package/ios-demo/Sources/MoMoUIKitsDemo/UIKitsDemoModels.swift +15 -0
  145. package/ios-demo/Sources/MoMoUIKitsDemo/UIKitsDemoRegistry.swift +73 -0
  146. package/local.properties +8 -0
  147. package/package.json +1 -1
  148. package/publish.sh +50 -0
  149. package/scripts/gen-ios-color.mjs +136 -0
@@ -0,0 +1,210 @@
1
+ import SwiftUI
2
+
3
+ // MARK: - Constants
4
+
5
+ private let datePickerHeight: CGFloat = 210
6
+ private let datePickerWithLabelsHeight: CGFloat = 238
7
+
8
+ // MARK: - DateTimePicker
9
+
10
+ /// A wheel-style date/time picker matching Compose `DateTimePicker`.
11
+ ///
12
+ /// The `format` string drives which columns are shown:
13
+ /// - `DD` day, `MM` month, `YYYY` year, `HH` hour, `mm` minute.
14
+ /// - `"HH:mm"` shows hour + minute; a lone `"HH"` switches the hour wheel to a
15
+ /// 12-hour clock with an AM/PM column.
16
+ public struct DateTimePicker: View {
17
+ // MARK: Lifecycle
18
+
19
+ public init(
20
+ format: String = "DD-MM-YYYY",
21
+ minuteInterval: Int = 1,
22
+ selectedValue: Date? = nil,
23
+ minDate: Date? = nil,
24
+ maxDate: Date? = nil,
25
+ arrayLabelTime: [String] = [],
26
+ onChange: @escaping (Date) -> Void
27
+ ) {
28
+ self.format = format
29
+ self.minuteInterval = minuteInterval
30
+ self.selectedValue = selectedValue
31
+ self.minDate = minDate
32
+ self.maxDate = maxDate
33
+ self.arrayLabelTime = arrayLabelTime
34
+ self.onChange = onChange
35
+
36
+ let calendar = Calendar.current
37
+ let effectiveSelected = selectedValue ?? DateTimePickerUtils.getCurrentDateTime()
38
+ let effectiveMin = minDate ?? DateTimePickerUtils.createRelativeDate(years: -10)
39
+ let effectiveMax = maxDate ?? DateTimePickerUtils.createRelativeDate(years: 10)
40
+
41
+ // needCheckRange is true only when the caller supplied no bounds.
42
+ let needCheckRange = (minDate == nil && maxDate == nil)
43
+
44
+ let initialValue: Date
45
+ if !needCheckRange {
46
+ initialValue = effectiveSelected
47
+ } else if effectiveSelected < effectiveMin {
48
+ initialValue = effectiveMin
49
+ } else if effectiveSelected > effectiveMax {
50
+ initialValue = effectiveMax
51
+ } else {
52
+ initialValue = effectiveSelected
53
+ }
54
+
55
+ let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: initialValue)
56
+ self._currentData = State(initialValue: DateTimePickerData(
57
+ day: comps.day ?? 1,
58
+ month: comps.month ?? 1,
59
+ year: comps.year ?? 2000,
60
+ hour: comps.hour ?? 0,
61
+ minute: comps.minute ?? 0
62
+ ))
63
+
64
+ self.effectiveMinComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: effectiveMin)
65
+ self.effectiveMaxComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: effectiveMax)
66
+ self.needCheckRange = needCheckRange
67
+ }
68
+
69
+ // MARK: Public
70
+
71
+ public var body: some View {
72
+ let components = DateTimePickerUtils.getDateComponents(
73
+ format: format,
74
+ currentData: currentData,
75
+ minDate: effectiveMinComponents,
76
+ maxDate: effectiveMaxComponents,
77
+ minuteInterval: minuteInterval
78
+ )
79
+ let pickerHeight = arrayLabelTime.isEmpty ? datePickerHeight : datePickerWithLabelsHeight
80
+
81
+ return HStack(spacing: Spacing.M) {
82
+ ForEach(Array(components.enumerated()), id: \.offset) { index, component in
83
+ let hasLabel = index < arrayLabelTime.count && !arrayLabelTime[index].isEmpty
84
+
85
+ VStack(spacing: 0) {
86
+ if hasLabel {
87
+ MomoText(arrayLabelTime[index], typography: .actionSBold, color: Colors.text)
88
+ .padding(.bottom, Spacing.S)
89
+ }
90
+
91
+ WheelPicker(
92
+ name: component.name,
93
+ data: component.data,
94
+ selectedData: selectedDataFor(component.name),
95
+ onChange: onWheelChange
96
+ )
97
+ }
98
+ .frame(maxWidth: .infinity)
99
+ }
100
+ }
101
+ .frame(maxWidth: .infinity)
102
+ .frame(height: pickerHeight)
103
+ .background(Colors.background)
104
+ .padding(.horizontal, Spacing.M)
105
+ .onAppear { emitIfValid() }
106
+ }
107
+
108
+ // MARK: Internal
109
+
110
+ let format: String
111
+ let minuteInterval: Int
112
+ let selectedValue: Date?
113
+ let minDate: Date?
114
+ let maxDate: Date?
115
+ let arrayLabelTime: [String]
116
+ let onChange: (Date) -> Void
117
+
118
+ @State private var currentData: DateTimePickerData
119
+
120
+ private let effectiveMinComponents: DateComponents
121
+ private let effectiveMaxComponents: DateComponents
122
+ private let needCheckRange: Bool
123
+
124
+ private func selectedDataFor(_ name: String) -> String {
125
+ switch name {
126
+ case "day": return DateTimePickerUtils.paddingNum(currentData.day)
127
+ case "month": return DateTimePickerUtils.paddingNum(currentData.month)
128
+ case "year": return String(currentData.year)
129
+ case "hour": return DateTimePickerUtils.paddingNum(currentData.hour)
130
+ case "minute": return DateTimePickerUtils.paddingNum(currentData.minute)
131
+ case "timeMode": return currentData.timeMode
132
+ default: return ""
133
+ }
134
+ }
135
+
136
+ /// Mirrors Compose `onWheelChangeValue`: applies value and coerces day/month
137
+ /// to stay within the effective min/max bounds.
138
+ private func onWheelChange(_ name: String, _ value: String) {
139
+ var day = name == "day" ? (Int(value) ?? currentData.day) : currentData.day
140
+ var month = name == "month" ? (Int(value) ?? currentData.month) : currentData.month
141
+ let year = name == "year" ? (Int(value) ?? currentData.year) : currentData.year
142
+ let hour = name == "hour" ? (Int(value) ?? currentData.hour) : currentData.hour
143
+ let minute = name == "minute" ? (Int(value) ?? currentData.minute) : currentData.minute
144
+ let timeMode = name == "timeMode" ? value : currentData.timeMode
145
+
146
+ let maxYear = effectiveMaxComponents.year ?? year
147
+ let minYear = effectiveMinComponents.year ?? year
148
+ let maxMonth = effectiveMaxComponents.month ?? 12
149
+ let minMonth = effectiveMinComponents.month ?? 1
150
+ let maxDayBound = effectiveMaxComponents.day ?? 31
151
+ let minDayBound = effectiveMinComponents.day ?? 1
152
+
153
+ if name == "year" {
154
+ if year == maxYear {
155
+ month = min(max(month, 1), maxMonth)
156
+ } else if year == minYear {
157
+ month = min(max(month, minMonth), 12)
158
+ }
159
+ }
160
+
161
+ if name == "month" || name == "year" {
162
+ let maxDayOfMonth: Int
163
+ switch month {
164
+ case 4, 6, 9, 11: maxDayOfMonth = 30
165
+ case 2: maxDayOfMonth = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28
166
+ default: maxDayOfMonth = 31
167
+ }
168
+
169
+ if year == maxYear && month == maxMonth {
170
+ day = min(max(day, 1), maxDayBound)
171
+ } else if year == minYear && month == minMonth {
172
+ day = min(max(day, minDayBound), maxDayOfMonth)
173
+ } else if day > maxDayOfMonth {
174
+ day = maxDayOfMonth
175
+ }
176
+ }
177
+
178
+ currentData = DateTimePickerData(
179
+ day: day,
180
+ month: month,
181
+ year: year,
182
+ hour: hour,
183
+ minute: minute,
184
+ timeMode: timeMode
185
+ )
186
+ emitIfValid()
187
+ }
188
+
189
+ /// Emit `onChange` only when the assembled date is valid and (when bounds were
190
+ /// not supplied) within the effective min/max range. Mirrors the Compose
191
+ /// `snapshotFlow.mapNotNull` filter.
192
+ private func emitIfValid() {
193
+ guard let date = currentData.toDate() else { return }
194
+ if !needCheckRange {
195
+ onChange(date)
196
+ return
197
+ }
198
+ let calendar = Calendar.current
199
+ guard
200
+ let minDate = calendar.date(from: effectiveMinComponents),
201
+ let maxDate = calendar.date(from: effectiveMaxComponents)
202
+ else {
203
+ onChange(date)
204
+ return
205
+ }
206
+ if date >= minDate && date <= maxDate {
207
+ onChange(date)
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,55 @@
1
+ import Foundation
2
+
3
+ // MARK: - PickerData
4
+
5
+ /// Internal mutable representation of the picker's current selection.
6
+ /// Mirrors Compose `PickerData`.
7
+ public struct DateTimePickerData: Equatable {
8
+ public var day: Int
9
+ public var month: Int
10
+ public var year: Int
11
+ public var hour: Int
12
+ public var minute: Int
13
+ public var timeMode: String
14
+
15
+ public init(
16
+ day: Int,
17
+ month: Int,
18
+ year: Int,
19
+ hour: Int = 0,
20
+ minute: Int = 0,
21
+ timeMode: String = ""
22
+ ) {
23
+ self.day = day
24
+ self.month = month
25
+ self.year = year
26
+ self.hour = hour
27
+ self.minute = minute
28
+ self.timeMode = timeMode
29
+ }
30
+
31
+ /// Convert to a `DateComponents` based value. Returns `nil` if the date is invalid.
32
+ public func toDate(calendar: Calendar = Calendar.current) -> Date? {
33
+ var components = DateComponents()
34
+ components.year = year
35
+ components.month = month
36
+ components.day = day
37
+ components.hour = hour
38
+ components.minute = minute
39
+ components.second = 0
40
+ return calendar.date(from: components)
41
+ }
42
+ }
43
+
44
+ // MARK: - DateComponent
45
+
46
+ /// A single wheel column descriptor. Mirrors Compose `DateComponent`.
47
+ public struct DateTimePickerComponent: Equatable {
48
+ public let name: String
49
+ public let data: [String]
50
+
51
+ public init(name: String, data: [String]) {
52
+ self.name = name
53
+ self.data = data
54
+ }
55
+ }
@@ -0,0 +1,167 @@
1
+ import Foundation
2
+
3
+ // MARK: - DateTimePickerUtils
4
+
5
+ /// Pure helper functions mirroring Compose `DateTimePickerUtils.kt`.
6
+ public enum DateTimePickerUtils {
7
+
8
+ /// Time mode list for 12-hour clock (only-hour format).
9
+ public static let timeMode: [String] = ["AM", "PM"]
10
+
11
+ /// Pad a number with a leading zero if it's less than 10.
12
+ public static func paddingNum(_ num: Int) -> String {
13
+ return num > 9 ? String(num) : "0\(num)"
14
+ }
15
+
16
+ /// Format a `Date` into a string, matching Compose `formatLocalDateTime`.
17
+ public static func formatDate(
18
+ _ date: Date,
19
+ format: String = "yyyy-MM-dd HH:mm",
20
+ calendar: Calendar = Calendar.current
21
+ ) -> String {
22
+ let c = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
23
+ let year = c.year ?? 0
24
+ let month = c.month ?? 1
25
+ let day = c.day ?? 1
26
+ let hour = c.hour ?? 0
27
+ let minute = c.minute ?? 0
28
+
29
+ switch format {
30
+ case "yyyy-MM-dd HH:mm":
31
+ return "\(year)-\(paddingNum(month))-\(paddingNum(day)) \(paddingNum(hour)):\(paddingNum(minute))"
32
+ case "dd-MM-yyyy":
33
+ return "\(paddingNum(day))-\(paddingNum(month))-\(year)"
34
+ case "HH:mm":
35
+ return "\(paddingNum(hour)):\(paddingNum(minute))"
36
+ default:
37
+ return "\(year)-\(paddingNum(month))-\(paddingNum(day)) \(paddingNum(hour)):\(paddingNum(minute))"
38
+ }
39
+ }
40
+
41
+ /// Number of days in a given month/year.
42
+ private static func daysInMonth(year: Int, month: Int) -> Int {
43
+ switch month {
44
+ case 1, 3, 5, 7, 8, 10, 12: return 31
45
+ case 4, 6, 9, 11: return 30
46
+ case 2: return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28
47
+ default: return 31
48
+ }
49
+ }
50
+
51
+ /// Get a list of day strings constrained by min/max date.
52
+ public static func getDaysInMonth(
53
+ year: Int,
54
+ month: Int,
55
+ minDate: DateComponents,
56
+ maxDate: DateComponents
57
+ ) -> [String] {
58
+ let maxDay = daysInMonth(year: year, month: month)
59
+ var startDay = 1
60
+ var endDay = maxDay
61
+
62
+ if year == minDate.year && month == minDate.month {
63
+ startDay = minDate.day ?? 1
64
+ }
65
+ if year == maxDate.year && month == maxDate.month {
66
+ endDay = maxDate.day ?? maxDay
67
+ }
68
+
69
+ guard startDay <= endDay else { return [paddingNum(startDay)] }
70
+ return (startDay...endDay).map { paddingNum($0) }
71
+ }
72
+
73
+ /// Get a list of month strings constrained by min/max date for a year.
74
+ public static func getMonths(
75
+ minDate: DateComponents,
76
+ maxDate: DateComponents,
77
+ currentYear: Int
78
+ ) -> [String] {
79
+ var startMonth = 1
80
+ var endMonth = 12
81
+
82
+ if currentYear == minDate.year {
83
+ startMonth = minDate.month ?? 1
84
+ }
85
+ if currentYear == maxDate.year {
86
+ endMonth = maxDate.month ?? 12
87
+ }
88
+
89
+ guard startMonth <= endMonth else { return [paddingNum(startMonth)] }
90
+ return (startMonth...endMonth).map { paddingNum($0) }
91
+ }
92
+
93
+ /// Get a list of year strings between min and max date.
94
+ public static func getYears(minDate: DateComponents, maxDate: DateComponents) -> [String] {
95
+ let startYear = minDate.year ?? 0
96
+ let endYear = maxDate.year ?? 0
97
+ guard startYear <= endYear else { return [String(startYear)] }
98
+ return (startYear...endYear).map { String($0) }
99
+ }
100
+
101
+ /// Get a list of hours for either a 12-hour or 24-hour clock.
102
+ public static func getHours(hourMode: Int) -> [String] {
103
+ var hours: [String] = []
104
+ for i in 0..<hourMode {
105
+ let hour = hourMode == 12 ? i + 1 : i
106
+ hours.append(paddingNum(hour))
107
+ }
108
+ return hours
109
+ }
110
+
111
+ /// Get a list of minutes stepped by `interval`.
112
+ public static func getMinutes(interval: Int) -> [String] {
113
+ let step = max(1, interval)
114
+ var minutes: [String] = []
115
+ var min = 0
116
+ while min < 60 {
117
+ minutes.append(paddingNum(min))
118
+ min += step
119
+ }
120
+ return minutes
121
+ }
122
+
123
+ /// Today's date.
124
+ public static func getCurrentDateTime() -> Date {
125
+ return Date()
126
+ }
127
+
128
+ /// Create a date a certain number of years before/after now.
129
+ public static func createRelativeDate(years: Int, calendar: Calendar = Calendar.current) -> Date {
130
+ let now = Date()
131
+ return calendar.date(byAdding: .year, value: years, to: now) ?? now
132
+ }
133
+
134
+ /// Resolve the wheel columns from a format string. Mirrors Compose `getDateComponents`.
135
+ public static func getDateComponents(
136
+ format: String,
137
+ currentData: DateTimePickerData,
138
+ minDate: DateComponents,
139
+ maxDate: DateComponents,
140
+ minuteInterval: Int
141
+ ) -> [DateTimePickerComponent] {
142
+ let formatParts = format.split(whereSeparator: { !$0.isLetter }).map { String($0) }
143
+ let isOnlyHour = formatParts.count == 1 && formatParts[0] == "HH"
144
+
145
+ let dayData = getDaysInMonth(year: currentData.year, month: currentData.month, minDate: minDate, maxDate: maxDate)
146
+ let monthData = getMonths(minDate: minDate, maxDate: maxDate, currentYear: currentData.year)
147
+ let yearData = getYears(minDate: minDate, maxDate: maxDate)
148
+ let hourData = getHours(hourMode: isOnlyHour ? 12 : 24)
149
+ let minData = getMinutes(interval: minuteInterval)
150
+
151
+ var components: [DateTimePickerComponent] = []
152
+ for part in formatParts {
153
+ switch part {
154
+ case "DD": components.append(DateTimePickerComponent(name: "day", data: dayData))
155
+ case "MM": components.append(DateTimePickerComponent(name: "month", data: monthData))
156
+ case "YYYY": components.append(DateTimePickerComponent(name: "year", data: yearData))
157
+ case "HH": components.append(DateTimePickerComponent(name: "hour", data: hourData))
158
+ case "mm": components.append(DateTimePickerComponent(name: "minute", data: minData))
159
+ default: break
160
+ }
161
+ }
162
+ if isOnlyHour {
163
+ components.append(DateTimePickerComponent(name: "timeMode", data: timeMode))
164
+ }
165
+ return components
166
+ }
167
+ }
@@ -0,0 +1,161 @@
1
+ import SwiftUI
2
+
3
+ // MARK: - Constants
4
+
5
+ private let wheelItemHeight: CGFloat = 42
6
+ private let wheelVisibleCount: Int = 5
7
+ private let wheelPickerBorderWidth: CGFloat = 1
8
+
9
+ // MARK: - WheelPicker
10
+
11
+ /// A reusable single-column wheel picker, mirroring Compose `WheelPicker.kt`.
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)`.
16
+ public struct WheelPicker: View {
17
+ // MARK: Lifecycle
18
+
19
+ public init(
20
+ name: String,
21
+ data: [String],
22
+ selectedData: String,
23
+ onChange: @escaping (String, String) -> Void
24
+ ) {
25
+ self.name = name
26
+ self.data = data
27
+ self.selectedData = selectedData
28
+ self.onChange = onChange
29
+ }
30
+
31
+ // MARK: Public
32
+
33
+ public var body: some View {
34
+ let paddedData = ["", ""] + data + ["", ""]
35
+
36
+ return GeometryReader { geo in
37
+ let height = geo.size.height
38
+ ScrollViewReader { proxy in
39
+ ScrollView(.vertical, showsIndicators: false) {
40
+ LazyVStack(spacing: 0) {
41
+ ForEach(Array(paddedData.enumerated()), id: \.offset) { index, text in
42
+ WheelPickerItem(
43
+ text: text,
44
+ isSelected: index == selectedIndex,
45
+ index: index,
46
+ scrollOffset: scrollOffset,
47
+ viewportHeight: height
48
+ )
49
+ .frame(height: wheelItemHeight)
50
+ .id(index)
51
+ }
52
+ }
53
+ .background(
54
+ GeometryReader { inner in
55
+ Color.clear.preference(
56
+ key: WheelOffsetKey.self,
57
+ value: inner.frame(in: .named(scrollSpace)).minY
58
+ )
59
+ }
60
+ )
61
+ }
62
+ .coordinateSpace(name: scrollSpace)
63
+ .onPreferenceChange(WheelOffsetKey.self) { value in
64
+ scrollOffset = value
65
+ updateSelection(paddedData: paddedData, viewportHeight: height)
66
+ }
67
+ .onAppear {
68
+ let initial = max(paddedData.firstIndex(of: selectedData) ?? 2, 2)
69
+ selectedIndex = initial
70
+ DispatchQueue.main.async {
71
+ proxy.scrollTo(initial, anchor: .center)
72
+ }
73
+ }
74
+ }
75
+ }
76
+ .frame(height: wheelItemHeight * CGFloat(wheelVisibleCount))
77
+ .overlay(
78
+ RoundedRectangle(cornerRadius: Radius.S)
79
+ .stroke(Colors.border, lineWidth: wheelPickerBorderWidth)
80
+ )
81
+ .background(Colors.background.clipShape(RoundedRectangle(cornerRadius: Radius.S)))
82
+ .overlay(
83
+ // Centered translucent selection band.
84
+ Rectangle()
85
+ .fill(Colors.blue10)
86
+ .opacity(0.2)
87
+ .frame(height: wheelItemHeight)
88
+ .allowsHitTesting(false)
89
+ )
90
+ .clipShape(RoundedRectangle(cornerRadius: Radius.S))
91
+ }
92
+
93
+ // MARK: Internal
94
+
95
+ let name: String
96
+ let data: [String]
97
+ let selectedData: String
98
+ let onChange: (String, String) -> Void
99
+
100
+ @State private var selectedIndex: Int = 0
101
+ @State private var scrollOffset: CGFloat = 0
102
+
103
+ private let scrollSpace = "wheelScroll"
104
+
105
+ /// Map the current scroll offset to the centered index and report changes.
106
+ private func updateSelection(paddedData: [String], viewportHeight: CGFloat) {
107
+ // `scrollOffset` is the top of the content relative to the viewport (<= 0 when scrolled).
108
+ let centerY = viewportHeight / 2
109
+ let contentCenter = -scrollOffset + centerY
110
+ let rawIndex = Int((contentCenter / wheelItemHeight).rounded()) - 1
111
+ let clamped = max(2, min(rawIndex, paddedData.count - 3))
112
+ guard clamped != selectedIndex else { return }
113
+ selectedIndex = clamped
114
+ let value = paddedData[clamped]
115
+ if !value.isEmpty {
116
+ onChange(name, value)
117
+ }
118
+ }
119
+ }
120
+
121
+ // MARK: - WheelPickerItem
122
+
123
+ private struct WheelPickerItem: View {
124
+ let text: String
125
+ let isSelected: Bool
126
+ let index: Int
127
+ let scrollOffset: CGFloat
128
+ let viewportHeight: CGFloat
129
+
130
+ var body: some View {
131
+ let itemCenterInContent = CGFloat(index) * wheelItemHeight + wheelItemHeight / 2
132
+ let itemCenterInViewport = itemCenterInContent + scrollOffset
133
+ let distanceFromCenter = abs(itemCenterInViewport - viewportHeight / 2) / wheelItemHeight
134
+
135
+ let alpha: Double
136
+ switch distanceFromCenter {
137
+ case ..<1: alpha = 1.0
138
+ case ..<2: alpha = 0.8
139
+ default: alpha = 0.4
140
+ }
141
+
142
+ let scale: CGFloat = isSelected ? 1.0 : 0.87
143
+
144
+ return ZStack {
145
+ MomoText(text, typography: .actionSBold, color: Colors.text)
146
+ .scaleEffect(scale)
147
+ }
148
+ .frame(maxWidth: .infinity)
149
+ .frame(height: wheelItemHeight)
150
+ .opacity(alpha)
151
+ }
152
+ }
153
+
154
+ // MARK: - WheelOffsetKey
155
+
156
+ private struct WheelOffsetKey: PreferenceKey {
157
+ static var defaultValue: CGFloat = 0
158
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
159
+ value = nextValue()
160
+ }
161
+ }
@@ -0,0 +1,16 @@
1
+ import Foundation
2
+ import SwiftUI
3
+
4
+ public struct MomoDivider: View {
5
+ @Environment(\.appTheme) private var theme
6
+
7
+ public init() {}
8
+
9
+ public var body: some View {
10
+ Rectangle()
11
+ .fill(theme.colors.border.default)
12
+ .frame(maxWidth: .infinity)
13
+ .frame(height: 1)
14
+ .padding(.vertical, Spacing.XS)
15
+ }
16
+ }
@@ -23,6 +23,9 @@ func loadIconSources() -> [String: IconSource] {
23
23
  // Load the JSON data into a global variable
24
24
  let iconSources: [String: IconSource] = loadIconSources()
25
25
 
26
+ // Icons that must never be tinted (mirror Compose `noThemeIcons`)
27
+ let noThemeIcons: [String] = ["ic_round_momo_tiny"]
28
+
26
29
  // Icon ViewModel
27
30
  public struct Icon: View {
28
31
  var source: String
@@ -44,7 +47,9 @@ public struct Icon: View {
44
47
 
45
48
  public var body: some View {
46
49
  if let uri = iconSources[source]?.uri {
47
- ImageView(uri, color: color).frame(width: size, height: size)
50
+ // Mirror Compose: certain icons (e.g. branded glyphs) are never tinted
51
+ let resolvedColor = noThemeIcons.contains(source) ? nil : color
52
+ ImageView(uri, color: resolvedColor).frame(width: size, height: size)
48
53
  }
49
54
  }
50
55
  }