craft-native 0.0.123 → 0.0.125

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/README.md CHANGED
@@ -470,6 +470,9 @@ should use the canonical spellings. A capability disabled by app configuration
470
470
  is distinct from a user denying permission, and `BUSY` is distinct from a failed
471
471
  native call.
472
472
 
473
+ On iOS, `window.addEventListener('craftTabBarLayout', ...)` reports the native
474
+ tab bar's current height as `{ detail: { height } }` in CSS pixels.
475
+
473
476
  ## Requirements
474
477
 
475
478
  - Bun >= 1.0.0 (for development)
@@ -9,10 +9,13 @@ export interface CraftAndroidConfig {
9
9
  appName: string;
10
10
  packageName: string;
11
11
  renderer?: 'web' | 'native';
12
+ /** "system" follows the device; light/dark pin the generated host. */
13
+ appearance?: 'light' | 'dark' | 'system';
12
14
  version?: string;
13
15
  versionCode?: number;
14
16
  darkMode?: boolean;
15
17
  backgroundColor?: string;
18
+ backgroundColorDark?: string;
16
19
  enableSpeechRecognition?: boolean;
17
20
  enableHaptics?: boolean;
18
21
  enableShare?: boolean;
@@ -216,6 +216,9 @@ function validateAndroidConfig(config) {
216
216
  if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
217
217
  throw new Error(`Unknown Android renderer: ${config.renderer}. Expected web or native.`);
218
218
  }
219
+ if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
220
+ throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
221
+ }
219
222
  const packageSegments = config.packageName.split(".");
220
223
  if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
221
224
  throw new Error(`Invalid Android package name: ${config.packageName}`);
