android-example-voting
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseKuira Voting dApp
Kuira 投票dApp
This skill generates a runnable voting dApp for Midnight Network using
the Kuira Android SDK.
Every snippet below is taken directly from the working starter-android
codebase. If the build fails, read the Failure Modes sections —
they cover the three most common things that go wrong.
Primary references:
- — Kuira SDK docs
https://kuiralabs.github.io/kuira-sdk-android/ - — Dokka API reference
https://kuiralabs.github.io/kuira-sdk-android/api/ - — official starter
https://github.com/kuiralabs/kuira-starter-android - — Maven Central
https://central.sonatype.com/namespace/io.github.kuiralabs
本教程将指导你使用Kuira Android SDK为Midnight Network构建一个可运行的投票dApp。以下所有代码片段均直接取自可运行的starter-android代码库。如果构建失败,请查看故障模式部分——该部分涵盖了最常见的三类问题。
主要参考资料:
- — Kuira SDK 官方文档
https://kuiralabs.github.io/kuira-sdk-android/ - — Dokka API 参考
https://kuiralabs.github.io/kuira-sdk-android/api/ - — 官方启动模板
https://github.com/kuiralabs/kuira-starter-android - — Maven Central 仓库
https://central.sonatype.com/namespace/io.github.kuiralabs
What this builds
构建内容
| Capability | How |
|---|---|
| Forge sigil | |
| Deploy contract | Deploy the Compact contract to Midnight |
| Create poll | Organizer-only via SHA-256 admin key match |
| Cast vote | Select one of 4 options, ZK-proved on-device |
| Close poll | Only the admin key holder can close |
| Live results | |
| 功能 | 实现方式 |
|---|---|
| 生成Sigil | |
| 部署合约 | 将Compact合约部署到Midnight Network |
| 创建投票 | 仅管理员可操作,通过SHA-256管理员密钥匹配验证 |
| 提交投票 | 选择4个选项之一,在设备端完成ZK证明 |
| 关闭投票 | 仅管理员密钥持有者可执行关闭操作 |
| 实时结果 | |
Pinned Versions
固定版本
These are the actual versions from the working codebase. Deviating
causes dependency resolution failures (see §9).
| Layer | Version |
|---|---|
Kuira SDK ( | |
| Kuira Contract plugin | |
| Kuira Localnet plugin | |
| AGP | |
| Kotlin | |
| KSP | |
| Hilt | |
| Compose BOM | |
| Gradle | |
| JDK | |
| |
| |
| |
| |
| |
| |
| |
| |
| Compact compiler | |
| Compact language pragma | |
以下是可运行代码库中使用的实际版本。偏离这些版本会导致依赖解析失败(参见第9节)。
| 层级 | 版本 |
|---|---|
Kuira SDK ( | |
| Kuira Contract 插件 | |
| Kuira Localnet 插件 | |
| AGP | |
| Kotlin | |
| KSP | |
| Hilt | |
| Compose BOM | |
| Gradle | |
| JDK | |
| |
| |
| |
| |
| |
| |
| |
| |
| Compact 编译器 | |
| Compact 语言版本声明 | |
1) Project Structure
1) 项目结构
kuira-starter-android/
├── settings.gradle.kts
├── build.gradle.kts # Root (plugin declarations only)
├── gradle.properties # MUST exist — jvmargs, caching
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ └── gradle-wrapper.properties # Gradle 8.13
├── local.properties # sdk.dir — NOT committed
├── contract/
│ ├── package.json
│ ├── scripts/
│ │ └── patch-voting.js
│ └── src/
│ ├── voting.compact
│ └── managed/voting/ # Compiled artifacts (gitignored)
├── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── res/
│ │ │ ├── values/
│ │ │ │ ├── strings.xml
│ │ │ │ └── themes.xml
│ │ │ ├── xml/
│ │ │ │ └── network_security_config.xml
│ │ │ ├── drawable/
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ └── mipmap-anydpi-v26/
│ │ │ └── ic_launcher.xml
│ │ └── java/com/kuiralabs/starter/counter/
│ │ ├── KuiraStarterApp.kt
│ │ ├── MainActivity.kt
│ │ ├── di/PasskeyConfigModule.kt
│ │ ├── data/
│ │ │ ├── VotingContract.kt
│ │ │ └── ContractAddressStore.kt
│ │ └── ui/
│ │ ├── theme/{Color,Type,Theme}.kt
│ │ ├── VotingScreen.kt
│ │ ├── VotingCard.kt
│ │ ├── VotingViewModel.kt
│ │ └── VotingUiState.kt
│ └── debug/AndroidManifest.xml # Cleartext for localnetWarning: must exist with or the
AGP block can't resolve . Android Studio creates
it; in CI you must create it manually or use an env var.
local.propertiessdk.dir=...androidcompileSdkkuira-starter-android/
├── settings.gradle.kts
├── build.gradle.kts # 根目录(仅声明插件)
├── gradle.properties # 必须存在——配置jvm参数、缓存等
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ └── gradle-wrapper.properties # Gradle 8.13
├── local.properties # sdk.dir — 不提交到版本库
├── contract/
│ ├── package.json
│ ├── scripts/
│ │ └── patch-voting.js
│ └── src/
│ ├── voting.compact
│ └── managed/voting/ # 编译产物(已加入git忽略)
├── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── res/
│ │ │ ├── values/
│ │ │ │ ├── strings.xml
│ │ │ │ └── themes.xml
│ │ │ ├── xml/
│ │ │ │ └── network_security_config.xml
│ │ │ ├── drawable/
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ └── mipmap-anydpi-v26/
│ │ │ └── ic_launcher.xml
│ │ └── java/com/kuiralabs/starter/counter/
│ │ ├── KuiraStarterApp.kt
│ │ ├── MainActivity.kt
│ │ ├── di/PasskeyConfigModule.kt
│ │ ├── data/
│ │ │ ├── VotingContract.kt
│ │ │ └── ContractAddressStore.kt
│ │ └── ui/
│ │ ├── theme/{Color,Type,Theme}.kt
│ │ ├── VotingScreen.kt
│ │ ├── VotingCard.kt
│ │ ├── VotingViewModel.kt
│ │ └── VotingUiState.kt
│ └── debug/AndroidManifest.xml # 为本地网络启用明文传输警告: 必须包含 ,否则AGP的块无法解析。Android Studio会自动创建该文件;在CI环境中,你需要手动创建或使用环境变量。
local.propertiessdk.dir=...androidcompileSdk2) Prerequisites
2) 前置条件
2.1) Toolchain
2.1) 工具链
bash
java --version # JDK 17+
node --version # Node.js 18+
compact update 0.31.0 # Install Compact compiler
compact list # Verify 0.31.0 is the defaultbash
java --version # JDK 17+
node --version # Node.js 18+
compact update 0.31.0 # 安装Compact编译器
compact list # 验证0.31.0是否为默认版本2.2) Android Setup
2.2) Android 环境配置
- Android Studio Ladybug+
- Device/emulator Android 13+ (API 33+) with screen lock set
- Domain for passkey (or use GitHub Pages)
rpId - Google Password Manager (GMS Core 23.40.13+)
- Android Studio Ladybug及以上版本
- Android 13+(API 33+)的设备/模拟器,且已设置屏幕锁
- 用于Passkey 的域名(或使用GitHub Pages)
rpId - Google密码管理器(GMS Core 23.40.13+)
2.3) Gradle wrapper
2.3) Gradle 包装器
The wrapper at must specify
Gradle 8.13 (matching AGP 8.13.2):
gradle/wrapper/gradle-wrapper.propertiesproperties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/distsgradle/wrapper/gradle-wrapper.propertiesproperties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists3) Gradle Configuration
3) Gradle 配置
3.1) gradle.properties
— REQUIRED
gradle.properties3.1) gradle.properties
— 必填
gradle.propertiesWithout this file, Gradle uses defaults that make the build extremely
slow or break the configuration cache:
properties
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=trueFailure mode: Without , the SDK's
dependency resolves to the old support library and
fails at runtime with .
android.useAndroidX=trueAppCompatActivityClassNotFoundExceptionFailure mode: Without , KSP+Hilt
together can OOM on large-ish projects. Bump to if you see
.
org.gradle.jvmargs=-Xmx2048m-Xmx4096mOutOfMemoryError: MetaspaceFailure mode: will break if
your accesses project files at configuration
time (e.g., ). The Copy tasks below avoid this by
using with , but if you add a plugin
that reads files during evaluation, disable configuration cache.
org.gradle.configuration-cache=trueapp/build.gradle.ktsfile("../contract")rootProject.file()doFirst如果没有此文件,Gradle会使用默认配置,导致构建极慢或配置缓存失效:
properties
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true故障模式: 若缺少,SDK的依赖会解析为旧版支持库,运行时会抛出。
android.useAndroidX=trueAppCompatActivityClassNotFoundException故障模式: 若缺少,KSP+Hilt组合在较大项目中可能会出现内存溢出。如果遇到,可将值调整为。
org.gradle.jvmargs=-Xmx2048mOutOfMemoryError: Metaspace-Xmx4096m故障模式: 会在在配置阶段访问项目文件(如)时失效。以下的Copy任务通过使用配合避免了此问题,但如果添加了在评估阶段读取文件的插件,请禁用配置缓存。
org.gradle.configuration-cache=trueapp/build.gradle.ktsfile("../contract")rootProject.file()doFirst3.2) settings.gradle.kts
settings.gradle.kts3.2) settings.gradle.kts
settings.gradle.ktskotlin
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "kuira-starter-android"
include(":app")Gotcha: means you CANNOT declare
inside . If a library
isn't in google() or mavenCentral(), add it to
here.
FAIL_ON_PROJECT_REPOSrepositories { ... }app/build.gradle.ktsdependencyResolutionManagementkotlin
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "kuira-starter-android"
include(":app")注意: 意味着你不能在中声明。如果某个库不在google()或mavenCentral()中,请将其添加到此处的中。
FAIL_ON_PROJECT_REPOSapp/build.gradle.ktsrepositories { ... }dependencyResolutionManagement3.3) Root build.gradle.kts
build.gradle.kts3.3) 根目录 build.gradle.kts
build.gradle.ktskotlin
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}Note: The Kuira contract plugin () and
localnet plugin () are applied by ID and
version directly in , not through the version
catalog. They do NOT appear here.
io.github.kuiralabs.contractio.github.kuiralabs.localnetapp/build.gradle.ktskotlin
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}注意:Kuira合约插件()和localnet插件()通过ID和版本直接在中应用,而非通过版本目录。它们不会出现在此文件中。
io.github.kuiralabs.contractio.github.kuiralabs.localnetapp/build.gradle.kts3.4) gradle/libs.versions.toml
gradle/libs.versions.toml3.4) gradle/libs.versions.toml
gradle/libs.versions.tomltoml
[versions]
agp = "8.13.2"
kotlin = "2.3.20"
ksp = "2.3.6"
hilt = "2.58"
composeBom = "2026.03.01"
androidxAppcompat = "1.7.0"
androidxActivity = "1.10.1"
androidxFragment = "1.8.5"
androidxLifecycle = "2.8.7"
androidxHiltNavCompose = "1.1.0"
androidxSecurityCrypto = "1.1.0-alpha07"
kuira = "0.1.0-alpha04"
[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
kuira-dapp-ui = { group = "io.github.kuiralabs", name = "dapp-ui", version.ref = "kuira" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }Version pinning rules:
- AGP and Gradle wrapper MUST match major.minor: AGP 8.13.x ↔ Gradle 8.13
- Kotlin and KSP MUST match exactly: Kotlin 2.3.20 ↔ KSP 2.3.6
- Compose BOM and Compose compiler plugin are coupled via
plugin — do NOT set a compose-compiler version manually
kotlin-compose - Hilt and ksp-hilt must match the Hilt version (the ksp artifact resolves transitively, no explicit dep needed)
toml
[versions]
agp = "8.13.2"
kotlin = "2.3.20"
ksp = "2.3.6"
hilt = "2.58"
composeBom = "2026.03.01"
androidxAppcompat = "1.7.0"
androidxActivity = "1.10.1"
androidxFragment = "1.8.5"
androidxLifecycle = "2.8.7"
androidxHiltNavCompose = "1.1.0"
androidxSecurityCrypto = "1.1.0-alpha07"
kuira = "0.1.0-alpha04"
[libraries]
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose", name = "material3" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
kuira-dapp-ui = { group = "io.github.kuiralabs", name = "dapp-ui", version.ref = "kuira" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }版本固定规则:
- AGP和Gradle包装器的主版本和次版本必须匹配:AGP 8.13.x ↔ Gradle 8.13
- Kotlin和KSP版本必须完全匹配:Kotlin 2.3.20 ↔ KSP 2.3.6
- Compose BOM和Compose编译器插件通过插件关联——不要手动设置compose-compiler版本
kotlin-compose - Hilt和ksp-hilt版本必须匹配Hilt版本(ksp依赖会自动解析,无需显式声明)
3.5) app/build.gradle.kts
app/build.gradle.kts3.5) app/build.gradle.kts
app/build.gradle.ktskotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
id("io.github.kuiralabs.contract") version "0.1.0-alpha04"
id("io.github.kuiralabs.localnet") version "0.1.0-alpha04"
}
android {
namespace = "com.kuiralabs.starter.counter"
compileSdk = 36
defaultConfig {
applicationId = "com.kuiralabs.starter.counter"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
debug { isMinifyEnabled = false }
release { isMinifyEnabled = false }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
kuiraContract {
source.set("../contract/src/managed/voting")
bundleWalletKeys.set(true)
}
// Hand-rolled Copy to sync voting contract assets (the plugin handles
// the primary source; this is explicit for clarity and matches the
// starter codebase pattern):
tasks.register<Copy>("syncVotingAssets") {
description = "Sync compiled Voting compact contract artifacts into app assets."
group = "build"
from("../contract/src/managed/voting/contract") {
include("index.js")
rename { "voting-contract.js" }
into("runtime")
}
from("../contract/src/managed/voting/keys") {
include("*.prover", "*.verifier")
into("keys")
}
from("../contract/src/managed/voting/zkir") {
include("*.bzkir")
into("keys")
}
into("src/main/assets")
doFirst {
if (!file("../contract/src/managed/voting").exists()) {
throw GradleException("Contract not compiled — run `npm run compile:voting` in contract/ first.")
}
}
}
tasks.named("preBuild") { dependsOn("syncVotingAssets") }
dependencies {
implementation(libs.kuira.dapp.ui)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}Plugin details:
io.github.kuiralabs.contractkuiraContract { ... }- Registers — fails the build with a clear message if
validateKuiraContractSourcedoesn't exist../contract/src/managed/voting - Registers — copies JS + keys into
syncContractAssetssrc/main/assets/{runtime,keys}/ - Wires into
syncContractAssetspreBuild - The flag makes the plugin copy
bundleWalletKeys.set(true)bundle files into assets during the bundle task.bzkir
io.github.kuiralabs.localnetadb reverseinstallDebug10.0.2.2Why both plugin and Copy task? The plugin handles
the primary source automatically (syncing JS + keys + bundleWalletKeys).
The hand-rolled task exists in the starter codebase as an
explicit parallel sync. For this skill, the plugin alone is sufficient;
the Copy task is provided for clarity.
kuiraContractCopyFailure mode: If , the SDK fails at runtime because
it depends on (API 30+)
and (API 34+, with API 30+ fallback path).
minSdk < 30android.security.identity.IdentityCredentialCredentialManagerFailure mode: If or , the
Kuira SDK's constant (
added API 33, behavior change in API 34) may not resolve correctly.
Keep compileSdk = 36.
compileSdk != 36targetSdk != 36WalletNotifications.PERMISSIONPOST_NOTIFICATIONSkotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
id("io.github.kuiralabs.contract") version "0.1.0-alpha04"
id("io.github.kuiralabs.localnet") version "0.1.0-alpha04"
}
android {
namespace = "com.kuiralabs.starter.counter"
compileSdk = 36
defaultConfig {
applicationId = "com.kuiralabs.starter.counter"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
debug { isMinifyEnabled = false }
release { isMinifyEnabled = false }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
kuiraContract {
source.set("../contract/src/managed/voting")
bundleWalletKeys.set(true)
}
// 手动编写Copy任务同步投票合约资源(插件会处理主要资源;此任务为了清晰展示,与启动模板代码模式一致):
tasks.register<Copy>("syncVotingAssets") {
description = "将编译后的Voting compact合约产物同步到app资源目录。"
group = "build"
from("../contract/src/managed/voting/contract") {
include("index.js")
rename { "voting-contract.js" }
into("runtime")
}
from("../contract/src/managed/voting/keys") {
include("*.prover", "*.verifier")
into("keys")
}
from("../contract/src/managed/voting/zkir") {
include("*.bzkir")
into("keys")
}
into("src/main/assets")
doFirst {
if (!file("../contract/src/managed/voting").exists()) {
throw GradleException("合约未编译——请先在contract/目录下运行`npm run compile:voting`。")
}
}
}
tasks.named("preBuild") { dependsOn("syncVotingAssets") }
dependencies {
implementation(libs.kuira.dapp.ui)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}插件详情:
io.github.kuiralabs.contractkuiraContract { ... }- 注册— 如果
validateKuiraContractSource不存在,会清晰提示并终止构建../contract/src/managed/voting - 注册— 将JS文件和密钥复制到
syncContractAssetssrc/main/assets/{runtime,keys}/ - 将关联到
syncContractAssets任务preBuild - 标志会让插件在bundle任务中将
bundleWalletKeys.set(true)包文件复制到资源目录.bzkir
io.github.kuiralabs.localnetinstallDebugadb reverse10.0.2.2为什么同时使用插件和Copy任务? 插件会自动处理主要资源同步(JS、密钥、bundleWalletKeys)。手动编写的Copy任务在启动模板中作为显式的同步示例存在。对于本教程,仅使用插件即可;提供Copy任务是为了更清晰地展示流程。
kuiraContract故障模式: 如果,SDK运行时会失败,因为它依赖于(API 30+)和(API 34+,API 30+有兼容路径)。
minSdk < 30android.security.identity.IdentityCredentialCredentialManager故障模式: 如果或,Kuira SDK的常量(在API 33添加,API 34有行为变更)可能无法正确解析。请保持compileSdk = 36。
compileSdk != 36targetSdk != 36WalletNotifications.PERMISSIONPOST_NOTIFICATIONS4) Android Resources
4) Android 资源
4.1) app/src/main/res/values/strings.xml
app/src/main/res/values/strings.xml4.1) app/src/main/res/values/strings.xml
app/src/main/res/values/strings.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kuira Starter</string>
</resources>xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kuira Starter</string>
</resources>4.2) app/src/main/res/values/themes.xml
app/src/main/res/values/themes.xml4.2) app/src/main/res/values/themes.xml
app/src/main/res/values/themes.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KuiraStarter" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>Why AppCompat parent? extends
(not ) because the SDK's hosts
a biometric prompt internally. The prompt requires a
— satisfies that; does not.
The theme XML must have a compatible parent.
MainActivityAppCompatActivityComponentActivitySigilStatusPanelFragmentActivityAppCompatActivityComponentActivityxml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KuiraStarter" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>为什么使用AppCompat父主题? 继承自(而非),因为SDK的内部包含生物识别提示框。该提示框需要——满足此要求,而不满足。主题XML必须使用兼容的父主题。
MainActivityAppCompatActivityComponentActivitySigilStatusPanelFragmentActivityAppCompatActivityComponentActivity4.3) app/src/main/res/xml/network_security_config.xml
app/src/main/res/xml/network_security_config.xml4.3) app/src/main/res/xml/network_security_config.xml
app/src/main/res/xml/network_security_config.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>Prefer this over in the
manifest. Both work on localnet, but this approach doesn't open
cleartext to the whole internet for the debug build. If you use the
debug manifest approach (see §4.5), it's enabled only for the
build type, so it's also safe.
android:usesCleartextTraffic="true"debugxml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>推荐使用此方式,而非在清单中设置。两种方式在本地网络中都能工作,但此方法不会为debug构建开放全局明文传输。如果使用debug清单方式(参见第4.5节),仅会在构建类型中启用,因此也是安全的。
android:usesCleartextTraffic="true"debug4.4) Main AndroidManifest.xml
AndroidManifest.xml4.4) 主AndroidManifest.xml
AndroidManifest.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".KuiraStarterApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.KuiraStarter">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.KuiraStarter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>Network config: The attribute
pointing to allows cleartext to
localhost/emulator host for localnet without globally enabling it.
android:networkSecurityConfig@xml/network_security_configxml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".KuiraStarterApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.KuiraStarter">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.KuiraStarter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>网络配置: 属性指向,允许本地网络/模拟器主机使用明文传输,而无需全局启用。
android:networkSecurityConfig@xml/network_security_config4.5) Debug manifest (alternative to network_security_config)
4.5) Debug清单(network_security_config的替代方案)
app/src/debug/AndroidManifest.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>Merge rules: the debug manifest is merged into the main manifest for
debug builds only. It's simpler than the security config approach but
opens cleartext to ALL hosts, not just localnet.
Failure mode: If you use both AND
, the security config wins. If the
config doesn't include the right domain, cleartext still fails.
networkSecurityConfigusesCleartextTraffic="true"app/src/debug/AndroidManifest.xmlxml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>合并规则:debug清单仅在debug构建时合并到主清单中。此方法比安全配置方法更简单,但会向所有主机开放明文传输,而非仅本地网络。
故障模式: 如果同时使用和,安全配置会优先生效。如果配置中未包含正确的域名,明文传输仍会失败。
networkSecurityConfigusesCleartextTraffic="true"5) Contract
5) 合约
5.1) contract/package.json
contract/package.json5.1) contract/package.json
contract/package.jsonjson
{
"name": "@yourapp/voting-contract",
"version": "0.1.0",
"private": true,
"description": "Voting contract for the Kuira Voting dApp. Compiled artifacts under src/managed/voting/ are consumed by the Android app via the kuiraContract Gradle plugin.",
"scripts": {
"compile:voting": "compact compile +0.31.0 src/voting.compact src/managed/voting && node scripts/patch-voting.js",
"inspect:voting": "mn contract inspect --managed src/managed/voting"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0"
},
"engines": {
"compactc": "0.31.0"
}
}Failure mode: Runtime version mismatch. The compiled JS at
has a hard-coded runtime
version require. If the version in
doesn't match what the compiler baked in at compile
time, the Android app throws a JS runtime error when loading the
contract.
src/managed/voting/contract/index.js@midnight-ntwrk/compact-runtimepackage.jsonSymptoms:
or a cryptic from the JS engine.
"Could not find module '@midnight-ntwrk/compact-runtime'"TypeErrorFix: Check in the managed output:
contract-info.jsonbash
cat src/managed/voting/compiler/contract-info.jsonjson
{
"name": "@yourapp/voting-contract",
"version": "0.1.0",
"private": true,
"description": "Kuira投票dApp的投票合约。src/managed/voting/下的编译产物通过kuiraContract Gradle插件供Android应用使用。",
"scripts": {
"compile:voting": "compact compile +0.31.0 src/voting.compact src/managed/voting && node scripts/patch-voting.js",
"inspect:voting": "mn contract inspect --managed src/managed/voting"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.16.0"
},
"engines": {
"compactc": "0.31.0"
}
}故障模式:运行时版本不匹配。中的编译后JS硬编码了运行时版本依赖。如果中的版本与编译时编译器内置的版本不匹配,Android应用加载合约时会抛出JS运行时错误。
src/managed/voting/contract/index.jspackage.json@midnight-ntwrk/compact-runtime症状: 或来自JS引擎的模糊。
"Could not find module '@midnight-ntwrk/compact-runtime'"TypeError修复: 检查输出目录中的:
contract-info.jsonbash
cat src/managed/voting/compiler/contract-info.jsonLook for "runtime-version" — must match package.json's dep
查找"runtime-version" — 必须与package.json中的依赖版本匹配
If they differ, either bump `@midnight-ntwrk/compact-runtime` in
`package.json` to match, or recompile with the correct toolchain
version.
**The `+0.31.0` pin** on the `compact compile` command is critical.
Without it, the command uses whatever compiler `compact` toolchain
has as default, which may mismatch the runtime version the contract
was last compiled with. Always pin.如果版本不同,要么将`package.json`中的`@midnight-ntwrk/compact-runtime`版本升级到匹配版本,要么使用正确的工具链版本重新编译。
**`compact compile`命令中的`+0.31.0`固定版本至关重要**。如果没有此固定版本,命令会使用`compact`工具链中的默认编译器版本,可能与合约上次编译时的运行时版本不匹配。请始终固定版本。5.2) contract/scripts/patch-voting.js
contract/scripts/patch-voting.js5.2) contract/scripts/patch-voting.js
contract/scripts/patch-voting.jsjavascript
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '..', 'src', 'managed', 'voting', 'contract', 'index.js');
let src = fs.readFileSync(file, 'utf8');
src = src.replace(
"const optionIdx_0 = args_1[1]",
"let optionIdx_0 = args_1[1];\n if (typeof optionIdx_0 !== 'bigint') optionIdx_0 = BigInt(optionIdx_0)"
);
fs.writeFileSync(file, src, 'utf8');
console.log('Patched BigInt shim into voting contract JS');Why this patch is needed: The Kuira SDK's Kotlin-to-JS bridge
sends Kotlin as JS , not . The Compact type
expects . Without the patch, passes
(JS number) which fails checks inside the runtime.
LongnumberbigintUint<64>bigintcastVote(0)0typeofFailure mode: If generates different variable
names, this string match silently no-ops. After any toolchain bump,
diff against a known-good copy.
compact compilecontract/index.jsFailure mode: The patched line assumes
is the first argument to the castVote circuit. This is compiler-
generated and can change. Always verify the patch works after
recompiling.
optionIdx_0 = args_1[1]javascript
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '..', 'src', 'managed', 'voting', 'contract', 'index.js');
let src = fs.readFileSync(file, 'utf8');
src = src.replace(
"const optionIdx_0 = args_1[1]",
"let optionIdx_0 = args_1[1];\n if (typeof optionIdx_0 !== 'bigint') optionIdx_0 = BigInt(optionIdx_0)"
);
fs.writeFileSync(file, src, 'utf8');
console.log('Patched BigInt shim into voting contract JS');为什么需要此补丁? Kuira SDK的Kotlin-to-JS桥接将Kotlin 作为JS 发送,而非。Compact类型期望接收。如果没有此补丁,会传递(JS number),这会在运行时内部的检查中失败。
LongnumberbigintUint<64>bigintcastVote(0)0typeof故障模式: 如果生成了不同的变量名,此字符串匹配会静默失效。任何工具链升级后,请对比与已知正确的版本。
compact compilecontract/index.js故障模式: 补丁行假设是castVote电路的第一个参数。这是编译器生成的,可能会变化。重新编译后请始终验证补丁是否有效。
optionIdx_0 = args_1[1]5.3) contract/src/voting.compact
contract/src/voting.compact5.3) contract/src/voting.compact
contract/src/voting.compactcompact
pragma language_version >= 0.22;
import CompactStandardLibrary;
export ledger pollQuestion: Opaque<"string">;
export ledger pollOptions: Opaque<"string">;
export ledger adminKeyHash: Bytes<32>;
export ledger voteCount: Uint<64>;
export ledger tally0: Uint<64>;
export ledger tally1: Uint<64>;
export ledger tally2: Uint<64>;
export ledger tally3: Uint<64>;
export ledger pollClosed: Boolean;
witness adminSecret(): Bytes<32>;
pure circuit deriveAdminKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "poll:admin:v1"), sk]);
}
constructor() {
pollClosed = false;
tally0 = 0;
tally1 = 0;
tally2 = 0;
tally3 = 0;
voteCount = 0;
}
export circuit createPoll(question: Opaque<"string">, options: Opaque<"string">): [] {
pollQuestion = disclose(question);
pollOptions = disclose(options);
adminKeyHash = disclose(deriveAdminKey(adminSecret()));
}
export circuit castVote(optionIdx: Uint<64>): [] {
assert(!pollClosed, "Poll is closed");
assert(optionIdx < 4, "Invalid option index (0-3)");
const idx = disclose(optionIdx);
const count = voteCount;
voteCount = disclose((count + 1) as Uint<64>);
if (idx == 0) {
tally0 = (tally0 + 1) as Uint<64>;
} else if (idx == 1) {
tally1 = (tally1 + 1) as Uint<64>;
} else if (idx == 2) {
tally2 = (tally2 + 1) as Uint<64>;
} else {
tally3 = (tally3 + 1) as Uint<64>;
}
}
export circuit closePoll(): [] {
assert(adminKeyHash == deriveAdminKey(adminSecret()), "Only admin can close poll");
pollClosed = disclose(true);
}Key contract design decisions:
-
Noledger field. Unlike typical CRUD contracts, this contract doesn't track whether a poll has been created. The UI infers this from
created. CallingpollQuestion != ""twice would overwrite the first poll (there's nocreatePollguard). This is intentional for simplicity.assert(!created) -
is a
deriveAdminKey. This means it produces no public output on its own — it's only used inside other circuits. Thepure circuitkeyword means the circuit has no ledger writes and no public inputs; it's a helper function.pure -
language version range (not exact). This accepts any compatible 0.22.x compiler. The
>= 0.22command line pin is what actually selects the compiler.compact compile +0.31.0 -
is set to
pollClosedviatrue. When the poll is closed,disclose()becomes public and visible on-chain. Before closing, it defaults topollClosed(from the constructor).false -
No double-vote guard. There's nochecking voter identity. The
Set<Bytes<32>>flag in the UI is just a local cache fromhasVoted. A user who votes, reinstalls, and votes again would succeed. This is by design for this simple contract.observeLedger() -
is set in
adminKeyHash, not in the constructor. The constructor initializes it to... nothing (the Compact default). This means the firstcreatePollsets the admin key; subsequent calls would overwrite it (there's no guard). The Android side'screatePollassert checks against the CURRENTclosePoll, so only someone who knowsadminKeyHashcan close.adminSecret()
compact
pragma language_version >= 0.22;
import CompactStandardLibrary;
export ledger pollQuestion: Opaque<"string">;
export ledger pollOptions: Opaque<"string">;
export ledger adminKeyHash: Bytes<32>;
export ledger voteCount: Uint<64>;
export ledger tally0: Uint<64>;
export ledger tally1: Uint<64>;
export ledger tally2: Uint<64>;
export ledger tally3: Uint<64>;
export ledger pollClosed: Boolean;
witness adminSecret(): Bytes<32>;
pure circuit deriveAdminKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "poll:admin:v1"), sk]);
}
constructor() {
pollClosed = false;
tally0 = 0;
tally1 = 0;
tally2 = 0;
tally3 = 0;
voteCount = 0;
}
export circuit createPoll(question: Opaque<"string">, options: Opaque<"string">): [] {
pollQuestion = disclose(question);
pollOptions = disclose(options);
adminKeyHash = disclose(deriveAdminKey(adminSecret()));
}
export circuit castVote(optionIdx: Uint<64>): [] {
assert(!pollClosed, "Poll is closed");
assert(optionIdx < 4, "Invalid option index (0-3)");
const idx = disclose(optionIdx);
const count = voteCount;
voteCount = disclose((count + 1) as Uint<64>);
if (idx == 0) {
tally0 = (tally0 + 1) as Uint<64>;
} else if (idx == 1) {
tally1 = (tally1 + 1) as Uint<64>;
} else if (idx == 2) {
tally2 = (tally2 + 1) as Uint<64>;
} else {
tally3 = (tally3 + 1) as Uint<64>;
}
}
export circuit closePoll(): [] {
assert(adminKeyHash == deriveAdminKey(adminSecret()), "Only admin can close poll");
pollClosed = disclose(true);
}合约设计关键决策:
-
无账本字段。与典型的CRUD合约不同,本合约不跟踪投票是否已创建。UI通过
created推断投票状态。多次调用pollQuestion != ""会覆盖之前的投票(没有createPoll守卫)。这是为了简化设计而故意为之。assert(!created) -
是
deriveAdminKey。这意味着它自身不会产生任何公开输出——仅在其他电路内部使用。pure circuit关键字表示该电路没有账本写入操作,也没有公开输入;它是一个辅助函数。pure -
语言版本范围(非精确版本)。这会接受任何兼容的0.22.x编译器。
>= 0.22命令行固定版本才是实际选择编译器的依据。compact compile +0.31.0 -
通过
pollClosed设置为disclose()。投票关闭后,true变为公开状态并在链上可见。关闭前,它从构造函数默认值pollClosed开始。false -
无重复投票守卫。没有检查投票者身份。UI中的
Set<Bytes<32>>标志只是来自hasVoted的本地缓存。用户投票后重新安装应用并再次投票会成功。这是本简单合约的设计意图。observeLedger() -
在
adminKeyHash中设置,而非构造函数。构造函数未初始化该字段(使用Compact默认值)。这意味着第一次createPoll会设置管理员密钥;后续调用会覆盖它(没有守卫)。Android端的createPoll断言会检查当前的closePoll,因此只有知道adminKeyHash的人才能关闭投票。adminSecret()
5.4) Compile the contract
5.4) 编译合约
bash
cd contract
npm install
npm run compile:votingExpected output:
Compiling 3 circuits:
circuit "createPoll" (k=...)
circuit "castVote" (k=...)
circuit "closePoll" (k=...)
Patched BigInt shim into voting contract JSIf the patch line doesn't appear, the doesn't
have the expected variable name. Check and update the regex.
contract/index.jsAfter compiling, verify the runtime version:
bash
cat src/managed/voting/compiler/contract-info.jsonbash
cd contract
npm install
npm run compile:voting预期输出:
Compiling 3 circuits:
circuit "createPoll" (k=...)
circuit "castVote" (k=...)
circuit "closePoll" (k=...)
Patched BigInt shim into voting contract JS如果未出现补丁行,说明没有预期的变量名。请检查并更新正则表达式。
contract/index.js编译完成后,验证运行时版本:
bash
cat src/managed/voting/compiler/contract-info.jsonConfirm runtime-version matches @midnight-ntwrk/compact-runtime in package.json
确认runtime-version与package.json中的@midnight-ntwrk/compact-runtime版本匹配
---
---6) Application & DI
6) 应用与依赖注入
6.1) KuiraStarterApp.kt
KuiraStarterApp.kt6.1) KuiraStarterApp.kt
KuiraStarterApp.ktkotlin
package com.kuiralabs.starter.counter
import android.app.Application
import com.midnight.kuira.sdk.walletruntime.SessionLock
import com.midnight.kuira.sdk.walletruntime.WalletForegroundService
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class KuiraStarterApp : Application() {
override fun onCreate() {
super.onCreate()
SessionLock.attach(this)
WalletForegroundService.attach(this)
}
}SessionLock.attach(this)onUserInteractionWalletForegroundService.attach(this)Failure mode: If you forget , Dagger can't
initialize the dependency graph and you get a runtime crash on launch
().
@HiltAndroidAppdagger.hilt.internal.NoopComponentManagerkotlin
package com.kuiralabs.starter.counter
import android.app.Application
import com.midnight.kuira.sdk.walletruntime.SessionLock
import com.midnight.kuira.sdk.walletruntime.WalletForegroundService
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class KuiraStarterApp : Application() {
override fun onCreate() {
super.onCreate()
SessionLock.attach(this)
WalletForegroundService.attach(this)
}
}SessionLock.attach(this)onUserInteractionWalletForegroundService.attach(this)故障模式: 如果忘记添加,Dagger无法初始化依赖图,启动时会崩溃()。
@HiltAndroidAppdagger.hilt.internal.NoopComponentManager6.2) di/PasskeyConfigModule.kt
di/PasskeyConfigModule.kt6.2) di/PasskeyConfigModule.kt
di/PasskeyConfigModule.ktkotlin
package com.kuiralabs.starter.counter.di
import com.midnight.kuira.core.identity.passkey.PasskeyConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PasskeyConfigModule {
private const val PASSKEY_RP_ID = "YOUR_DOMAIN.example.com"
@Provides
@Singleton
fun providePasskeyConfig(): PasskeyConfig =
PasskeyConfig(rpId = PASSKEY_RP_ID, rpName = "Kuira Sigil")
}Why Dagger won't compile without this: The Kuira SDK declares
as a required binding in its own module. If the
consuming app doesn't provide one, Dagger fails at compile time with
. This is
intentional — the SDK can't know your domain.
PasskeyConfig[Dagger/MissingBinding] PasskeyConfig is injected at ...Passkey domain setup:
- Set to a domain you control
PASSKEY_RP_ID - Host at
assetlinks.jsonwith your app'shttps://<rpId>/.well-known/assetlinks.jsonand signing SHA-256:applicationIdbash./gradlew signingReport | grep SHA-256json[{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.kuiralabs.starter.counter", "sha256_cert_fingerprints": ["SHA-256:..."] } }]
Failure mode: at runtime: the passkey credential
was created with one but you're now using a different one.
Only solution: delete the credential from Google Password Manager and
re-create it.
RP_ID_MISMATCHrpIdkotlin
package com.kuiralabs.starter.counter.di
import com.midnight.kuira.core.identity.passkey.PasskeyConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PasskeyConfigModule {
private const val PASSKEY_RP_ID = "YOUR_DOMAIN.example.com"
@Provides
@Singleton
fun providePasskeyConfig(): PasskeyConfig =
PasskeyConfig(rpId = PASSKEY_RP_ID, rpName = "Kuira Sigil")
}为什么没有此模块Dagger无法编译? Kuira SDK在其自身模块中声明为必需绑定。如果消费应用未提供该绑定,Dagger会在编译时抛出错误。这是故意设计的——SDK无法知晓你的域名。
PasskeyConfig[Dagger/MissingBinding] PasskeyConfig is injected at ...Passkey域名设置:
- 将设置为你控制的域名
PASSKEY_RP_ID - 在托管
https://<rpId>/.well-known/assetlinks.json,包含应用的assetlinks.json和签名SHA-256:applicationIdbash./gradlew signingReport | grep SHA-256json[{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.kuiralabs.starter.counter", "sha256_cert_fingerprints": ["SHA-256:..."] } }]
故障模式: 运行时出现:Passkey凭据是使用一个创建的,但现在使用了不同的。唯一解决方案是从Google密码管理器中删除凭据并重新创建。
RP_ID_MISMATCHrpIdrpId7) Data Layer
7) 数据层
7.1) data/VotingContract.kt
data/VotingContract.kt7.1) data/VotingContract.kt
data/VotingContract.ktThis is the core contract wrapper. Every detail matters.
kotlin
package com.kuiralabs.starter.counter.data
import android.content.Context
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.compact.WitnessResult
import com.midnight.kuira.core.compact.proving.ProvingKeyManager
import com.midnight.kuira.sdk.MidnightSdk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.MessageDigest
internal object VotingContract {
private const val NAME = "voting"
private const val CIRCUIT_CREATE_POLL = "createPoll"
private const val CIRCUIT_CAST_VOTE = "castVote"
private const val CIRCUIT_CLOSE_POLL = "closePoll"
private const val WITNESS_ADMIN_SECRET = "adminSecret"
private const val LEDGER_FIELD_POLL_QUESTION = "pollQuestion"
private const val LEDGER_FIELD_POLL_OPTIONS = "pollOptions"
private const val LEDGER_FIELD_ADMIN_KEY_HASH = "adminKeyHash"
private const val LEDGER_FIELD_VOTE_COUNT = "voteCount"
private const val LEDGER_FIELD_TALLY0 = "tally0"
private const val LEDGER_FIELD_TALLY1 = "tally1"
private const val LEDGER_FIELD_TALLY2 = "tally2"
private const val LEDGER_FIELD_TALLY3 = "tally3"
private const val LEDGER_FIELD_POLL_CLOSED = "pollClosed"
private const val CONTRACT_JS_ASSET = "runtime/$NAME-contract.js"
private const val VERIFIER_ASSET_CREATE = "keys/$CIRCUIT_CREATE_POLL.verifier"
private const val VERIFIER_ASSET_CAST = "keys/$CIRCUIT_CAST_VOTE.verifier"
private const val VERIFIER_ASSET_CLOSE = "keys/$CIRCUIT_CLOSE_POLL.verifier"
private val ADMIN_SECRET = ByteArray(32) { 0x42 }
private fun loadVerifierKeys(context: Context): Map<String, ByteArray> {
val createVerifier = context.assets.open(VERIFIER_ASSET_CREATE).use { it.readBytes() }
val castVerifier = context.assets.open(VERIFIER_ASSET_CAST).use { it.readBytes() }
val closeVerifier = context.assets.open(VERIFIER_ASSET_CLOSE).use { it.readBytes() }
return mapOf(
CIRCUIT_CREATE_POLL to createVerifier,
CIRCUIT_CAST_VOTE to castVerifier,
CIRCUIT_CLOSE_POLL to closeVerifier,
)
}
private fun installProvingKeys(context: Context) {
ProvingKeyManager(context).installCircuitKeysFromAssets()
}
private fun buildHandle(
context: Context,
sdk: MidnightSdk,
address: String?,
forWrite: Boolean,
): MidnightContract = MidnightContract.create(sdk.config) {
name = NAME
contractJs = context.assets.open(CONTRACT_JS_ASSET)
if (address != null) this.address = address
if (forWrite) {
coinPublicKey = sdk.coinPublicKey
circuitVerifierKeys = loadVerifierKeys(context)
witness(WITNESS_ADMIN_SECRET) { adminSecretWitness() }
}
}
private fun adminSecretWitness(): WitnessResult =
WitnessResult(null, ADMIN_SECRET)
private suspend fun stageKeysAndHandle(
context: Context,
sdk: MidnightSdk,
address: String?,
): MidnightContract {
installProvingKeys(context)
return buildHandle(context, sdk, address = address, forWrite = true)
}
suspend fun deploy(
context: Context,
sdk: MidnightSdk,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
): String {
val handle = stageKeysAndHandle(context, sdk, address = null)
return handle.deploy(onProgress = onProgress).contractAddress
}
suspend fun createPoll(
context: Context,
sdk: MidnightSdk,
address: String,
question: String,
options: String,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CREATE_POLL, question, options, onProgress = onProgress)
}
suspend fun castVote(
context: Context,
sdk: MidnightSdk,
address: String,
optionIdx: Long,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CAST_VOTE, optionIdx, onProgress = onProgress)
}
suspend fun closePoll(
context: Context,
sdk: MidnightSdk,
address: String,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CLOSE_POLL, onProgress = onProgress)
}
fun buildReadHandle(context: Context, sdk: MidnightSdk, address: String): MidnightContract =
buildHandle(context, sdk, address = address, forWrite = false)
data class VotingLedger(
val pollQuestion: String,
val pollOptions: String,
val adminKeyHash: ByteArray,
val voteCount: Long,
val tally0: Long,
val tally1: Long,
val tally2: Long,
val tally3: Long,
val pollClosed: Boolean,
)
suspend fun readLedger(handle: MidnightContract): VotingLedger {
val ledger = handle.ledger()
return VotingLedger(
pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
)
}
fun observeLedger(handle: MidnightContract): Flow<VotingLedger> =
handle.observeLedger().map { ledger ->
VotingLedger(
pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
)
}
}Critical pattern: vs — this is a singleton, not
a per-instance class. The is hardcoded as
(all 0x42 bytes). Every deploy from the
same APK uses the same admin secret. Anyone who decompiles the APK
gets the admin key for every deployed poll.
objectclassADMIN_SECRETByteArray(32) { 0x42 }WitnessResult(null, ADMIN_SECRET)privateState: Any?nullWitnessResultMidnightContract.create(sdk.config) { ... }- : used as a key for the contract JS asset lookup
name - : an
contractJsto the contract JS fileInputStream - : the on-chain address (null for deploy)
address - : needed for write operations (signing)
coinPublicKey - : map of circuit name → verifier key bytes
circuitVerifierKeys - : registers a witness provider
witness(name) { ... }
Asset path convention: The contract JS goes in
and verifier keys go in . The Gradle
plugin copies them there automatically from the managed output
directory.
assets/runtime/assets/keys/kuiraContractFailure mode: . The asset wasn't synced. Run
to trigger the sync, then check
exists with the right files.
IllegalStateException: Contract JS not found at assets/runtime/voting-contract.js./gradlew :app:assembleDebugapp/src/main/assets/Failure mode: throws
if hasn't been set yet (before ). The
contract doesn't initialize in the constructor, so
it may be all-zeros or an undefined value. The actual contract sets
it in . The UI must handle being absent.
getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32)adminKeyHashcreatePolladminKeyHashcreatePolladminKeyHash这是核心合约包装类。每个细节都很重要。
kotlin
package com.kuiralabs.starter.counter.data
import android.content.Context
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.compact.WitnessResult
import com.midnight.kuira.core.compact.proving.ProvingKeyManager
import com.midnight.kuira.sdk.MidnightSdk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.security.MessageDigest
internal object VotingContract {
private const val NAME = "voting"
private const val CIRCUIT_CREATE_POLL = "createPoll"
private const val CIRCUIT_CAST_VOTE = "castVote"
private const val CIRCUIT_CLOSE_POLL = "closePoll"
private const val WITNESS_ADMIN_SECRET = "adminSecret"
private const val LEDGER_FIELD_POLL_QUESTION = "pollQuestion"
private const val LEDGER_FIELD_POLL_OPTIONS = "pollOptions"
private const val LEDGER_FIELD_ADMIN_KEY_HASH = "adminKeyHash"
private const val LEDGER_FIELD_VOTE_COUNT = "voteCount"
private const val LEDGER_FIELD_TALLY0 = "tally0"
private const val LEDGER_FIELD_TALLY1 = "tally1"
private const val LEDGER_FIELD_TALLY2 = "tally2"
private const val LEDGER_FIELD_TALLY3 = "tally3"
private const val LEDGER_FIELD_POLL_CLOSED = "pollClosed"
private const val CONTRACT_JS_ASSET = "runtime/$NAME-contract.js"
private const val VERIFIER_ASSET_CREATE = "keys/$CIRCUIT_CREATE_POLL.verifier"
private const val VERIFIER_ASSET_CAST = "keys/$CIRCUIT_CAST_VOTE.verifier"
private const val VERIFIER_ASSET_CLOSE = "keys/$CIRCUIT_CLOSE_POLL.verifier"
private val ADMIN_SECRET = ByteArray(32) { 0x42 }
private fun loadVerifierKeys(context: Context): Map<String, ByteArray> {
val createVerifier = context.assets.open(VERIFIER_ASSET_CREATE).use { it.readBytes() }
val castVerifier = context.assets.open(VERIFIER_ASSET_CAST).use { it.readBytes() }
val closeVerifier = context.assets.open(VERIFIER_ASSET_CLOSE).use { it.readBytes() }
return mapOf(
CIRCUIT_CREATE_POLL to createVerifier,
CIRCUIT_CAST_VOTE to castVerifier,
CIRCUIT_CLOSE_POLL to closeVerifier,
)
}
private fun installProvingKeys(context: Context) {
ProvingKeyManager(context).installCircuitKeysFromAssets()
}
private fun buildHandle(
context: Context,
sdk: MidnightSdk,
address: String?,
forWrite: Boolean,
): MidnightContract = MidnightContract.create(sdk.config) {
name = NAME
contractJs = context.assets.open(CONTRACT_JS_ASSET)
if (address != null) this.address = address
if (forWrite) {
coinPublicKey = sdk.coinPublicKey
circuitVerifierKeys = loadVerifierKeys(context)
witness(WITNESS_ADMIN_SECRET) { adminSecretWitness() }
}
}
private fun adminSecretWitness(): WitnessResult =
WitnessResult(null, ADMIN_SECRET)
private suspend fun stageKeysAndHandle(
context: Context,
sdk: MidnightSdk,
address: String?,
): MidnightContract {
installProvingKeys(context)
return buildHandle(context, sdk, address = address, forWrite = true)
}
suspend fun deploy(
context: Context,
sdk: MidnightSdk,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
): String {
val handle = stageKeysAndHandle(context, sdk, address = null)
return handle.deploy(onProgress = onProgress).contractAddress
}
suspend fun createPoll(
context: Context,
sdk: MidnightSdk,
address: String,
question: String,
options: String,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CREATE_POLL, question, options, onProgress = onProgress)
}
suspend fun castVote(
context: Context,
sdk: MidnightSdk,
address: String,
optionIdx: Long,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CAST_VOTE, optionIdx, onProgress = onProgress)
}
suspend fun closePoll(
context: Context,
sdk: MidnightSdk,
address: String,
onProgress: (suspend (ContractCallStage) -> Unit)? = null,
) {
val handle = stageKeysAndHandle(context, sdk, address = address)
handle.call(CIRCUIT_CLOSE_POLL, onProgress = onProgress)
}
fun buildReadHandle(context: Context, sdk: MidnightSdk, address: String): MidnightContract =
buildHandle(context, sdk, address = address, forWrite = false)
data class VotingLedger(
val pollQuestion: String,
val pollOptions: String,
val adminKeyHash: ByteArray,
val voteCount: Long,
val tally0: Long,
val tally1: Long,
val tally2: Long,
val tally3: Long,
val pollClosed: Boolean,
)
suspend fun readLedger(handle: MidnightContract): VotingLedger {
val ledger = handle.ledger()
return VotingLedger(
pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
)
}
fun observeLedger(handle: MidnightContract): Flow<VotingLedger> =
handle.observeLedger().map { ledger ->
VotingLedger(
pollQuestion = ledger.getStringOrNull(LEDGER_FIELD_POLL_QUESTION) ?: "",
pollOptions = ledger.getStringOrNull(LEDGER_FIELD_POLL_OPTIONS) ?: "",
adminKeyHash = ledger.getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32),
voteCount = ledger.getUint64(LEDGER_FIELD_VOTE_COUNT),
tally0 = ledger.getUint64(LEDGER_FIELD_TALLY0),
tally1 = ledger.getUint64(LEDGER_FIELD_TALLY1),
tally2 = ledger.getUint64(LEDGER_FIELD_TALLY2),
tally3 = ledger.getUint64(LEDGER_FIELD_TALLY3),
pollClosed = ledger.getBoolean(LEDGER_FIELD_POLL_CLOSED),
)
}
}关键模式: vs — 这是一个单例,而非实例类。硬编码为(所有字节为0x42)。同一个APK部署的所有投票都使用相同的管理员密钥。任何反编译APK的人都能获取所有已部署投票的管理员密钥。
objectclassADMIN_SECRETByteArray(32) { 0x42 }WitnessResult(null, ADMIN_SECRET)privateState: Any?nullWitnessResultMidnightContract.create(sdk.config) { ... }- :用作合约JS资源查找的键
name - :合约JS文件的
contractJsInputStream - :链上地址(部署时为null)
address - :写操作需要(签名)
coinPublicKey - :电路名称→验证密钥字节的映射
circuitVerifierKeys - :注册见证提供者
witness(name) { ... }
资源路径约定: 合约JS放在,验证密钥放在。 Gradle插件会自动从托管输出目录将它们复制到对应位置。
assets/runtime/assets/keys/kuiraContract故障模式: 。资源未同步。运行触发同步,然后检查是否存在正确的文件。
IllegalStateException: Contract JS not found at assets/runtime/voting-contract.js./gradlew :app:assembleDebugapp/src/main/assets/故障模式: 在调用前抛出错误。合约在构造函数中未初始化,因此它可能全为0或未定义。实际合约在中设置该字段。UI必须处理不存在的情况。
getBytes(LEDGER_FIELD_ADMIN_KEY_HASH, 32)createPolladminKeyHashcreatePolladminKeyHash7.2) data/ContractAddressStore.kt
data/ContractAddressStore.kt7.2) data/ContractAddressStore.kt
data/ContractAddressStore.ktkotlin
package com.kuiralabs.starter.counter.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.midnight.kuira.core.network.MidnightNetwork
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContractAddressStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"kuira-starter-contract-prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun get(network: MidnightNetwork, prefix: String = "voting"): String? =
prefs.getString("$prefix.${network.name}", null)
fun put(network: MidnightNetwork, prefix: String = "voting", address: String) {
prefs.edit().putString("$prefix.${network.name}", address).apply()
}
fun clear(network: MidnightNetwork, prefix: String = "voting") {
prefs.edit().remove("$prefix.${network.name}").apply()
}
}The default prefix is , matching the VotingContract name.
Each network gets its own slot (e.g. ), so
switching networks in the wallet panel doesn't strand the previous
deploy.
"voting"voting.UNDEPLOYEDEncryptedSharedPreferences: Uses AES256-GCM for the master key
and AES256-SIV for key encryption. The uses
Android Keystore under the hood, so the encryption keys are
hardware-backed on devices with a TEE.
MasterKey.BuilderFailure mode: requires API 23+.
The minSdk is 30, so this is fine. But if you backport to lower
minSdk, use instead.
MasterKey.Builder.setKeySchemeMasterKey.DEFAULT_MASTER_KEY_ALIASkotlin
package com.kuiralabs.starter.counter.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.midnight.kuira.core.network.MidnightNetwork
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContractAddressStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"kuira-starter-contract-prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun get(network: MidnightNetwork, prefix: String = "voting"): String? =
prefs.getString("$prefix.${network.name}", null)
fun put(network: MidnightNetwork, prefix: String = "voting", address: String) {
prefs.edit().putString("$prefix.${network.name}", address).apply()
}
fun clear(network: MidnightNetwork, prefix: String = "voting") {
prefs.edit().remove("$prefix.${network.name}").apply()
}
}默认前缀为,与VotingContract名称匹配。每个网络都有自己的存储槽(如),因此在钱包面板中切换网络不会影响之前的部署。
"voting"voting.UNDEPLOYEDEncryptedSharedPreferences: 使用AES256-GCM作为主密钥,AES256-SIV作为密钥加密。在底层使用Android Keystore,因此在带有TEE的设备上,加密密钥是硬件支持的。
MasterKey.Builder故障模式: 需要API 23+。本项目minSdk为30,因此没问题。但如果向后兼容到更低的minSdk,请使用替代。
MasterKey.Builder.setKeySchemeMasterKey.DEFAULT_MASTER_KEY_ALIAS8) UI Layer
8) UI层
8.1) ui/VotingUiState.kt
ui/VotingUiState.kt8.1) ui/VotingUiState.kt
ui/VotingUiState.ktkotlin
package com.kuiralabs.starter.counter.ui
sealed interface VotingUiState {
data object NotReady : VotingUiState
data object ReadyToDeploy : VotingUiState
data class Deployed(
val address: String,
val pollQuestion: String?,
val pollOptions: List<String>?,
val adminKeyHash: ByteArray?,
val voteCount: Long?,
val tallies: List<Long>?,
val pollClosed: Boolean?,
val hasVoted: Boolean,
) : VotingUiState
}Note: — it's nullable because the
contract doesn't set it until is called. Before that,
the ledger doesn't have a meaningful value for this field.
adminKeyHash: ByteArray?createPollhasVotedtruecastVote()falsekotlin
package com.kuiralabs.starter.counter.ui
sealed interface VotingUiState {
data object NotReady : VotingUiState
data object ReadyToDeploy : VotingUiState
data class Deployed(
val address: String,
val pollQuestion: String?,
val pollOptions: List<String>?,
val adminKeyHash: ByteArray?,
val voteCount: Long?,
val tallies: List<Long>?,
val pollClosed: Boolean?,
val hasVoted: Boolean,
) : VotingUiState
}注意: — 它是可空的,因为合约在调用前不会设置该字段。在此之前,账本中该字段没有有意义的值。
adminKeyHash: ByteArray?createPollhasVotedcastVote()truefalse8.2) ui/VotingViewModel.kt
ui/VotingViewModel.kt8.2) ui/VotingViewModel.kt
ui/VotingViewModel.ktkotlin
package com.kuiralabs.starter.counter.ui
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuiralabs.starter.counter.data.ContractAddressStore
import com.kuiralabs.starter.counter.data.VotingContract
import com.kuiralabs.starter.counter.data.VotingContract.VotingLedger
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.network.MidnightNetwork
import com.midnight.kuira.sdk.MidnightSdk
import com.midnight.kuira.sdk.walletruntime.MidnightSdkProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class VotingViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkProvider: MidnightSdkProvider,
private val addressStore: ContractAddressStore,
) : ViewModel() {
private val _state = MutableStateFlow<VotingUiState>(VotingUiState.NotReady)
val state: StateFlow<VotingUiState> = _state.asStateFlow()
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _callStage = MutableStateFlow<ContractCallStage?>(null)
val callStage: StateFlow<ContractCallStage?> = _callStage.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _question = MutableStateFlow("")
val question: StateFlow<String> = _question.asStateFlow()
private val _options = MutableStateFlow("")
val options: StateFlow<String> = _options.asStateFlow()
val selectedNetwork: StateFlow<MidnightNetwork> get() = sdkProvider.selectedNetwork
fun selectNetwork(network: MidnightNetwork) = sdkProvider.selectNetwork(network)
private var ledgerJob: Job? = null
init {
viewModelScope.launch {
sdkProvider.sdk.combine(sdkProvider.selectedNetwork) { sdk, net -> sdk to net }
.collect { (sdk, network) -> recomputeState(sdk, network) }
}
}
private fun recomputeState(sdk: MidnightSdk?, network: MidnightNetwork) {
val persisted = addressStore.get(network, "voting")
val next = when {
sdk == null -> VotingUiState.NotReady
persisted == null -> VotingUiState.ReadyToDeploy
else -> VotingUiState.Deployed(
address = persisted,
pollQuestion = null,
pollOptions = null,
adminKeyHash = null,
voteCount = null,
tallies = null,
pollClosed = null,
hasVoted = false,
)
}
_state.value = next
if (sdk != null && persisted != null) startObserving(sdk, persisted) else stopObserving()
}
fun deploy() {
val sdk = sdkProvider.sdk.value ?: return
val network = sdkProvider.selectedNetwork.value
runAction {
val address = VotingContract.deploy(context, sdk) { _callStage.value = it }
addressStore.put(network, "voting", address)
recomputeState(sdk, network)
}
}
fun disconnect() {
val network = sdkProvider.selectedNetwork.value
addressStore.clear(network, "voting")
recomputeState(sdkProvider.sdk.value, network)
}
fun updateQuestion(value: String) { _question.value = value }
fun updateOptions(value: String) { _options.value = value }
fun createPoll() {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
val q = _question.value.trim()
val o = _options.value.trim()
if (q.isBlank() || o.isBlank()) return
runAction {
VotingContract.createPoll(context, sdk, address, q, o) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
}
}
fun castVote(optionIdx: Int) {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
runAction {
VotingContract.castVote(context, sdk, address, optionIdx.toLong()) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
_state.update { current ->
if (current is VotingUiState.Deployed && current.address == address) {
current.copy(hasVoted = true)
} else current
}
}
}
fun closePoll() {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
runAction {
VotingContract.closePoll(context, sdk, address) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
}
}
private suspend fun readAndUpdateLedger(sdk: MidnightSdk, address: String) {
val fresh = VotingContract.readLedger(readHandleFor(sdk, address))
applyLedger(fresh, address)
}
private fun runAction(block: suspend () -> Unit) {
viewModelScope.launch {
_busy.value = true
_error.value = null
try {
block()
} catch (t: Throwable) {
_error.value = t.message ?: t::class.simpleName ?: "Unknown error"
} finally {
_busy.value = false
_callStage.value = null
}
}
}
private var readHandle: MidnightContract? = null
private var readHandleAddress: String? = null
private fun readHandleFor(sdk: MidnightSdk, address: String): MidnightContract {
if (readHandle == null || readHandleAddress != address) {
readHandle = VotingContract.buildReadHandle(context, sdk, address)
readHandleAddress = address
}
return readHandle!!
}
private fun startObserving(sdk: MidnightSdk, address: String) {
ledgerJob?.cancel()
ledgerJob = viewModelScope.launch {
val handle = readHandleFor(sdk, address)
VotingContract.observeLedger(handle)
.catch { }
.collect { fresh -> applyLedger(fresh, address) }
}
}
private fun applyLedger(fresh: VotingLedger, address: String) {
val optList = fresh.pollOptions.split(",").map { it.trim() }.filter { it.isNotBlank() }
_state.update { current ->
if (current is VotingUiState.Deployed && current.address == address) {
current.copy(
pollQuestion = fresh.pollQuestion,
pollOptions = optList,
adminKeyHash = fresh.adminKeyHash,
voteCount = fresh.voteCount,
tallies = listOf(fresh.tally0, fresh.tally1, fresh.tally2, fresh.tally3),
pollClosed = fresh.pollClosed,
)
} else current
}
}
private fun stopObserving() {
ledgerJob?.cancel()
ledgerJob = null
readHandle = null
readHandleAddress = null
}
}Key patterns:
-
Network selection:is a
sdkProvider.selectedNetwork. The SDK'sStateFlow<MidnightNetwork>composable binds to it. The ViewModel reads it to pass toPanelBar.ContractAddressStore -
vs
recomputeState:applyLedgeris called when the SDK or network changes (e.g., wallet unlock, network switch). It resets to initial state.recomputeStateis called when the on-chain ledger updates (viaapplyLedgeror manualobserveLedger()). It preserves the Deployed state and fills in live data.readLedger() -
caching: A
readHandlehandle for read-only access is cached per address. When the address changes (e.g., deploying a new contract), the old handle is discarded.MidnightContract -
error handling: Catches ALL exceptions and surfaces the message. A failed
runAction(wrong admin secret) throwsclosePoll. TheCircuitExecutionException("Only admin can close poll")progress is cleared in the_callStageblock.finally -
: The SDK defines a sealed interface for deploy/call progress. It's surfaced via the
ContractCallStagecomposable. The ViewModel exposes it asContractCallProgressBar.callStage: StateFlow -
: Silently swallows ledger observation errors. If the connection drops during observation, it won't crash the ViewModel. The UI will just stop updating until the next user action triggers
observeLedger().catch { }.readAndUpdateLedger
kotlin
package com.kuiralabs.starter.counter.ui
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kuiralabs.starter.counter.data.ContractAddressStore
import com.kuiralabs.starter.counter.data.VotingContract
import com.kuiralabs.starter.counter.data.VotingContract.VotingLedger
import com.midnight.kuira.core.compact.ContractCallStage
import com.midnight.kuira.core.compact.MidnightContract
import com.midnight.kuira.core.network.MidnightNetwork
import com.midnight.kuira.sdk.MidnightSdk
import com.midnight.kuira.sdk.walletruntime.MidnightSdkProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class VotingViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkProvider: MidnightSdkProvider,
private val addressStore: ContractAddressStore,
) : ViewModel() {
private val _state = MutableStateFlow<VotingUiState>(VotingUiState.NotReady)
val state: StateFlow<VotingUiState> = _state.asStateFlow()
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _callStage = MutableStateFlow<ContractCallStage?>(null)
val callStage: StateFlow<ContractCallStage?> = _callStage.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _question = MutableStateFlow("")
val question: StateFlow<String> = _question.asStateFlow()
private val _options = MutableStateFlow("")
val options: StateFlow<String> = _options.asStateFlow()
val selectedNetwork: StateFlow<MidnightNetwork> get() = sdkProvider.selectedNetwork
fun selectNetwork(network: MidnightNetwork) = sdkProvider.selectNetwork(network)
private var ledgerJob: Job? = null
init {
viewModelScope.launch {
sdkProvider.sdk.combine(sdkProvider.selectedNetwork) { sdk, net -> sdk to net }
.collect { (sdk, network) -> recomputeState(sdk, network) }
}
}
private fun recomputeState(sdk: MidnightSdk?, network: MidnightNetwork) {
val persisted = addressStore.get(network, "voting")
val next = when {
sdk == null -> VotingUiState.NotReady
persisted == null -> VotingUiState.ReadyToDeploy
else -> VotingUiState.Deployed(
address = persisted,
pollQuestion = null,
pollOptions = null,
adminKeyHash = null,
voteCount = null,
tallies = null,
pollClosed = null,
hasVoted = false,
)
}
_state.value = next
if (sdk != null && persisted != null) startObserving(sdk, persisted) else stopObserving()
}
fun deploy() {
val sdk = sdkProvider.sdk.value ?: return
val network = sdkProvider.selectedNetwork.value
runAction {
val address = VotingContract.deploy(context, sdk) { _callStage.value = it }
addressStore.put(network, "voting", address)
recomputeState(sdk, network)
}
}
fun disconnect() {
val network = sdkProvider.selectedNetwork.value
addressStore.clear(network, "voting")
recomputeState(sdkProvider.sdk.value, network)
}
fun updateQuestion(value: String) { _question.value = value }
fun updateOptions(value: String) { _options.value = value }
fun createPoll() {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
val q = _question.value.trim()
val o = _options.value.trim()
if (q.isBlank() || o.isBlank()) return
runAction {
VotingContract.createPoll(context, sdk, address, q, o) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
}
}
fun castVote(optionIdx: Int) {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
runAction {
VotingContract.castVote(context, sdk, address, optionIdx.toLong()) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
_state.update { current ->
if (current is VotingUiState.Deployed && current.address == address) {
current.copy(hasVoted = true)
} else current
}
}
}
fun closePoll() {
val sdk = sdkProvider.sdk.value ?: return
val address = (state.value as? VotingUiState.Deployed)?.address ?: return
runAction {
VotingContract.closePoll(context, sdk, address) { _callStage.value = it }
readAndUpdateLedger(sdk, address)
}
}
private suspend fun readAndUpdateLedger(sdk: MidnightSdk, address: String) {
val fresh = VotingContract.readLedger(readHandleFor(sdk, address))
applyLedger(fresh, address)
}
private fun runAction(block: suspend () -> Unit) {
viewModelScope.launch {
_busy.value = true
_error.value = null
try {
block()
} catch (t: Throwable) {
_error.value = t.message ?: t::class.simpleName ?: "Unknown error"
} finally {
_busy.value = false
_callStage.value = null
}
}
}
private var readHandle: MidnightContract? = null
private var readHandleAddress: String? = null
private fun readHandleFor(sdk: MidnightSdk, address: String): MidnightContract {
if (readHandle == null || readHandleAddress != address) {
readHandle = VotingContract.buildReadHandle(context, sdk, address)
readHandleAddress = address
}
return readHandle!!
}
private fun startObserving(sdk: MidnightSdk, address: String) {
ledgerJob?.cancel()
ledgerJob = viewModelScope.launch {
val handle = readHandleFor(sdk, address)
VotingContract.observeLedger(handle)
.catch { }
.collect { fresh -> applyLedger(fresh, address) }
}
}
private fun applyLedger(fresh: VotingLedger, address: String) {
val optList = fresh.pollOptions.split(",").map { it.trim() }.filter { it.isNotBlank() }
_state.update { current ->
if (current is VotingUiState.Deployed && current.address == address) {
current.copy(
pollQuestion = fresh.pollQuestion,
pollOptions = optList,
adminKeyHash = fresh.adminKeyHash,
voteCount = fresh.voteCount,
tallies = listOf(fresh.tally0, fresh.tally1, fresh.tally2, fresh.tally3),
pollClosed = fresh.pollClosed,
)
} else current
}
}
private fun stopObserving() {
ledgerJob?.cancel()
ledgerJob = null
readHandle = null
readHandleAddress = null
}
}关键模式:
-
网络选择:是
sdkProvider.selectedNetwork。SDK的StateFlow<MidnightNetwork>可组合项会绑定到它。ViewModel读取它并传递给PanelBar。ContractAddressStore -
vs
recomputeState:applyLedger在SDK或网络变化时调用(如钱包解锁、网络切换)。它会重置为初始状态。recomputeState在链上账本更新时调用(通过applyLedger或手动observeLedger())。它会保留Deployed状态并填充实时数据。readLedger() -
缓存: 为只读访问的
readHandle句柄按地址缓存。当地址变化时(如部署新合约),旧句柄会被丢弃。MidnightContract -
错误处理: 捕获所有异常并显示错误消息。失败的
runAction(错误的管理员密钥)会抛出closePoll。CircuitExecutionException("Only admin can close poll")进度会在_callStage块中清除。finally -
: SDK定义了一个密封接口用于部署/调用进度。它通过
ContractCallStage可组合项展示。ViewModel将其暴露为ContractCallProgressBar。callStage: StateFlow -
: 静默忽略账本观察错误。如果观察期间连接断开,不会导致ViewModel崩溃。UI会停止更新,直到下一次用户操作触发
observeLedger().catch { }。readAndUpdateLedger
8.3) ui/VotingScreen.kt
ui/VotingScreen.kt8.3) ui/VotingScreen.kt
ui/VotingScreen.ktkotlin
package com.kuiralabs.starter.counter.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.PanelBar
@Composable
fun VotingScreen(
modifier: Modifier = Modifier,
viewModel: VotingViewModel = hiltViewModel(),
) {
val selectedNetwork by viewModel.selectedNetwork.collectAsState()
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(16.dp)
.padding(top = 56.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Voting",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
VotingCard()
}
PanelBar(
floating = true,
network = selectedNetwork,
onNetworkChange = viewModel::selectNetwork,
)
}
}PanelBar(floating=true)WindowInsets.safeDrawingkotlin
package com.kuiralabs.starter.counter.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.PanelBar
@Composable
fun VotingScreen(
modifier: Modifier = Modifier,
viewModel: VotingViewModel = hiltViewModel(),
) {
val selectedNetwork by viewModel.selectedNetwork.collectAsState()
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(16.dp)
.padding(top = 56.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "投票",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
)
VotingCard()
}
PanelBar(
floating = true,
network = selectedNetwork,
onNetworkChange = viewModel::selectNetwork,
)
}
}PanelBar(floating=true)WindowInsets.safeDrawing8.4) ui/VotingCard.kt
ui/VotingCard.kt8.4) ui/VotingCard.kt
ui/VotingCard.ktkotlin
package com.kuiralabs.starter.counter.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.ContractCallProgressBar
@Composable
fun VotingCard(
modifier: Modifier = Modifier,
viewModel: VotingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val busy by viewModel.busy.collectAsState()
val callStage by viewModel.callStage.collectAsState()
val error by viewModel.error.collectAsState()
val question by viewModel.question.collectAsState()
val options by viewModel.options.collectAsState()
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Header(state)
when (val s = state) {
VotingUiState.NotReady -> NotReadyBody()
VotingUiState.ReadyToDeploy -> ReadyToDeployBody(busy = busy, onDeploy = viewModel::deploy)
is VotingUiState.Deployed -> DeployedBody(
state = s, busy = busy,
question = question, onQuestionChange = viewModel::updateQuestion,
options = options, onOptionsChange = viewModel::updateOptions,
onCreatePoll = viewModel::createPoll, onCastVote = viewModel::castVote,
onClosePoll = viewModel::closePoll, onDeployNew = viewModel::deploy,
onDisconnect = viewModel::disconnect,
)
}
if (busy) {
if (callStage == null) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ContractCallProgressBar(
stage = callStage,
accent = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val err = error
if (err != null) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = err,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
}
}
@Composable
private fun Header(state: VotingUiState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Voting",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
StatusBadge(state)
}
}
@Composable
private fun StatusBadge(state: VotingUiState) {
val (label, dotColor) = when (state) {
VotingUiState.NotReady -> "Not connected" to MaterialTheme.colorScheme.error
VotingUiState.ReadyToDeploy -> "Ready" to MaterialTheme.colorScheme.tertiary
is VotingUiState.Deployed -> "Active" to MaterialTheme.colorScheme.primary
}
Surface(
shape = RoundedCornerShape(20.dp),
color = dotColor.copy(alpha = 0.12f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.size(8.dp).clip(CircleShape).background(dotColor)
)
Spacer(Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = dotColor,
)
}
}
}
@Composable
private fun NotReadyBody() {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Forge a sigil above, then fund the wallet and register dust.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ReadyToDeployBody(busy: Boolean, onDeploy: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Wallet ready. Deploy a new voting contract to the current network.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Button(
onClick = onDeploy, enabled = !busy,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
) { Text(text = "Deploy contract") }
}
}
@Composable
private fun DeployedBody(
state: VotingUiState.Deployed, busy: Boolean,
question: String, onQuestionChange: (String) -> Unit,
options: String, onOptionsChange: (String) -> Unit,
onCreatePoll: () -> Unit, onCastVote: (Int) -> Unit,
onClosePoll: () -> Unit, onDeployNew: () -> Unit, onDisconnect: () -> Unit,
) {
AddressSection(state.address)
if (state.pollQuestion.isNullOrBlank()) {
CreatePollSection(
question = question, onQuestionChange = onQuestionChange,
options = options, onOptionsChange = onOptionsChange,
onCreatePoll = onCreatePoll, busy = busy,
)
} else {
PollResultsSection(
question = state.pollQuestion ?: "",
options = state.pollOptions ?: emptyList(),
tallies = state.tallies ?: listOf(0, 0, 0, 0),
totalVotes = state.voteCount ?: 0,
hasVoted = state.hasVoted, pollClosed = state.pollClosed == true,
onCastVote = onCastVote, onClosePoll = onClosePoll, busy = busy,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onDeployNew, enabled = !busy,
modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
) { Text(text = "Deploy new") }
OutlinedButton(
onClick = onDisconnect, enabled = !busy,
modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
) { Text(text = "Disconnect") }
}
Text(
text = "Results update live from chain.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun AddressSection(address: String) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Contract",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CreatePollSection(
question: String, onQuestionChange: (String) -> Unit,
options: String, onOptionsChange: (String) -> Unit,
onCreatePoll: () -> Unit, busy: Boolean,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Create a poll",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "No poll exists yet. Fill in the details below to create one.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = question, onValueChange = onQuestionChange,
label = { Text("Poll question") },
placeholder = { Text("e.g. What's the best programming language?") },
singleLine = true, shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = options, onValueChange = onOptionsChange,
label = { Text("Options") },
placeholder = { Text("e.g. Rust, TypeScript, Kotlin, Go") },
supportingText = { Text("Comma-separated, max 4 options") },
singleLine = true, shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onCreatePoll,
enabled = !busy && question.isNotBlank() && options.isNotBlank(),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
) { Text(text = "Create poll") }
}
}
}
@Composable
private fun PollResultsSection(
question: String, options: List<String>, tallies: List<Long>,
totalVotes: Long, hasVoted: Boolean, pollClosed: Boolean,
onCastVote: (Int) -> Unit, onClosePoll: () -> Unit, busy: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = question,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (pollClosed) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.errorContainer,
) {
Text(
text = "Closed",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
if (hasVoted && !pollClosed) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
text = "You voted",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
if (totalVotes > 0) {
Text(
text = "$totalVotes vote${if (totalVotes != 1L) \"s\" else \"\"}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
}
val maxTally = tallies.maxOrNull()?.coerceAtLeast(1) ?: 1
options.forEachIndexed { idx, option ->
val tally = tallies.getOrElse(idx) { 0L }
val fraction = if (totalVotes > 0) tally.toFloat() / totalVotes else 0f
OptionCard(
label = option, tally = tally, fraction = fraction,
totalVotes = totalVotes,
isWinning = tally > 0 && tally == tallies.maxOrNull(),
canVote = !hasVoted && !pollClosed && !busy,
onVote = { onCastVote(idx) },
)
}
if (!pollClosed) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Admin",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onClosePoll, enabled = !busy,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { Text(text = "Close poll") }
}
}
}
}
}
@Composable
private fun OptionCard(
label: String, tally: Long, fraction: Float,
totalVotes: Long, isWinning: Boolean, canVote: Boolean, onVote: () -> Unit,
) {
val bgColor = when {
isWinning && tally > 0 -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
canVote -> MaterialTheme.colorScheme.surface
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
}
Surface(
shape = RoundedCornerShape(12.dp),
color = bgColor,
tonalElevation = if (canVote) 1.dp else 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (canVote) Modifier.clickable(onClick = onVote) else Modifier),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Text(
text = if (tally > 0) "$tally" else "0",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isWinning && tally > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
color = if (isWinning && tally > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = if (totalVotes > 0) "${(fraction * 100).toInt()}%" else "-",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (canVote) {
Text(
text = "Tap to vote",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
if (isWinning && tally > 0) {
Text(
text = "Leading",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}kotlin
package com.kuiralabs.starter.counter.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.midnight.kuira.dapp.ContractCallProgressBar
@Composable
fun VotingCard(
modifier: Modifier = Modifier,
viewModel: VotingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val busy by viewModel.busy.collectAsState()
val callStage by viewModel.callStage.collectAsState()
val error by viewModel.error.collectAsState()
val question by viewModel.question.collectAsState()
val options by viewModel.options.collectAsState()
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Header(state)
when (val s = state) {
VotingUiState.NotReady -> NotReadyBody()
VotingUiState.ReadyToDeploy -> ReadyToDeployBody(busy = busy, onDeploy = viewModel::deploy)
is VotingUiState.Deployed -> DeployedBody(
state = s, busy = busy,
question = question, onQuestionChange = viewModel::updateQuestion,
options = options, onOptionsChange = viewModel::updateOptions,
onCreatePoll = viewModel::createPoll, onCastVote = viewModel::castVote,
onClosePoll = viewModel::closePoll, onDeployNew = viewModel::deploy,
onDisconnect = viewModel::disconnect,
)
}
if (busy) {
if (callStage == null) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
ContractCallProgressBar(
stage = callStage,
accent = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val err = error
if (err != null) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = err,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
}
}
@Composable
private fun Header(state: VotingUiState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "投票",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
StatusBadge(state)
}
}
@Composable
private fun StatusBadge(state: VotingUiState) {
val (label, dotColor) = when (state) {
VotingUiState.NotReady -> "未连接" to MaterialTheme.colorScheme.error
VotingUiState.ReadyToDeploy -> "就绪" to MaterialTheme.colorScheme.tertiary
is VotingUiState.Deployed -> "活跃" to MaterialTheme.colorScheme.primary
}
Surface(
shape = RoundedCornerShape(20.dp),
color = dotColor.copy(alpha = 0.12f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.size(8.dp).clip(CircleShape).background(dotColor)
)
Spacer(Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = dotColor,
)
}
}
}
@Composable
private fun NotReadyBody() {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "请点击上方生成sigil,然后为钱包充值并注册dust。",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ReadyToDeployBody(busy: Boolean, onDeploy: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "钱包已就绪。请在当前网络部署新的投票合约。",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Button(
onClick = onDeploy, enabled = !busy,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
) { Text(text = "部署合约") }
}
}
@Composable
private fun DeployedBody(
state: VotingUiState.Deployed, busy: Boolean,
question: String, onQuestionChange: (String) -> Unit,
options: String, onOptionsChange: (String) -> Unit,
onCreatePoll: () -> Unit, onCastVote: (Int) -> Unit,
onClosePoll: () -> Unit, onDeployNew: () -> Unit, onDisconnect: () -> Unit,
) {
AddressSection(state.address)
if (state.pollQuestion.isNullOrBlank()) {
CreatePollSection(
question = question, onQuestionChange = onQuestionChange,
options = options, onOptionsChange = onOptionsChange,
onCreatePoll = onCreatePoll, busy = busy,
)
} else {
PollResultsSection(
question = state.pollQuestion ?: "",
options = state.pollOptions ?: emptyList(),
tallies = state.tallies ?: listOf(0, 0, 0, 0),
totalVotes = state.voteCount ?: 0,
hasVoted = state.hasVoted, pollClosed = state.pollClosed == true,
onCastVote = onCastVote, onClosePoll = onClosePoll, busy = busy,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onDeployNew, enabled = !busy,
modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
) { Text(text = "部署新合约") }
OutlinedButton(
onClick = onDisconnect, enabled = !busy,
modifier = Modifier.weight(1f), shape = RoundedCornerShape(8.dp),
) { Text(text = "断开连接") }
}
Text(
text = "结果将从链上实时更新。",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun AddressSection(address: String) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "合约地址",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun CreatePollSection(
question: String, onQuestionChange: (String) -> Unit,
options: String, onOptionsChange: (String) -> Unit,
onCreatePoll: () -> Unit, busy: Boolean,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "创建投票",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "当前不存在投票。请填写以下信息创建投票。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = question, onValueChange = onQuestionChange,
label = { Text("投票问题") },
placeholder = { Text("例如:最好的编程语言是什么?") },
singleLine = true, shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = options, onValueChange = onOptionsChange,
label = { Text("选项") },
placeholder = { Text("例如:Rust, TypeScript, Kotlin, Go") },
supportingText = { Text("逗号分隔,最多4个选项") },
singleLine = true, shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onCreatePoll,
enabled = !busy && question.isNotBlank() && options.isNotBlank(),
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
) { Text(text = "创建投票") }
}
}
}
@Composable
private fun PollResultsSection(
question: String, options: List<String>, tallies: List<Long>,
totalVotes: Long, hasVoted: Boolean, pollClosed: Boolean,
onCastVote: (Int) -> Unit, onClosePoll: () -> Unit, busy: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = question,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (pollClosed) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.errorContainer,
) {
Text(
text = "已关闭",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
if (hasVoted && !pollClosed) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
text = "已投票",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
if (totalVotes > 0) {
Text(
text = "$totalVotes 票${if (totalVotes != 1L) "" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
}
val maxTally = tallies.maxOrNull()?.coerceAtLeast(1) ?: 1
options.forEachIndexed { idx, option ->
val tally = tallies.getOrElse(idx) { 0L }
val fraction = if (totalVotes > 0) tally.toFloat() / totalVotes else 0f
OptionCard(
label = option, tally = tally, fraction = fraction,
totalVotes = totalVotes,
isWinning = tally > 0 && tally == tallies.maxOrNull(),
canVote = !hasVoted && !pollClosed && !busy,
onVote = { onCastVote(idx) },
)
}
if (!pollClosed) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "管理员操作",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onClosePoll, enabled = !busy,
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { Text(text = "关闭投票") }
}
}
}
}
}
@Composable
private fun OptionCard(
label: String, tally: Long, fraction: Float,
totalVotes: Long, isWinning: Boolean, canVote: Boolean, onVote: () -> Unit,
) {
val bgColor = when {
isWinning && tally > 0 -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
canVote -> MaterialTheme.colorScheme.surface
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
}
Surface(
shape = RoundedCornerShape(12.dp),
color = bgColor,
tonalElevation = if (canVote) 1.dp else 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (canVote) Modifier.clickable(onClick = onVote) else Modifier),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Text(
text = if (tally > 0) "$tally" else "0",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isWinning && tally > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
color = if (isWinning && tally > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = if (totalVotes > 0) "${(fraction * 100).toInt()}%" else "-",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (canVote) {
Text(
text = "点击投票",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
if (isWinning && tally > 0) {
Text(
text = "领先",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}8.5) Theme files
8.5) 主题文件
ui/theme/Color.ktkotlin
package com.kuiralabs.starter.counter.ui.theme
import androidx.compose.ui.graphics.Color
val PrimaryRed = Color(0xFFDC0000)
val DarkRed = Color(0xFF8B0000)
val LightRed = Color(0xFFFF4D4D)
val LightRedBg = Color(0xFFFFDAD4)
val AccentYellow = Color(0xFFFFD700)
val DarkYellow = Color(0xFF9A8200)
val LightYellow = Color(0xFFFFF8D4)
val NearBlack = Color(0xFF0D0D0D)
val DarkSurface = Color(0xFF1A1A1A)
val SurfaceVariant = Color(0xFF2D2D2D)
val OutlineColor = Color(0xFF6B6B6B)
val OnSurfaceVariantColor = Color(0xFFC4C4C4)
val AccentGreen = Color(0xFF00D2A0)
val DarkGreen = Color(0xFF003828)
val White = Color(0xFFF5F5F5)ui/theme/Type.ktkotlin
package com.kuiralabs.starter.counter.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val VoteTypography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 24.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 14.sp,
),
)ui/theme/Theme.ktVoteThemekotlin
package com.kuiralabs.starter.counter.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val VoteDarkColorScheme = darkColorScheme(
primary = PrimaryRed,
onPrimary = White,
primaryContainer = DarkRed,
onPrimaryContainer = LightRedBg,
secondary = AccentYellow,
onSecondary = NearBlack,
secondaryContainer = DarkYellow,
onSecondaryContainer = LightYellow,
tertiary = AccentGreen,
onTertiary = NearBlack,
tertiaryContainer = DarkGreen,
onTertiaryContainer = AccentGreen,
background = NearBlack,
onBackground = White,
surface = DarkSurface,
onSurface = White,
surfaceVariant = SurfaceVariant,
onSurfaceVariant = OnSurfaceVariantColor,
outline = OutlineColor,
outlineVariant = SurfaceVariant,
error = Color(0xFFCF6679),
onError = NearBlack,
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
@Composable
fun VoteTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = VoteDarkColorScheme,
typography = VoteTypography,
content = content,
)
}The theme is dark-only. All three files are required — omitting any
one breaks the build.
ui/theme/Color.ktkotlin
package com.kuiralabs.starter.counter.ui.theme
import androidx.compose.ui.graphics.Color
val PrimaryRed = Color(0xFFDC0000)
val DarkRed = Color(0xFF8B0000)
val LightRed = Color(0xFFFF4D4D)
val LightRedBg = Color(0xFFFFDAD4)
val AccentYellow = Color(0xFFFFD700)
val DarkYellow = Color(0xFF9A8200)
val LightYellow = Color(0xFFFFF8D4)
val NearBlack = Color(0xFF0D0D0D)
val DarkSurface = Color(0xFF1A1A1A)
val SurfaceVariant = Color(0xFF2D2D2D)
val OutlineColor = Color(0xFF6B6B6B)
val OnSurfaceVariantColor = Color(0xFFC4C4C4)
val AccentGreen = Color(0xFF00D2A0)
val DarkGreen = Color(0xFF003828)
val White = Color(0xFFF5F5F5)ui/theme/Type.ktkotlin
package com.kuiralabs.starter.counter.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val VoteTypography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 24.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 22.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 14.sp,
),
)ui/theme/Theme.ktVoteThemekotlin
package com.kuiralabs.starter.counter.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val VoteDarkColorScheme = darkColorScheme(
primary = PrimaryRed,
onPrimary = White,
primaryContainer = DarkRed,
onPrimaryContainer = LightRedBg,
secondary = AccentYellow,
onSecondary = NearBlack,
secondaryContainer = DarkYellow,
onSecondaryContainer = LightYellow,
tertiary = AccentGreen,
onTertiary = NearBlack,
tertiaryContainer = DarkGreen,
onTertiaryContainer = AccentGreen,
background = NearBlack,
onBackground = White,
surface = DarkSurface,
onSurface = White,
surfaceVariant = SurfaceVariant,
onSurfaceVariant = OnSurfaceVariantColor,
outline = OutlineColor,
outlineVariant = SurfaceVariant,
error = Color(0xFFCF6679),
onError = NearBlack,
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
@Composable
fun VoteTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = VoteDarkColorScheme,
typography = VoteTypography,
content = content,
)
}主题仅支持深色模式。三个文件都是必需的——缺少任何一个都会导致构建失败。
8.6) MainActivity.kt
MainActivity.kt8.6) MainActivity.kt
MainActivity.ktkotlin
package com.kuiralabs.starter.counter
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.kuiralabs.starter.counter.ui.VotingScreen
import com.kuiralabs.starter.counter.ui.theme.VoteTheme
import com.midnight.kuira.dapp.wallet.WalletAppShell
import com.midnight.kuira.sdk.walletruntime.WalletNotifications
import com.midnight.kuira.sdk.walletruntime.SessionLock
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var sessionLock: SessionLock
private val notifPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (WalletNotifications.shouldRequest(this)) {
notifPermission.launch(WalletNotifications.PERMISSION)
}
setContent {
WalletAppShell {
VoteTheme {
Surface(modifier = Modifier.fillMaxSize()) {
VotingScreen()
}
}
}
}
}
override fun onUserInteraction() {
super.onUserInteraction()
sessionLock.onUserActivity()
}
}Why ? The SDK's hosts a
biometric prompt, which requires .
is a subclass of ; is not.
AppCompatActivitySigilStatusPanelFragmentActivityAppCompatActivityFragmentActivityComponentActivityWalletAppShell- — shows a lock screen overlay when the session is locked (auto-lock or manual lock)
SessionLockGate - — hosts full-screen Send/Receive/Settings overlays in the activity window
WalletOverlayHost
WalletNotifications.shouldRequest(this)POST_NOTIFICATIONSkotlin
package com.kuiralabs.starter.counter
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.kuiralabs.starter.counter.ui.VotingScreen
import com.kuiralabs.starter.counter.ui.theme.VoteTheme
import com.midnight.kuira.dapp.wallet.WalletAppShell
import com.midnight.kuira.sdk.walletruntime.WalletNotifications
import com.midnight.kuira.sdk.walletruntime.SessionLock
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var sessionLock: SessionLock
private val notifPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (WalletNotifications.shouldRequest(this)) {
notifPermission.launch(WalletNotifications.PERMISSION)
}
setContent {
WalletAppShell {
VoteTheme {
Surface(modifier = Modifier.fillMaxSize()) {
VotingScreen()
}
}
}
}
}
override fun onUserInteraction() {
super.onUserInteraction()
sessionLock.onUserActivity()
}
}为什么使用? SDK的包含生物识别提示框,该提示框需要。是的子类;不是。
AppCompatActivitySigilStatusPanelFragmentActivityAppCompatActivityFragmentActivityComponentActivityWalletAppShell- — 会话锁定时(自动锁定或手动锁定)显示锁屏覆盖层
SessionLockGate - — 在Activity窗口中托管全屏的发送/接收/设置覆盖层
WalletOverlayHost
WalletNotifications.shouldRequest(this)POST_NOTIFICATIONS9) Failure Modes & Troubleshooting
9) 故障模式与故障排除
9.1) Build failures
9.1) 构建失败
| Symptom | Cause | Fix |
|---|---|---|
| | Check the plugin ID and version in |
| Kuira dep not resolved | Check |
| No | Create the module (§6.2) |
| Not enough JVM heap | Bump |
| Changed config cache setting mid-project | Delete |
| SDK platform not installed | Install Android SDK Platform 36 |
| Asset not synced | Run |
| | Run |
| 症状 | 原因 | 修复 |
|---|---|---|
| 未应用 | 检查 |
| Kuira依赖未解析 | 检查 |
| 缺少 | 创建该模块(第6.2节) |
| JVM堆内存不足 | 将 |
| 项目中途更改了配置缓存设置 | 删除 |
| 未安装SDK平台 | 安装Android SDK Platform 36 |
| 资源未同步 | 运行 |
| | 在 |
9.2) Runtime failures
9.2) 运行时失败
| Symptom | Cause | Fix |
|---|---|---|
| Runtime version mismatch | Check |
| Wrong witness value | The hardcoded |
| Already closed | Wait for next poll — no reopen circuit exists |
| BigInt patch failed | Check |
| Called from wrong lifecycle | Call in |
| Runtime check not guarded | The SDK handles this internally; if it crashes, you may have minSdk < 30 |
| | Handle |
| App shows "Forge a sigil" but user already has one | SDK state reset | Clear app data and re-forge, or check logcat for |
| 症状 | 原因 | 修复 |
|---|---|---|
| 运行时版本不匹配 | 检查 |
| 见证值错误 | 硬编码的 |
| 投票已关闭 | 等待下一次投票——没有重新打开的电路 |
| BigInt补丁失败 | 检查 |
| 在错误的生命周期中调用 | 在 |
| 未进行运行时检查 | SDK内部会处理此问题;如果崩溃,可能是minSdk < 30 |
| | 在UI中处理 |
| 应用显示"生成sigil"但用户已拥有sigil | SDK状态重置 | 清除应用数据并重新生成,或检查logcat中的 |
9.3) Network failures
9.3) 网络失败
| Symptom | Cause | Fix |
|---|---|---|
| Localnet not running | Run |
| Cleartext blocked on localnet | Add |
| Passkey domain changed | Delete credential in Google Password Manager, re-forge |
| Wallet not funded | Airdrop NIGHT and register dust (§10.2) |
| Transaction pending forever | Indexer not synced | Wait for |
| 症状 | 原因 | 修复 |
|---|---|---|
| 本地网络未运行 | 运行 |
| 本地网络明文传输被阻止 | 将 |
| Passkey域名已更改 | 在Google密码管理器中删除凭据,重新生成 |
| 钱包未充值 | 领取NIGHT并注册dust(第10.2节) |
| 交易一直处于待处理状态 | 索引器未同步 | 等待 |
9.4) Contract compilation failures
9.4) 合约编译失败
| Symptom | Cause | Fix |
|---|---|---|
| compactc not installed at that version | |
| npm deps not installed | |
| Language version mismatch | Check |
| Import path issue | Ensure |
| 症状 | 原因 | 修复 |
|---|---|---|
| 未安装对应版本的compactc | |
| npm依赖未安装 | |
| 语言版本不匹配 | 检查 |
| 导入路径问题 | 确保 |
9.5) Gradle version catalog gotchas
9.5) Gradle版本目录注意事项
-
Kotlin ↔ KSP version coupling: Kotlin 2.3.20 requires KSP 2.3.6. If you upgrade Kotlin to 2.3.21, you MUST also find and upgrade KSP to the matching version. The KSP version format is.
<kotlin-version>-<ksp-release> -
AppCompat 1.7.x requires fragment-ktx 1.8.x — mixing a newer appcompat with an older fragment causes.
NoSuchMethodError: Fragment.getParentFragmentManager() -
AGP ↔ Gradle coupling: AGP 8.13 requires Gradle 8.13. If you change the wrapper distribution URL, theclasspath will fail.
buildscript -
Compose BOM and compiler: Theplugin in Kotlin 2.0+ replaces the old
kotlin-composeblock. Do NOT set a compose compiler version manually — the BOM and plugin coordinate automatically.composeOptions { kotlinCompilerExtensionVersion }
-
Kotlin ↔ KSP版本耦合: Kotlin 2.3.20需要KSP 2.3.6。如果将Kotlin升级到2.3.21,必须同时找到并升级KSP到匹配版本。KSP版本格式为。
<kotlin-version>-<ksp-release> -
AppCompat 1.7.x需要fragment-ktx 1.8.x — 新版本appcompat与旧版本fragment混合使用会导致。
NoSuchMethodError: Fragment.getParentFragmentManager() -
AGP ↔ Gradle耦合: AGP 8.13需要Gradle 8.13。如果更改了包装器分发URL,类路径会失败。
buildscript -
Compose BOM和编译器: Kotlin 2.0+中的插件替代了旧的
kotlin-compose块。不要手动设置compose编译器版本——BOM和插件会自动协调版本。composeOptions { kotlinCompilerExtensionVersion }
10) Wallet & Deploy Flow
10) 钱包与部署流程
10.1) Prerequisite: Passkey domain
10.1) 前置条件:Passkey域名
- Pick a domain you control (e.g., )
yourname.github.io - Upload to
assetlinks.jsonhttps://<domain>/.well-known/ - Set in
PASSKEY_RP_IDPasskeyConfigModule.kt
- 选择一个你控制的域名(如)
yourname.github.io - 将上传到
assetlinks.jsonhttps://<domain>/.well-known/ - 在中设置
PasskeyConfigModule.ktPASSKEY_RP_ID
10.2) Wallet funding (localnet)
10.2) 钱包充值(本地网络)
- Open app, tap Forge sigil in the wallet panel
- Copy wallet address from the wallet panel
- Airdrop NIGHT:
bash
mn airdrop 1000 --wallet <addr> --network undeployed - In app, tap Register dust in the wallet panel
- Wait ~30 seconds for dust sync
- Tap Deploy contract
- 打开应用,点击钱包面板中的生成sigil
- 从钱包面板复制钱包地址
- 领取NIGHT:
bash
mn airdrop 1000 --wallet <addr> --network undeployed - 在应用中,点击钱包面板中的注册dust
- 等待约30秒完成dust同步
- 点击部署合约
10.3) Using the app
10.3) 使用应用
- Create poll: Enter question + options (comma-separated), tap "Create poll". Wait for the transaction to confirm.
- Cast vote: Tap an option card. The shows ZK proving progress. After success, the "You Voted" badge appears and tallies update.
ContractCallProgressBar - Close poll: Tap "Close poll" (only works on the device that deployed). After close, the "Closed" badge appears and voting is disabled.
- Switch network: Use the network selector in . Each network has its own deployed contract address.
PanelBar - Disconnect: Clears the persisted address, returns to deploy screen.
- 创建投票: 输入问题和选项(逗号分隔),点击"创建投票"。等待交易确认。
- 提交投票: 点击选项卡片。会显示ZK证明进度。成功后,会出现"已投票"徽章,票数会更新。
ContractCallProgressBar - 关闭投票: 点击"关闭投票"(仅部署设备可操作)。关闭后,会出现"已关闭"徽章,投票功能被禁用。
- 切换网络: 使用中的网络选择器。每个网络都有自己的已部署合约地址。
PanelBar - 断开连接: 清除持久化地址,返回部署界面。
11) Testing & Verification
11) 测试与验证
Localnet flow
本地网络流程
bash
undefinedbash
undefinedStart localnet (Docker stack)
启动本地网络(Docker栈)
mn localnet up
mn localnet up
Compile contract
编译合约
cd contract && npm install && npm run compile:voting
cd contract && npm install && npm run compile:voting
Install + run
安装并运行
cd .. && ./gradlew :app:installDebug
undefinedcd .. && ./gradlew :app:installDebug
undefinedVerify APK assets
验证APK资源
bash
./gradlew :app:assembleDebug
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep votingbash
./gradlew :app:assembleDebug
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep votingExpected:
预期输出:
assets/runtime/voting-contract.js
assets/runtime/voting-contract.js
assets/keys/createPoll.verifier
assets/keys/createPoll.verifier
assets/keys/castVote.verifier
assets/keys/castVote.verifier
assets/keys/closePoll.verifier
assets/keys/closePoll.verifier
assets/keys/createPoll.prover
assets/keys/createPoll.prover
assets/keys/castVote.prover
assets/keys/castVote.prover
assets/keys/closePoll.prover
assets/keys/closePoll.prover
assets/keys/*.bzkir (if bundleWalletKeys is enabled)
assets/keys/*.bzkir (如果启用了bundleWalletKeys)
undefinedundefinedPre-release check
发布前检查
bash
./gradlew kuiraDoctorCatches: missing binding, unhosted ,
stale contract artifacts, missing assets.
PasskeyConfigassetlinks.jsonbash
./gradlew kuiraDoctor检查内容:缺少绑定、未托管、过期合约产物、缺少资源。
PasskeyConfigassetlinks.json12) Toolchain Bump Procedure
12) 工具链升级流程
When , the language version, or
updates:
compactc@midnight-ntwrk/compact-runtime- and
compact update <new-version>to confirmcompact list - Bump in
engines.compactccontract/package.json - Bump in
@midnight-ntwrk/compact-runtimecontract/package.json - CRITICAL: Update the pin in
+<version>scriptcompile:voting - Check against newly generated JS — variable names may have changed
scripts/patch-voting.js rm -rf src/managed/voting && npm run compile:voting- Verify runtime version in matches
contract-info.jsonpackage.json - to trigger asset resync
./gradlew :app:assembleDebug - Run the localnet flow to verify deploy + createPoll + castVote + closePoll all work
./gradlew kuiraDoctor- Commit regenerated artifacts
当、语言版本或更新时:
compactc@midnight-ntwrk/compact-runtime- 并
compact update <new-version>确认版本compact list - 在中升级
contract/package.jsonengines.compactc - 在中升级
contract/package.json@midnight-ntwrk/compact-runtime - 关键: 更新脚本中的
compile:voting固定版本+<version> - 检查是否与新生成的JS匹配——变量名可能已更改
scripts/patch-voting.js rm -rf src/managed/voting && npm run compile:voting- 验证中的运行时版本与
contract-info.json匹配package.json - 触发资源重新同步
./gradlew :app:assembleDebug - 运行本地网络流程,验证部署、创建投票、提交投票、关闭投票均正常工作
./gradlew kuiraDoctor- 提交重新生成的产物
13) SDK APIs Used
13) 使用的SDK API
| API | Purpose |
|---|---|
| Injected singleton providing |
| Build contract handle with name, JS, address, verifier keys, witnesses |
| Deploy contract, returns |
| Call a circuit — circuit |
| Read current on-chain state, returns typed |
| Reactive |
| Typed ledger field accessors |
| Stage proving keys from assets for on-device proving |
| Drop-in floating sigil + wallet pills |
| Root wrapper — |
| Built-in progress for deploy/call stages |
| Background wallet ops survive app backgrounding |
| App-wide auto-lock + idle-timer reset |
| |
| The wallet's public key needed for write handles |
| Witness provider returning a static byte array |
| API | 用途 |
|---|---|
| 注入的单例,提供 |
| 使用名称、JS、地址、验证密钥、见证构建合约句柄 |
| 部署合约,返回 |
| 调用电路——电路 |
| 读取当前链上状态,返回类型化的 |
| 响应式 |
| 类型化账本字段访问器 |
| 从资源中加载证明密钥,用于设备端证明 |
| 开箱即用的浮动sigil + 钱包芯片 |
| 根包装器——包含 |
| 内置的部署/调用进度条 |
| 后台钱包操作在应用后台时仍能运行 |
| 应用级自动锁定 + 空闲计时器重置 |
| API 33+的 |
| 写句柄需要的钱包公钥 |
| 返回静态字节数组的见证提供者 |