@momo-kits/native-kits 0.162.30 → 0.162.31

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 (90) hide show
  1. package/CLAUDE.md +19 -0
  2. package/ios/Application/ApplicationEnvironment.swift +14 -6
  3. package/ios/Application/LanguageSource.swift +93 -0
  4. package/ios/Application/Localize.swift +73 -4
  5. package/ios/Application/LocalizeModifier.swift +30 -0
  6. package/ios/Application/Navigation/NavigationContainer.swift +28 -18
  7. package/ios/Application/Navigation/Navigator.swift +121 -53
  8. package/ios/Application/Navigation/NavigatorManager.swift +49 -0
  9. package/ios/Application/Navigation/component/NavigationFooter.swift +90 -0
  10. package/ios/Application/StackScreen.swift +19 -28
  11. package/ios/Badge/Badge.swift +2 -1
  12. package/ios/Badge/BadgeRibbon.swift +6 -2
  13. package/ios/Button/Button.swift +41 -61
  14. package/ios/Chip/Chip.swift +6 -5
  15. package/ios/Icon/Icon.swift +13 -4
  16. package/ios/Input/ErrorView.swift +2 -1
  17. package/ios/Input/Input.swift +9 -2
  18. package/ios/Input/InputPhoneNumber.swift +2 -1
  19. package/ios/Input/InputSearch.swift +3 -2
  20. package/ios/Input/InputTextArea.swift +6 -1
  21. package/ios/InputMoney/InputMoney.swift +5 -2
  22. package/ios/InputOTP/InputOTP.swift +2 -1
  23. package/ios/Layout/GridContext.swift +30 -0
  24. package/ios/Layout/Item.swift +42 -0
  25. package/ios/Layout/Section.swift +134 -0
  26. package/ios/Popup/PopupNotify.swift +2 -1
  27. package/ios/ProgressInfo/ProgressInfo.swift +4 -3
  28. package/ios/Rating/Rating.swift +2 -1
  29. package/ios/Stepper/Stepper.swift +2 -1
  30. package/ios/SuggestAction/SuggestAction.swift +3 -2
  31. package/ios/Tag/Tag.swift +2 -1
  32. package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
  33. package/ios/Title/Title.swift +3 -2
  34. package/ios/Tooltip/Tooltip.swift +2 -1
  35. package/ios/Typography/FontScale.swift +103 -0
  36. package/ios/Typography/FontScaleStore.swift +52 -13
  37. package/ios/Typography/Text.swift +12 -29
  38. package/ios/Typography/Typography.swift +66 -86
  39. package/ios/native-kits.podspec +2 -1
  40. package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
  41. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/AvatarDemo.swift +87 -73
  42. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/BadgeDemo.swift +55 -249
  43. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ButtonDemo.swift +90 -82
  44. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CarouselDemo.swift +74 -104
  45. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CheckboxDemo.swift +47 -50
  46. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ChipDemo.swift +63 -7
  47. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/CollapseDemo.swift +90 -81
  48. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DateTimePickerDemo.swift +73 -97
  49. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DemoScreen.swift +420 -0
  50. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/DividerDemo.swift +46 -4
  51. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/Home.swift +54 -57
  52. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconButtonDemo.swift +80 -45
  53. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/IconDemo.swift +81 -0
  54. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ImageDemo.swift +49 -62
  55. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InformationDemo.swift +66 -116
  56. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDemo.swift +104 -98
  57. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputDropDownDemo.swift +76 -118
  58. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputMoneyDemo.swift +73 -86
  59. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputOTPDemo.swift +69 -54
  60. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputPhoneNumberDemo.swift +74 -54
  61. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputSearchDemo.swift +73 -20
  62. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/InputTextAreaDemo.swift +68 -38
  63. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LoaderDemo.swift +55 -52
  64. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/LocalizeDemo.swift +51 -4
  65. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/NavigationDemo.swift +14 -2
  66. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PaginationDemo.swift +47 -51
  67. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PlaygroundData.swift +76 -0
  68. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupNotifyDemo.swift +67 -93
  69. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/PopupPromotionDemo.swift +34 -43
  70. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ProgressInfoDemo.swift +83 -71
  71. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RadioDemo.swift +55 -23
  72. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/RatingDemo.swift +58 -46
  73. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SectionDemo.swift +83 -0
  74. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SkeletonDemo.swift +41 -12
  75. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SliderDemo.swift +67 -127
  76. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SnackBarDemo.swift +54 -110
  77. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepperDemo.swift +69 -79
  78. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/StepsDemo.swift +78 -58
  79. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SuggestActionDemo.swift +76 -65
  80. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwipeDemo.swift +82 -127
  81. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/SwitchDemo.swift +44 -19
  82. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TabViewDemo.swift +92 -168
  83. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TagDemo.swift +79 -27
  84. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/ThemeDemo.swift +0 -1
  85. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TitleDemo.swift +87 -92
  86. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TooltipDemo.swift +94 -163
  87. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/TypographyDemo.swift +70 -70
  88. package/ios-demo/Sources/MoMoUIKitsDemo/SampleDemos/UploaderDemo.swift +68 -114
  89. package/ios-demo/Sources/MoMoUIKitsDemo/SwiftUIDemoBrowserView.swift +3 -1
  90. package/package.json +2 -2
@@ -50,6 +50,7 @@ public func formatNumberToMoney(_ digits: String, currency: String = "đ") -> St
50
50
 
51
51
  public struct InputMoney: View {
52
52
  @Environment(\.appTheme) private var theme
53
+ @Environment(\.momoFontScale) private var fontScale
53
54
  @Binding public var text: String
54
55
 
55
56
  public var floatingValue: String
@@ -214,7 +215,7 @@ public struct InputMoney: View {
214
215
  }
215
216
  }
216
217
  .padding(.horizontal, Spacing.M)
217
- .frame(height: scaleSize(size == .small ? 48 : 56, 1.1))
218
+ .frame(height: fontScale(size == .small ? 48 : 56))
218
219
  .background(
219
220
  RoundedRectangle(cornerRadius: Radius.S)
220
221
  .fill(theme.colors.background.surface)
@@ -278,7 +279,7 @@ private struct MoneyTextField: UIViewRepresentable {
278
279
  let field = UITextField()
279
280
  field.delegate = context.coordinator
280
281
  field.keyboardType = keyboardType
281
- field.font = UIFont.systemFont(ofSize: scaleSize(20), weight: fontWeight)
282
+ field.font = UIFont.systemFont(ofSize: context.environment.momoFontScale(20), weight: fontWeight)
282
283
  field.textColor = textColor
283
284
  field.isEnabled = !isDisabled
284
285
  field.tintColor = cursorColor
@@ -295,6 +296,8 @@ private struct MoneyTextField: UIViewRepresentable {
295
296
 
296
297
  func updateUIView(_ field: UITextField, context: Context) {
297
298
  context.coordinator.parent = self
299
+ // Re-applied here, not just in makeUIView, so a font-scale change reaches an existing field.
300
+ field.font = UIFont.systemFont(ofSize: context.environment.momoFontScale(20), weight: fontWeight)
298
301
  field.isEnabled = !isDisabled
299
302
  field.textColor = textColor
300
303
  field.tintColor = cursorColor
@@ -7,12 +7,13 @@ import SwiftUI
7
7
  private struct OTPCaret: View {
8
8
  @Environment(\.appTheme) private var theme
9
9
  @State private var visible: Bool = true
10
+ @Environment(\.momoFontScale) private var fontScale
10
11
 
11
12
  var body: some View {
12
13
  HStack(spacing: 0) {
13
14
  Rectangle()
14
15
  .fill(theme.colors.primary)
15
- .frame(width: 1, height: scaleSize(12))
16
+ .frame(width: 1, height: fontScale(12))
16
17
  .opacity(visible ? 1 : 0)
17
18
  .animation(
18
19
  .linear(duration: 0.5).repeatForever(autoreverses: true),
@@ -0,0 +1,30 @@
1
+ import SwiftUI
2
+
3
+ /// Grid metrics published by a layout container (`SectionView`) so span-based children
4
+ /// can size themselves. Mirrors `vn.momo.kits.layout.GridContext`.
5
+ public struct GridContext: Equatable {
6
+ public let numberOfColumns: Int
7
+ public let gutter: CGFloat
8
+ public let sizePerSpan: CGFloat
9
+
10
+ public init(numberOfColumns: Int = 12, gutter: CGFloat = Spacing.M, sizePerSpan: CGFloat = 0) {
11
+ self.numberOfColumns = numberOfColumns
12
+ self.gutter = gutter
13
+ self.sizePerSpan = sizePerSpan
14
+ }
15
+
16
+ public func sizeForSpan(_ span: Int) -> CGFloat {
17
+ sizePerSpan * CGFloat(span) + gutter * CGFloat(span - 1)
18
+ }
19
+ }
20
+
21
+ private struct GridContextKey: EnvironmentKey {
22
+ static let defaultValue: GridContext? = nil
23
+ }
24
+
25
+ public extension EnvironmentValues {
26
+ var gridContext: GridContext? {
27
+ get { self[GridContextKey.self] }
28
+ set { self[GridContextKey.self] = newValue }
29
+ }
30
+ }
@@ -0,0 +1,42 @@
1
+ import SwiftUI
2
+
3
+ /// Span-sized cell inside a `SectionView`. Reads the grid published by its container;
4
+ /// without one it just wraps its content. Mirrors `vn.momo.kits.layout.Item`.
5
+ public struct ItemView<Content: View>: View {
6
+ // MARK: Lifecycle
7
+
8
+ public init(
9
+ widthSpan: Int = 12,
10
+ heightSpan: Int = 0,
11
+ @ViewBuilder content: @escaping () -> Content
12
+ ) {
13
+ self.widthSpan = widthSpan
14
+ self.heightSpan = heightSpan
15
+ self.content = content
16
+ }
17
+
18
+ // MARK: Public
19
+
20
+ public var body: some View {
21
+ // A VStack (not a ZStack) so multiple children flow VERTICALLY instead of stacking
22
+ // on top of each other — matches the React Item's flex-column content.
23
+ VStack(alignment: .leading, spacing: 0) { content() }
24
+ .frame(width: width, height: height, alignment: .topLeading)
25
+ }
26
+
27
+ // MARK: Private
28
+
29
+ private let widthSpan: Int
30
+ private let heightSpan: Int
31
+ private let content: () -> Content
32
+
33
+ @Environment(\.gridContext) private var grid
34
+
35
+ private var width: CGFloat? {
36
+ grid.map { $0.sizeForSpan(widthSpan) }
37
+ }
38
+
39
+ private var height: CGFloat? {
40
+ heightSpan > 0 ? grid.map { $0.sizeForSpan(heightSpan) } : nil
41
+ }
42
+ }
@@ -0,0 +1,134 @@
1
+ import SwiftUI
2
+
3
+ /// 12-column section container: horizontal margin, optional background image, and a
4
+ /// wrapping row of children that read their span size from `\.gridContext`.
5
+ /// Mirrors `vn.momo.kits.layout.Section`.
6
+ public struct SectionView<Content: View>: View {
7
+ // MARK: Lifecycle
8
+
9
+ public init(
10
+ useMargin: Bool = true,
11
+ backgroundImage: String? = nil,
12
+ @ViewBuilder content: @escaping () -> Content
13
+ ) {
14
+ self.useMargin = useMargin
15
+ self.backgroundImage = backgroundImage
16
+ self.content = content
17
+ }
18
+
19
+ // MARK: Public
20
+
21
+ public var body: some View {
22
+ flow
23
+ .environment(\.gridContext, grid)
24
+ .frame(maxWidth: .infinity, alignment: .topLeading)
25
+ .background(widthReader)
26
+ .background(background)
27
+ .padding(.horizontal, useMargin ? Spacing.M : 0)
28
+ }
29
+
30
+ // MARK: Private
31
+
32
+ private static var columns: Int { 12 }
33
+ private static var gutter: CGFloat { Spacing.M }
34
+
35
+ private let useMargin: Bool
36
+ private let backgroundImage: String?
37
+ private let content: () -> Content
38
+
39
+ /// Width available *inside* the horizontal margin — the Compose `BoxWithConstraints` equivalent.
40
+ @State private var availableWidth: CGFloat = 0
41
+
42
+ private var grid: GridContext {
43
+ let columns = CGFloat(Self.columns)
44
+ // The trailing `- 1 / columns` mirrors Compose: it absorbs rounding so 12 spans
45
+ // plus their gutters never overflow the row by a sub-point.
46
+ let sizePerSpan = (availableWidth - Self.gutter * (columns - 1)) / columns - 1 / columns
47
+ return GridContext(numberOfColumns: Self.columns, gutter: Self.gutter, sizePerSpan: max(0, sizePerSpan))
48
+ }
49
+
50
+ @ViewBuilder private var flow: some View {
51
+ if #available(iOS 16.0, *) {
52
+ FlowRowLayout(gutter: Self.gutter) { content() }
53
+ } else {
54
+ // No `Layout` protocol before iOS 16: children stack instead of wrapping.
55
+ // Full-width (span 12) items — the common case — look identical.
56
+ VStack(alignment: .leading, spacing: Self.gutter) { content() }
57
+ }
58
+ }
59
+
60
+ @ViewBuilder private var background: some View {
61
+ if let backgroundImage {
62
+ ImageView(backgroundImage, contentMode: .fill).clipped()
63
+ }
64
+ }
65
+
66
+ private var widthReader: some View {
67
+ GeometryReader { proxy in
68
+ Color.clear.preference(key: SectionWidthKey.self, value: proxy.size.width)
69
+ }
70
+ .onPreferenceChange(SectionWidthKey.self) { availableWidth = $0 }
71
+ }
72
+ }
73
+
74
+ private struct SectionWidthKey: PreferenceKey {
75
+ static var defaultValue: CGFloat = 0
76
+
77
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
78
+ value = max(value, nextValue())
79
+ }
80
+ }
81
+
82
+ /// Wrapping row with equal horizontal/vertical spacing — the Compose `FlowRow` equivalent.
83
+ @available(iOS 16.0, *)
84
+ private struct FlowRowLayout: Layout {
85
+ struct Row {
86
+ var items: [(index: Int, size: CGSize)] = []
87
+ var width: CGFloat = 0
88
+ var height: CGFloat = 0
89
+ }
90
+
91
+ let gutter: CGFloat
92
+
93
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
94
+ let maxWidth = proposal.width ?? .infinity
95
+ let rows = rows(for: subviews, maxWidth: maxWidth)
96
+ let height = rows.reduce(0) { $0 + $1.height } + gutter * CGFloat(max(0, rows.count - 1))
97
+ return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
98
+ }
99
+
100
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
101
+ var y = bounds.minY
102
+ for row in rows(for: subviews, maxWidth: bounds.width) {
103
+ var x = bounds.minX
104
+ for item in row.items {
105
+ subviews[item.index].place(
106
+ at: CGPoint(x: x, y: y),
107
+ anchor: .topLeading,
108
+ proposal: ProposedViewSize(item.size)
109
+ )
110
+ x += item.size.width + gutter
111
+ }
112
+ y += row.height + gutter
113
+ }
114
+ }
115
+
116
+ private func rows(for subviews: Subviews, maxWidth: CGFloat) -> [Row] {
117
+ var rows: [Row] = []
118
+ var current = Row()
119
+ for index in subviews.indices {
120
+ let size = subviews[index].sizeThatFits(ProposedViewSize(width: maxWidth, height: nil))
121
+ let widthIfAppended = current.items.isEmpty ? size.width : current.width + gutter + size.width
122
+ // Sub-point tolerance: spans are computed to land exactly on `maxWidth`.
123
+ if !current.items.isEmpty, widthIfAppended > maxWidth + 0.5 {
124
+ rows.append(current)
125
+ current = Row()
126
+ }
127
+ current.width = current.items.isEmpty ? size.width : current.width + gutter + size.width
128
+ current.height = max(current.height, size.height)
129
+ current.items.append((index, size))
130
+ }
131
+ if !current.items.isEmpty { rows.append(current) }
132
+ return rows
133
+ }
134
+ }
@@ -39,6 +39,7 @@ public struct PopupNotifyProps {
39
39
 
40
40
  public struct PopupNotify: View {
41
41
  @Environment(\.localize) private var localize
42
+ @Environment(\.momoFontScale) private var fontScale
42
43
 
43
44
  public let props: PopupNotifyProps
44
45
 
@@ -49,7 +50,7 @@ public struct PopupNotify: View {
49
50
  }
50
51
 
51
52
  private var maxDescriptionHeight: CGFloat {
52
- scaleSize(TypographyStyle.bodyDefaultRegular.lineHeight) * 8.5
53
+ fontScale(TypographyStyle.bodyDefaultRegular.lineHeight) * 8.5
53
54
  }
54
55
 
55
56
  private var shouldUseColumnButtons: Bool {
@@ -254,15 +254,16 @@ private struct ProgressInfoVertical: View {
254
254
  // MARK: - Step icon
255
255
 
256
256
  private struct ProgressInfoStepIcon: View {
257
+ @Environment(\.momoFontScale) private var fontScale
257
258
  let index: Int
258
259
  let size: ProgressInfoSize
259
260
  let useNumber: Bool
260
261
  let customIcon: String?
261
262
 
262
263
  var body: some View {
263
- let containerSize = scaleSize(size == .small ? 16 : 24)
264
- let dotSize = scaleSize(size == .small ? 6 : 8)
265
- let iconSize = scaleSize(size == .small ? 12 : 16)
264
+ let containerSize = fontScale(size == .small ? 16 : 24)
265
+ let dotSize = fontScale(size == .small ? 6 : 8)
266
+ let iconSize = fontScale(size == .small ? 12 : 16)
266
267
 
267
268
  ZStack {
268
269
  Circle()
@@ -15,6 +15,7 @@ public enum RatingSize {
15
15
  }
16
16
 
17
17
  public struct Rating: View {
18
+ @Environment(\.momoFontScale) private var fontScale
18
19
  private let numOfStars: Int
19
20
  private let rating: Float
20
21
  private let onRatingChange: ((Int) -> Void)?
@@ -44,7 +45,7 @@ public struct Rating: View {
44
45
  private func star(at index: Int) -> some View {
45
46
  let icon = Icon(
46
47
  source: starIcon(at: index),
47
- size: scaleSize(size.iconSize),
48
+ size: fontScale(size.iconSize),
48
49
  color: starColor(at: index)
49
50
  )
50
51
 
@@ -84,6 +84,7 @@ private struct StepperButton: View {
84
84
 
85
85
  private struct StepperNumberView: View {
86
86
  @Environment(\.appTheme) private var theme
87
+ @Environment(\.momoFontScale) private var fontScale
87
88
 
88
89
  @Binding var text: String
89
90
  var size: StepperSize
@@ -105,7 +106,7 @@ private struct StepperNumberView: View {
105
106
  MomoText(text, typography: .descriptionDefaultRegular, color: textColor)
106
107
  }
107
108
  }
108
- .font(.system(size: scaleSize(12)))
109
+ .font(.system(size: fontScale(12)))
109
110
  .ignoreBoldTextSetting()
110
111
  .foregroundColor(textColor)
111
112
  .frame(minWidth: specs.numberMinWidth)
@@ -3,6 +3,7 @@ import Foundation
3
3
 
4
4
  public struct SuggestAction: View {
5
5
  @Environment(\.appTheme) private var theme
6
+ @Environment(\.momoFontScale) private var fontScale
6
7
 
7
8
  private let message: String
8
9
  private let buttonTitle: String
@@ -32,7 +33,7 @@ public struct SuggestAction: View {
32
33
 
33
34
  // Builds an attributed string with `boldMessage` (case-insensitive) emphasized.
34
35
  private var annotatedMessage: AttributedString {
35
- let baseFont = Font.system(size: scaleSize(TypographyStyle.bodyDefaultRegular.fontSize), weight: .regular)
36
+ let baseFont = Font.system(size: fontScale(TypographyStyle.bodyDefaultRegular.fontSize), weight: .regular)
36
37
  var attributed = AttributedString(message)
37
38
  attributed.font = baseFont
38
39
 
@@ -44,7 +45,7 @@ public struct SuggestAction: View {
44
45
 
45
46
  if let boldRange = Range(range, in: attributed) {
46
47
  attributed[boldRange].font = Font.system(
47
- size: scaleSize(TypographyStyle.bodyDefaultRegular.fontSize),
48
+ size: fontScale(TypographyStyle.bodyDefaultRegular.fontSize),
48
49
  weight: .bold
49
50
  )
50
51
  }
package/ios/Tag/Tag.swift CHANGED
@@ -1,6 +1,7 @@
1
1
  import SwiftUI
2
2
 
3
3
  public struct Tag: View {
4
+ @Environment(\.momoFontScale) private var fontScale
4
5
  public enum TagSize {
5
6
  case large
6
7
  case medium
@@ -71,7 +72,7 @@ public struct Tag: View {
71
72
  }
72
73
  MomoText(label, typography: .labelSMedium, color: labelColor)
73
74
  }
74
- .frame(height: scaleSize(size.height))
75
+ .frame(height: fontScale(size.height))
75
76
  .padding(.horizontal, Spacing.S)
76
77
  .background(
77
78
  RoundedRectangle(cornerRadius: Radius.S, style: .continuous)
@@ -74,7 +74,8 @@ public struct TrustBanner: View {
74
74
  .scaledToFit()
75
75
  .frame(width: 64, height: 64)
76
76
  VStack(alignment: .leading){
77
- Text(applicationEnvironment.config?.trustBanner?.content[localize.currentLanguage] ?? defaultBanner.content[localize.currentLanguage])
77
+ Text(applicationEnvironment.config?.trustBanner?.content[localize.currentLanguage]
78
+ ?? localize.translateData(defaultBanner.content))
78
79
  .foregroundColor(Color(hex: "484848"))
79
80
  .lineLimit(2)
80
81
  .font(.system(size: 13, weight: Font.Weight.regular))
@@ -83,7 +84,8 @@ public struct TrustBanner: View {
83
84
  .padding(.bottom, 8)
84
85
  HStack {
85
86
  HStack(spacing: 0) {
86
- Text(applicationEnvironment.config?.trustBanner?.subContent[localize.currentLanguage] ?? defaultBanner.subContent[localize.currentLanguage])
87
+ Text(applicationEnvironment.config?.trustBanner?.subContent[localize.currentLanguage]
88
+ ?? localize.translateData(defaultBanner.subContent))
87
89
  .foregroundColor(Colors.pink03)
88
90
  .font(.system(size: 13, weight: .bold))
89
91
  .ignoreBoldTextSetting()
@@ -28,6 +28,7 @@ public enum TitleButtonSize {
28
28
 
29
29
  public struct Title: View {
30
30
  @Environment(\.appTheme) private var theme
31
+ @Environment(\.momoFontScale) private var fontScale
31
32
 
32
33
  private let title: String
33
34
  private let type: TitleType
@@ -152,10 +153,10 @@ public struct Title: View {
152
153
 
153
154
  private var titleText: some View {
154
155
  SwiftUI.Text(title)
155
- .font(.system(size: scaleSize(titleFontSize), weight: titleFontWeight))
156
+ .font(.system(size: fontScale(titleFontSize), weight: titleFontWeight))
156
157
  .ignoreBoldTextSetting()
157
158
  .foregroundColor(theme.colors.text.default)
158
- .lineSpacing(scaleSize(titleLineHeight) - scaleSize(titleFontSize))
159
+ .lineSpacing(fontScale(titleLineHeight) - fontScale(titleFontSize))
159
160
  .lineLimit(titleMaxLines)
160
161
  .truncationMode(.tail)
161
162
  }
@@ -469,11 +469,12 @@ private struct TooltipSecondaryButton: View {
469
469
  }
470
470
 
471
471
  private struct TooltipIconButton: View {
472
+ @Environment(\.momoFontScale) private var fontScale
472
473
  let icon: String
473
474
  let onPress: () -> Void
474
475
 
475
476
  var body: some View {
476
- let iconButtonSize = scaleSize(36)
477
+ let iconButtonSize = fontScale(36)
477
478
  SwiftUI.Button(action: onPress) {
478
479
  Icon(source: icon, size: 24)
479
480
  .frame(width: iconButtonSize, height: iconButtonSize)
@@ -0,0 +1,103 @@
1
+ // File: ios/Typography/FontScale.swift
2
+ // Created At: 2026-08-06 10:00:00 +07:00
3
+ // Created By: AI
4
+ // AI Agent: Claude Code
5
+ // Model: claude-opus-5
6
+
7
+ // AI-GENERATED START: reactive font-scale snapshot injected through the environment
8
+ import SwiftUI
9
+
10
+ /// Immutable snapshot of the kit's font-scale inputs. The formula is a 1:1 port of Compose's
11
+ /// `scaleSize()` (`const/Typography.kt`): the device-width scale and the OS Dynamic Type scale are
12
+ /// computed independently and the larger one wins.
13
+ public struct FontScale: Equatable {
14
+ private static let defaultScreenSize: CGFloat = 375
15
+ private static let maxFontScale: CGFloat = 1.5
16
+ private static let maxDeviceScale: CGFloat = 5
17
+
18
+ public let userScaleRate: CGFloat
19
+ public let useOSScaleRate: Bool
20
+ /// OS Dynamic Type factor at the time the snapshot was taken.
21
+ public let osFontScale: CGFloat
22
+
23
+ public init(userScaleRate: CGFloat = 1, useOSScaleRate: Bool = true, osFontScale: CGFloat = 1) {
24
+ self.userScaleRate = userScaleRate
25
+ self.useOSScaleRate = useOSScaleRate
26
+ self.osFontScale = osFontScale
27
+ }
28
+
29
+ /// `callAsFunction` so call sites keep reading like Compose's `scaleSize(16)`.
30
+ public func callAsFunction(_ size: CGFloat) -> CGFloat {
31
+ guard useOSScaleRate else { return size * userScaleRate }
32
+
33
+ // Device width is read live rather than snapshotted, matching Compose's
34
+ // getScreenDimensions() read inside scaleSize(), so a rotation or split-view resize counts.
35
+ let deviceScale = UIScreen.main.bounds.width / Self.defaultScreenSize
36
+
37
+ var deviceScaled = size
38
+ var osScaled = size
39
+ if deviceScale > 1 {
40
+ deviceScaled = min(size * deviceScale, size + Self.maxDeviceScale)
41
+ }
42
+ if osFontScale > 1 {
43
+ osScaled = min(size * osFontScale, size * Self.maxFontScale)
44
+ }
45
+ return max(deviceScaled, osScaled)
46
+ }
47
+
48
+ /// `.system(size:)` is a fixed-size font — SwiftUI does not Dynamic-Type it — so the scale has to
49
+ /// be baked into the point size here, and there is no double-scaling to undo.
50
+ public func font(size: CGFloat, weight: Font.Weight = .regular) -> Font {
51
+ .system(size: self(size), weight: weight)
52
+ }
53
+
54
+ /// A host-pushed mini-app context wins per field, matching Compose's
55
+ /// `context?.useOSScaleRate ?: KitFontScale.useOSScaleRate`.
56
+ public func overriding(userScaleRate: CGFloat?, useOSScaleRate: Bool?) -> FontScale {
57
+ guard userScaleRate != nil || useOSScaleRate != nil else { return self }
58
+ return FontScale(
59
+ userScaleRate: userScaleRate ?? self.userScaleRate,
60
+ useOSScaleRate: useOSScaleRate ?? self.useOSScaleRate,
61
+ osFontScale: osFontScale
62
+ )
63
+ }
64
+ }
65
+
66
+ private struct FontScaleKey: EnvironmentKey {
67
+ /// Computed, never stored: a bare `UIHostingController` root that never got `.momoFontScale()`
68
+ /// still reads the live store instead of a value frozen at first access.
69
+ static var defaultValue: FontScale { FontScaleStore.shared.snapshot }
70
+ }
71
+
72
+ public extension EnvironmentValues {
73
+ var momoFontScale: FontScale {
74
+ get { self[FontScaleKey.self] }
75
+ set { self[FontScaleKey.self] = newValue }
76
+ }
77
+ }
78
+
79
+ public extension View {
80
+ /// Injects a Dynamic-Type-observing font scale and re-renders this subtree on change.
81
+ /// Use at every `UIHostingController` root that is not inside a `NavigationContainer`.
82
+ func momoFontScale(_ store: FontScaleStore = .shared, context: MiniAppContext? = nil) -> some View {
83
+ modifier(MomoFontScaleModifier(store: store, context: context))
84
+ }
85
+ }
86
+
87
+ /// `@ObservedObject` is load-bearing, same as `MomoLocalizeModifier`: `@Environment(\.momoFontScale)`
88
+ /// only invalidates when the boxed snapshot changes, which requires *this* body to re-run on publish.
89
+ private struct MomoFontScaleModifier: ViewModifier {
90
+ @ObservedObject var store: FontScaleStore
91
+ let context: MiniAppContext?
92
+
93
+ func body(content: Content) -> some View {
94
+ content.environment(
95
+ \.momoFontScale,
96
+ store.snapshot.overriding(
97
+ userScaleRate: context?.userScaleRate,
98
+ useOSScaleRate: context?.useOSScaleRate
99
+ )
100
+ )
101
+ }
102
+ }
103
+ // AI-GENERATED END: reactive font-scale snapshot injected through the environment
@@ -1,19 +1,58 @@
1
- import CoreGraphics
2
-
3
- /// App-wide font-scale preference for the SwiftUI kit. An exported holder the host writes once at
4
- /// app startup (from the cache-backed `FontScaleProvider`); `scaleSize`/`appFont` read the snapshot.
5
- /// No realtime: the SwiftUI kit has no navigation container to scope reactive updates, so a startup
6
- /// snapshot is enough. When `useOSScaleRate` is false the kit applies `userScaleRate` instead of the
7
- /// OS Dynamic Type factor.
8
- public final class FontScaleStore {
1
+ import SwiftUI
2
+
3
+ /// App-wide font-scale inputs for the SwiftUI kit, and the single place they publish from.
4
+ /// The host writes `update(...)` at startup (from the cache-backed `FontScaleProvider`) and again
5
+ /// whenever the in-app font-size preference changes; the OS Dynamic Type factor is tracked here.
6
+ /// Mirrors Compose's `KitFontScale` + `ProvideOsFontScale` pair, including its live behaviour
7
+ /// views re-render on change as long as the subtree got `.momoFontScale()` (`NavigationContainer`
8
+ /// applies it). When `useOSScaleRate` is false the kit applies `userScaleRate` instead of the OS factor.
9
+ public final class FontScaleStore: ObservableObject {
9
10
  public static let shared = FontScaleStore()
10
- private init() {}
11
11
 
12
- public private(set) var userScaleRate: CGFloat = 1
13
- public private(set) var useOSScaleRate: Bool = true
12
+ @Published public private(set) var userScaleRate: CGFloat = 1
13
+ @Published public private(set) var useOSScaleRate: Bool = true
14
+ @Published public private(set) var osFontScale: CGFloat = FontScaleStore.currentOsFontScale()
15
+
16
+ private init() {
17
+ NotificationCenter.default.addObserver(
18
+ self,
19
+ selector: #selector(contentSizeCategoryDidChange),
20
+ name: UIContentSizeCategory.didChangeNotification,
21
+ object: nil
22
+ )
23
+ }
24
+
25
+ public var snapshot: FontScale {
26
+ FontScale(
27
+ userScaleRate: userScaleRate,
28
+ useOSScaleRate: useOSScaleRate,
29
+ osFontScale: osFontScale
30
+ )
31
+ }
14
32
 
15
33
  public func update(userScaleRate: CGFloat, useOSScaleRate: Bool) {
16
- self.userScaleRate = userScaleRate
17
- self.useOSScaleRate = useOSScaleRate
34
+ onMain {
35
+ // Re-assigning an unchanged value would still publish and invalidate every kit subtree.
36
+ if self.userScaleRate != userScaleRate { self.userScaleRate = userScaleRate }
37
+ if self.useOSScaleRate != useOSScaleRate { self.useOSScaleRate = useOSScaleRate }
38
+ }
39
+ }
40
+
41
+ @objc private func contentSizeCategoryDidChange() {
42
+ let next = Self.currentOsFontScale()
43
+ guard next != osFontScale else { return }
44
+ osFontScale = next
45
+ }
46
+
47
+ /// Same factor Compose's iOS `ProvideOsFontScale` derives from
48
+ /// `UIFont.preferredFont(forTextStyle: .body).pointSize / 17`, so both iOS surfaces scale alike.
49
+ static func currentOsFontScale() -> CGFloat {
50
+ let base = UIFont.systemFont(ofSize: UIFont.labelFontSize)
51
+ return UIFontMetrics.default.scaledFont(for: base).pointSize / base.pointSize
52
+ }
53
+
54
+ /// Synchronous when already on main — an async hop would defer the change by a frame.
55
+ private func onMain(_ work: @escaping () -> Void) {
56
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
18
57
  }
19
58
  }