peachsim 0.1.666 → 0.1.668

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist-lib/agent-daemon-client.cjs +1 -1
  2. package/dist-lib/agent-events.cjs +1 -1
  3. package/dist-lib/agent-identity.cjs +1 -1
  4. package/dist-lib/agent-sessions.cjs +1 -1
  5. package/dist-lib/attached-projects.cjs +1 -1
  6. package/dist-lib/auth/shared-session.cjs +1 -1
  7. package/dist-lib/backend-origin.cjs +1 -1
  8. package/dist-lib/beta.cjs +1 -1
  9. package/dist-lib/beta.mjs +1 -1
  10. package/dist-lib/bridge-constants.cjs +1 -1
  11. package/dist-lib/bridge-contract-input.cjs +1 -1
  12. package/dist-lib/bridge-contract-input.mjs +1 -1
  13. package/dist-lib/bridge-contract.cjs +1 -1
  14. package/dist-lib/bridge-contract.mjs +1 -1
  15. package/dist-lib/capture-contract.cjs +1 -1
  16. package/dist-lib/capture-contract.mjs +1 -1
  17. package/dist-lib/cli-constants.cjs +1 -1
  18. package/dist-lib/cloud-contract.cjs +1 -1
  19. package/dist-lib/cloud-contract.mjs +1 -1
  20. package/dist-lib/cloud-test-args.cjs +1 -1
  21. package/dist-lib/cloud-test-args.mjs +1 -1
  22. package/dist-lib/cloud.cjs +1 -1
  23. package/dist-lib/cloud.mjs +1 -1
  24. package/dist-lib/config.cjs +1 -1
  25. package/dist-lib/detox/index.cjs +1 -1
  26. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  27. package/dist-lib/home-paths.cjs +1 -1
  28. package/dist-lib/host/bridge-host.cjs +9 -3
  29. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  30. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  31. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  32. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  33. package/dist-lib/host/websocket-proxy.cjs +1 -1
  34. package/dist-lib/index.cjs +1 -1
  35. package/dist-lib/jump-to-source-babel.cjs +1 -1
  36. package/dist-lib/jump-to-source-native.cjs +1 -1
  37. package/dist-lib/kotlin-host.cjs +4 -1
  38. package/dist-lib/menu.cjs +1 -1
  39. package/dist-lib/menu.mjs +1 -1
  40. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  42. package/dist-lib/metro-production-bundle.cjs +1 -1
  43. package/dist-lib/metro-production-bundle.mjs +1 -1
  44. package/dist-lib/metro.cjs +1 -1
  45. package/dist-lib/native-app-identity.cjs +1 -1
  46. package/dist-lib/native-app-identity.mjs +1 -1
  47. package/dist-lib/plist.cjs +1 -1
  48. package/dist-lib/plist.mjs +1 -1
  49. package/dist-lib/profiles.cjs +1 -1
  50. package/dist-lib/public-brand.cjs +1 -1
  51. package/dist-lib/react-native-host-modules.cjs +1 -1
  52. package/dist-lib/react-native-host-modules.mjs +1 -1
  53. package/dist-lib/render-mode.cjs +1 -1
  54. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  55. package/dist-lib/sdk.cjs +1 -1
  56. package/dist-lib/sdk.mjs +1 -1
  57. package/dist-lib/skills.cjs +22 -19
  58. package/dist-lib/swift-compile.cjs +1 -1
  59. package/dist-lib/swift-compile.mjs +1 -1
  60. package/dist-lib/swift-package.cjs +1 -1
  61. package/dist-lib/swift-package.mjs +1 -1
  62. package/dist-lib/swift-submit.cjs +1 -1
  63. package/dist-lib/swift-submit.mjs +1 -1
  64. package/dist-lib/swift.cjs +1 -1
  65. package/dist-lib/vite-swift.cjs +1 -1
  66. package/dist-lib/vite.cjs +1 -1
  67. package/kotlin/AppSources/dev/peach/app/PeachApp.kt +15 -3
  68. package/kotlin/ComposeSources/PeachComposeHost.kt +200 -0
  69. package/kotlin/Sources/org/json/JSON.kt +13 -0
  70. package/kotlin/fixtures/kotlin-app-variants/Anim.kt +62 -0
  71. package/kotlin/fixtures/kotlin-app-variants/Controls.kt +62 -0
  72. package/kotlin/fixtures/kotlin-app-variants/Drag.kt +56 -0
  73. package/kotlin/fixtures/kotlin-app-variants/Draw.kt +74 -0
  74. package/kotlin/fixtures/kotlin-app-variants/Form.kt +47 -0
  75. package/kotlin/fixtures/kotlin-app-variants/Ime.kt +51 -0
  76. package/kotlin/fixtures/kotlin-app-variants/Note.kt +40 -0
  77. package/package.json +1 -1
  78. package/src/expo-swift-host.ts +6 -2
  79. package/src/kotlin-host.ts +3 -0
@@ -68,8 +68,9 @@ internal fun peachApplication(): Application = runningApp().application
68
68
  // them on an edge-to-edge window, none once the decor fits system windows.
