@momo-kits/native-kits 0.168.1-beta.2-debug → 0.168.1-beta.24-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.
- package/CLAUDE.md +10 -6
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/const/HostFont.android.kt +4 -3
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/ReservedRegions.android.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/Context.kt +0 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/application/DesignSystemKits.kt +62 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupNotify.kt +25 -16
- package/compose/src/commonMain/kotlin/vn/momo/kits/components/PopupPromotion.kt +3 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/HostFont.kt +2 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/Typography.kt +18 -14
- package/compose/src/commonMain/kotlin/vn/momo/kits/layout/ScreenLayout.kt +25 -13
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/BottomSheet.kt +206 -45
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/ModalScreen.kt +14 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/Navigator.kt +53 -30
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoBarCompression.kt +2 -3
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/bottomtab/DuoTabBar.kt +124 -59
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/Header.kt +1 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderBackground.kt +3 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/component/HeaderTitle.kt +2 -2
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/ReservedRegions.kt +10 -3
- package/compose/src/iosMain/kotlin/vn/momo/kits/const/HostFont.ios.kt +3 -1
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +9 -8
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/ReservedRegions.ios.kt +54 -10
- package/gradle.properties +1 -1
- package/ios/Application/ApplicationEnvironment.swift +7 -6
- package/ios/Application/DesignSystemKits.swift +163 -0
- package/ios/Application/DesignSystemKitsPayload.swift +68 -0
- package/ios/Badge/BadgeRibbon.swift +8 -1
- package/ios/Extensions/Color++.swift +2 -4
- package/ios/Input/Input.swift +13 -3
- package/ios/Input/InputPhoneNumber.swift +1 -3
- package/ios/Input/InputSearch.swift +1 -2
- package/ios/Input/InputTextArea.swift +5 -3
- package/ios/InputMoney/InputMoney.swift +6 -7
- package/ios/Layout/ScreenLayout.swift +65 -10
- package/ios/Navigation/BottomSheet.swift +153 -23
- package/ios/Navigation/BottomTab/DuoBarCompression.swift +2 -3
- package/ios/Navigation/BottomTab/DuoTabBar.swift +94 -69
- package/ios/Navigation/Component/HeaderBackground.swift +22 -1
- package/ios/Navigation/Component/SnackBar.swift +4 -0
- package/ios/Navigation/ModalScreen.swift +7 -3
- package/ios/Navigation/StackScreen.swift +3 -7
- package/ios/Popup/PopupDisplay.swift +2 -2
- package/ios/Popup/PopupInput.swift +2 -2
- package/ios/Popup/PopupLayout.swift +15 -10
- package/ios/Popup/PopupNotify.swift +2 -2
- package/ios/Stepper/Stepper.swift +1 -2
- package/ios/Template/TrustBanner/TrustBanner.swift +4 -2
- package/ios/Typography/FontScale.swift +29 -15
- package/ios/Typography/Text.swift +86 -12
- package/ios/Typography/Typography.swift +2 -2
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/local.properties +8 -0
- package/package.json +1 -1
- package/.claude/settings.local.json +0 -9
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitDesignSystem.kt +0 -18
- package/compose/src/commonMain/kotlin/vn/momo/kits/const/KitFontScale.kt +0 -22
- package/ios/Typography/FontScaleStore.swift +0 -58
- package/ios/Typography/KitDesignSystem.swift +0 -17
package/CLAUDE.md
CHANGED
|
@@ -48,7 +48,9 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
48
48
|
|
|
49
49
|
- **Entry point:** wrap content in `NavigationContainer(...)` from `navigation/`. It builds the `Navigator`, takes a host-supplied `Localize` (falls back to an empty one), and provides the CompositionLocals below. The legacy `Screen` / `ApplicationContainer` / `ComposeApi` stack has been removed on both sides — don't reintroduce it.
|
|
50
50
|
- **Configuration flows via CompositionLocals**, not params. Public ones: `AppTheme`, `AppStatusBar`, `AppNavigationBar` (`const/Theme.kt`); `ApplicationContext`, `AppConfig`, `AppLanguage`, `LocalLocalize` (`application/`); `LocalNavigator`, `LocalMaxApi` (`navigation/`). Read with `X.current`.
|
|
51
|
-
-
|
|
51
|
+
- **`DesignSystemKits`** (`application/DesignSystemKits.kt`) is the single app-wide holder for the host-owned design-system inputs: font scale (`userScaleRate` / `useOSScaleRate`), whitelist (`isWhiteList`) and the remote `DesignSystemConfig`, bundled as `DesignSystemKitsConfigs`. The host calls `DesignSystemKits.init(source)` once at startup with its `DesignSystemKitsSource` (sync `getConfigs()` seed + `configsFlow`); `init` is idempotent. Without a source, call `setConfigs(...)` directly. Read with `getConfigs()` (snapshot state, recomposes); non-Compose observers use `configsFlow`.
|
|
52
|
+
- **SwiftUI twin:** `ios/Application/DesignSystemKits.swift` mirrors it field for field (`DesignSystemKitsConfigs` with `designSystemConfig: KitConfig?`, `setConfigs` / `getConfigs`), plus the OS Dynamic Type factor (`osFontScale`). There is no `init(source:)` (Swift reserves `init`): the host observes its own bridge and pushes. A host that can't share Swift types (the KMP bridge) calls `DesignSystemKits.shared.setConfigs(json:)` (`ios/Application/DesignSystemKitsPayload.swift`); the JSON field names are a contract with momo-app's serializer (`HostDesignSystemKits.observeConfigsJson`, payload classes in `DesignSystemData.kt`) — change both together. Invalid JSON is ignored. The SwiftUI kit can't link the Kotlin `kits` framework (a second Kotlin/Native runtime would hold a second, never-initialized singleton), so the render-time logic — `FontScale` formula, Dynamic Type observation — is intentionally duplicated in Swift; only values flow across.
|
|
53
|
+
- **Design-system config** (`trustBanner`, `headerBar`, `headerGradient`): `NavigationContainer` provides it as `LocalDesignSystemConfig` (param `designSystemConfig` → parent → `DesignSystemKits`) and applies `headerBar` as the header image when the theme has none.
|
|
52
54
|
- **Platform code** goes through `expect`/`actual` in `platform/` — never reference Android/iOS APIs from `commonMain` directly.
|
|
53
55
|
- **Native bridge:** `IMaxApi` (`vn.momo.maxapi`, exposed as `LocalMaxApi`) is the host-app bridge (dismiss, tracking, device info, language).
|
|
54
56
|
- Navigation uses a runtime `DynamicScreenRegistry`; overlays (modal/bottom-sheet/snackbar) go through `Navigator` + `OverplayComponentRegistry`.
|
|
@@ -69,7 +71,7 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
69
71
|
## Screen layout / iPhone Duo (port of RN `useScreenLayout`, momo-kits !664)
|
|
70
72
|
|
|
71
73
|
- `layout/ScreenLayout.kt`: `getScreenLayout()` returns window size, raw top/bottom + side insets, `contentWidth`, header geometry, the iPhone Duo `sideBar` and `duoTopInset`. `NavigationContainer` computes it once and provides `LocalScreenLayout`; reactive to fold/unfold and rotation (window size + `WindowInsets`). Metrics (`DuoMetrics`, 72pt iPhone header floor, 20pt status-bar threshold) mirror RN `Layout/utils.ts` — change both together.
|
|
72
|
-
- iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (
|
|
74
|
+
- iPhone-only: everything gates on `isPhoneIdiom()` (`platform/`), never on "iOS", so iPad windows keep the regular layout. Two detectors, as in RN: `isIPhoneDuo` (window aspect < 1.6 or asymmetric side insets) drives layout; `isIPhoneDuoDevice` (Apple's foldable check: a `.division` reserved region, queried with include-inactive; Swift also latches on `UIHingeInteraction`; or asymmetric side insets, since the outer display reports no hinge) drives popups, modal and bottom sheet.
|
|
73
75
|
- Header height is `headerHeight` (not `AppStatusBar + HEADER_HEIGHT`); the bar row is `headerBarHeight` and starts `headerStatusBarHeight` below the window top. When the status bar is off the top edge (iPhone landscape, iPhone Duo) `headerStatusBarHeight` is 0 so the title stays centered — except on the Duo outer display: in portrait the header is 76pt and the row starts at 14pt so the title centers on the camera punchout; in landscape the row centers on the camera when it sits at the top end of the strip (else on the back button) and the header reaches 8pt below the back button.
|
|
74
76
|
- iPhone Duo inner display in portrait (status bar on top): the header row shares the status bar band — `headerHeight` 94pt (2 × the clock digits' y 47), `headerStatusBarHeight` 0 — and `ScreenLayout.statusBarClearance` keeps it between the clock / battery clusters (from reserved regions, else 128pt on the right). Back and toolkit use the 48pt side bar controls laid out horizontally (`ToolkitLayout.SideBarRow`); the collapsed search and a centered title stay clear of the clusters. Port of momo-kits `90af33ba2`.
|
|
75
77
|
- iPhone Duo with a side strip ≥ 60pt: back + toolkit leave the bar for `HeaderSideBar` (`navigation/component/SideBar.kt`, 48pt glass controls); custom header-right stays in the bar. `sideBar.top` is 170pt in portrait, 120pt on the inner display in landscape (height ≥ 600), 24pt on the outer display in landscape, or below the header when the status bar is on top; UIKit's `statusBarFrame` is useless on Duo. Unlike RN the column is clamped above footer (24pt gap) / rail (12pt gap).
|
|
@@ -78,8 +80,10 @@ Publishing is automated (`publish.sh` + GitLab CI on `[ci build]` / `main`). Do
|
|
|
78
80
|
- Bar compression (`bottomtab/DuoBarCompression.kt`, HIG "Vertical controls"): when toolbar + rail don't fit in the strip, one is compressed — the toolkit into a `navigation_menu` button, the rail into one button showing the current tab — and tapping it swaps them; `BottomTab(compressionBehavior)` picks which starts compressed. If neither fits beside the other compressed, both rest compressed and a tap opens one for a moment. Side bar columns report their full height (0 when the screen puts nothing there) so the decision follows each screen's toolkit.
|
|
79
81
|
- Glass header: on Duo with a side bar, Default/Extended headers without search and with a `ScrollState` let content scroll under the bar (`usesDuoGlassHeader`); the bar fades in a translucent surface + hairline over 16pt (Compose has no backdrop blur — RN's fallback look), the gradient leaves with the content.
|
|
80
82
|
- Quick return header: glass headers and Animated headers (with a `ScrollState`) on the Duo side bar layout slide out with the content and back in on a reverse scroll; a half-shown header settles in or out once the scroll stops. The side bar is drawn apart from the header, so it stays put.
|
|
81
|
-
- Overlays on Duo:
|
|
83
|
+
- Overlays on Duo: the modal sits inside the safe area (all four insets, so clear of the side toolbar strip) and caps its content at `duoModalMaxWidth` / `duoModalMaxHeight` (440 × 640pt, at least XL off the safe area); popups fill that width with radius XL and drop their own side padding (body scrolls, actions stay). Snackbars keep clear of the strip.
|
|
84
|
+
- Bottom sheet on Duo: radius XL, window height less `duoTopInset` (it never passes the side status bar: unlike RN, nothing restyles the clock over it), centered when the short side ≥ 600 (inner display in either orientation, buttons stay in the header row; RN parity). Below that (outer display, landscape too) it floats `Spacing.M` off the side edges, pads header/content/footer by `inset − M`, and a `Title` header moves back / close into 48pt side bar buttons on the strip part of the sheet (HIG: sheet controls go vertical on the outer display) when the strip fits one; they follow the header row while the sheet is short and stay below `sideBar.top` when it is tall. `BottomHeader.Title(onBack)` / `.title(onBack:)` closes the sheet, then runs (RN `leftOptions.onPressIconLeft`).
|
|
82
85
|
- Font scale deliberately uses the short side (`min(w, h)`), unlike RN which reads the window width — landscape must not inflate every font.
|
|
86
|
+
- Font scale measures the **window**, never `UIScreen.main`: on iPhone Duo the main screen stays on one display while the window follows the one in use. Compose iOS `getScreenDimensions()` reads `LocalWindowInfo.containerSize` (recomposes on fold/unfold); SwiftUI `DesignSystemKits` KVO-observes each `UIWindowScene.effectiveGeometry` and republishes `windowShortSide`.
|
|
83
87
|
- `StackScreen` pads content/footer/search/FAB by the side insets and provides an inset-consumed layout to its content; a bottom-tab root stays full width (tab bar + each tab's StackScreen handle insets). Grid columns use `contentWidth`.
|
|
84
88
|
|
|
85
89
|
## i18n / Localize (port of RN `Localize`)
|
|
@@ -104,9 +108,9 @@ The SwiftUI port differs from Compose in one important way: **it follows the hos
|
|
|
104
108
|
|
|
105
109
|
One formula, both platforms: the device-width scale (`width / 375`, capped at `+5pt`) and the OS Dynamic Type scale (capped at `1.5×`) are computed independently and the **larger wins**. When `useOSScaleRate` is false neither applies — the size is just multiplied by `userScaleRate`. Max font scale is fixed at 1.5; the old per-call/per-scope override is deprecated and ignored on both sides.
|
|
106
110
|
|
|
107
|
-
- **Compose** — `const/Typography.kt` (`scaleSize` overloads for `Float`/`Dp`/`TextUnit`/`TextStyle`). The `TextUnit` overload divides by `density.fontScale` so Compose's own `sp` scaling isn't applied twice. Inputs: `LocalContext.current` (`MiniAppContext.userScaleRate` / `useOSScaleRate`, per-field) falling back to
|
|
108
|
-
- **SwiftUI** — `ios/Typography/FontScale.swift`. `FontScale` is an immutable snapshot with `callAsFunction`, so a view reads `@Environment(\.momoFontScale) private var fontScale` and calls `fontScale(16)`. `
|
|
109
|
-
- **Never set `\.momoFontScale` directly** — same trap as `\.localize`. Inject via `.momoFontScale(_:context:)`, which holds
|
|
111
|
+
- **Compose** — `const/Typography.kt` (`scaleSize` overloads for `Float`/`Dp`/`TextUnit`/`TextStyle`). The `TextUnit` overload divides by `density.fontScale` so Compose's own `sp` scaling isn't applied twice. Inputs: `LocalContext.current` (`MiniAppContext.userScaleRate` / `useOSScaleRate`, per-field) falling back to `DesignSystemKits.getConfigs()`. On iOS, `platform/OsFontScale.ios.kt` observes `UIContentSizeCategoryDidChangeNotification` and overrides `LocalDensity` — Compose MP otherwise reads `fontScale` once at `ComposeUIViewController` creation. Wired at `NavigationContainer`.
|
|
112
|
+
- **SwiftUI** — `ios/Typography/FontScale.swift`. `FontScale` is an immutable snapshot with `callAsFunction`, so a view reads `@Environment(\.momoFontScale) private var fontScale` and calls `fontScale(16)`. `DesignSystemKits.shared` is the `ObservableObject` holding the host inputs and the OS factor (it observes `UIContentSizeCategory.didChangeNotification`); the host pushes them with `setConfigs(_:)`.
|
|
113
|
+
- **Never set `\.momoFontScale` directly** — same trap as `\.localize`. Inject via `.momoFontScale(_:context:)`, which holds `DesignSystemKits` as `@ObservedObject`; that is what re-publishes the snapshot. It also injects `\.designSystemKitsConfigs`, which `MomoText` (whitelist font) and `TrustBanner` (fallback after `ApplicationEnvironment.config`) read so a host push re-renders them. `NavigationContainer` does it (passing `MiniAppContext` so the per-mini-app override applies); a bare `UIHostingController` root must too. The environment default reads the live store, so an un-injected subtree still gets correct values — just not live ones.
|
|
110
114
|
- The global `scaleSize(_:)` and `Font.appFont(size:)` remain for call sites that can't read the environment (UIKit bridges, free functions). They are **not** reactive. `Font.header_default_bold` & friends are computed `static var`s for the same reason — as stored `static let`s they froze the scale at first access. `UIViewRepresentable`s should read `context.environment.momoFontScale` instead, and re-apply the font in `updateUIView`, not only in `makeUIView`.
|
|
111
115
|
|
|
112
116
|
## Code conventions
|
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -5,15 +5,16 @@ import androidx.compose.runtime.remember
|
|
|
5
5
|
import androidx.compose.ui.platform.LocalContext
|
|
6
6
|
import androidx.compose.ui.text.font.Font
|
|
7
7
|
import androidx.compose.ui.text.font.FontFamily
|
|
8
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
8
9
|
|
|
9
10
|
@Composable
|
|
10
|
-
internal actual fun hostFontFamily(fileName: String): FontFamily? {
|
|
11
|
+
internal actual fun hostFontFamily(fileName: String, weight: FontWeight): FontFamily? {
|
|
11
12
|
val assets = LocalContext.current.applicationContext.assets
|
|
12
|
-
return remember(fileName) {
|
|
13
|
+
return remember(fileName, weight) {
|
|
13
14
|
val path = "fonts/$fileName"
|
|
14
15
|
// AssetManager.open ném IOException nếu thiếu file; Font(path, assetManager) thì lỗi
|
|
15
16
|
// muộn lúc render nên phải kiểm tra tồn tại trước.
|
|
16
17
|
val exists = runCatching { assets.open(path).close() }.isSuccess
|
|
17
|
-
if (exists) FontFamily(Font(path = path, assetManager = assets)) else null
|
|
18
|
+
if (exists) FontFamily(Font(path = path, assetManager = assets, weight = weight)) else null
|
|
18
19
|
}
|
|
19
20
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
package vn.momo.kits.application
|
|
2
2
|
|
|
3
3
|
import androidx.compose.runtime.Immutable
|
|
4
|
-
import androidx.compose.runtime.getValue
|
|
5
|
-
import androidx.compose.runtime.mutableStateOf
|
|
6
|
-
import androidx.compose.runtime.setValue
|
|
7
4
|
import androidx.compose.runtime.staticCompositionLocalOf
|
|
8
5
|
import vn.momo.kits.components.TrustBannerData
|
|
9
6
|
|
|
@@ -19,17 +16,6 @@ data class DesignSystemConfig(
|
|
|
19
16
|
val headerGradient: String? = null,
|
|
20
17
|
)
|
|
21
18
|
|
|
22
|
-
// App-wide holder seeded by the host (same pattern as KitFontScale), so direct NavigationContainer
|
|
23
|
-
// callers get the config without passing it. Snapshot state: a config fetched after first frame recomposes.
|
|
24
|
-
object KitDesignSystemConfig {
|
|
25
|
-
var config by mutableStateOf<DesignSystemConfig?>(null)
|
|
26
|
-
private set
|
|
27
|
-
|
|
28
|
-
fun update(config: DesignSystemConfig?) {
|
|
29
|
-
this.config = config
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
19
|
@Immutable
|
|
34
20
|
data class MiniAppContext(
|
|
35
21
|
val appIcon: String = "",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package vn.momo.kits.application
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Immutable
|
|
4
|
+
import androidx.compose.runtime.getValue
|
|
5
|
+
import androidx.compose.runtime.mutableStateOf
|
|
6
|
+
import androidx.compose.runtime.setValue
|
|
7
|
+
import kotlinx.coroutines.CoroutineScope
|
|
8
|
+
import kotlinx.coroutines.Dispatchers
|
|
9
|
+
import kotlinx.coroutines.Job
|
|
10
|
+
import kotlinx.coroutines.SupervisorJob
|
|
11
|
+
import kotlinx.coroutines.flow.Flow
|
|
12
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
13
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
14
|
+
import kotlinx.coroutines.flow.asStateFlow
|
|
15
|
+
import kotlinx.coroutines.launch
|
|
16
|
+
|
|
17
|
+
@Immutable
|
|
18
|
+
data class DesignSystemKitsConfigs(
|
|
19
|
+
val userScaleRate: Float = 1f,
|
|
20
|
+
val useOSScaleRate: Boolean = true,
|
|
21
|
+
val isWhiteList: Boolean = false,
|
|
22
|
+
val designSystemConfig: DesignSystemConfig? = null,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// The kit is sandboxed from the host's storage/config, so the host implements this and hands it to init().
|
|
26
|
+
interface DesignSystemKitsSource {
|
|
27
|
+
// Sync read: seeds the kit before the first frame, so there is no default-then-real flicker.
|
|
28
|
+
fun getConfigs(): DesignSystemKitsConfigs
|
|
29
|
+
|
|
30
|
+
val configsFlow: Flow<DesignSystemKitsConfigs>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// App-wide design-system inputs (font scale, whitelist, remote config). scaleSize(), defaultTheme and
|
|
34
|
+
// NavigationContainer fall back to these when no MiniAppContext / LocalDesignSystemConfig is provided.
|
|
35
|
+
object DesignSystemKits {
|
|
36
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
37
|
+
private var observeJob: Job? = null
|
|
38
|
+
|
|
39
|
+
// Snapshot state so Compose readers recompose (including non-composable getters like defaultTheme);
|
|
40
|
+
// the StateFlow is for non-Compose observers, e.g. the host's SwiftUI bridge, where Compose's
|
|
41
|
+
// global snapshot apply loop may never have started.
|
|
42
|
+
private var currentConfigs by mutableStateOf(DesignSystemKitsConfigs())
|
|
43
|
+
private val mutableConfigsFlow = MutableStateFlow(currentConfigs)
|
|
44
|
+
val configsFlow: StateFlow<DesignSystemKitsConfigs> = mutableConfigsFlow.asStateFlow()
|
|
45
|
+
|
|
46
|
+
// Idempotent: a repeated call (e.g. Activity re-creation) replaces the previous observer.
|
|
47
|
+
fun init(source: DesignSystemKitsSource) {
|
|
48
|
+
observeJob?.cancel()
|
|
49
|
+
setConfigs(source.getConfigs())
|
|
50
|
+
observeJob = scope.launch {
|
|
51
|
+
source.configsFlow.collect { setConfigs(it) }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// While init() is observing a source, its next emission overrides a value set here.
|
|
56
|
+
fun setConfigs(configs: DesignSystemKitsConfigs) {
|
|
57
|
+
currentConfigs = configs
|
|
58
|
+
mutableConfigsFlow.value = configs
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fun getConfigs(): DesignSystemKitsConfigs = currentConfigs
|
|
62
|
+
}
|
|
@@ -22,6 +22,8 @@ import vn.momo.kits.application.LocalLocalize
|
|
|
22
22
|
import vn.momo.kits.const.*
|
|
23
23
|
import vn.momo.kits.modifier.conditional
|
|
24
24
|
import vn.momo.kits.modifier.setAutomationId
|
|
25
|
+
import vn.momo.kits.layout.duoModalMaxHeight
|
|
26
|
+
import vn.momo.kits.layout.duoModalMaxWidth
|
|
25
27
|
import vn.momo.kits.layout.getScreenLayout
|
|
26
28
|
import kotlin.math.min
|
|
27
29
|
|
|
@@ -36,22 +38,29 @@ enum class PopupActionDirection {
|
|
|
36
38
|
AUTO
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
+
// Card width cap on iPhone Duo (the modal bounds, RN Popup/utils.ts); unbounded elsewhere.
|
|
42
|
+
@Composable
|
|
43
|
+
internal fun popupMaxWidth(): Dp {
|
|
44
|
+
val layout = getScreenLayout()
|
|
45
|
+
return if (layout.isIPhoneDuoDevice) layout.duoModalMaxWidth else Dp.Unspecified
|
|
46
|
+
}
|
|
41
47
|
|
|
42
|
-
//
|
|
48
|
+
// The modal bounds already keep iPhone Duo popups off the edges, so their own side padding goes.
|
|
43
49
|
@Composable
|
|
44
|
-
internal fun
|
|
45
|
-
if (getScreenLayout().isIPhoneDuoDevice)
|
|
50
|
+
internal fun popupHorizontalPadding(padding: Dp): Dp =
|
|
51
|
+
if (getScreenLayout().isIPhoneDuoDevice) 0.dp else padding
|
|
46
52
|
|
|
47
|
-
//
|
|
53
|
+
// Tallest a popup gets on iPhone Duo, landscape and the inner display included; null elsewhere.
|
|
48
54
|
@Composable
|
|
49
55
|
internal fun popupMaxHeight(): Dp? {
|
|
50
56
|
val layout = getScreenLayout()
|
|
51
|
-
if (
|
|
52
|
-
return layout.height - layout.duoTopInset - layout.insetBottom - Spacing.XL * 2
|
|
57
|
+
return if (layout.isIPhoneDuoDevice) layout.duoModalMaxHeight else null
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
// Rounder on iPhone Duo, closer to its system alerts.
|
|
61
|
+
@Composable
|
|
62
|
+
internal fun popupRadius(): Dp = if (getScreenLayout().isIPhoneDuoDevice) Radius.XL else Radius.L
|
|
63
|
+
|
|
55
64
|
private fun heightForLines(
|
|
56
65
|
layout: TextLayoutResult,
|
|
57
66
|
): Float {
|
|
@@ -145,20 +154,19 @@ fun PopupNotify(
|
|
|
145
154
|
|
|
146
155
|
val maxHeight = popupMaxHeight()
|
|
147
156
|
val bodyScrollState = rememberScrollState()
|
|
157
|
+
val horizontalPadding = popupHorizontalPadding(12.dp)
|
|
158
|
+
val shape = RoundedCornerShape(popupRadius())
|
|
148
159
|
|
|
149
160
|
Box(
|
|
150
161
|
Modifier.setAutomationId("popup_notify"), contentAlignment = Alignment.TopEnd
|
|
151
162
|
) {
|
|
152
163
|
Column(
|
|
153
164
|
modifier = Modifier
|
|
154
|
-
.widthIn(max = popupMaxWidth(
|
|
165
|
+
.widthIn(max = popupMaxWidth())
|
|
155
166
|
.fillMaxWidth()
|
|
156
|
-
.padding(horizontal =
|
|
157
|
-
.clip(
|
|
158
|
-
.background(
|
|
159
|
-
AppTheme.current.colors.background.surface,
|
|
160
|
-
RoundedCornerShape(Radius.L)
|
|
161
|
-
)
|
|
167
|
+
.padding(horizontal = horizontalPadding)
|
|
168
|
+
.clip(shape)
|
|
169
|
+
.background(AppTheme.current.colors.background.surface, shape)
|
|
162
170
|
.conditional(IsShowBaseLineDebug) {
|
|
163
171
|
border(1.dp, Colors.blue_03)
|
|
164
172
|
}
|
|
@@ -215,7 +223,8 @@ fun PopupNotify(
|
|
|
215
223
|
Modifier
|
|
216
224
|
.width(22.dp)
|
|
217
225
|
.height(22.dp)
|
|
218
|
-
|
|
226
|
+
// centered on the card's top right corner
|
|
227
|
+
.offset(x = 11.dp - horizontalPadding, y = (-11).dp)
|
|
219
228
|
.background(
|
|
220
229
|
color = AppTheme.current.colors.text.default,
|
|
221
230
|
shape = RoundedCornerShape(Radius.M)
|
|
@@ -32,15 +32,15 @@ fun PopupPromotion(
|
|
|
32
32
|
onClose: (() -> Unit) = {},
|
|
33
33
|
) {
|
|
34
34
|
val maxHeight = popupMaxHeight()
|
|
35
|
-
var maxWidth = popupMaxWidth(
|
|
35
|
+
var maxWidth = popupMaxWidth()
|
|
36
36
|
if (maxHeight != null) {
|
|
37
37
|
// fit the portrait image by height when iPhone Duo leaves too little room
|
|
38
|
-
maxWidth = minOf(maxWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO
|
|
38
|
+
maxWidth = minOf(maxWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
Column(Modifier
|
|
42
42
|
.widthIn(max = maxWidth)
|
|
43
|
-
.padding(horizontal = Spacing.M)
|
|
43
|
+
.padding(horizontal = popupHorizontalPadding(Spacing.M))
|
|
44
44
|
.conditional(IsShowBaseLineDebug) {
|
|
45
45
|
border(1.dp, Colors.blue_03)
|
|
46
46
|
}
|
|
@@ -2,6 +2,7 @@ package vn.momo.kits.const
|
|
|
2
2
|
|
|
3
3
|
import androidx.compose.runtime.Composable
|
|
4
4
|
import androidx.compose.ui.text.font.FontFamily
|
|
5
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Nạp font từ tài nguyên native của app host thay vì đóng gói bản copy riêng trong SDK.
|
|
@@ -13,4 +14,4 @@ import androidx.compose.ui.text.font.FontFamily
|
|
|
13
14
|
* Trả về `null` nếu host không có font đó — caller tự fallback [FontFamily.Default].
|
|
14
15
|
*/
|
|
15
16
|
@Composable
|
|
16
|
-
internal expect fun hostFontFamily(fileName: String): FontFamily?
|
|
17
|
+
internal expect fun hostFontFamily(fileName: String, weight: FontWeight): FontFamily?
|
|
@@ -8,6 +8,7 @@ import androidx.compose.ui.text.font.FontFamily
|
|
|
8
8
|
import androidx.compose.ui.text.font.FontWeight
|
|
9
9
|
import androidx.compose.ui.text.style.TextDecoration
|
|
10
10
|
import androidx.compose.ui.unit.*
|
|
11
|
+
import vn.momo.kits.application.DesignSystemKits
|
|
11
12
|
import vn.momo.kits.application.LocalContext
|
|
12
13
|
import vn.momo.kits.platform.getScreenDimensions
|
|
13
14
|
import kotlin.math.max
|
|
@@ -20,10 +21,9 @@ const val MAX_DEVICE_SCALE = 5
|
|
|
20
21
|
@Composable
|
|
21
22
|
fun scaleSize(size: Float): Float {
|
|
22
23
|
val context = LocalContext.current
|
|
23
|
-
|
|
24
|
-
val useOsScale = context?.useOSScaleRate ?: KitFontScale.useOSScaleRate
|
|
24
|
+
val useOsScale = context?.useOSScaleRate ?: DesignSystemKits.getConfigs().useOSScaleRate
|
|
25
25
|
if (!useOsScale) {
|
|
26
|
-
return size * (context?.userScaleRate ?:
|
|
26
|
+
return size * (context?.userScaleRate ?: DesignSystemKits.getConfigs().userScaleRate)
|
|
27
27
|
}
|
|
28
28
|
val scaleSizeMaxRate: Float = MAX_FONT_SCALE
|
|
29
29
|
// Short side, not width: landscape must not inflate every font.
|
|
@@ -84,12 +84,6 @@ fun scaleSize(textStyle: TextStyle): TextStyle {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
* Tên file font trong tài nguyên native của app host (xem [hostFontFamily]).
|
|
89
|
-
*
|
|
90
|
-
* SDK không đóng gói font riêng nữa: app host đã bundle sẵn toàn bộ font từ `font-source`
|
|
91
|
-
* cho cả React Native lẫn native, nên bản copy trong `composeResources` chỉ làm phình app.
|
|
92
|
-
*/
|
|
93
87
|
private const val LEGACY_DEFAULT_FONT_FILE = "SFProText-Regular.ttf"
|
|
94
88
|
private const val NEW_DEFAULT_FONT_FILE = "MomoTrustSans-Regular.ttf"
|
|
95
89
|
|
|
@@ -124,17 +118,27 @@ private val hostFontFiles: Map<String, String> = mapOf(
|
|
|
124
118
|
private const val NEW_DEFAULT_FONT_FAMILY = "MomoTrustSans"
|
|
125
119
|
private const val LEGACY_DEFAULT_FONT_FAMILY = "SFProText"
|
|
126
120
|
|
|
121
|
+
// Families that ship one file per weight; the others map every weight to a single file.
|
|
122
|
+
private val weightedFontFamilies = setOf(NEW_DEFAULT_FONT_FAMILY, LEGACY_DEFAULT_FONT_FAMILY)
|
|
123
|
+
|
|
127
124
|
internal val defaultFontFamily: String
|
|
128
|
-
get() = if (
|
|
125
|
+
get() = if (DesignSystemKits.getConfigs().isWhiteList) NEW_DEFAULT_FONT_FAMILY else LEGACY_DEFAULT_FONT_FAMILY
|
|
129
126
|
|
|
130
127
|
@Composable
|
|
131
128
|
fun getFontFamily(fontFamily: String, fontWeight: FontWeight? = FontWeight.Normal): FontFamily {
|
|
132
|
-
val isWhiteList =
|
|
129
|
+
val isWhiteList = DesignSystemKits.getConfigs().isWhiteList
|
|
133
130
|
val defaultFile = if (isWhiteList) NEW_DEFAULT_FONT_FILE else LEGACY_DEFAULT_FONT_FILE
|
|
134
131
|
val key = "$fontFamily-${fontWeight?.weight}"
|
|
135
|
-
val
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
val mappedFile = hostFontFiles[key]
|
|
133
|
+
val fileName = mappedFile ?: defaultFile
|
|
134
|
+
// Declaring the file's real weight keeps Compose from synthesizing bold on top of a bold file.
|
|
135
|
+
val fileWeight = if (mappedFile != null && fontFamily in weightedFontFamilies) {
|
|
136
|
+
fontWeight ?: FontWeight.Normal
|
|
137
|
+
} else {
|
|
138
|
+
FontWeight.Normal
|
|
139
|
+
}
|
|
140
|
+
return hostFontFamily(fileName, fileWeight)
|
|
141
|
+
?: hostFontFamily(defaultFile, FontWeight.Normal)
|
|
138
142
|
?: FontFamily.Default
|
|
139
143
|
}
|
|
140
144
|
|
|
@@ -14,7 +14,6 @@ import androidx.compose.ui.unit.dp
|
|
|
14
14
|
import vn.momo.kits.const.Spacing
|
|
15
15
|
import vn.momo.kits.navigation.component.HEADER_HEIGHT
|
|
16
16
|
import vn.momo.kits.platform.ReservedRegion
|
|
17
|
-
import vn.momo.kits.platform.getScreenDimensions
|
|
18
17
|
import vn.momo.kits.platform.isPhoneIdiom
|
|
19
18
|
import vn.momo.kits.platform.rememberReservedRegions
|
|
20
19
|
import vn.momo.kits.utils.getAppStatusBarHeight
|
|
@@ -52,7 +51,8 @@ data class ScreenLayout(
|
|
|
52
51
|
val headerStatusBarHeight: Dp,
|
|
53
52
|
// iPhone Duo moves back and toolkit into a column in the side safe area; null while they stay in the bar.
|
|
54
53
|
val sideBar: SideBarLayout? = null,
|
|
55
|
-
//
|
|
54
|
+
// iPhone Duo folded or not, whatever the window size (its hinge, or the outer display's one-sided strip);
|
|
55
|
+
// drives popups, modal and bottom sheet.
|
|
56
56
|
val isIPhoneDuoDevice: Boolean = isIPhoneDuo,
|
|
57
57
|
// Portrait iPhone Duo puts the status bar in the side strip, about twice as tall as the strip is wide.
|
|
58
58
|
// Stored so it survives consumeHorizontalInsets().
|
|
@@ -86,11 +86,18 @@ internal object DuoMetrics {
|
|
|
86
86
|
// (x 550–643 of 669, clock digits centered on y 47); its reserved region is taller than the glyphs.
|
|
87
87
|
val innerStatusBarCenterY = 47.dp
|
|
88
88
|
val innerStatusBarWidth = 120.dp
|
|
89
|
+
// Host app's iOS 26 tab bar on the inner display in portrait: a 62pt glass pill sized to its tabs,
|
|
90
|
+
// QR as a 62pt platter beside it, ≥ 20pt from the sides, 13pt into the home indicator inset.
|
|
91
|
+
val floatingTabBarHeight = 62.dp
|
|
92
|
+
val floatingTabBarMargin = 20.dp
|
|
93
|
+
val floatingTabBarInsetOverlap = 13.dp
|
|
94
|
+
// Width of a system alert on the inner display, in both orientations.
|
|
95
|
+
val modalMaxWidth = 440.dp
|
|
96
|
+
// Fits a PopupNotify with image, scrolled description and actions at the max width (RN
|
|
97
|
+
// Application/device.ts); taller content scrolls instead of filling the inner display.
|
|
98
|
+
val modalMaxHeight = 640.dp
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
// Widest short side of any single-display iPhone.
|
|
92
|
-
private val IPHONE_MAX_SHORT_SIDE = 440.dp
|
|
93
|
-
|
|
94
101
|
private val IPHONE_HEADER_MIN_HEIGHT = 72.dp
|
|
95
102
|
|
|
96
103
|
// A smaller top inset means the status bar is not on the top edge.
|
|
@@ -129,10 +136,9 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
|
|
|
129
136
|
val leftPx = insets.getLeft(density, layoutDirection)
|
|
130
137
|
val rightPx = insets.getRight(density, layoutDirection)
|
|
131
138
|
val bottomPx = WindowInsets.systemBars.getBottom(density)
|
|
132
|
-
val screen = getScreenDimensions()
|
|
133
139
|
val reservedRegions = rememberReservedRegions()
|
|
134
140
|
|
|
135
|
-
return remember(size, leftPx, rightPx, bottomPx, topInset,
|
|
141
|
+
return remember(size, leftPx, rightPx, bottomPx, topInset, density, reservedRegions) {
|
|
136
142
|
with(density) {
|
|
137
143
|
val width = size.width.toDp()
|
|
138
144
|
val height = size.height.toDp()
|
|
@@ -145,16 +151,21 @@ internal fun calculateScreenLayout(topInset: Dp, parent: ScreenLayout? = null):
|
|
|
145
151
|
bottomInset = bottomPx.toDp(),
|
|
146
152
|
isPhone = isPhone,
|
|
147
153
|
asymmetricInsets = leftPx != rightPx,
|
|
148
|
-
isDuoDevice = isPhone &&
|
|
149
|
-
|
|
150
|
-
minOf(width, height),
|
|
151
|
-
) > IPHONE_MAX_SHORT_SIDE,
|
|
152
|
-
reservedRegions = reservedRegions,
|
|
154
|
+
isDuoDevice = isPhone && reservedRegions.isFoldable,
|
|
155
|
+
reservedRegions = reservedRegions.occlusion,
|
|
153
156
|
)
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
|
|
161
|
+
// Room for a dialog on iPhone Duo: inside the safe area, so clear of the side toolbar strip, and
|
|
162
|
+
// capped so it stays a dialog on the inner display.
|
|
163
|
+
internal val ScreenLayout.duoModalMaxWidth: Dp
|
|
164
|
+
get() = minOf(width - insetLeft - insetRight - Spacing.XL * 2, DuoMetrics.modalMaxWidth)
|
|
165
|
+
|
|
166
|
+
internal val ScreenLayout.duoModalMaxHeight: Dp
|
|
167
|
+
get() = minOf(height - insetTop - insetBottom - Spacing.XL * 2, DuoMetrics.modalMaxHeight)
|
|
168
|
+
|
|
158
169
|
// Content inside the safe-area padding: the insets are spent and the side bar belongs to the screen.
|
|
159
170
|
internal fun ScreenLayout.consumeHorizontalInsets(): ScreenLayout =
|
|
160
171
|
copy(width = contentWidth, insetLeft = 0.dp, insetRight = 0.dp, sideBar = null)
|
|
@@ -302,7 +313,8 @@ private fun screenLayoutOf(
|
|
|
302
313
|
headerHeight = headerHeight,
|
|
303
314
|
headerStatusBarHeight = headerStatusBarHeight,
|
|
304
315
|
sideBar = sideBar,
|
|
305
|
-
|
|
316
|
+
// the outer display reports no hinge; its one-sided strip still gives it away
|
|
317
|
+
isIPhoneDuoDevice = isDuoDevice || (isPhone && asymmetricInsets),
|
|
306
318
|
duoTopInset = maxOf(topInset, if (height > width) sideInset * 2 else 0.dp),
|
|
307
319
|
statusBarClearance = statusBarClearance,
|
|
308
320
|
)
|