craft-native 0.0.189 → 0.0.192

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.
@@ -286,7 +286,10 @@ internal class CraftNativeServices(
286
286
  else -> Intent(Intent.ACTION_SEND).apply { type = "text/plain" }
287
287
  }
288
288
  if (text.isNotBlank()) intent.putExtra(Intent.EXTRA_TEXT, text)
289
- options.optString("title").takeIf { it.isNotBlank() }?.let { intent.putExtra(Intent.EXTRA_SUBJECT, it) }
289
+ options.optString("title").takeIf { it.isNotBlank() }?.let {
290
+ intent.putExtra(Intent.EXTRA_SUBJECT, it)
291
+ intent.putExtra(Intent.EXTRA_TITLE, it)
292
+ }
290
293
  intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
291
294
  host.startActivity(Intent.createChooser(intent, options.optString("title").ifBlank { null }))
292
295
  // Android does not say whether something was shared; the sheet opened.
@@ -576,6 +576,9 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
576
576
  private val buttons = LinearLayout(context)
577
577
  private val tabBar = LinearLayout(context)
578
578
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
579
+ private val tabIconText = mutableMapOf<String, String>()
580
+ private val tabImageURLs = mutableMapOf<String, String>()
581
+ private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
579
582
  private val density = resources.displayMetrics.density
580
583
  private var selectedTab = 0
581
584
  private var swipeTracking = false
@@ -703,6 +706,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
703
706
 
704
707
  /** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
705
708
  fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
709
+ options.optJSONObject("tabItem")?.let(::updateTab)
706
710
  val hidden = options.optBoolean("hidden", false)
707
711
  val large = options.optBoolean("largeTitle", false) && depth == 0
708
712
  val title = options.optString("title").takeIf { options.has("title") } ?: fallbackTitle.orEmpty()
@@ -728,6 +732,42 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
728
732
  refreshColors()
729
733
  }
730
734
 
735
+ private fun updateTab(item: JSONObject) {
736
+ val id = item.optString("id")
737
+ val index = tabs.indexOfFirst { it.id == id }
738
+ if (index < 0) return
739
+ val cell = tabBar.getChildAt(index)
740
+ if (item.has("accessibilityLabel")) cell.contentDescription = item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() } ?: tabs[index].title
741
+ if (item.has("iconText")) {
742
+ tabIconText.remove(id)
743
+ item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
744
+ }
745
+ if (item.has("image")) {
746
+ val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
747
+ if (address != tabImageURLs[id]) {
748
+ tabImages.remove(id)
749
+ tabImageURLs.remove(id)
750
+ if (address != null) {
751
+ tabImageURLs[id] = address
752
+ Thread {
753
+ val image = runCatching {
754
+ val connection = java.net.URL(address).openConnection().apply { connectTimeout = 5000; readTimeout = 5000 }
755
+ val bitmap = connection.getInputStream().use { android.graphics.BitmapFactory.decodeStream(it) } ?: return@runCatching null
756
+ androidx.core.graphics.drawable.RoundedBitmapDrawableFactory.create(resources, android.graphics.Bitmap.createScaledBitmap(bitmap, dp(27), dp(27), true)).apply { isCircular = true }
757
+ }.getOrNull()
758
+ post {
759
+ if (tabImageURLs[id] == address && image != null) {
760
+ tabImages[id] = image
761
+ refreshColors()
762
+ }
763
+ }
764
+ }.start()
765
+ }
766
+ }
767
+ }
768
+ refreshColors()
769
+ }
770
+
731
771
  /** Light or dark changed: the bars follow the screen's background. */
732
772
  fun refreshColors() {
733
773
  val background = ContextCompat.getColor(context, R.color.background)
@@ -749,11 +789,12 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
749
789
  )).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
750
790
  tabViews.forEachIndexed { index, (icon, label) ->
751
791
  val color = if (index == selectedTab) accent else muted
752
- icon.setTextColor(color)
792
+ val number = tabIconText[tabs[index].id]
793
+ icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
753
794
  label.setTextColor(color)
754
- val vector = CraftNativeIcons.drawable(context, null, tabs[index].symbol, color)
795
+ val vector = tabImages[tabs[index].id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, color) else null
755
796
  icon.foreground = vector
756
- if (vector != null) icon.text = ""
797
+ icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
757
798
  }
758
799
  for (index in 0 until buttons.childCount) (buttons.getChildAt(index) as? TextView)?.setTextColor(accent)
759
800
  }
@@ -2653,6 +2653,37 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2653
2653
  }
2654
2654
  }
2655
2655
 
2656
+ /** A field's pending native edits and the props acknowledged by the screen. */
2657
+ internal class CraftNativeInputEcho {
2658
+ private val pending = ArrayDeque<String>()
2659
+ private var rendered: String? = null
2660
+ private var initialized = false
2661
+
2662
+ fun record(value: String) {
2663
+ pending.addLast(value)
2664
+ while (pending.size > 64) pending.removeFirst()
2665
+ }
2666
+
2667
+ fun accept(value: String?, nativeValue: String): Boolean {
2668
+ if (value == null) {
2669
+ initialized = false
2670
+ rendered = null
2671
+ pending.clear()
2672
+ return false
2673
+ }
2674
+ // A retained field can render the same prop more than once, after
2675
+ // its echo was consumed. That repeated prop must not overwrite a
2676
+ // keystroke that has not made the native/JS round trip yet.
2677
+ val changed = !initialized || rendered != value
2678
+ initialized = true
2679
+ rendered = value
2680
+ if (!changed) return false
2681
+ val index = pending.indexOf(value)
2682
+ if (index >= 0) repeat(index + 1) { pending.removeFirst() }
2683
+ return nativeValue != value && index < 0
2684
+ }
2685
+ }
2686
+
2656
2687
  /**
2657
2688
  * A native screen's `<Video>`: the platform's VideoView.
2658
2689
  *
@@ -3261,7 +3292,7 @@ class MainActivity : AppCompatActivity() {
3261
3292
  * the screen answer with a value the field has already moved past;
3262
3293
  * that echo is not written back over what was typed since.
3263
3294
  */
3264
- val inputEchoes = mutableMapOf<EditText, ArrayDeque<String>>()
3295
+ val inputEchoes = mutableMapOf<EditText, CraftNativeInputEcho>()
3265
3296
  val controls = mutableMapOf<String, View>()
3266
3297
  // Protocol ids identify mutation targets; authored keys identify the
3267
3298
  // retained view. Keep both mappings so a targeted update can recover