69
69
  // insets lists the status bars, navigation bars, display cutout, system
70
70
  // gestures, mandatory system gestures and tappable element, each left, top,
71
- // right and bottom.
72
- private class DeviceInsets(private val window: Window, insets: String) : PlatformWindowInsets {
71
+ // right and bottom. the IME's inset is the keyboard's height above the
72
+ // screen's bottom edge, which moves with the keyboard frame by frame.
73
+ private class DeviceInsets(private val window: Window, insets: String, imeBottom: () -> Int) : PlatformWindowInsets {
73
74
  private val pixels = insets.split(',').map { it.toInt() }.also { require(it.size == 24) }
74
75
 
75
76
  // read when compose lays out, so a later change to the window applies
@@ -84,6 +85,7 @@ private class DeviceInsets(private val window: Window, insets: String) : Platfor
84
85
  override val systemGestures = type(3)
85
86
  override val mandatorySystemGestures = type(4)
86
87
  override val tappableElement = type(5)
88
+ override val ime = PlatformInsets({ 0 }, { 0 }, { 0 }, { if (window.fitsSystemWindows) 0 else imeBottom() })
87
89
  override val systemBars = PlatformInsets(
88
90
  { maxOf(statusBars.left, navigationBars.left) },
89
91
  { maxOf(statusBars.top, navigationBars.top) },
@@ -127,6 +129,8 @@ private class AppProcess(
127
129
  // the night mode last reported to the activity: ActivityRecord compares
128
130
  // the configuration with it before a relaunch or onConfigurationChanged
129
131
  var reportedNight = false
132
+ // the software keyboard's height above the screen's bottom edge, in pixels
133
+ var ime by mutableIntStateOf(0)
130
134
  }
131
135
 
132
136
  private var process: AppProcess? = null
@@ -228,7 +232,7 @@ private fun launchActivity(app: AppProcess, lastNonConfigurationInstance: Any?):
228
232
  app.density, app.fontScale, app.locale, app.gpu, app.stencilBits, app.fonts, app.invalidate,
229
233
  night = isNight(activity.resources.configuration),
230
234
  owners = componentActivity?.let(::ActivityOwners),
231
- insets = DeviceInsets(window, app.insets),
235
+ insets = DeviceInsets(window, app.insets) { app.ime },
232
236
  clockRunning = { componentActivity == null || componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) },
233
237
  )
234
238
  mount.setContent {
@@ -322,6 +326,14 @@ private fun reportConfiguration(app: AppProcess): Boolean {
322
326
  private fun isNight(configuration: Configuration) =
323
327
  configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
324
328
 
329
+ // the software keyboard moved: its inset reaches the window's content
330
+ internal fun peachAppIme(bottom: Int) {
331
+ val app = runningApp()
332
+ if (app.ime == bottom) return
333
+ app.ime = bottom
334
+ app.invalidate(true)
335
+ }
336
+
325
337
  // the device's night mode changed. the process's configuration changes in
326
338
  // place and the Application hears it first; a visible activity then takes it.
327
339
  // a stopped activity takes it once visible again, if it still differs.
@@ -9,9 +9,13 @@ import androidx.compose.runtime.setValue
9
9
  import androidx.compose.runtime.snapshots.Snapshot
10
10
  import androidx.compose.ui.LocalSystemTheme
11
11
  import androidx.compose.ui.geometry.Offset
12
+ import androidx.compose.ui.geometry.Rect
12
13
  import androidx.compose.ui.graphics.asComposeCanvas
13
14
  import androidx.compose.ui.input.InputMode
14
15
  import androidx.compose.ui.input.InputModeManager
16
+ import androidx.compose.ui.input.key.Key
17
+ import androidx.compose.ui.input.key.KeyEvent
18
+ import androidx.compose.ui.input.key.KeyEventType
15
19
  import androidx.compose.ui.input.pointer.PointerEventType
16
20
  import androidx.compose.ui.input.pointer.PointerType
17
21
  import androidx.compose.ui.platform.FrameRecomposer
@@ -19,6 +23,7 @@ import androidx.compose.ui.platform.LocalFontFamilyResolver
19
23
  import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner
20
24
  import androidx.compose.ui.platform.PlatformWindowInsets
21
25
  import androidx.compose.ui.platform.PlatformContext
26
+ import androidx.compose.ui.platform.PlatformTextInputMethodRequest
22
27
  import androidx.compose.ui.platform.WindowInfo
23
28
  import androidx.compose.ui.platform.registerSkikoComposeImplementation
24
29
  import androidx.compose.ui.scene.CanvasLayersComposeScene
@@ -34,6 +39,19 @@ import androidx.compose.ui.text.font.FontWeight
34
39
  import androidx.compose.ui.text.font.PlatformTypefacesLoader
35
40
  import androidx.compose.ui.text.font.createPlatformFontFamilyResolver
36
41
  import androidx.compose.ui.text.font.platformTypefacesLoader
42
+ import androidx.compose.ui.text.input.BackspaceCommand
43
+ import androidx.compose.ui.text.input.CommitTextCommand
44
+ import androidx.compose.ui.text.input.EditCommand
45
+ import androidx.compose.ui.text.input.FinishComposingTextCommand
46
+ import androidx.compose.ui.text.input.ImeAction
47
+ import androidx.compose.ui.text.input.ImeOptions
48
+ import androidx.compose.ui.text.input.KeyboardCapitalization
49
+ import androidx.compose.ui.text.input.KeyboardType
50
+ import androidx.compose.ui.text.input.PlatformTextInputService
51
+ import androidx.compose.ui.text.input.SetComposingRegionCommand
52
+ import androidx.compose.ui.text.input.SetComposingTextCommand
53
+ import androidx.compose.ui.text.input.SetSelectionCommand
54
+ import androidx.compose.ui.text.input.TextFieldValue
37
55
  import androidx.compose.ui.text.intl.LocaleList
38
56
  import androidx.compose.ui.text.intl.setPlatformLocaleProvider
39
57
  import androidx.compose.ui.text.platform.Font
@@ -45,6 +63,7 @@ import androidx.compose.ui.unit.LayoutDirection
45
63
  import dev.onejs.one.OneNativeSourceViewDispatch
46
64
  import kotlin.io.encoding.Base64
47
65
  import kotlinx.coroutines.Dispatchers
66
+ import kotlinx.coroutines.awaitCancellation
48
67
  import org.jetbrains.skiko.SystemTheme
49
68
  import org.jetbrains.skia.BackendRenderTarget
50
69
  import org.jetbrains.skia.Bitmap
@@ -94,6 +113,129 @@ internal class PeachComposePlatform(
94
113
  override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { owners.remove(semanticsOwner) }
95
114
  override fun onSemanticsChange(semanticsOwner: SemanticsOwner) = Unit
96
115
  override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) = Unit
116
+
117
+ // the input method session the focused text field holds, android's
118
+ // InputConnection. each session gets a new token, so the host can tell a
119
+ // late close from the session that replaced it
120
+ var input: PeachInputSession? = null
121
+ private set
122
+ var onInputChange: () -> Unit = {}
123
+ private var nextInput = 1
124
+
125
+ override suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing {
126
+ val session = PeachInputSession(nextInput++, request)
127
+ input = session
128
+ onInputChange()
129
+ try { awaitCancellation() }
130
+ finally {
131
+ if (input === session) input = null
132
+ onInputChange()
133
+ }
134
+ }
135
+
136
+ // compose's keyboard controller: show and hide move the keyboard and keep
137
+ // the session, as Done does on android. a session opens with the keyboard
138
+ // up, as android's legacy text input service shows it on startInput
139
+ @Suppress("DEPRECATION")
140
+ override val textInputService: PlatformTextInputService = object : PlatformTextInputService {
141
+ override fun startInput(value: TextFieldValue, imeOptions: ImeOptions,
142
+ onEditCommand: (List<EditCommand>) -> Unit, onImeActionPerformed: (ImeAction) -> Unit) = Unit
143
+ override fun stopInput() = Unit
144
+ override fun showSoftwareKeyboard() { input?.request(true); onInputChange() }
145
+ override fun hideSoftwareKeyboard() { input?.request(false); onInputChange() }
146
+ override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) = Unit
147
+ }
148
+ }
149
+
150
+ internal class PeachInputSession(val token: Int, val request: PlatformTextInputMethodRequest) {
151
+ var keyboard = true
152
+ private set
153
+ // each show or hide compose asks for, so a repeat still reaches the keyboard
154
+ private var keyboardRequest = 0
155
+ private val options get() = request.imeOptions
156
+
157
+ // EditorInfo.android.kt: a single line field's default action is Done; a
158
+ // multi line field's default has no enter action, so enter types a newline
159
+ private val action: ImeAction? get() = when (options.imeAction) {
160
+ ImeAction.Default -> if (options.singleLine) ImeAction.Done else null
161
+ ImeAction.None -> null
162
+ else -> options.imeAction
163
+ }
164
+
165
+ fun request(visible: Boolean) {
166
+ keyboard = visible
167
+ keyboardRequest++
168
+ }
169
+
170
+ private fun rect(rect: Rect?) = rect?.let { JSONArray().put(it.left).put(it.top).put(it.right).put(it.bottom) }
171
+
172
+ fun state(): JSONObject {
173
+ val value = request.value()
174
+ return JSONObject().put("token", token).put("keyboard", keyboard).put("keyboardRequest", keyboardRequest)
175
+ .put("value", value.text).put("selectionStart", value.selection.start).put("selectionEnd", value.selection.end)
176
+ .put("singleLine", options.singleLine).put("enterInsertsNewline", action == null && !options.singleLine)
177
+ .put("keyboardType", when (options.keyboardType) {
178
+ KeyboardType.Number -> "number"
179
+ KeyboardType.Phone -> "phone"
180
+ KeyboardType.Uri -> "uri"
181
+ KeyboardType.Email -> "email"
182
+ KeyboardType.Password -> "password"
183
+ KeyboardType.NumberPassword -> "number-password"
184
+ KeyboardType.Decimal -> "decimal"
185
+ KeyboardType.Ascii -> "ascii"
186
+ else -> "text"
187
+ })
188
+ .put("imeAction", when (action) {
189
+ ImeAction.Done -> "done"
190
+ ImeAction.Go -> "go"
191
+ ImeAction.Search -> "search"
192
+ ImeAction.Send -> "send"
193
+ ImeAction.Next -> "next"
194
+ ImeAction.Previous -> "previous"
195
+ else -> "default"
196
+ })
197
+ .put("capitalization", when (options.capitalization) {
198
+ KeyboardCapitalization.Characters -> "characters"
199
+ KeyboardCapitalization.Words -> "words"
200
+ KeyboardCapitalization.Sentences -> "sentences"
201
+ else -> "none"
202
+ })
203
+ .put("autoCorrect", options.autoCorrect)
204
+ .put("field", rect(request.textFieldRectInRoot()))
205
+ .put("caret", rect(request.focusedRectInRoot()))
206
+ }
207
+
208
+ // one keyboard edit, applied as one batch the way RecordingInputConnection
209
+ // ends a batch edit. typed text commits as an IME's commitText does; a
210
+ // composition replaces its own region, else the edit's range
211
+ fun edit(command: JSONObject): Boolean {
212
+ val value = request.value()
213
+ val start = command.optInt("start")
214
+ val end = command.optInt("end")
215
+ val composing = value.composition?.let { it.start == start && it.end == end } == true
216
+ val commands: List<EditCommand> = when (command.getString("kind")) {
217
+ "text" -> listOf(CommitTextCommand(command.getString("text"), 1))
218
+ "backspace" -> listOf(BackspaceCommand())
219
+ "enter" -> {
220
+ val performed = action
221
+ if (performed != null) {
222
+ request.onImeAction?.invoke(performed)
223
+ return true
224
+ }
225
+ if (options.singleLine) return false
226
+ listOf(CommitTextCommand("\n", 1))
227
+ }
228
+ "compositionStart" -> if (start == end) return true else listOf(SetComposingRegionCommand(start, end))
229
+ "replace" -> {
230
+ val text = command.getString("text")
231
+ val place = if (composing) emptyList() else listOf(FinishComposingTextCommand(), SetSelectionCommand(start, end))
232
+ place + if (command.getString("composition") == "update") SetComposingTextCommand(text, 1) else CommitTextCommand(text, 1)
233
+ }
234
+ else -> error("unknown input edit ${command.getString("kind")}")
235
+ }
236
+ request.onEditCommand(commands)
237
+ return true
238
+ }
97
239
  }
98
240
 
99
241
  // android's default and sans-serif families use the same device font bytes as
@@ -150,12 +292,16 @@ internal class PeachComposeMount(
150
292
  recomposer, platform.density, LayoutDirection.Ltr, null, platform,
151
293
  invalidateLayout = { needsMeasure = true; invalidate(true) }, invalidateDraw = { invalidate(false) },
152
294
  )
295
+ // the session's state as last reported; a change reports it again
296
+ private var reportedInput: String? = null
153
297
  private var surface: Surface? = null
154
298
  private var context: DirectContext? = null
155
299
  private var target: BackendRenderTarget? = null
156
300
  private var pixels: Bitmap? = null
157
301
  private var size = IntSize.Zero
158
302
 
303
+ init { platform.onInputChange = { invalidate(false) } }
304
+
159
305
  fun setContent(content: @Composable () -> Unit) {
160
306
  try {
161
307
  scene.setContent {
@@ -221,6 +367,11 @@ internal class PeachComposeMount(
221
367
  for ((name, args) in frameState) events.add(JSONObject().put("name", name).put("args", args()))
222
368
  frameState.clear()
223
369
  reply.put("events", JSONArray(events))
370
+ val input = platform.input?.state()?.toString()
371
+ if (input != reportedInput) {
372
+ reportedInput = input
373
+ reply.put("input", input?.let { JSONObject(it) } ?: JSONObject.NULL)
374
+ }
224
375
  events.clear()
225
376
  if (gpu) checkNotNull(surface).flushAndSubmit()
226
377
  else {
@@ -245,6 +396,37 @@ internal class PeachComposeMount(
245
396
  invalidate(false)
246
397
  }
247
398
 
399
+ private fun session(token: Int) = platform.input?.takeIf { it.token == token }
400
+
401
+ fun inputEdit(token: Int, command: String): Boolean {
402
+ val session = session(token) ?: return false
403
+ return session.edit(JSONObject(command)).also { invalidate(false) }
404
+ }
405
+
406
+ // a hardware key, pressed and released, through compose's key input as an
407
+ // android view dispatches it; true when compose consumed the press
408
+ fun inputKey(token: Int, name: String, ctrl: Boolean, shift: Boolean, alt: Boolean): Boolean {
409
+ if (session(token) == null) return false
410
+ val key = hardwareKeys[name] ?: name.singleOrNull()?.lowercaseChar()?.let { letterKeys["$it"] } ?: return false
411
+ val codePoint = name.singleOrNull()?.code ?: 0
412
+ fun send(type: KeyEventType) = scene.sendKeyEvent(KeyEvent(key, type, codePoint,
413
+ isCtrlPressed = ctrl, isAltPressed = alt, isShiftPressed = shift))
414
+ val consumed = send(KeyEventType.KeyDown)
415
+ send(KeyEventType.KeyUp)
416
+ invalidate(false)
417
+ return consumed
418
+ }
419
+
420
+ fun inputState(token: Int): String = session(token)?.state()?.toString() ?: ""
421
+
422
+ // the host gave focus to another input: the view loses focus, as
423
+ // View.clearFocus does, and compose closes the session itself
424
+ fun inputBlur(token: Int) {
425
+ if (session(token) == null) return
426
+ scene.focusManager.releaseFocus()
427
+ invalidate(false)
428
+ }
429
+
248
430
  fun semantics(): String {
249
431
  val nodes = JSONArray()
250
432
  fun collect(node: SemanticsNode) {
@@ -274,6 +456,19 @@ internal class PeachComposeMount(
274
456
  }
275
457
  }
276
458
 
459
+ // the keys a hardware keyboard sends, by their DOM names
460
+ private val hardwareKeys = mapOf(
461
+ "ArrowLeft" to Key.DirectionLeft, "ArrowRight" to Key.DirectionRight,
462
+ "ArrowUp" to Key.DirectionUp, "ArrowDown" to Key.DirectionDown,
463
+ "Home" to Key.MoveHome, "End" to Key.MoveEnd, "PageUp" to Key.PageUp, "PageDown" to Key.PageDown,
464
+ "Tab" to Key.Tab, "Delete" to Key.Delete, "Backspace" to Key.Backspace,
465
+ "Enter" to Key.Enter, "Escape" to Key.Escape,
466
+ )
467
+ private val letterKeys = listOf(
468
+ Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M,
469
+ Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z,
470
+ ).mapIndexed { index, key -> "${'a' + index}" to key }.toMap()
471
+
277
472
  internal val mounts = mutableMapOf<Int, PeachComposeMount>()
278
473
  private var nextMount = 1
279
474
  private var deviceLocale: String? = null
@@ -330,6 +525,11 @@ fun peachViewMount(source: String, view: String, hash: String, props: String,
330
525
  }
331
526
  @JsExport fun peachViewFrame(id: Int, millis: Double): String = checkNotNull(mounts[id]).frame((millis * 1_000_000).toLong())
332
527
  @JsExport fun peachViewPointer(id: Int, phase: String, x: Float, y: Float, millis: Double) = checkNotNull(mounts[id]).pointer(phase, x, y, millis.toLong())
528
+ @JsExport fun peachViewInputEdit(id: Int, token: Int, command: String): Boolean = checkNotNull(mounts[id]).inputEdit(token, command)
529
+ @JsExport fun peachViewInputKey(id: Int, token: Int, key: String, ctrl: Boolean, shift: Boolean, alt: Boolean): Boolean =
530
+ checkNotNull(mounts[id]).inputKey(token, key, ctrl, shift, alt)
531
+ @JsExport fun peachViewInputState(id: Int, token: Int): String = checkNotNull(mounts[id]).inputState(token)
532
+ @JsExport fun peachViewInputBlur(id: Int, token: Int) = checkNotNull(mounts[id]).inputBlur(token)
333
533
  @JsExport fun peachViewSemantics(id: Int): String = checkNotNull(mounts[id]).semantics()
334
534
  @JsExport fun peachViewDispose(id: Int) { mounts.remove(id)?.close() }
335
535
 
@@ -207,6 +207,19 @@ class JSONObject() {
207
207
  return this
208
208
  }
209
209
 
210
+ // android's coercions: any value reads as its string; a number or a
211
+ // numeric string reads as an int, anything else as the fallback
212
+ fun getString(name: String): String = when (val value = get(name)) {
213
+ is String -> value
214
+ else -> value.toString()
215
+ }
216
+
217
+ fun optInt(name: String, fallback: Int = 0): Int = when (val value = opt(name)) {
218
+ is Number -> value.toInt()
219
+ is String -> value.toDoubleOrNull()?.toInt() ?: fallback
220
+ else -> fallback
221
+ }
222
+
210
223
  fun optBoolean(name: String): Boolean = when (val value = opt(name)) {
211
224
  is Boolean -> value
212
225
  is String -> value.equals("true", ignoreCase = true)
@@ -0,0 +1,62 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import androidx.compose.animation.AnimatedVisibility
4
+ import androidx.compose.animation.animateColorAsState
5
+ import androidx.compose.animation.core.animateDpAsState
6
+ import androidx.compose.foundation.background
7
+ import androidx.compose.foundation.layout.Arrangement
8
+ import androidx.compose.foundation.layout.Box
9
+ import androidx.compose.foundation.layout.Column
10
+ import androidx.compose.foundation.layout.fillMaxSize
11
+ import androidx.compose.foundation.layout.fillMaxWidth
12
+ import androidx.compose.foundation.layout.height
13
+ import androidx.compose.foundation.layout.padding
14
+ import androidx.compose.foundation.layout.safeDrawingPadding
15
+ import androidx.compose.foundation.layout.width
16
+ import androidx.compose.material3.Button
17
+ import androidx.compose.material3.Text
18
+ import androidx.compose.runtime.Composable
19
+ import androidx.compose.runtime.LaunchedEffect
20
+ import androidx.compose.runtime.withFrameNanos
21
+ import androidx.compose.runtime.getValue
22
+ import androidx.compose.runtime.mutableStateOf
23
+ import androidx.compose.runtime.remember
24
+ import androidx.compose.runtime.setValue
25
+ import androidx.compose.ui.Modifier
26
+ import androidx.compose.ui.graphics.Color
27
+ import androidx.compose.ui.unit.dp
28
+ import kotlin.math.roundToInt
29
+
30
+ // a tap flips one state that AnimatedVisibility, animateDpAsState and
31
+ // animateColorAsState each animate on the frame clock; the label reads the
32
+ // animated width, so it settles only when the animation has finished. took
33
+ // is the frame clock time and frame count from the flip to the width's arrival
34
+ @Composable
35
+ fun Anim() {
36
+ var on by remember { mutableStateOf(false) }
37
+ var flips by remember { mutableStateOf(0) }
38
+ var took by remember { mutableStateOf("none") }
39
+ val target = if (on) 240.dp else 80.dp
40
+ val width by animateDpAsState(target, label = "width")
41
+ LaunchedEffect(flips) {
42
+ if (flips == 0) return@LaunchedEffect
43
+ val start = withFrameNanos { it }
44
+ var count = 0
45
+ var end = start
46
+ while (width != target) end = withFrameNanos { count += 1; it }
47
+ took = "${(end - start) / 1_000_000}ms $count frames"
48
+ }
49
+ val color by animateColorAsState(if (on) Color(0xFF3F51B5) else Color(0xFFE91E63), label = "color")
50
+ Column(
51
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
52
+ verticalArrangement = Arrangement.spacedBy(12.dp),
53
+ ) {
54
+ Text("Anim: $on width ${if (width == target) "${target.value.roundToInt()}" else "moving"}")
55
+ Button(onClick = { on = !on; flips += 1 }) { Text("Animate") }
56
+ Text("Took: $took")
57
+ Box(Modifier.width(width).height(48.dp).background(color))
58
+ AnimatedVisibility(on) {
59
+ Text("Panel", Modifier.fillMaxWidth().background(Color(0xFF009688)).padding(24.dp), color = Color.White)
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.layout.Arrangement
5
+ import androidx.compose.foundation.layout.Column
6
+ import androidx.compose.foundation.layout.Row
7
+ import androidx.compose.foundation.layout.fillMaxSize
8
+ import androidx.compose.foundation.layout.padding
9
+ import androidx.compose.foundation.layout.safeDrawingPadding
10
+ import androidx.compose.material3.AssistChip
11
+ import androidx.compose.material3.Badge
12
+ import androidx.compose.material3.BadgedBox
13
+ import androidx.compose.material3.DatePicker
14
+ import androidx.compose.material3.ExperimentalMaterial3Api
15
+ import androidx.compose.material3.ExtendedFloatingActionButton
16
+ import androidx.compose.material3.SegmentedButton
17
+ import androidx.compose.material3.SegmentedButtonDefaults
18
+ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
19
+ import androidx.compose.material3.SuggestionChip
20
+ import androidx.compose.material3.Text
21
+ import androidx.compose.material3.rememberDatePickerState
22
+ import androidx.compose.runtime.Composable
23
+ import androidx.compose.runtime.getValue
24
+ import androidx.compose.runtime.mutableStateOf
25
+ import androidx.compose.runtime.remember
26
+ import androidx.compose.runtime.setValue
27
+ import androidx.compose.ui.Modifier
28
+ import androidx.compose.ui.graphics.Color
29
+ import androidx.compose.ui.unit.dp
30
+
31
+ // segmented buttons a tap selects, chips, a badge, an extended FAB and a date
32
+ // picker showing a fixed past month, so no today marker depends on the clock
33
+ @OptIn(ExperimentalMaterial3Api::class)
34
+ @Composable
35
+ fun Controls() {
36
+ val options = listOf("Day", "Week", "Month")
37
+ var picked by remember { mutableStateOf(0) }
38
+ // march 2020, utc
39
+ val dates = rememberDatePickerState(initialSelectedDateMillis = 1584230400000L, initialDisplayedMonthMillis = 1583020800000L)
40
+ Column(
41
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
42
+ verticalArrangement = Arrangement.spacedBy(12.dp),
43
+ ) {
44
+ Text("Range: ${options[picked]}")
45
+ SingleChoiceSegmentedButtonRow {
46
+ options.forEachIndexed { index, label ->
47
+ SegmentedButton(
48
+ selected = picked == index,
49
+ onClick = { picked = index },
50
+ shape = SegmentedButtonDefaults.itemShape(index, options.size),
51
+ ) { Text(label) }
52
+ }
53
+ }
54
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
55
+ AssistChip(onClick = {}, label = { Text("Assist") })
56
+ SuggestionChip(onClick = {}, label = { Text("Suggest") })
57
+ BadgedBox(badge = { Badge { Text("8") } }, Modifier.padding(top = 12.dp, start = 8.dp)) { Text("Inbox") }
58
+ }
59
+ ExtendedFloatingActionButton(onClick = {}) { Text("Compose") }
60
+ DatePicker(dates, showModeToggle = false)
61
+ }
62
+ }
@@ -0,0 +1,56 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.gestures.detectDragGestures
5
+ import androidx.compose.foundation.layout.Arrangement
6
+ import androidx.compose.foundation.layout.Box
7
+ import androidx.compose.foundation.layout.Column
8
+ import androidx.compose.foundation.layout.fillMaxSize
9
+ import androidx.compose.foundation.layout.fillMaxWidth
10
+ import androidx.compose.foundation.layout.height
11
+ import androidx.compose.foundation.layout.offset
12
+ import androidx.compose.foundation.layout.padding
13
+ import androidx.compose.foundation.layout.safeDrawingPadding
14
+ import androidx.compose.foundation.layout.size
15
+ import androidx.compose.foundation.shape.CircleShape
16
+ import androidx.compose.material3.Slider
17
+ import androidx.compose.material3.Text
18
+ import androidx.compose.runtime.Composable
19
+ import androidx.compose.runtime.getValue
20
+ import androidx.compose.runtime.mutableFloatStateOf
21
+ import androidx.compose.runtime.mutableStateOf
22
+ import androidx.compose.runtime.remember
23
+ import androidx.compose.runtime.setValue
24
+ import androidx.compose.ui.Modifier
25
+ import androidx.compose.ui.geometry.Offset
26
+ import androidx.compose.ui.graphics.Color
27
+ import androidx.compose.ui.input.pointer.pointerInput
28
+ import androidx.compose.ui.platform.LocalDensity
29
+ import androidx.compose.ui.platform.testTag
30
+ import androidx.compose.ui.unit.IntOffset
31
+ import androidx.compose.ui.unit.dp
32
+ import kotlin.math.roundToInt
33
+
34
+ // a puck detectDragGestures moves, reporting the drag it received after the
35
+ // touch slop in dp, and a slider a drag sets
36
+ @Composable
37
+ fun Drag() {
38
+ var puck by remember { mutableStateOf(Offset.Zero) }
39
+ var drags by remember { mutableStateOf(0) }
40
+ var slider by remember { mutableFloatStateOf(0f) }
41
+ val density = LocalDensity.current.density
42
+ Column(
43
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
44
+ verticalArrangement = Arrangement.spacedBy(12.dp),
45
+ ) {
46
+ Text("Drag: ${(puck.x / density).roundToInt()},${(puck.y / density).roundToInt()} ends $drags slider ${(slider * 100).roundToInt()}")
47
+ Box(
48
+ Modifier.fillMaxWidth().height(300.dp).background(Color(0xFFCFD8DC)).testTag("pad").pointerInput(Unit) {
49
+ detectDragGestures(onDragEnd = { drags += 1 }) { change, amount -> change.consume(); puck += amount }
50
+ },
51
+ ) {
52
+ Box(Modifier.offset { IntOffset(puck.x.roundToInt(), puck.y.roundToInt()) }.size(48.dp).background(Color(0xFF3F51B5), CircleShape))
53
+ }
54
+ Slider(slider, { slider = it }, Modifier.testTag("slider"))
55
+ }
56
+ }
@@ -0,0 +1,74 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import androidx.compose.foundation.Canvas
4
+ import androidx.compose.foundation.background
5
+ import androidx.compose.foundation.gestures.detectTapGestures
6
+ import androidx.compose.foundation.layout.Arrangement
7
+ import androidx.compose.foundation.layout.Box
8
+ import androidx.compose.foundation.layout.Column
9
+ import androidx.compose.foundation.layout.Row
10
+ import androidx.compose.foundation.layout.fillMaxSize
11
+ import androidx.compose.foundation.layout.fillMaxWidth
12
+ import androidx.compose.foundation.layout.height
13
+ import androidx.compose.foundation.layout.padding
14
+ import androidx.compose.foundation.layout.safeDrawingPadding
15
+ import androidx.compose.foundation.layout.size
16
+ import androidx.compose.foundation.shape.RoundedCornerShape
17
+ import androidx.compose.material3.Text
18
+ import androidx.compose.runtime.Composable
19
+ import androidx.compose.runtime.getValue
20
+ import androidx.compose.runtime.mutableStateOf
21
+ import androidx.compose.runtime.remember
22
+ import androidx.compose.runtime.setValue
23
+ import androidx.compose.ui.Modifier
24
+ import androidx.compose.ui.draw.clip
25
+ import androidx.compose.ui.draw.rotate
26
+ import androidx.compose.ui.geometry.CornerRadius
27
+ import androidx.compose.ui.geometry.Offset
28
+ import androidx.compose.ui.geometry.Size
29
+ import androidx.compose.ui.graphics.Brush
30
+ import androidx.compose.ui.graphics.Color
31
+ import androidx.compose.ui.graphics.Path
32
+ import androidx.compose.ui.graphics.StrokeCap
33
+ import androidx.compose.ui.graphics.drawscope.Stroke
34
+ import androidx.compose.ui.graphics.graphicsLayer
35
+ import androidx.compose.ui.input.pointer.pointerInput
36
+ import androidx.compose.ui.unit.dp
37
+ import kotlin.math.roundToInt
38
+
39
+ // a canvas of paths, arcs, gradients and strokes, transformed layers, and a
40
+ // pointer input pad that reports where a tap lands in its own dp
41
+ @Composable
42
+ fun Draw() {
43
+ var tap by remember { mutableStateOf("none") }
44
+ Column(
45
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
46
+ verticalArrangement = Arrangement.spacedBy(12.dp),
47
+ ) {
48
+ Text("Tap: $tap")
49
+ Canvas(Modifier.fillMaxWidth().height(200.dp)) {
50
+ drawRect(Brush.linearGradient(listOf(Color(0xFF3F51B5), Color(0xFFE91E63)), Offset.Zero, Offset(size.width, size.height)))
51
+ drawCircle(Brush.radialGradient(listOf(Color.White, Color(0x00FFFFFF)), Offset(size.width * 0.25f, size.height * 0.5f), 120f), 120f, Offset(size.width * 0.25f, size.height * 0.5f))
52
+ drawArc(Color(0xFFFFC107), -90f, 250f, false, Offset(size.width * 0.55f, 40f), Size(240f, 240f), style = Stroke(24f, cap = StrokeCap.Round))
53
+ val path = Path().apply {
54
+ moveTo(size.width * 0.8f, 40f)
55
+ cubicTo(size.width, 80f, size.width * 0.7f, size.height - 80f, size.width * 0.95f, size.height - 30f)
56
+ lineTo(size.width * 0.75f, size.height - 30f)
57
+ close()
58
+ }
59
+ drawPath(path, Color(0xCC009688))
60
+ drawRoundRect(Color.White, Offset(30f, size.height - 90f), Size(200f, 60f), CornerRadius(30f), style = Stroke(6f))
61
+ drawLine(Color.Black, Offset(0f, size.height), Offset(size.width, 0f), 3f)
62
+ }
63
+ Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
64
+ Box(Modifier.padding(12.dp).size(72.dp).rotate(30f).background(Color(0xFF673AB7)))
65
+ Box(Modifier.size(96.dp).graphicsLayer { alpha = 0.5f; scaleX = 0.8f; scaleY = 1.2f }.background(Color(0xFFFF5722)))
66
+ Box(Modifier.size(96.dp).graphicsLayer { rotationX = 40f; cameraDistance = 8 * density }.clip(RoundedCornerShape(16.dp)).background(Color(0xFF4CAF50)))
67
+ }
68
+ Box(
69
+ Modifier.fillMaxWidth().height(160.dp).background(Color(0xFFCFD8DC)).pointerInput(Unit) {
70
+ detectTapGestures { tap = "${(it.x / density).roundToInt()},${(it.y / density).roundToInt()}" }
71
+ },
72
+ ) { Text("Pad", Modifier.padding(8.dp)) }
73
+ }
74
+ }
@@ -0,0 +1,47 @@
1
+ package dev.contrast.kotlinapp
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.layout.Arrangement
5
+ import androidx.compose.foundation.layout.Column
6
+ import androidx.compose.foundation.layout.fillMaxSize
7
+ import androidx.compose.foundation.layout.fillMaxWidth
8
+ import androidx.compose.foundation.layout.padding
9
+ import androidx.compose.foundation.layout.safeDrawingPadding
10
+ import androidx.compose.foundation.text.KeyboardActions
11
+ import androidx.compose.foundation.text.KeyboardOptions
12
+ import androidx.compose.material3.OutlinedTextField
13
+ import androidx.compose.material3.Text
14
+ import androidx.compose.runtime.Composable
15
+ import androidx.compose.runtime.getValue
16
+ import androidx.compose.runtime.mutableStateOf
17
+ import androidx.compose.runtime.remember
18
+ import androidx.compose.runtime.setValue
19
+ import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.focus.onFocusChanged
21
+ import androidx.compose.ui.graphics.Color
22
+ import androidx.compose.ui.text.input.ImeAction
23
+ import androidx.compose.ui.unit.dp
24
+
25
+ // a single line field a tap focuses, typing fills and Done finishes, keeping
26
+ // its focus as android does
27
+ @Composable
28
+ fun Form() {
29
+ var name by remember { mutableStateOf("") }
30
+ var focused by remember { mutableStateOf(false) }
31
+ var done by remember { mutableStateOf(0) }
32
+ Column(
33
+ Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
34
+ verticalArrangement = Arrangement.spacedBy(12.dp),
35
+ ) {
36
+ Text("Name: ${name.ifEmpty { "empty" }} focus $focused done $done")
37
+ OutlinedTextField(
38
+ value = name,
39
+ onValueChange = { name = it },
40
+ label = { Text("Name") },
41
+ singleLine = true,
42
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
43
+ keyboardActions = KeyboardActions(onDone = { done += 1; defaultKeyboardAction(ImeAction.Done) }),
44
+ modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
45
+ )
46
+ }
47
+ }