also looking at this
feat: finish native customization and platform seams
#401
21 files
+707
-17
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -34,6 +34,28 @@ describe('Craft Android builder', () => { | ||
| 34 | 34 | .toThrow('postGenerate hook failed with exit code 7: patch failed') |
| 35 | 35 | }) |
| 36 | 36 | |
| 37 | it('generates static tab-bar selection appearance and validates its shape', async () => { | |
| 38 | const output = createTempDir('craft-android-tab-bar-appearance-') | |
| 39 | await init({ name: 'Tab Bar', output, runtimeDir: null, config: { | |
| 40 | renderer: 'native', | |
| 41 | tabBar: { selection: { cornerRadius: 8, color: '#123456' } }, | |
| 42 | } }) | |
| 43 | const config = JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')) | |
| 44 | const shell = readFileSync(join(output, 'app/src/main/java/com/craft/tabbar/CraftNativeShell.kt'), 'utf8') | |
| 45 | const activity = readFileSync(join(output, 'app/src/main/java/com/craft/tabbar/MainActivity.kt'), 'utf8') | |
| 46 | expect(config.tabBar).toEqual({ selection: { cornerRadius: 8, color: '#123456' } }) | |
| 47 | expect(shell).toContain('data class CraftNativeTabBarAppearance') | |
| 48 | expect(shell).toContain('private fun tabCellBackground(index: Int, dark: Boolean)') | |
| 49 | expect(activity).toContain('routes.tabBarAppearance') | |
| 50 | await expect(init({ name: 'Invalid Tab Bar', output: join(output, 'invalid'), runtimeDir: null, config: { | |
| 51 | renderer: 'native', tabBar: { selection: { cornerRadius: -1 } }, | |
| 52 | } })).rejects.toThrow('cornerRadius must be a finite non-negative number') | |
| 53 | await expect(init({ name: 'Invalid Tab Color', output: join(output, 'invalid-color'), runtimeDir: null, config: { | |
| 54 | renderer: 'native', tabBar: { selection: { color: 'red' } }, | |
| 55 | } })).rejects.toThrow('color must be a #RRGGBB color') | |
| 56 | }) | |
| 57 | ||
| 58 | ||
| 37 | 59 | it('bundles custom fonts with their licenses and removes retired fonts', async () => { |
| 38 | 60 | const root = createTempDir('craft-native-fonts-') |
| 39 | 61 | const font = join(root, 'Signature.ttf') |
| @@ -138,6 +138,8 @@ export interface CraftAndroidConfig { | ||
| 138 | 138 | renderer?: 'web' | 'native' |
| 139 | 139 | /** "system" follows the device; light/dark pin the generated host. */ |
| 140 | 140 | appearance?: 'light' | 'dark' | 'system' |
| 141 | /** Static native tab-bar styling applied before the page's own tabBar.set call. */ | |
| 142 | tabBar?: CraftTabBarConfig | |
| 141 | 143 | /** Runs from the generated project root before Gradle compiles the app. */ |
| 142 | 144 | postGenerate?: CraftPostGenerateHook |
| 143 | 145 | version?: string |
| @@ -263,6 +265,15 @@ export interface CraftAndroidConfig { | ||
| 263 | 265 | } |
| 264 | 266 | } |
| 265 | 267 | |
| 268 | export interface CraftTabBarConfig { | |
| 269 | selection?: { | |
| 270 | /** The selected tab indicator's corner radius in dp; omitted keeps the default. */ | |
| 271 | cornerRadius?: number | |
| 272 | /** The selected tab indicator fill as #RRGGBB. */ | |
| 273 | color?: string | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 266 | 277 | /** A declared command Craft runs after the native project has been generated. */ |
| 267 | 278 | export interface CraftPostGenerateHook { |
| 268 | 279 | command: string |
| @@ -565,6 +576,11 @@ function validateAndroidConfig(config: CraftAndroidConfig): void { | ||
| 565 | 576 | if (config.appearance !== undefined && !['light', 'dark', 'system'].includes(config.appearance)) { |
| 566 | 577 | throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`) |
| 567 | 578 | } |
| 579 | const selection = config.tabBar?.selection | |
| 580 | if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0)) | |
| 581 | throw new Error('Android tabBar.selection.cornerRadius must be a finite non-negative number') | |
| 582 | if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color)) | |
| 583 | throw new Error('Android tabBar.selection.color must be a #RRGGBB color') | |
| 568 | 584 | |
| 569 | 585 | const packageSegments = config.packageName.split('.') |
| 570 | 586 | if (packageSegments.length < 2 |
| @@ -86,6 +86,11 @@ internal data class CraftNativeTab( | ||
| 86 | 86 | val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null, |
| 87 | 87 | ) |
| 88 | 88 | |
| 89 | internal data class CraftNativeTabBarAppearance( | |
| 90 | val selectionColor: String? = null, | |
| 91 | val selectionCornerRadius: Float? = null, | |
| 92 | ) | |
| 93 | ||
| 89 | 94 | internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean) |
| 90 | 95 | |
| 91 | 96 | /** The app's paths (`nativeScreens`), its tabs and its site. */ |
| @@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) { | ||
| 122 | 127 | } |
| 123 | 128 | } |
| 124 | 129 | |
| 130 | val tabBarAppearance: CraftNativeTabBarAppearance = config.optJSONObject("tabBar") | |
| 131 | ?.optJSONObject("selection") | |
| 132 | ?.let { selection -> | |
| 133 | CraftNativeTabBarAppearance( | |
| 134 | selection.optString("color").takeIf { it.isNotBlank() }, | |
| 135 | (selection.opt("cornerRadius") as? Number)?.toFloat()?.takeIf { it.isFinite() && it >= 0 }, | |
| 136 | ) | |
| 137 | } | |
| 138 | ?: CraftNativeTabBarAppearance() | |
| 139 | ||
| 125 | 140 | /** The app's site (`devServerURL`): its origin answers paths no screen draws. */ |
| 126 | 141 | val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse) |
| 127 | 142 | val siteOrigin: String? = site?.let { uri -> |
| @@ -755,7 +770,11 @@ internal object CraftNativePrelude { | ||
| 755 | 770 | * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons, |
| 756 | 771 | * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`. |
| 757 | 772 | */ |
| 758 | internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) { | |
| 773 | internal class CraftNativeChrome( | |
| 774 | context: Context, | |
| 775 | private val tabs: List<CraftNativeTab>, | |
| 776 | private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(), | |
| 777 | ) : LinearLayout(context) { | |
| 759 | 778 | val content = FrameLayout(context) |
| 760 | 779 | private val bar = LinearLayout(context) |
| 761 | 780 | private val backButton = ImageView(context) |
| @@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN | ||
| 1099 | 1118 | accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) } |
| 1100 | 1119 | tabViews.forEachIndexed { index, (icon, label) -> |
| 1101 | 1120 | tabBar.getChildAt(index).isSelected = index == selectedTab |
| 1121 | tabBar.getChildAt(index).background = tabCellBackground(index, dark) | |
| 1102 | 1122 | val color = if (index == selectedTab) accent else muted |
| 1103 | 1123 | val id = tabs[index].id |
| 1104 | 1124 | val number = tabIconText[id] |
| @@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN | ||
| 1188 | 1208 | |
| 1189 | 1209 | private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF") |
| 1190 | 1210 | |
| 1211 | private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? { | |
| 1212 | if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground() | |
| 1213 | val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() } | |
| 1214 | ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0) | |
| 1215 | val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat() | |
| 1216 | return GradientDrawable().apply { | |
| 1217 | setColor(color) | |
| 1218 | cornerRadius = radius | |
| 1219 | } | |
| 1220 | } | |
| 1221 | ||
| 1191 | 1222 | private fun rippleBackground(): android.graphics.drawable.Drawable? { |
| 1192 | 1223 | val value = TypedValue() |
| 1193 | 1224 | return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) { |
| @@ -4198,7 +4198,7 @@ class MainActivity : AppCompatActivity() { | ||
| 4198 | 4198 | } |
| 4199 | 4199 | |
| 4200 | 4200 | private fun installChrome() { |
| 4201 | val bars = CraftNativeChrome(this, routes.tabs) | |
| 4201 | val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance) | |
| 4202 | 4202 | bars.layoutParams = FrameLayout.LayoutParams(-1, -1) |
| 4203 | 4203 | bars.onBack = { onBackPressedDispatcherCompat() } |
| 4204 | 4204 | bars.onAccessory = { path -> navigateOpen(path) } |