@@ -223,6 +226,9 @@ function validateAndroidConfig(config) {
223
226
  if (!/^#(?:[\dA-F]{3,4}|[\dA-F]{6}|[\dA-F]{8})$/i.test(config.backgroundColor ?? "")) {
224
227
  throw new Error(`Invalid Android background color: ${config.backgroundColor}`);
225
228
  }
229
+ if (config.backgroundColorDark !== undefined && !config.backgroundColorDark.match(/^#[\dA-F]{3,4}$|^#[\dA-F]{6}$|^#[\dA-F]{8}$/i)) {
230
+ throw new Error(`Invalid Android dark background color: ${config.backgroundColorDark}`);
231
+ }
226
232
  for (const [name, value] of [
227
233
  ["versionCode", config.versionCode],
228
234
  ["minSdk", config.minSdk],
@@ -395,6 +401,7 @@ async function init(options) {
395
401
  join(output, "app/src/main/java", packagePath),
396
402
  join(output, "app/src/main/res/layout"),
397
403
  join(output, "app/src/main/res/values"),
404
+ join(output, "app/src/main/res/values-night"),
398
405
  join(output, "app/src/main/res/drawable"),
399
406
  join(output, "app/src/main/assets"),
400
407
  join(output, "gradle/wrapper")
@@ -418,10 +425,12 @@ async function init(options) {
418
425
  if (config.renderer === "native") {
419
426
  const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
420
427
  const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
428
+ const refreshableFlatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeRefreshableFlatList.kt.template"), "utf-8");
421
429
  const capabilityTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
422
430
  const notificationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
423
431
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
424
432
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
433
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
425
434
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
426
435
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
427
436
  }
@@ -468,7 +477,7 @@ async function init(options) {
468
477
  const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
469
478
  writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
470
479
  const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
471
- const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
480
+ const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
472
481
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.javascriptengine:javascriptengine:1.1.0")' : "");
473
482
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
474
483
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
@@ -508,6 +517,16 @@ zipStorePath=wrapper/dists
508
517
  </resources>
509
518
  `;
510
519
  writeFileSync(join(output, "app/src/main/res/values/colors.xml"), colorsXml);
520
+ const darkColorsPath = join(output, "app/src/main/res/values-night/colors.xml");
521
+ if (config.backgroundColorDark) {
522
+ writeFileSync(darkColorsPath, `<?xml version="1.0" encoding="utf-8"?>
523
+ <resources>
524
+ <color name="background">${config.backgroundColorDark}</color>
525
+ </resources>
526
+ `);
527
+ } else {
528
+ rmSync(darkColorsPath, { force: true });
529
+ }
511
530
  const appIconXml = `<?xml version="1.0" encoding="utf-8"?>
512
531
  <vector xmlns:android="http://schemas.android.com/apk/res/android"
513
532
  android:width="108dp"
@@ -601,7 +620,7 @@ zipStorePath=wrapper/dists
601
620
  console.log("Next steps:");
602
621
  console.log(` 1. cd ${output}`);
603
622
  if (config.renderer === "native") {
604
- console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
623
+ console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
605
624
  console.log(" 3. Run: craft android build --native-bundle screen.js");
606
625
  } else {
607
626
  console.log(" 2. Add your web content to app/src/main/assets/index.html");
@@ -627,7 +646,7 @@ async function build(options) {
627
646
  if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
628
647
  cpSync(nativeBundlePath, destination);
629
648
  if (!existsSync(destination)) {
630
- throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with stx-native first.");
649
+ throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
631
650
  }
632
651
  } else if (nativeBundlePath) {
633
652
  throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
@@ -33,7 +33,7 @@
33
33
  <activity
34
34
  android:name=".MainActivity"
35
35
  android:exported="true"
36
- android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
36
+ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
37
37
  android:windowSoftInputMode="adjustResize"
38
38
  android:launchMode="singleTask">
39
39
  <intent-filter>
@@ -14,7 +14,7 @@ import kotlin.math.ceil
14
14
 
15
15
  internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
16
16
 
17
- /** A stable-key RecyclerView host for stx-native FlatList nodes. */
17
+ /** A stable-key RecyclerView host for stx native FlatList nodes. */
18
18
  internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
19
19
  private data class Item(
20
20
  val identity: String,
@@ -37,12 +37,16 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
37
37
  override fun getItemId(position: Int): Long = stableId(getItem(position).identity)
38
38
 
39
39
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(FrameLayout(parent.context).apply {
40
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
40
+ layoutParams = LayoutParams(
41
+ if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
42
+ LayoutParams.WRAP_CONTENT,
43
+ )
41
44
  })
42
45
 
43
46
  override fun onBindViewHolder(holder: Holder, position: Int) {
44
47
  val item = getItem(position)
45
48
  if (holder.identity != null && holder.identity != item.identity) release(holder)
49
+ holder.host.layoutParams = holderLayoutParams()
46
50
  val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
47
51
  val renderedLayout = view.layoutParams
48
52
  val hostedLayout = FrameLayout.LayoutParams(
@@ -83,19 +87,134 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
83
87
  private var endReached: (() -> Unit)? = null
84
88
  private var threshold = 0.1
85
89
  private var endReachedSignature: String? = null
90
+ private var dataContentSignature = ""
86
91
  private var horizontal = false
87
92
  private var columns = 1
93
+ private var keyboardShouldPersistTaps = "never"
88
94
  private var submissionGeneration = 0
89
95
  private var isSubmittingList = false
96
+ private var pendingThemeRefresh = false
97
+ var onScrollEvent: (() -> Unit)? = null
98
+ var onScrollBeginDrag: (() -> Unit)? = null
99
+ var onScrollEndDrag: (() -> Unit)? = null
100
+ var onMomentumScrollBegin: (() -> Unit)? = null
101
+ var onMomentumScrollEnd: (() -> Unit)? = null
102
+ var onLayoutChanged: (() -> Unit)? = null
103
+
104
+ private fun holderLayoutParams() = LayoutParams(
105
+ if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
106
+ LayoutParams.WRAP_CONTENT,
107
+ )
108
+
109
+ private fun updateVisibleHolderLayoutParams() {
110
+ for (index in 0 until childCount) {
111
+ (getChildViewHolder(getChildAt(index)) as? Holder)?.host?.layoutParams = holderLayoutParams()
112
+ }
113
+ }
114
+
115
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
116
+ super.onLayout(changed, left, top, right, bottom)
117
+ onLayoutChanged?.invoke()
118
+ }
90
119
 
91
120
  init {
92
121
  adapter = listAdapter
93
122
  itemAnimator = null
94
123
  addOnScrollListener(object : OnScrollListener() {
95
- override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = evaluateEndReached()
124
+ private var dragging = false
125
+ private var momentum = false
126
+
127
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
128
+ onScrollEvent?.invoke()
129
+ onLayoutChanged?.invoke()
130
+ evaluateEndReached()
131
+ }
132
+
133
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
134
+ when (newState) {
135
+ RecyclerView.SCROLL_STATE_DRAGGING -> if (!dragging) {
136
+ dragging = true
137
+ onScrollBeginDrag?.invoke()
138
+ }
139
+ RecyclerView.SCROLL_STATE_SETTLING -> {
140
+ if (dragging) {
141
+ dragging = false
142
+ onScrollEndDrag?.invoke()
143
+ }
144
+ if (!momentum) {
145
+ momentum = true
146
+ onMomentumScrollBegin?.invoke()
147
+ }
148
+ }
149
+ RecyclerView.SCROLL_STATE_IDLE -> {
150
+ if (dragging) {
151
+ dragging = false
152
+ onScrollEndDrag?.invoke()
153
+ }
154
+ if (momentum) {
155
+ momentum = false
156
+ onMomentumScrollEnd?.invoke()
157
+ }
158
+ }
159
+ }
160
+ }
96
161
  })
97
162
  }
98
163
 
164
+ fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
165
+ if (mode !in setOf("on-drag", "interactive") && keyboardShouldPersistTaps == "always") {
166
+ setOnTouchListener(null)
167
+ return
168
+ }
169
+ var dismissed = false
170
+ setOnTouchListener { _, event ->
171
+ when (event.actionMasked) {
172
+ android.view.MotionEvent.ACTION_DOWN -> dismissed = false
173
+ android.view.MotionEvent.ACTION_MOVE -> if (!dismissed) {
174
+ dismissed = true
175
+ dismissKeyboard()
176
+ }
177
+ android.view.MotionEvent.ACTION_UP -> if (shouldDismissKeyboard(event)) dismissKeyboard()
178
+ }
179
+ false
180
+ }
181
+ }
182
+
183
+ fun setKeyboardShouldPersistTaps(value: String?) {
184
+ keyboardShouldPersistTaps = value?.takeIf { it in setOf("always", "handled", "never") } ?: "never"
185
+ }
186
+
187
+ private fun tapTarget(view: View, x: Float, y: Float): View? {
188
+ if (x < 0 || y < 0 || x >= view.width || y >= view.height || view.visibility != View.VISIBLE) return null
189
+ if (view is ViewGroup) {
190
+ for (index in view.childCount - 1 downTo 0) {
191
+ val child = view.getChildAt(index)
192
+ tapTarget(child, x - child.left, y - child.top)?.let { return it }
193
+ }
194
+ }
195
+ return view
196
+ }
197
+
198
+ private fun shouldDismissKeyboard(event: android.view.MotionEvent): Boolean {
199
+ if (keyboardShouldPersistTaps == "always") return false
200
+ if (keyboardShouldPersistTaps == "never") return true
201
+ return tapTarget(this, event.x, event.y)?.isClickable != true
202
+ }
203
+
204
+ fun setContentContainerStyle(style: JSONObject?, density: Float) {
205
+ val padding = (style?.optDouble("padding", 0.0) ?: 0.0) * density
206
+ val horizontal = (style?.optDouble("paddingHorizontal", padding / density) ?: (padding / density)) * density
207
+ val vertical = (style?.optDouble("paddingVertical", padding / density) ?: (padding / density)) * density
208
+ setPadding(
209
+ ((style?.optDouble("paddingLeft", horizontal / density) ?: (horizontal / density)) * density).toInt(),
210
+ ((style?.optDouble("paddingTop", vertical / density) ?: (vertical / density)) * density).toInt(),
211
+ ((style?.optDouble("paddingRight", horizontal / density) ?: (horizontal / density)) * density).toInt(),
212
+ ((style?.optDouble("paddingBottom", vertical / density) ?: (vertical / density)) * density).toInt(),
213
+ )
214
+ clipToPadding = true
215
+ requestLayout()
216
+ }
217
+
99
218
  fun apply(
100
219
  nodes: List<JSONObject>,
101
220
  horizontal: Boolean,
@@ -116,7 +235,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
116
235
  val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
117
236
  this.horizontal = horizontal
118
237
  this.columns = nextColumns
119
- if (layoutChanged || layoutManager == null) configureLayoutManager()
238
+ if (layoutChanged || layoutManager == null) {
239
+ configureLayoutManager()
240
+ updateVisibleHolderLayoutParams()
241
+ }
120
242
 
121
243
  val seen = mutableMapOf<String, Int>()
122
244
  val next = nodes.mapIndexed { index, node ->
@@ -132,7 +254,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
132
254
  val nextIds = next.mapTo(mutableSetOf()) { it.identity }
133
255
  (oldIds - nextIds).forEach(recycleItem)
134
256
  val dataItems = next.filterNot { it.chrome }
135
- val contentSignature = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
257
+ val contentSignature = dataItems.joinToString("") {
258
+ "${it.identity.length}:${it.identity}${it.signature.length}:${it.signature}"
259
+ }
260
+ dataContentSignature = contentSignature
136
261
  if (contentSignature != endReachedSignature) endReachedSignature = null
137
262
  val generation = ++submissionGeneration
138
263
  isSubmittingList = true
@@ -140,6 +265,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
140
265
  if (generation != submissionGeneration) return@submitList
141
266
  retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
142
267
  isSubmittingList = false
268
+ if (pendingThemeRefresh) {
269
+ pendingThemeRefresh = false
270
+ refreshThemeDefaults()
271
+ }
143
272
  evaluateEndReached()
144
273
  }
145
274
  }
@@ -148,12 +277,28 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
148
277
  listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
149
278
  val generation = ++submissionGeneration
150
279
  isSubmittingList = true
280
+ pendingThemeRefresh = false
151
281
  listAdapter.submitList(emptyList()) {
152
282
  if (generation == submissionGeneration) isSubmittingList = false
153
283
  }
154
284
  renderItem = null
155
285
  recycleItem = null
156
286
  endReached = null
287
+ dataContentSignature = ""
288
+ onScrollEvent = null
289
+ onScrollBeginDrag = null
290
+ onScrollEndDrag = null
291
+ onMomentumScrollBegin = null
292
+ onMomentumScrollEnd = null
293
+ onLayoutChanged = null
294
+ }
295
+
296
+ fun refreshThemeDefaults() {
297
+ if (isSubmittingList) {
298
+ pendingThemeRefresh = true
299
+ return
300
+ }
301
+ if (listAdapter.itemCount > 0) listAdapter.notifyItemRangeChanged(0, listAdapter.itemCount)
157
302
  }
158
303
 
159
304
  private fun configureLayoutManager() {
@@ -179,8 +324,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
179
324
  val remaining = dataPositions.size - lastDataOrdinal - 1
180
325
  val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
181
326
  if (remaining > thresholdItems) return
182
- val lastIdentity = listAdapter.currentList[dataPositions.last()].identity
183
- val signature = "${dataPositions.size}:$lastIdentity"
327
+ val signature = dataContentSignature
184
328
  if (endReachedSignature == signature) return
185
329
  endReachedSignature = signature
186
330
  endReached?.invoke()
@@ -0,0 +1,29 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
5
+
6
+ /** Swipe-to-refresh host for a native FlatList. */
7
+ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLayout(context) {
8
+ val list = CraftNativeFlatList(context)
9
+ private var refreshHandler: (() -> Unit)? = null
10
+
11
+ init {
12
+ addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
13
+ setOnRefreshListener { refreshHandler?.invoke() }
14
+ isEnabled = false
15
+ }
16
+
17
+ fun configureRefresh(handler: (() -> Unit)?, refreshing: Boolean) {
18
+ refreshHandler = handler
19
+ isEnabled = handler != null
20
+ isRefreshing = handler != null && refreshing
21
+ if (handler == null) isRefreshing = false
22
+ }
23
+
24
+ override fun onDetachedFromWindow() {
25
+ refreshHandler = null
26
+ isRefreshing = false
27
+ super.onDetachedFromWindow()
28
+ }
29
+ }
@@ -17,6 +17,7 @@ import android.webkit.WebSettings
17
17
  import android.webkit.WebView
18
18
  import android.webkit.WebViewClient
19
19
  import androidx.appcompat.app.AppCompatActivity
20
+ import androidx.appcompat.app.AppCompatDelegate
20
21
  import androidx.core.view.WindowCompat
21
22
  import androidx.webkit.WebViewAssetLoader
22
23
  import org.json.JSONObject
@@ -75,6 +76,7 @@ class MainActivity : AppCompatActivity() {
75
76
 
76
77
  @SuppressLint("SetJavaScriptEnabled")
77
78
  override fun onCreate(savedInstanceState: Bundle?) {
79
+ applyConfiguredAppearance()
78
80
  super.onCreate(savedInstanceState)
79
81
 
80
82
  // Enable edge-to-edge
@@ -111,6 +113,21 @@ class MainActivity : AppCompatActivity() {
111
113
  setupNetworkMonitoring()
112
114
  }
113
115
 
116
+ private fun applyConfiguredAppearance() {
117
+ val configured = configJson()
118
+ val mode = when (configured?.optString("appearance")) {
119
+ "light" -> AppCompatDelegate.MODE_NIGHT_NO
120
+ "dark" -> AppCompatDelegate.MODE_NIGHT_YES
121
+ "system" -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
122
+ else -> if (configured?.optBoolean("darkMode", true) == false) {
123
+ AppCompatDelegate.MODE_NIGHT_NO
124
+ } else {
125
+ AppCompatDelegate.MODE_NIGHT_YES
126
+ }
127
+ }
128
+ AppCompatDelegate.setDefaultNightMode(mode)
129
+ }
130
+
114
131
  @SuppressLint("SetJavaScriptEnabled")
115
132
  private fun setupWebView() {
116
133
  webView.settings.apply {