v7.5: wallet linking for MAP token rewards
- Add walletAddress field to SettingsDataStore - Settings screen: input field, QR scanner, address validation - Include wallet_address in detection uploads to API - Show linked wallet status in main UI (green wallet icon) - Cardano address validation (addr1/stake1 prefixes) - Version bumped to 1.7.5 (versionCode 11) - APK built and signed at varroa-v7.5.apk
This commit is contained in:
parent
109d7c19df
commit
ccd2e01008
7 changed files with 281 additions and 9 deletions
|
|
@ -13,8 +13,8 @@ android {
|
||||||
applicationId = "com.adamaps.varroa"
|
applicationId = "com.adamaps.varroa"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 10
|
versionCode = 11
|
||||||
versionName = "1.7.4"
|
versionName = "1.7.5"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
@ -78,5 +78,11 @@ dependencies {
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
// SSH connectivity for device_id fallback
|
// SSH connectivity for device_id fallback
|
||||||
implementation("com.jcraft:jsch:0.1.55")
|
implementation("com.jcraft:jsch:0.1.55")
|
||||||
|
// QR Code scanning
|
||||||
|
implementation("com.google.zxing:core:3.5.2")
|
||||||
|
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||||
|
implementation("androidx.camera:camera-camera2:1.3.0")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:1.3.0")
|
||||||
|
implementation("androidx.camera:camera-view:1.3.0")
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,15 @@ data class AdaMapsDetection(
|
||||||
@SerializedName("width") val width: Double?,
|
@SerializedName("width") val width: Double?,
|
||||||
@SerializedName("height") val height: Double?,
|
@SerializedName("height") val height: Double?,
|
||||||
@SerializedName("pos_confidence") val posConfidence: Double?,
|
@SerializedName("pos_confidence") val posConfidence: Double?,
|
||||||
@SerializedName("azimuth") val azimuth: Double?
|
@SerializedName("azimuth") val azimuth: Double?,
|
||||||
|
@SerializedName("wallet_address") val walletAddress: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server expects flat array of detections, each with device_id
|
// Server expects flat array of detections, each with device_id
|
||||||
// (NOT nested {device_id, detections: [...]})
|
// (NOT nested {device_id, detections: [...]})
|
||||||
typealias AdaMapsIngestRequest = List<AdaMapsDetection>
|
typealias AdaMapsIngestRequest = List<AdaMapsDetection>
|
||||||
|
|
||||||
fun BeeDetection.toAdaMapsDetection(deviceId: String) = AdaMapsDetection(
|
fun BeeDetection.toAdaMapsDetection(deviceId: String, walletAddress: String? = null) = AdaMapsDetection(
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
id = id,
|
id = id,
|
||||||
classLabel = classLabel,
|
classLabel = classLabel,
|
||||||
|
|
@ -82,7 +83,8 @@ fun BeeDetection.toAdaMapsDetection(deviceId: String) = AdaMapsDetection(
|
||||||
width = width,
|
width = width,
|
||||||
height = height,
|
height = height,
|
||||||
posConfidence = posConfidence,
|
posConfidence = posConfidence,
|
||||||
azimuth = azimuth
|
azimuth = azimuth,
|
||||||
|
walletAddress = walletAddress?.takeIf { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── App state ─────────────────────────────────────────────────────────────────
|
// ── App state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ data class VarroaSettings(
|
||||||
val cameraEndpoint: String = "/api/1/camera/frame",
|
val cameraEndpoint: String = "/api/1/camera/frame",
|
||||||
val cameraRefreshSeconds: Int = 30,
|
val cameraRefreshSeconds: Int = 30,
|
||||||
val forwardingEnabled: Boolean = true,
|
val forwardingEnabled: Boolean = true,
|
||||||
val cachedDeviceId: String = "unknown"
|
val cachedDeviceId: String = "unknown",
|
||||||
|
val walletAddress: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsDataStore(private val context: Context) {
|
class SettingsDataStore(private val context: Context) {
|
||||||
|
|
@ -35,6 +36,7 @@ class SettingsDataStore(private val context: Context) {
|
||||||
private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds")
|
private val KEY_CAMERA_REFRESH = intPreferencesKey("camera_refresh_seconds")
|
||||||
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
|
private val KEY_FORWARDING_ENABLED = booleanPreferencesKey("forwarding_enabled")
|
||||||
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
|
private val KEY_CACHED_DEVICE_ID = stringPreferencesKey("cached_device_id")
|
||||||
|
private val KEY_WALLET_ADDRESS = stringPreferencesKey("wallet_address")
|
||||||
}
|
}
|
||||||
|
|
||||||
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
|
val settings: Flow<VarroaSettings> = context.dataStore.data.map { prefs ->
|
||||||
|
|
@ -46,7 +48,8 @@ class SettingsDataStore(private val context: Context) {
|
||||||
cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
|
cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
|
||||||
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
|
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,
|
||||||
forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true,
|
forwardingEnabled = prefs[KEY_FORWARDING_ENABLED] ?: true,
|
||||||
cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown"
|
cachedDeviceId = prefs[KEY_CACHED_DEVICE_ID] ?: "unknown",
|
||||||
|
walletAddress = prefs[KEY_WALLET_ADDRESS] ?: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +63,7 @@ class SettingsDataStore(private val context: Context) {
|
||||||
prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds
|
prefs[KEY_CAMERA_REFRESH] = s.cameraRefreshSeconds
|
||||||
prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled
|
prefs[KEY_FORWARDING_ENABLED] = s.forwardingEnabled
|
||||||
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId
|
prefs[KEY_CACHED_DEVICE_ID] = s.cachedDeviceId
|
||||||
|
prefs[KEY_WALLET_ADDRESS] = s.walletAddress
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,10 @@ class AdaMapsUploadWorker(
|
||||||
private suspend fun uploadBatch(batch: List<DetectionEntity>): Result {
|
private suspend fun uploadBatch(batch: List<DetectionEntity>): Result {
|
||||||
Log.d(TAG, "Converting ${batch.size} DetectionEntities to AdaMapsDetection format...")
|
Log.d(TAG, "Converting ${batch.size} DetectionEntities to AdaMapsDetection format...")
|
||||||
|
|
||||||
|
// Load settings to get wallet address
|
||||||
|
val settings = settingsStore.settings.first()
|
||||||
|
val walletAddress = settings.walletAddress.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
// Convert to ADAMaps format
|
// Convert to ADAMaps format
|
||||||
val request = batch.map { entity ->
|
val request = batch.map { entity ->
|
||||||
AdaMapsDetection(
|
AdaMapsDetection(
|
||||||
|
|
@ -231,7 +235,8 @@ class AdaMapsUploadWorker(
|
||||||
width = entity.width,
|
width = entity.width,
|
||||||
height = entity.height,
|
height = entity.height,
|
||||||
posConfidence = entity.posConfidence,
|
posConfidence = entity.posConfidence,
|
||||||
azimuth = entity.azimuth
|
azimuth = entity.azimuth,
|
||||||
|
walletAddress = walletAddress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ fun DashboardScreen(
|
||||||
val deviceInfo by vm.deviceInfo.collectAsState()
|
val deviceInfo by vm.deviceInfo.collectAsState()
|
||||||
val gnss by vm.gnss.collectAsState()
|
val gnss by vm.gnss.collectAsState()
|
||||||
val cameraBytes by vm.cameraBytes.collectAsState()
|
val cameraBytes by vm.cameraBytes.collectAsState()
|
||||||
|
val settings by vm.settings.collectAsState()
|
||||||
|
|
||||||
// Collection state
|
// Collection state
|
||||||
val isCollecting by vm.isCollecting.collectAsState()
|
val isCollecting by vm.isCollecting.collectAsState()
|
||||||
|
|
@ -66,6 +67,9 @@ fun DashboardScreen(
|
||||||
// Network status
|
// Network status
|
||||||
val networkStatus by vm.networkStatus.collectAsState()
|
val networkStatus by vm.networkStatus.collectAsState()
|
||||||
|
|
||||||
|
// Wallet status
|
||||||
|
val hasLinkedWallet = settings.walletAddress.isNotBlank()
|
||||||
|
|
||||||
// Combined error display
|
// Combined error display
|
||||||
val displayError = beeError ?: uploadError
|
val displayError = beeError ?: uploadError
|
||||||
|
|
||||||
|
|
@ -85,6 +89,17 @@ fun DashboardScreen(
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface),
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface),
|
||||||
actions = {
|
actions = {
|
||||||
|
// Wallet status indicator
|
||||||
|
if (hasLinkedWallet) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AccountBalance,
|
||||||
|
contentDescription = "Wallet linked",
|
||||||
|
tint = Color.Green,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(onClick = onNavigateToSettings) {
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber)
|
Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.adamaps.varroa.ui.settings
|
package com.adamaps.varroa.ui.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
|
@ -7,19 +9,29 @@ import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.adamaps.varroa.data.VarroaSettings
|
import com.adamaps.varroa.data.VarroaSettings
|
||||||
import com.adamaps.varroa.ui.theme.*
|
import com.adamaps.varroa.ui.theme.*
|
||||||
|
import com.adamaps.varroa.util.CardanoAddressValidator
|
||||||
import com.adamaps.varroa.viewmodel.SettingsViewModel
|
import com.adamaps.varroa.viewmodel.SettingsViewModel
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -37,6 +49,7 @@ fun SettingsScreen(
|
||||||
var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) }
|
var pollInterval by remember(currentSettings) { mutableStateOf(currentSettings.pollIntervalSeconds.toString()) }
|
||||||
var cameraRefresh by remember(currentSettings) { mutableStateOf(currentSettings.cameraRefreshSeconds.toString()) }
|
var cameraRefresh by remember(currentSettings) { mutableStateOf(currentSettings.cameraRefreshSeconds.toString()) }
|
||||||
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
|
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
|
||||||
|
var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) }
|
||||||
|
|
||||||
// Show snackbar on save
|
// Show snackbar on save
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
@ -76,7 +89,8 @@ fun SettingsScreen(
|
||||||
adamapsApiKey = adamapsApiKey.trim(),
|
adamapsApiKey = adamapsApiKey.trim(),
|
||||||
pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30,
|
pollIntervalSeconds = pollInterval.toIntOrNull() ?: 30,
|
||||||
cameraRefreshSeconds = cameraRefresh.toIntOrNull() ?: 30,
|
cameraRefreshSeconds = cameraRefresh.toIntOrNull() ?: 30,
|
||||||
cameraEndpoint = cameraEndpoint.trim()
|
cameraEndpoint = cameraEndpoint.trim(),
|
||||||
|
walletAddress = walletAddress.trim()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -119,6 +133,11 @@ fun SettingsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WalletLinkingSection(
|
||||||
|
walletAddress = walletAddress,
|
||||||
|
onWalletAddressChange = { walletAddress = it }
|
||||||
|
)
|
||||||
|
|
||||||
SettingsSection("CAMERA") {
|
SettingsSection("CAMERA") {
|
||||||
SettingsField(
|
SettingsField(
|
||||||
label = "Camera Endpoint",
|
label = "Camera Endpoint",
|
||||||
|
|
@ -208,3 +227,173 @@ private fun SettingsField(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WalletLinkingSection(
|
||||||
|
walletAddress: String,
|
||||||
|
onWalletAddressChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
// QR Scanner
|
||||||
|
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||||
|
if (result.contents != null) {
|
||||||
|
val scannedAddress = result.contents.trim()
|
||||||
|
if (CardanoAddressValidator.isValidCardanoAddress(scannedAddress)) {
|
||||||
|
onWalletAddressChange(scannedAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation state
|
||||||
|
val isValid = walletAddress.isBlank() || CardanoAddressValidator.isValidCardanoAddress(walletAddress)
|
||||||
|
val hasWallet = walletAddress.isNotBlank() && isValid
|
||||||
|
|
||||||
|
SettingsSection("WALLET LINKING") {
|
||||||
|
// Status indicator
|
||||||
|
if (hasWallet) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Linked Wallet",
|
||||||
|
color = Color.Gray,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
CardanoAddressValidator.formatAddressForDisplay(walletAddress),
|
||||||
|
color = if (CardanoAddressValidator.isMainnetAddress(walletAddress)) Color.Green else Color.Yellow,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (CardanoAddressValidator.isMainnetAddress(walletAddress)) "Mainnet Address" else "Stake Address",
|
||||||
|
color = Color.Gray,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onWalletAddressChange("") }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Clear", tint = Color.Red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address input field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = walletAddress,
|
||||||
|
onValueChange = onWalletAddressChange,
|
||||||
|
label = { Text("Cardano Address", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
||||||
|
placeholder = { Text("addr1... or stake1...", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
|
||||||
|
supportingText = {
|
||||||
|
when {
|
||||||
|
walletAddress.isNotBlank() && !isValid -> {
|
||||||
|
Text(
|
||||||
|
"Invalid address format. Use addr1... or stake1...",
|
||||||
|
color = Color.Red,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hasWallet -> {
|
||||||
|
Text(
|
||||||
|
"✓ Valid Cardano address",
|
||||||
|
color = Color.Green,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
"Link wallet to earn MAP tokens for detections",
|
||||||
|
color = Color.Gray,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = walletAddress.isNotBlank() && !isValid,
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = if (isValid) Amber else Color.Red,
|
||||||
|
unfocusedBorderColor = if (isValid) SurfaceVariant else Color.Red,
|
||||||
|
focusedLabelColor = if (isValid) Amber else Color.Red,
|
||||||
|
unfocusedLabelColor = Color.Gray,
|
||||||
|
cursorColor = Amber,
|
||||||
|
focusedTextColor = OnSurface,
|
||||||
|
unfocusedTextColor = OnSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// QR Scanner button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val options = ScanOptions().apply {
|
||||||
|
setPrompt("Scan Cardano wallet address")
|
||||||
|
setBeepEnabled(false)
|
||||||
|
setOrientationLocked(false)
|
||||||
|
}
|
||||||
|
scanLauncher.launch(options)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = Amber,
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, Amber)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.QrCodeScanner, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Scan QR", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
clipboardManager.getText()?.text?.let { text ->
|
||||||
|
val cleanText = text.trim()
|
||||||
|
if (CardanoAddressValidator.isValidCardanoAddress(cleanText)) {
|
||||||
|
onWalletAddressChange(cleanText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = Amber,
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, Amber)
|
||||||
|
) {
|
||||||
|
Text("Paste", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Info text
|
||||||
|
Text(
|
||||||
|
"MAP token rewards require a linked Cardano wallet.\n" +
|
||||||
|
"Detections will still upload without a wallet linked.",
|
||||||
|
color = Color.Gray,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
lineHeight = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.adamaps.varroa.util
|
||||||
|
|
||||||
|
object CardanoAddressValidator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Cardano address format.
|
||||||
|
* Supports mainnet addresses (addr1) and stake addresses (stake1).
|
||||||
|
*/
|
||||||
|
fun isValidCardanoAddress(address: String): Boolean {
|
||||||
|
if (address.isBlank()) return false
|
||||||
|
|
||||||
|
// Remove whitespace
|
||||||
|
val trimmedAddress = address.trim()
|
||||||
|
|
||||||
|
// Check prefix - mainnet addresses start with addr1, stake addresses with stake1
|
||||||
|
if (!trimmedAddress.startsWith("addr1") && !trimmedAddress.startsWith("stake1")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length - Cardano addresses are typically 100-120 characters (bech32 encoded)
|
||||||
|
if (trimmedAddress.length < 90 || trimmedAddress.length > 130) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic character validation - bech32 uses specific character set
|
||||||
|
val validChars = "023456789acdefghjklmnpqrstuvwxyz"
|
||||||
|
return trimmedAddress.drop(5).all { it in validChars } // Skip prefix for character check
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format address for display - show first 8 and last 8 characters
|
||||||
|
*/
|
||||||
|
fun formatAddressForDisplay(address: String): String {
|
||||||
|
if (address.length <= 20) return address
|
||||||
|
return "${address.take(8)}...${address.takeLast(8)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if address is a mainnet address (addr1)
|
||||||
|
*/
|
||||||
|
fun isMainnetAddress(address: String): Boolean {
|
||||||
|
return address.trim().startsWith("addr1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if address is a stake address (stake1)
|
||||||
|
*/
|
||||||
|
fun isStakeAddress(address: String): Boolean {
|
||||||
|
return address.trim().startsWith("stake1")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue