tauri-plugin-system-components 0.1.3

Native system UI components for Tauri 2 — native iOS tab bar over the webview, native controls, and glass window backgrounds on macOS/iOS.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
package com.sosweetham.systemcomponents

import android.app.Activity
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.util.Base64
import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.ScrollView
import android.widget.Switch
import android.widget.TextView
import com.google.android.material.bottomsheet.BottomSheetDialog
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import org.json.JSONObject

/** Interaction callbacks the plugin maps 1:1 to Tauri `trigger(...)` events. */
internal class SheetCallbacks(
    val onRow: (rowId: String) -> Unit,
    val onField: (rowId: String, value: String) -> Unit,
    val onSubmit: (rowId: String, valuesJson: String) -> Unit,
    val onDismissed: () -> Unit,
)

/**
 * A native Material bottom sheet that renders the same row vocabulary as the iOS
 * `SheetController` (header / action / text / textfield / toggle / datetime /
 * select / submit). Single responsibility: present rows + track form state +
 * report interactions via [SheetCallbacks]. The plugin owns the Tauri bridge.
 */
internal class SheetController(
    activity: Activity,
    tint: String?,
    rows: List<SheetRowArg>,
    private val callbacks: SheetCallbacks,
) {
    private val ctx = ContextThemeWrapper(activity, MATERIAL_THEME)
    private val accent: Int = parseHexColor(tint) ?: DEFAULT_ACCENT
    /** Live form state by row id — seeded from initial values, emitted on submit. */
    private val values = LinkedHashMap<String, String>()
    private val dialog = BottomSheetDialog(ctx)
    private val container =
        LinearLayout(ctx).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(0, dp(8), 0, dp(20))
        }

    init {
        dialog.setContentView(ScrollView(ctx).apply { addView(container) })
        dialog.setOnDismissListener { callbacks.onDismissed() }
        render(rows)
    }

    val isShowing: Boolean
        get() = dialog.isShowing

    fun show() = dialog.show()

    fun dismiss() = dialog.dismiss()

    /** Re-render in place (e.g. a badge count changed) without reopening. */
    fun update(rows: List<SheetRowArg>) = render(rows)

    // ── Rendering ────────────────────────────────────────────────────────────

    private fun render(rows: List<SheetRowArg>) {
        seedValues(rows)
        container.removeAllViews()
        for (r in rows) {
            val view =
                when (kindOf(r)) {
                    "header" -> headerRow(r)
                    "text" -> textRow(r)
                    "textfield" -> textFieldRow(r)
                    "toggle" -> toggleRow(r)
                    "datetime" -> dateTimeRow(r)
                    "select" -> selectRow(r)
                    "submit" -> submitRow(r)
                    else -> actionRow(r) // "action"
                }
            container.addView(view)
        }
    }

    private fun kindOf(r: SheetRowArg): String =
        r.kind ?: if (r.header == true) "header" else "action"

    private fun seedValues(rows: List<SheetRowArg>) {
        for (r in rows) {
            if (values.containsKey(r.id)) continue
            when (kindOf(r)) {
                "toggle" -> values[r.id] = if (r.on == true) "true" else "false"
                "textfield", "datetime" -> r.value?.let { values[r.id] = it }
                // The button shows the first option when nothing is chosen, so seed
                // that value too — otherwise submit misses the field until the user
                // re-selects it manually.
                "select" ->
                    (r.value ?: r.options?.firstOrNull()?.value)?.let { values[r.id] = it }
            }
        }
    }

    private fun headerRow(r: SheetRowArg): View {
        val row = rowBase(clickable = false)
        val bmp = decodeImage(r.image)
        if (bmp != null) {
            row.addView(
                ImageView(ctx).apply {
                    setImageBitmap(bmp)
                    scaleType = ImageView.ScaleType.CENTER_CROP
                    layoutParams =
                        LinearLayout.LayoutParams(dp(44), dp(44)).apply { marginEnd = dp(12) }
                },
            )
        } else {
            leadingGlyph(r.sfSymbol, 22f)?.let { row.addView(it) }
        }
        val col = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
        col.addView(
            TextView(ctx).apply {
                text = r.label
                textSize = 17f
                setTypeface(typeface, Typeface.BOLD)
            },
        )
        r.detail?.let { col.addView(mutedText(it, 13f)) }
        row.addView(col)
        return row
    }

    private fun actionRow(r: SheetRowArg): View {
        val row = rowBase(clickable = true)
        leadingGlyph(r.sfSymbol)?.let { row.addView(it) }
        row.addView(
            TextView(ctx).apply {
                text = r.label
                textSize = 16f
                layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
                if (r.destructive == true) setTextColor(DESTRUCTIVE)
            },
        )
        r.badge?.let { row.addView(badgeView(it)) }
        row.setOnClickListener { callbacks.onRow(r.id) }
        return row
    }

    private fun textRow(r: SheetRowArg): View {
        val col = columnBase()
        col.addView(mutedText(r.label, 14f, 0.85f))
        r.detail?.let { col.addView(mutedText(it, 12f, 0.55f)) }
        return col
    }

    private fun textFieldRow(r: SheetRowArg): View {
        val col = columnBase()
        col.addView(mutedText(r.label, 13f, 0.7f))
        val et =
            EditText(ctx).apply {
                setText(values[r.id] ?: "")
                hint = r.placeholder ?: ""
                inputType = InputType.TYPE_CLASS_TEXT
                setSingleLine(true)
            }
        et.addTextChangedListener(
            object : TextWatcher {
                override fun afterTextChanged(s: Editable?) {
                    val v = s?.toString() ?: ""
                    values[r.id] = v
                    callbacks.onField(r.id, v)
                }

                override fun beforeTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {}

                override fun onTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) {}
            },
        )
        col.addView(et)
        return col
    }

    private fun toggleRow(r: SheetRowArg): View {
        val row = rowBase(clickable = false)
        row.addView(flexLabel(r.label))
        val sw = Switch(ctx).apply { isChecked = values[r.id] == "true" }
        sw.setOnCheckedChangeListener { _, checked ->
            val v = checked.toString()
            values[r.id] = v
            callbacks.onField(r.id, v)
        }
        row.addView(sw)
        return row
    }

    private fun dateTimeRow(r: SheetRowArg): View {
        val row = rowBase(clickable = false)
        row.addView(flexLabel(r.label))
        val btn = Button(ctx).apply { isAllCaps = false }
        fun refresh() {
            btn.text = values[r.id]?.let { localLabel(it) } ?: "Choose…"
        }
        refresh()
        btn.setOnClickListener {
            pickDateTime(values[r.id]) { iso ->
                values[r.id] = iso
                callbacks.onField(r.id, iso)
                refresh()
            }
        }
        row.addView(btn)
        return row
    }

    private fun selectRow(r: SheetRowArg): View {
        val row = rowBase(clickable = false)
        row.addView(flexLabel(r.label))
        val opts = r.options ?: emptyList()
        val btn = Button(ctx).apply { isAllCaps = false }
        fun labelFor(value: String?): String =
            opts.firstOrNull { it.value == value }?.label ?: opts.firstOrNull()?.label ?: ""
        btn.text = labelFor(values[r.id])
        btn.setOnClickListener {
            val pm = PopupMenu(ctx, btn)
            opts.forEachIndexed { i, o -> pm.menu.add(0, i, i, o.label) }
            pm.setOnMenuItemClickListener { mi ->
                val o = opts[mi.itemId]
                values[r.id] = o.value
                callbacks.onField(r.id, o.value)
                btn.text = o.label
                true
            }
            pm.show()
        }
        row.addView(btn)
        return row
    }

    private fun submitRow(r: SheetRowArg): View {
        val btn =
            Button(ctx).apply {
                text = r.label
                isAllCaps = false
                setTextColor(Color.WHITE)
                background =
                    GradientDrawable().apply {
                        cornerRadius = dp(14).toFloat()
                        setColor(accent)
                    }
                layoutParams =
                    LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
                        setMargins(dp(16), dp(10), dp(16), dp(6))
                    }
            }
        btn.setOnClickListener { callbacks.onSubmit(r.id, valuesJson()) }
        return btn
    }

    // ── View helpers ─────────────────────────────────────────────────────────

    private fun rowBase(clickable: Boolean): LinearLayout =
        LinearLayout(ctx).apply {
            orientation = LinearLayout.HORIZONTAL
            gravity = Gravity.CENTER_VERTICAL
            setPadding(dp(16), dp(14), dp(16), dp(14))
            layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
            if (clickable) {
                isClickable = true
                isFocusable = true
                val tv = TypedValue()
                context.theme.resolveAttribute(android.R.attr.selectableItemBackground, tv, true)
                if (tv.resourceId != 0) setBackgroundResource(tv.resourceId)
            }
        }

    private fun columnBase(): LinearLayout =
        LinearLayout(ctx).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(dp(16), dp(8), dp(16), dp(8))
        }

    private fun flexLabel(text: String): TextView =
        TextView(ctx).apply {
            this.text = text
            textSize = 16f
            layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
        }

    /** A leading icon for an SF Symbol name (Android has no SF Symbols, so we map
     *  the small set the app uses to glyphs). Returns null for unmapped names. */
    private fun leadingGlyph(sfSymbol: String?, size: Float = 17f): TextView? {
        val glyph = glyphFor(sfSymbol) ?: return null
        return TextView(ctx).apply {
            text = glyph
            textSize = size
            gravity = Gravity.CENTER
            layoutParams =
                LinearLayout.LayoutParams(dp(28), WRAP_CONTENT).apply { marginEnd = dp(10) }
        }
    }

    private fun mutedText(text: String, size: Float, alphaValue: Float = 0.6f): TextView =
        TextView(ctx).apply {
            this.text = text
            textSize = size
            alpha = alphaValue
        }

    private fun badgeView(text: String): TextView =
        TextView(ctx).apply {
            this.text = text
            textSize = 11f
            setTextColor(Color.WHITE)
            setPadding(dp(7), dp(2), dp(7), dp(2))
            background =
                GradientDrawable().apply {
                    cornerRadius = dp(10).toFloat()
                    setColor(accent)
                }
        }

    private fun valuesJson(): String {
        val obj = JSONObject()
        for ((k, v) in values) obj.put(k, v)
        return obj.toString()
    }

    private fun pickDateTime(currentIso: String?, onPicked: (String) -> Unit) {
        val cal = Calendar.getInstance()
        currentIso?.let { parseIso(it)?.let { d -> cal.time = d } }
        DatePickerDialog(
            ctx,
            { _, year, month, day ->
                TimePickerDialog(
                    ctx,
                    { _, hour, minute ->
                        val picked = Calendar.getInstance()
                        picked.set(year, month, day, hour, minute, 0)
                        picked.set(Calendar.MILLISECOND, 0)
                        onPicked(isoUtc(picked.time))
                    },
                    cal.get(Calendar.HOUR_OF_DAY),
                    cal.get(Calendar.MINUTE),
                    false,
                )
                    .show()
            },
            cal.get(Calendar.YEAR),
            cal.get(Calendar.MONTH),
            cal.get(Calendar.DAY_OF_MONTH),
        )
            .show()
    }

    private fun dp(value: Int): Int = (value * ctx.resources.displayMetrics.density).toInt()

    private fun localLabel(iso: String): String {
        val d = parseIso(iso) ?: return iso
        return SimpleDateFormat("EEE, MMM d · h:mm a", Locale.getDefault()).format(d)
    }

    companion object {
        private val DEFAULT_ACCENT = 0xFFE8743B.toInt() // Pendi clementine fallback
        private val DESTRUCTIVE = 0xFFE5484D.toInt()
        private val MATERIAL_THEME = com.google.android.material.R.style.Theme_Material3_DayNight
        private const val MAX_IMAGE_BYTES = 4 * 1024 * 1024 // 4 MB — row icons are small

        /** SF Symbol name → a glyph for the row's leading icon (the app's set). */
        private fun glyphFor(sf: String?): String? =
            when (sf) {
                "plus" -> "+"
                "bell", "bell.fill" -> "🔔"
                "newspaper", "newspaper.fill" -> "📰"
                "gearshape" -> "⚙"
                "heart" -> "❤"
                "arrow.clockwise" -> "↻"
                "rectangle.portrait.and.arrow.right" -> "⎋"
                "calendar.badge.clock" -> "🗓"
                "person.crop.circle.fill" -> "👤"
                else -> null
            }

        private fun isoUtc(d: Date): String {
            val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
            fmt.timeZone = TimeZone.getTimeZone("UTC")
            return fmt.format(d)
        }

        private fun parseIso(s: String): Date? =
            try {
                val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
                fmt.timeZone = TimeZone.getTimeZone("UTC")
                fmt.parse(s)
            } catch (e: Exception) {
                null
            }

        /** `#RRGGBB` / `#RRGGBBAA` → Android ARGB int (it wants `#AARRGGBB`). */
        private fun parseHexColor(hex: String?): Int? {
            if (hex.isNullOrBlank()) return null
            return try {
                var s = hex.trim()
                if (!s.startsWith("#")) s = "#$s"
                if (s.length == 9) {
                    val rgb = s.substring(1, 7)
                    val a = s.substring(7, 9)
                    s = "#$a$rgb"
                }
                Color.parseColor(s)
            } catch (e: Exception) {
                null
            }
        }

        private fun decodeImage(src: String?): Bitmap? {
            if (src.isNullOrBlank()) return null
            return try {
                val base64 = if (src.contains(",")) src.substringAfter(",") else src
                val bytes = Base64.decode(base64, Base64.DEFAULT)
                // Guard against an oversized payload OOMing the decode (row icons
                // are tiny; anything bigger is malformed/hostile).
                if (bytes.size > MAX_IMAGE_BYTES) return null
                BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
            } catch (e: Exception) {
                null
            }
        }
    }
}