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
package com.sosweetham.systemcomponents

import android.app.Activity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin

@InvokeArg
internal class SheetOptionArg {
    lateinit var value: String
    lateinit var label: String
}

@InvokeArg
internal class SheetRowArg {
    lateinit var id: String
    var kind: String? = null
    var label: String = ""
    var detail: String? = null
    var image: String? = null
    var sfSymbol: String? = null
    var badge: String? = null
    var destructive: Boolean? = null
    var header: Boolean? = null
    var value: String? = null
    var on: Boolean? = null
    var placeholder: String? = null
    var options: List<SheetOptionArg>? = null
}

@InvokeArg
internal class PresentSheetArgs {
    lateinit var id: String
    var tint: String? = null
    var rows: List<SheetRowArg> = emptyList()
}

@InvokeArg
internal class DismissSheetArgs {
    lateinit var id: String
}

/**
 * Android implementation of the system-components plugin. Only the **sheet**
 * surface is native here (a Material `BottomSheetDialog`, see [SheetController]);
 * the tab-bar / floating-component commands are iOS-only and never reach
 * Android — the Rust layer rejects them with `Unsupported`, so the web app keeps
 * its HTML pill there.
 *
 * Events mirror iOS exactly so the shared guest-js listeners work unchanged:
 *   `sheetRow {sheetId,rowId}` · `sheetField {sheetId,rowId,value}` ·
 *   `sheetSubmit {sheetId,rowId,values}` · `sheetDismissed {sheetId}`.
 */
@TauriPlugin
class SystemComponentsPlugin(private val activity: Activity) : Plugin(activity) {
    private val sheets = HashMap<String, SheetController>()
    private var cleanupHooked = false

    /**
     * Dismiss any open sheets when the host activity is destroyed — otherwise the
     * dialogs leak their window and the controllers dangle. Registered once,
     * lazily, the first time a sheet is presented. Clearing `sheets` before
     * dismissing avoids the dismiss listener mutating the map mid-iteration.
     */
    private fun ensureCleanup() {
        if (cleanupHooked) return
        val owner = activity as? LifecycleOwner ?: return
        cleanupHooked = true
        owner.lifecycle.addObserver(
            object : DefaultLifecycleObserver {
                override fun onDestroy(owner: LifecycleOwner) {
                    val open = sheets.values.toList()
                    sheets.clear()
                    for (controller in open) runCatching { controller.dismiss() }
                }
            },
        )
    }

    @Command
    fun presentSheet(invoke: Invoke) {
        val args = invoke.parseArgs(PresentSheetArgs::class.java)
        activity.runOnUiThread {
            ensureCleanup()
            try {
                val existing = sheets[args.id]
                if (existing != null && existing.isShowing) {
                    // Already up — refresh the rows in place (matches iOS).
                    existing.update(args.rows)
                    invoke.resolve()
                    return@runOnUiThread
                }
                val controller =
                    SheetController(
                        activity,
                        args.tint,
                        args.rows,
                        SheetCallbacks(
                            onRow = { rowId ->
                                emit("sheetRow", payload(args.id).put("rowId", rowId))
                            },
                            onField = { rowId, value ->
                                emit(
                                    "sheetField",
                                    payload(args.id).put("rowId", rowId).put("value", value),
                                )
                            },
                            onSubmit = { rowId, values ->
                                emit(
                                    "sheetSubmit",
                                    payload(args.id).put("rowId", rowId).put("values", values),
                                )
                            },
                            onDismissed = {
                                sheets.remove(args.id)
                                emit("sheetDismissed", payload(args.id))
                            },
                        ),
                    )
                sheets[args.id] = controller
                controller.show()
                invoke.resolve()
            } catch (e: Exception) {
                invoke.reject(e.message ?: "presentSheet failed")
            }
        }
    }

    @Command
    fun dismissSheet(invoke: Invoke) {
        val args = invoke.parseArgs(DismissSheetArgs::class.java)
        activity.runOnUiThread {
            sheets.remove(args.id)?.dismiss()
            invoke.resolve()
        }
    }

    private fun payload(sheetId: String): JSObject = JSObject().put("sheetId", sheetId)

    private fun emit(event: String, data: JSObject) = trigger(event, data)
}