craft-native 0.0.204 → 0.0.206
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/dist/android/src/index.js +90 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +117 -0
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -0
- package/dist/android/templates/CraftNativeShell.kt.template +42 -10
- package/dist/android/templates/MainActivityNative.kt.template +628 -99
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +122 -74
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +11 -0
- package/dist/ios/src/testing.d.ts +6 -4
- package/dist/ios/templates/CraftHybrid.swift +17 -3
- package/dist/ios/templates/CraftNativeFlatList.swift +68 -0
- package/dist/ios/templates/CraftNativeScreen.swift +339 -46
- package/dist/ios/templates/testing/DriverTests.swift.template +58 -6
- package/package.json +1 -1
|
@@ -380,6 +380,94 @@ function validateAndroidConfig(config) {
|
|
|
380
380
|
throw new Error("Android targetSdk must not exceed compileSdk");
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
|
+
function normalizeNativeRoutePath(raw) {
|
|
384
|
+
if (typeof raw !== "string")
|
|
385
|
+
return null;
|
|
386
|
+
let path = raw;
|
|
387
|
+
const cut = path.search(/[?#]/);
|
|
388
|
+
if (cut >= 0)
|
|
389
|
+
path = path.slice(0, cut);
|
|
390
|
+
if (!path.startsWith("/"))
|
|
391
|
+
return null;
|
|
392
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
393
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
394
|
+
path = path.slice(0, -1);
|
|
395
|
+
return path;
|
|
396
|
+
}
|
|
397
|
+
function nativeRouteSegments(path) {
|
|
398
|
+
return path === "/" ? [] : path.slice(1).split("/");
|
|
399
|
+
}
|
|
400
|
+
function matchesNativeRoute(nativeScreens, raw) {
|
|
401
|
+
const path = normalizeNativeRoutePath(raw);
|
|
402
|
+
if (path === null)
|
|
403
|
+
return false;
|
|
404
|
+
const parts = nativeRouteSegments(path);
|
|
405
|
+
return Object.entries(nativeScreens ?? {}).some(([pattern, screen]) => {
|
|
406
|
+
if (!screen)
|
|
407
|
+
return false;
|
|
408
|
+
const normalized = normalizeNativeRoutePath(pattern);
|
|
409
|
+
if (normalized === null)
|
|
410
|
+
return false;
|
|
411
|
+
const route = nativeRouteSegments(normalized);
|
|
412
|
+
for (let index = 0;index < route.length; index++) {
|
|
413
|
+
const segment = route[index];
|
|
414
|
+
if (segment === "*")
|
|
415
|
+
return index === route.length - 1;
|
|
416
|
+
if (index >= parts.length)
|
|
417
|
+
return false;
|
|
418
|
+
if (segment.startsWith(":")) {
|
|
419
|
+
if (!parts[index])
|
|
420
|
+
return false;
|
|
421
|
+
} else if (segment !== parts[index])
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
return route.length === parts.length;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function nativeStartPath(config) {
|
|
428
|
+
if (!config.devServerURL)
|
|
429
|
+
return "/";
|
|
430
|
+
try {
|
|
431
|
+
const url = new URL(config.devServerURL);
|
|
432
|
+
return `${url.pathname || "/"}${url.search}`;
|
|
433
|
+
} catch {
|
|
434
|
+
return "/";
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function validateNativeRouteConfig(config) {
|
|
438
|
+
const screens = Object.entries(config.nativeScreens ?? {});
|
|
439
|
+
for (const [pattern, screen] of screens) {
|
|
440
|
+
if (normalizeNativeRoutePath(pattern) !== pattern || /[?#]/.test(pattern))
|
|
441
|
+
throw new Error(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
|
|
442
|
+
if (nativeRouteSegments(normalizeNativeRoutePath(pattern) ?? "/").slice(0, -1).includes("*"))
|
|
443
|
+
throw new Error(`nativeScreens path "${pattern}" may only end in *.`);
|
|
444
|
+
if (typeof screen !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(screen))
|
|
445
|
+
throw new Error(`nativeScreens screen "${String(screen)}" for "${pattern}" must be a screen name from the native bundle.`);
|
|
446
|
+
}
|
|
447
|
+
const ids = new Set;
|
|
448
|
+
for (const tab of config.tabs ?? []) {
|
|
449
|
+
if (!tab || typeof tab.id !== "string" || !tab.id || typeof tab.title !== "string" || !tab.title)
|
|
450
|
+
throw new Error("Each tab needs an id (its root path) and a title.");
|
|
451
|
+
if (ids.has(tab.id))
|
|
452
|
+
throw new Error(`Tab "${tab.id}" is listed twice.`);
|
|
453
|
+
ids.add(tab.id);
|
|
454
|
+
}
|
|
455
|
+
if (config.renderer === "native" && screens.length > 0) {
|
|
456
|
+
for (const id of ids) {
|
|
457
|
+
if (!matchesNativeRoute(config.nativeScreens, id))
|
|
458
|
+
throw new Error(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
|
|
459
|
+
}
|
|
460
|
+
const start = nativeStartPath(config);
|
|
461
|
+
if (!matchesNativeRoute(config.nativeScreens, start))
|
|
462
|
+
throw new Error(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
|
|
463
|
+
}
|
|
464
|
+
if (config.nativeApp !== undefined) {
|
|
465
|
+
if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
|
|
466
|
+
throw new Error(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
|
|
467
|
+
if (config.renderer !== "native" && screens.length === 0)
|
|
468
|
+
throw new Error('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
383
471
|
function writeAndroidConfig(output, config) {
|
|
384
472
|
writeFileSync(join2(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
385
473
|
const runtimeConfig = { ...config };
|
|
@@ -601,6 +689,7 @@ async function init(options) {
|
|
|
601
689
|
}
|
|
602
690
|
normalizeAndroidNetworkConfig(config);
|
|
603
691
|
validateAndroidConfig(config);
|
|
692
|
+
validateNativeRouteConfig(config);
|
|
604
693
|
if (config.enablePushNotifications && !config.googleServicesFile) {
|
|
605
694
|
throw new Error("Android push notifications require a googleServicesFile");
|
|
606
695
|
}
|
|
@@ -861,6 +950,7 @@ async function build(options) {
|
|
|
861
950
|
}
|
|
862
951
|
const config = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
863
952
|
validateAndroidConfig(config);
|
|
953
|
+
validateNativeRouteConfig(config);
|
|
864
954
|
if (config.renderer === "native") {
|
|
865
955
|
if (htmlPath || devServer)
|
|
866
956
|
throw new Error("A native Android screen cannot use --html-path or --dev-server.");
|
|
@@ -8,6 +8,7 @@ import androidx.recyclerview.widget.DiffUtil
|
|
|
8
8
|
import androidx.recyclerview.widget.GridLayoutManager
|
|
9
9
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
10
10
|
import androidx.recyclerview.widget.ListAdapter
|
|
11
|
+
import androidx.recyclerview.widget.PagerSnapHelper
|
|
11
12
|
import androidx.recyclerview.widget.RecyclerView
|
|
12
13
|
import org.json.JSONObject
|
|
13
14
|
import kotlin.math.ceil
|
|
@@ -101,6 +102,14 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
101
102
|
private var dataContentSignature = ""
|
|
102
103
|
private var horizontal = false
|
|
103
104
|
private var columns = 1
|
|
105
|
+
private var bounces = true
|
|
106
|
+
private var alwaysBounceVertical = true
|
|
107
|
+
private var alwaysBounceHorizontal = true
|
|
108
|
+
private var scrollEnabled = true
|
|
109
|
+
private var pagingEnabled = false
|
|
110
|
+
private val pagerSnapHelper = PagerSnapHelper()
|
|
111
|
+
private var scrollTargetKey: String? = null
|
|
112
|
+
private var pendingScrollTarget: Pair<String, Boolean>? = null
|
|
104
113
|
private var keyboardShouldPersistTaps = "never"
|
|
105
114
|
private var submissionGeneration = 0
|
|
106
115
|
private var isSubmittingList = false
|
|
@@ -117,6 +126,53 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
117
126
|
LayoutParams.WRAP_CONTENT,
|
|
118
127
|
)
|
|
119
128
|
|
|
129
|
+
fun setScrollIndicators(vertical: Boolean, horizontal: Boolean) {
|
|
130
|
+
isVerticalScrollBarEnabled = vertical
|
|
131
|
+
isHorizontalScrollBarEnabled = horizontal
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fun setScrollEnabled(enabled: Boolean) {
|
|
135
|
+
scrollEnabled = enabled
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
val isScrollEnabledForTest: Boolean
|
|
139
|
+
get() = scrollEnabled
|
|
140
|
+
|
|
141
|
+
fun setPagingEnabled(enabled: Boolean) {
|
|
142
|
+
if (pagingEnabled == enabled) return
|
|
143
|
+
pagingEnabled = enabled
|
|
144
|
+
if (enabled) pagerSnapHelper.attachToRecyclerView(this)
|
|
145
|
+
else pagerSnapHelper.attachToRecyclerView(null)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
val isPagingEnabledForTest: Boolean
|
|
149
|
+
get() = pagingEnabled
|
|
150
|
+
|
|
151
|
+
val isScrollTargetPendingForTest: Boolean
|
|
152
|
+
get() = pendingScrollTarget != null
|
|
153
|
+
|
|
154
|
+
override fun onInterceptTouchEvent(event: android.view.MotionEvent): Boolean {
|
|
155
|
+
if (!scrollEnabled) return false
|
|
156
|
+
return super.onInterceptTouchEvent(event)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
|
|
160
|
+
if (!scrollEnabled) return false
|
|
161
|
+
return super.onTouchEvent(event)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
fun setBounceOptions(enabled: Boolean, vertical: Boolean, horizontal: Boolean) {
|
|
165
|
+
bounces = enabled
|
|
166
|
+
alwaysBounceVertical = vertical
|
|
167
|
+
alwaysBounceHorizontal = horizontal
|
|
168
|
+
applyBounceMode()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun applyBounceMode() {
|
|
172
|
+
val axisEnabled = if (this.horizontal) alwaysBounceHorizontal else alwaysBounceVertical
|
|
173
|
+
overScrollMode = if (bounces && axisEnabled) View.OVER_SCROLL_ALWAYS else View.OVER_SCROLL_NEVER
|
|
174
|
+
}
|
|
175
|
+
|
|
120
176
|
private fun updateVisibleHolderLayoutParams() {
|
|
121
177
|
for (index in 0 until childCount) {
|
|
122
178
|
(getChildViewHolder(getChildAt(index)) as? Holder)?.host?.layoutParams = holderLayoutParams()
|
|
@@ -125,6 +181,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
125
181
|
|
|
126
182
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
127
183
|
super.onLayout(changed, left, top, right, bottom)
|
|
184
|
+
scrollToPendingTarget(0)
|
|
128
185
|
onLayoutChanged?.invoke()
|
|
129
186
|
}
|
|
130
187
|
|
|
@@ -246,6 +303,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
246
303
|
val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
|
|
247
304
|
this.horizontal = horizontal
|
|
248
305
|
this.columns = nextColumns
|
|
306
|
+
applyBounceMode()
|
|
249
307
|
if (layoutChanged || layoutManager == null) {
|
|
250
308
|
configureLayoutManager()
|
|
251
309
|
updateVisibleHolderLayoutParams()
|
|
@@ -280,10 +338,44 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
280
338
|
pendingThemeRefresh = false
|
|
281
339
|
refreshThemeDefaults()
|
|
282
340
|
}
|
|
341
|
+
scrollToPendingTarget(0)
|
|
283
342
|
evaluateEndReached()
|
|
284
343
|
}
|
|
285
344
|
}
|
|
286
345
|
|
|
346
|
+
/** Scroll to a virtualized row identified by its native testID, id, or key. */
|
|
347
|
+
fun setScrollTarget(value: Any?) {
|
|
348
|
+
val target: String
|
|
349
|
+
val animated: Boolean
|
|
350
|
+
val key: String
|
|
351
|
+
when (value) {
|
|
352
|
+
is String -> {
|
|
353
|
+
target = value.trim()
|
|
354
|
+
animated = true
|
|
355
|
+
key = target
|
|
356
|
+
}
|
|
357
|
+
is JSONObject -> {
|
|
358
|
+
target = craftNativeOptionalString(value.opt("id")).orEmpty().trim()
|
|
359
|
+
animated = value.optBoolean("animated", true)
|
|
360
|
+
key = value.toString()
|
|
361
|
+
}
|
|
362
|
+
else -> {
|
|
363
|
+
scrollTargetKey = null
|
|
364
|
+
pendingScrollTarget = null
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (target.isBlank()) {
|
|
369
|
+
scrollTargetKey = null
|
|
370
|
+
pendingScrollTarget = null
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
if (key == scrollTargetKey) return
|
|
374
|
+
scrollTargetKey = key
|
|
375
|
+
pendingScrollTarget = target to animated
|
|
376
|
+
post { scrollToPendingTarget(0) }
|
|
377
|
+
}
|
|
378
|
+
|
|
287
379
|
fun discardAll() {
|
|
288
380
|
listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
|
|
289
381
|
val generation = ++submissionGeneration
|
|
@@ -295,6 +387,9 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
295
387
|
renderItem = null
|
|
296
388
|
recycleItem = null
|
|
297
389
|
endReached = null
|
|
390
|
+
setPagingEnabled(false)
|
|
391
|
+
scrollTargetKey = null
|
|
392
|
+
pendingScrollTarget = null
|
|
298
393
|
dataContentSignature = ""
|
|
299
394
|
onScrollEvent = null
|
|
300
395
|
onScrollBeginDrag = null
|
|
@@ -325,6 +420,28 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
325
420
|
}
|
|
326
421
|
}
|
|
327
422
|
|
|
423
|
+
private fun targetPosition(id: String): Int? = listAdapter.currentList.indexOfFirst { item ->
|
|
424
|
+
val props = item.node.optJSONObject("props")
|
|
425
|
+
listOf(
|
|
426
|
+
props?.optString("testID"),
|
|
427
|
+
props?.optString("id"),
|
|
428
|
+
item.node.optString("id"),
|
|
429
|
+
item.identity,
|
|
430
|
+
).any { it == id }
|
|
431
|
+
}.takeIf { it >= 0 }
|
|
432
|
+
|
|
433
|
+
private fun scrollToPendingTarget(attempt: Int) {
|
|
434
|
+
val target = pendingScrollTarget ?: return
|
|
435
|
+
val position = targetPosition(target.first)
|
|
436
|
+
if (position == null || layoutManager == null || width <= 0 || height <= 0) {
|
|
437
|
+
if (attempt < 4) postDelayed({ scrollToPendingTarget(attempt + 1) }, 16L)
|
|
438
|
+
else if (!isSubmittingList) pendingScrollTarget = null
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
pendingScrollTarget = null
|
|
442
|
+
if (target.second) smoothScrollToPosition(position) else scrollToPosition(position)
|
|
443
|
+
}
|
|
444
|
+
|
|
328
445
|
private fun evaluateEndReached() {
|
|
329
446
|
if (isSubmittingList) return
|
|
330
447
|
val manager = layoutManager as? LinearLayoutManager ?: return
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package {{PACKAGE_NAME}}
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import org.json.JSONObject
|
|
4
5
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
5
6
|
|
|
6
7
|
/** Swipe-to-refresh host for a native FlatList. */
|
|
@@ -21,6 +22,12 @@ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLa
|
|
|
21
22
|
if (handler == null) isRefreshing = false
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
fun setRefreshAppearance(props: JSONObject, fallback: Int) {
|
|
26
|
+
val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
|
|
27
|
+
val resolved = tint?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
|
|
28
|
+
setColorSchemeColors(resolved)
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
override fun onDetachedFromWindow() {
|
|
25
32
|
refreshHandler = null
|
|
26
33
|
isRefreshing = false
|
|
@@ -115,13 +115,15 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
115
115
|
val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
|
|
116
116
|
val siteOrigin: String? = site?.let { uri ->
|
|
117
117
|
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
118
|
-
"${uri.scheme}://${uri.host}$port"
|
|
118
|
+
"${uri.scheme?.lowercase()}://${uri.host?.lowercase()}$port"
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** Where the app opens: the site's path when a screen draws it, else the first tab. */
|
|
122
|
-
val initialPath: String = site?.
|
|
123
|
-
|
|
124
|
-
?: "
|
|
122
|
+
val initialPath: String = site?.let { uri ->
|
|
123
|
+
val path = uri.encodedPath?.takeIf { it.isNotBlank() } ?: "/"
|
|
124
|
+
val query = uri.encodedQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: ""
|
|
125
|
+
(path + query).takeIf { match(it) != null }
|
|
126
|
+
} ?: tabs.firstOrNull()?.id ?: "/"
|
|
125
127
|
|
|
126
128
|
val isEmpty: Boolean get() = routes.isEmpty()
|
|
127
129
|
|
|
@@ -148,7 +150,7 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
148
150
|
}
|
|
149
151
|
val origin = siteOrigin ?: return null
|
|
150
152
|
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
151
|
-
if ("${
|
|
153
|
+
if ("${scheme}://${uri.host?.lowercase()}$port" != origin) return null
|
|
152
154
|
return (uri.encodedPath ?: "/") + (uri.encodedQuery?.let { "?$it" } ?: "")
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -161,7 +163,7 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
161
163
|
val params = bind(route.segments, parts) ?: continue
|
|
162
164
|
if (query.isNotBlank()) {
|
|
163
165
|
query.split('&').filter { it.isNotBlank() }.forEach { pair ->
|
|
164
|
-
val key = decode(pair.substringBefore('='))
|
|
166
|
+
val key = Uri.decode(pair.substringBefore('='))
|
|
165
167
|
if (key.isNotBlank() && !params.has(key)) params.put(key, decode(pair.substringAfter('=', "").replace('+', ' ')))
|
|
166
168
|
}
|
|
167
169
|
}
|
|
@@ -181,7 +183,7 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
181
183
|
if (index >= parts.size) return null
|
|
182
184
|
if (expected.startsWith(":")) {
|
|
183
185
|
if (parts[index].isBlank()) return null
|
|
184
|
-
params.put(expected.removePrefix(":"), decode(parts[index]))
|
|
186
|
+
params.put(expected.removePrefix(":"), Uri.decode(parts[index]))
|
|
185
187
|
} else if (expected != parts[index]) return null
|
|
186
188
|
}
|
|
187
189
|
return if (pattern.size == parts.size) params else null
|
|
@@ -580,7 +582,9 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
580
582
|
private val tabIconText = mutableMapOf<String, String>()
|
|
581
583
|
private val tabImageURLs = mutableMapOf<String, String>()
|
|
582
584
|
private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
|
|
585
|
+
private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
|
|
583
586
|
private val density = resources.displayMetrics.density
|
|
587
|
+
private var screenBackgroundColor: Int? = null
|
|
584
588
|
private var selectedTab = 0
|
|
585
589
|
private var swipeTracking = false
|
|
586
590
|
private var swipeIntercepted = false
|
|
@@ -710,6 +714,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
710
714
|
|
|
711
715
|
/** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
|
|
712
716
|
fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
|
|
717
|
+
screenBackgroundColor = options.opt("backgroundColor")
|
|
718
|
+
?.takeUnless { it == JSONObject.NULL }
|
|
719
|
+
?.let { craftNativeOptionalString(it) }
|
|
720
|
+
?.let { runCatching { craftNativeColor(it) }.getOrNull() }
|
|
713
721
|
options.optJSONObject("tabItem")?.let(::updateTab)
|
|
714
722
|
val hidden = options.optBoolean("hidden", false)
|
|
715
723
|
val large = options.optBoolean("largeTitle", false) && depth == 0
|
|
@@ -722,6 +730,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
722
730
|
largeTitleView.visibility = if (!hidden && large && title.isNotBlank()) View.VISIBLE else View.GONE
|
|
723
731
|
buttons.removeAllViews()
|
|
724
732
|
leadingButtons.removeAllViews()
|
|
733
|
+
navButtonSpecs.clear()
|
|
725
734
|
val leading = options.optJSONArray("leftButtons") ?: JSONArray()
|
|
726
735
|
for (index in 0 until leading.length()) {
|
|
727
736
|
val button = leading.optJSONObject(index) ?: continue
|
|
@@ -735,6 +744,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
735
744
|
val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
|
|
736
745
|
tabBar.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
|
|
737
746
|
tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
|
|
747
|
+
refreshColors()
|
|
738
748
|
}
|
|
739
749
|
|
|
740
750
|
fun selectTab(index: Int) {
|
|
@@ -780,13 +790,14 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
780
790
|
|
|
781
791
|
/** Light or dark changed: the bars follow the screen's background. */
|
|
782
792
|
fun refreshColors() {
|
|
783
|
-
val background = ContextCompat.getColor(context, R.color.background)
|
|
793
|
+
val background = screenBackgroundColor ?: ContextCompat.getColor(context, R.color.background)
|
|
784
794
|
val dark = Color.luminance(background) < 0.5
|
|
785
795
|
val text = if (dark) Color.WHITE else Color.BLACK
|
|
786
796
|
val muted = if (dark) Color.parseColor("#8E8E93") else Color.parseColor("#6C6C70")
|
|
787
797
|
val accent = accentColor(dark)
|
|
788
798
|
setBackgroundColor(background)
|
|
789
799
|
bar.setBackgroundColor(background)
|
|
800
|
+
largeTitleView.setBackgroundColor(background)
|
|
790
801
|
titleView.setTextColor(text)
|
|
791
802
|
largeTitleView.setTextColor(text)
|
|
792
803
|
backButton.imageTintList = ColorStateList.valueOf(accent)
|
|
@@ -806,6 +817,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
806
817
|
icon.foreground = vector
|
|
807
818
|
icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
|
|
808
819
|
}
|
|
820
|
+
navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
|
|
809
821
|
}
|
|
810
822
|
|
|
811
823
|
private fun navButton(button: JSONObject): View {
|
|
@@ -814,8 +826,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
814
826
|
val symbol = button.optString("symbol")
|
|
815
827
|
val view = TextView(context)
|
|
816
828
|
val dark = Color.luminance(ContextCompat.getColor(context, R.color.background)) < 0.5
|
|
817
|
-
val
|
|
818
|
-
val tint = button.optString("color").takeIf { it.isNotBlank() }?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: defaultTint
|
|
829
|
+
val tint = navButtonTint(button, dark)
|
|
819
830
|
val vector = if (symbol.isNotBlank()) CraftNativeIcons.drawable(context, null, symbol, tint) else null
|
|
820
831
|
view.text = if (vector != null) "" else if (symbol.isNotBlank()) CraftNativeSymbols.glyph(symbol) else title
|
|
821
832
|
view.textSize = if (symbol.isNotBlank()) 22f else 16f
|
|
@@ -829,6 +840,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
829
840
|
view.background = rippleBackground()
|
|
830
841
|
view.contentDescription = button.optString("accessibilityLabel").ifBlank { title.ifBlank { symbol } }
|
|
831
842
|
view.setTextColor(tint)
|
|
843
|
+
navButtonSpecs[view] = button
|
|
832
844
|
val badge = button.optInt("badge", 0)
|
|
833
845
|
if (badge > 0) {
|
|
834
846
|
view.text = "${view.text}${if (button.optString("badgeStyle") == "dot") " •" else " $badge"}"
|
|
@@ -838,6 +850,26 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
838
850
|
return view
|
|
839
851
|
}
|
|
840
852
|
|
|
853
|
+
private fun navButtonTint(button: JSONObject, dark: Boolean): Int {
|
|
854
|
+
val defaultTint = if (button.optString("variant") == "neutral") {
|
|
855
|
+
if (dark) Color.WHITE else Color.parseColor("#1C1C1E")
|
|
856
|
+
} else accentColor(dark)
|
|
857
|
+
return button.optString("color").takeIf { it.isNotBlank() }?.let {
|
|
858
|
+
runCatching { craftNativeColor(it) }.getOrNull()
|
|
859
|
+
} ?: defaultTint
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private fun applyNavButtonTint(view: TextView, button: JSONObject, dark: Boolean) {
|
|
863
|
+
val tint = navButtonTint(button, dark)
|
|
864
|
+
view.setTextColor(tint)
|
|
865
|
+
val symbol = button.optString("symbol")
|
|
866
|
+
if (symbol.isNotBlank()) {
|
|
867
|
+
CraftNativeIcons.drawable(context, null, symbol, tint)?.let {
|
|
868
|
+
view.foreground = android.graphics.drawable.InsetDrawable(it, dp(10), dp(15), dp(10), dp(15))
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
841
873
|
private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
|
|
842
874
|
|
|
843
875
|
private fun rippleBackground(): android.graphics.drawable.Drawable? {
|