@@ -4601,10 +4632,8 @@ class MainActivity : AppCompatActivity() {
4601
4632
  val autofillHint = autofillHintFor(props)
4602
4633
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
4603
4634
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
4604
- val echoes = screen.inputEchoes[this]
4605
- val echoIndex = if (controlledValue == null || echoes == null) -1 else echoes.indexOf(controlledValue)
4606
- if (echoIndex >= 0) repeat(echoIndex + 1) { echoes!!.removeFirst() }
4607
- if (controlledValue != null && text.toString() != controlledValue && echoIndex < 0) {
4635
+ val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
4636
+ if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
4608
4637
  screen.suppressedTextChanges.add(this)
4609
4638
  setTextPreservingSelection(this, controlledValue)
4610
4639
  }
@@ -4662,9 +4691,7 @@ class MainActivity : AppCompatActivity() {
4662
4691
  (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
4663
4692
  if (screen.suppressedTextChanges.remove(this@apply)) return
4664
4693
  val current = screen.handlers[this@apply] ?: return
4665
- val echoes = screen.inputEchoes.getOrPut(this@apply) { ArrayDeque() }
4666
- echoes.addLast(updated)
4667
- while (echoes.size > 64) echoes.removeFirst()
4694
+ screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
4668
4695
  send(screen, "EVENT", JSONObject()
4669
4696
  .put("handlerName", current)
4670
4697
  .put("nativeEvent", JSONObject().put("text", updated)))
@@ -5084,7 +5111,7 @@ class MainActivity : AppCompatActivity() {
5084
5111
  val pressOutHandler = craftNativeEventName(events, "onPressOut").takeIf { it.isNotBlank() }
5085
5112
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
5086
5113
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
5087
- if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
5114
+ if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
5088
5115
  val handler = craftNativeEventName(events, "onPress", "onClick")
5089
5116
  val disabled = props.optBoolean("disabled", false)
5090
5117
  val target = listOf(props.opt("href"), props.opt("to"))
@@ -5430,6 +5457,11 @@ class MainActivity : AppCompatActivity() {
5430
5457
  view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5431
5458
  view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
5432
5459
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
5460
+ // VideoView's Surface is behind this window. An opaque View
5461
+ // background paints over the decoded picture (only rounded corners
5462
+ // showed it). Keep the outline/borders, with a transparent fill.
5463
+ val backgroundColor = if (view is CraftNativeVideoView) Color.TRANSPARENT
5464
+ else color(style.optString("backgroundColor"), Color.TRANSPARENT)
5433
5465
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5434
5466
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
5435
5467
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
@@ -5452,14 +5484,14 @@ class MainActivity : AppCompatActivity() {
5452
5484
  if (background || border > 0 || radius > 0 || hasSideBorders) {
5453
5485
  if (hasSideBorders) {
5454
5486
  view.background = CraftNativeSideBorderDrawable(
5455
- color(style.optString("backgroundColor"), Color.TRANSPARENT),
5487
+ backgroundColor,
5456
5488
  cornerRadii,
5457
5489
  sideWidths,
5458
5490
  sideColors,
5459
5491
  )
5460
5492
  } else {
5461
5493
  view.background = GradientDrawable().apply {
5462
- setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
5494
+ setColor(backgroundColor)
5463
5495
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
5464
5496
  if (border > 0) {
5465
5497
  val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.189";
3427
+ var version = "0.0.192";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.189";
312
+ var version = "0.0.192";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.189";
60
+ var version = "0.0.192";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -614,7 +614,10 @@ final class CraftChrome: ObservableObject {
614
614
  let id: String
615
615
  let title: String
616
616
  let symbol: String
617
- let badge: String?
617
+ var badge: String?
618
+ var iconText: String? = nil
619
+ var accessibilityLabel: String? = nil
620
+ var image: String? = nil
618
621
  }
619
622
 
620
623
  @Published var tabs: [Tab] = []
@@ -668,6 +671,23 @@ final class CraftChrome: ObservableObject {
668
671
  /// the bar away while it shows (`tabBarHidden`, CraftNativeScreen.swift);
669
672
  /// the next screen shown says again. The page's own hideTabBar is apart.
670
673
  private(set) var hiddenByScreen = false
674
+
675
+ /// Change one tab's icon without replacing tabs, navigation stacks or selection.
676
+ func updateTab(_ item: [String: Any]) {
677
+ guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
678
+ if item.keys.contains("iconText") {
679
+ tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
680
+ }
681
+ if item.keys.contains("accessibilityLabel") {
682
+ tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
683
+ }
684
+ if item.keys.contains("image") {
685
+ tabs[index].image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
686
+ }
687
+ if item.keys.contains("badge") {
688
+ tabs[index].badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
689
+ }
690
+ }
671
691
  func setHiddenByScreen(_ hidden: Bool) {
672
692
  guard hiddenByScreen != hidden else { return }
673
693
  hiddenByScreen = hidden
@@ -941,9 +961,28 @@ struct CraftTabBarView: View {
941
961
  Button { chrome.tap(tab) } label: {
942
962
  VStack(spacing: 3) {
943
963
  ZStack(alignment: .topTrailing) {
944
- Image(systemName: symbol(tab.symbol, selected: isSelected))
945
- .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
964
+ if let image = tab.image, let url = URL(string: image) {
965
+ AsyncImage(url: url) { image in
966
+ image.resizable().scaledToFill()
967
+ } placeholder: {
968
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
969
+ }
970
+ .frame(width: iconSize + 4, height: iconSize + 4)
971
+ .clipShape(Circle())
946
972
  .frame(height: iconSize + 6)
973
+ } else if let text = tab.iconText {
974
+ Text(text)
975
+ .font(.system(size: iconSize, weight: .semibold, design: .rounded))
976
+ .monospacedDigit()
977
+ .foregroundStyle(Color.red)
978
+ .lineLimit(1)
979
+ .minimumScaleFactor(0.7)
980
+ .frame(height: iconSize + 6)
981
+ } else {
982
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
983
+ .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
984
+ .frame(height: iconSize + 6)
985
+ }
947
986
  if let badge = tab.badge {
948
987
  Text(badge)
949
988
  .font(.system(size: badgeSize, weight: .bold))
@@ -975,7 +1014,7 @@ struct CraftTabBarView: View {
975
1014
  Image(systemName: symbol(tab.symbol, selected: isSelected))
976
1015
  Text(tab.title)
977
1016
  }
978
- .accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
1017
+ .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
979
1018
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
980
1019
  }
981
1020
  }
@@ -5709,9 +5748,11 @@ struct CraftWebView: UIViewRepresentable {
5709
5748
  return
5710
5749
  }
5711
5750
  var items: [Any] = []
5712
- if let title = options["title"] as? String, !title.isEmpty { items.append(title) }
5751
+ let title = options["title"] as? String ?? ""
5713
5752
  if let text = options["text"] as? String, !text.isEmpty { items.append(text) }
5714
- if let urlString = options["url"] as? String, let url = URL(string: urlString) { items.append(url) }
5753
+ if let urlString = options["url"] as? String, let url = URL(string: urlString) {
5754
+ items.append(CraftShareLink(url: url, title: title))
5755
+ } else if !title.isEmpty { items.append(title) }
5715
5756
  if let files = options["files"] as? [String] {
5716
5757
  for file in files {
5717
5758
  if let url = URL(string: file), url.isFileURL { items.append(url) }
@@ -4355,6 +4355,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4355
4355
  /// `craft.navigation.setOptions`: this screen's bar. Every key is
4356
4356
  /// optional and only the keys given change.
4357
4357
  private func applyNavigationOptions(_ options: [String: Any]) {
4358
+ if let tab = options["tabItem"] as? [String: Any] {
4359
+ CraftChrome.shared.updateTab(tab)
4360
+ }
4358
4361
  // The app script has no screen, so no bar and no tab bar to change.
4359
4362
  guard !isAppScript else { return }
4360
4363
  if options.keys.contains("title") {
@@ -1,5 +1,6 @@
1
1
  import AVFoundation
2
2
  import Foundation
3
+ import LinkPresentation
3
4
  import UIKit
4
5
  import UserNotifications
5
6
 
@@ -687,6 +688,32 @@ enum CraftNativePush {
687
688
 
688
689
  // MARK: - Share sheet
689
690
 
691
+ /// A URL stays a URL in Messages, AirDrop and Copy. Its title belongs in the
692
+ /// system preview and mail subject, rather than becoming a second text item.
693
+ final class CraftShareLink: NSObject, UIActivityItemSource {
694
+ let url: URL
695
+ let title: String
696
+
697
+ init(url: URL, title: String) {
698
+ self.url = url
699
+ self.title = title
700
+ }
701
+
702
+ func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { url }
703
+
704
+ func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { url }
705
+
706
+ func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { title }
707
+
708
+ func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
709
+ let metadata = LPLinkMetadata()
710
+ metadata.originalURL = url
711
+ metadata.url = url
712
+ if !title.isEmpty { metadata.title = title }
713
+ return metadata
714
+ }
715
+ }
716
+
690
717
  /// `craft.share` on a native screen: the system share sheet with the page's
691
718
  /// options (`title`, `text`, `url`, `files`), plus `image`, a picture by URL
692
719
  /// that is shared as the picture rather than as its address. Answers true
@@ -694,9 +721,11 @@ enum CraftNativePush {
694
721
  enum CraftNativeShare {
695
722
  static func share(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
696
723
  var items: [Any] = []
697
- if let title = options["title"] as? String, !title.isEmpty { items.append(title) }
724
+ let title = options["title"] as? String ?? ""
698
725
  if let text = options["text"] as? String, !text.isEmpty { items.append(text) }
699
- if let address = options["url"] as? String, let url = URL(string: address) { items.append(url) }
726
+ if let address = options["url"] as? String, let url = URL(string: address) {
727
+ items.append(CraftShareLink(url: url, title: title))
728
+ } else if !title.isEmpty { items.append(title) }
700
729
  // A path or file URL, or a file craft.files made (`{ path, name }`).
701
730
  let files = (options["files"] as? [Any] ?? []).compactMap { item -> String? in
702
731
  item as? String ?? (item as? [String: Any])?["path"] as? String
@@ -715,7 +744,7 @@ enum CraftNativeShare {
715
744
  if let error { done(.failure(CraftNativeActionError(code: "SHARE_ERROR", message: error.localizedDescription))) }
716
745
  else { done(.success(completed)) }
717
746
  }
718
- if !CraftPresenter.present(sheet, from: nil, anchor: nil) {
747
+ if !CraftPresenter.present(sheet, from: nil, anchor: CraftPresenter.rect(options["anchor"])) {
719
748
  done(.failure(CraftNativeActionError(code: "SHARE_ERROR", message: "Unable to present the share sheet")))
720
749
  }
721
750
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.189",
3
+ "version": "0.0.192",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",