mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Build Rust core handshake and Compose UI foundation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
||||
|
||||
import gobley.gradle.cargo.dsl.appleMobile
|
||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
@@ -76,6 +77,15 @@ cargo {
|
||||
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
||||
publishJvmArtifacts = true
|
||||
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
|
||||
builds.appleMobile {
|
||||
variants {
|
||||
buildTaskProvider.configure {
|
||||
if (rustTarget.cinteropName == "ios") {
|
||||
additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uniffi {
|
||||
|
||||
@@ -1,228 +1,643 @@
|
||||
package com.vnidrop.app
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.CoreRepository
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.rememberShareFilePicker
|
||||
import com.vnidrop.app.core.sharePickedFile
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.ErrorBanner
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.MetadataRow
|
||||
import com.vnidrop.app.ui.components.PillTone
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.ProgressRow
|
||||
import com.vnidrop.app.ui.components.QuietButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.components.StatusPill
|
||||
import com.vnidrop.app.ui.state.AppDestination
|
||||
import com.vnidrop.app.ui.state.AppUiState
|
||||
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.activeReceiverRequests
|
||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import com.vnidrop.app.ui.state.friendlyCoreError
|
||||
import com.vnidrop.app.ui.state.summarizeProgress
|
||||
import com.vnidrop.app.ui.state.transferSubtitle
|
||||
import com.vnidrop.app.ui.state.windowClassFor
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.ShareResult
|
||||
import uniffi.vnidrop.StoredTransfer
|
||||
import uniffi.vnidrop.TicketInspection
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
val platform = remember { getPlatform() }
|
||||
val repository = remember { CoreRepository() }
|
||||
val state by repository.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val coreState by repository.state.collectAsState()
|
||||
var appState by remember { mutableStateOf(AppUiState()) }
|
||||
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) }
|
||||
var sourcePath by remember { mutableStateOf("") }
|
||||
var sendState by remember { mutableStateOf(SendUiState()) }
|
||||
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) }
|
||||
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
|
||||
var transferName by remember { mutableStateOf("VniDrop transfer") }
|
||||
var senderName by remember { mutableStateOf("") }
|
||||
var ticket by remember { mutableStateOf("") }
|
||||
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
|
||||
var receiverName by remember { mutableStateOf("") }
|
||||
val shareFilePicker = rememberShareFilePicker(
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val picker = rememberShareFilePicker(
|
||||
onFilePicked = { file ->
|
||||
selectedFile = file
|
||||
sourcePath = file.value
|
||||
if (transferName.isBlank() || transferName == "VniDrop transfer") {
|
||||
transferName = file.displayName
|
||||
}
|
||||
sendState = sendState.copy(
|
||||
selectedSource = file.value,
|
||||
selectedDisplayName = file.displayName,
|
||||
transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) {
|
||||
file.displayName
|
||||
} else {
|
||||
sendState.transferName
|
||||
},
|
||||
)
|
||||
},
|
||||
onError = { error -> scope.launch { repository.setError(error) } },
|
||||
)
|
||||
|
||||
MaterialTheme {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.safeContentPadding()
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
LaunchedEffect(coreState.lastShare?.transferId) {
|
||||
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) }
|
||||
}
|
||||
|
||||
VniDropTheme(mode = appState.themeMode) {
|
||||
BoxWithConstraints {
|
||||
val windowClass = windowClassFor(maxWidth.value)
|
||||
AppFrame(
|
||||
appState = appState,
|
||||
coreState = coreState,
|
||||
windowClass = windowClass,
|
||||
onDestinationChange = { appState = appState.copy(destination = it) },
|
||||
) {
|
||||
item {
|
||||
Text("VniDrop Core", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(platform.name, style = MaterialTheme.typography.bodySmall)
|
||||
Text(state.status, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
item {
|
||||
CoreCard(title = "Initialize") {
|
||||
OutlinedTextField(
|
||||
value = appDataDir,
|
||||
onValueChange = { appDataDir = it },
|
||||
label = { Text("Core data directory") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(onClick = {
|
||||
scope.launch { repository.initialize(appDataDir) }
|
||||
}) {
|
||||
Text("Initialize core")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
CoreCard(title = "Share file") {
|
||||
OutlinedTextField(
|
||||
value = sourcePath,
|
||||
onValueChange = {
|
||||
sourcePath = it
|
||||
selectedFile = null
|
||||
},
|
||||
label = { Text("Selected file") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(onClick = { shareFilePicker.pickFile() }) {
|
||||
Text("Select file")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = transferName,
|
||||
onValueChange = { transferName = it },
|
||||
label = { Text("Transfer name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = senderName,
|
||||
onValueChange = { senderName = it },
|
||||
label = { Text("Sender name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
enabled = state.isInitialized && (selectedFile != null || sourcePath.isNotBlank()),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val picked = selectedFile
|
||||
if (picked != null) {
|
||||
sharePickedFile(repository, picked, transferName, senderName)
|
||||
} else {
|
||||
repository.sharePath(sourcePath, transferName, senderName)
|
||||
}
|
||||
when (appState.destination) {
|
||||
AppDestination.Send -> SendScreen(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onSendStateChange = { sendState = it },
|
||||
onSelectFile = { picker.pickFile() },
|
||||
onCreateShare = {
|
||||
scope.launch {
|
||||
sendState = sendState.copy(isSharing = true)
|
||||
val file = selectedFile
|
||||
if (file != null) {
|
||||
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
|
||||
} else {
|
||||
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Create share ticket")
|
||||
}
|
||||
state.lastShare?.let { share ->
|
||||
Text("Ticket")
|
||||
SelectionContainer {
|
||||
Text(share.ticket, style = MaterialTheme.typography.bodySmall)
|
||||
sendState = sendState.copy(isSharing = false)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(share.ticket))
|
||||
}) {
|
||||
Text("Copy ticket")
|
||||
}
|
||||
Button(onClick = { ticket = share.ticket }) {
|
||||
Text("Use locally")
|
||||
}
|
||||
},
|
||||
onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) },
|
||||
onUseLocally = { ticket ->
|
||||
receiveState = receiveState.copy(ticket = ticket)
|
||||
appState = appState.copy(destination = AppDestination.Receive)
|
||||
},
|
||||
onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
|
||||
onRespondRequest = { requestId, accepted ->
|
||||
scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") }
|
||||
},
|
||||
)
|
||||
AppDestination.Receive -> ReceiveScreen(
|
||||
coreState = coreState,
|
||||
receiveState = receiveState,
|
||||
onReceiveStateChange = { receiveState = it },
|
||||
onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } },
|
||||
onReceive = {
|
||||
scope.launch {
|
||||
receiveState = receiveState.copy(isReceiving = true)
|
||||
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
|
||||
receiveState = receiveState.copy(isReceiving = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
AppDestination.Activity -> ActivityScreen(
|
||||
coreState = coreState,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
repository.refreshTransfers()
|
||||
repository.refreshEvents()
|
||||
}
|
||||
},
|
||||
onCancel = { transferId -> scope.launch { repository.cancel(transferId) } },
|
||||
)
|
||||
AppDestination.Requests -> RequestsScreen(
|
||||
requests = coreState.receiverRequests,
|
||||
lastShare = coreState.lastShare,
|
||||
onRefresh = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
|
||||
onRespond = { requestId, accepted ->
|
||||
scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") }
|
||||
},
|
||||
)
|
||||
AppDestination.Settings -> SettingsScreen(
|
||||
platformName = platform.name,
|
||||
appDataDir = appDataDir,
|
||||
onAppDataDirChange = { appDataDir = it },
|
||||
coreState = coreState,
|
||||
themeMode = appState.themeMode,
|
||||
onThemeModeChange = { appState = appState.copy(themeMode = it) },
|
||||
diagnosticsVisible = appState.diagnosticsVisible,
|
||||
onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) },
|
||||
onInitialize = { scope.launch { repository.initialize(appDataDir) } },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
CoreCard(title = "Receive") {
|
||||
OutlinedTextField(
|
||||
value = ticket,
|
||||
onValueChange = { ticket = it },
|
||||
label = { Text("Ticket") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = receiveDir,
|
||||
onValueChange = { receiveDir = it },
|
||||
label = { Text("Output directory") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = receiverName,
|
||||
onValueChange = { receiverName = it },
|
||||
label = { Text("Receiver name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
enabled = state.isInitialized && ticket.isNotBlank(),
|
||||
onClick = { scope.launch { repository.inspectTicket(ticket) } },
|
||||
) {
|
||||
Text("Inspect ticket")
|
||||
}
|
||||
Button(
|
||||
enabled = state.isInitialized && ticket.isNotBlank() && receiveDir.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch { repository.receive(ticket, receiveDir, receiverName) }
|
||||
},
|
||||
) {
|
||||
Text("Receive")
|
||||
}
|
||||
state.lastInspection?.let { inspection ->
|
||||
Text("Kind: ${inspection.kind}")
|
||||
Text("Blob ticket: ${inspection.blobTicket.take(96)}")
|
||||
inspection.metadata?.let { metadata ->
|
||||
Text("${metadata.transferName} | ${metadata.fileCount} files | ${metadata.totalSize} bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (appState.diagnosticsVisible) {
|
||||
DiagnosticsPanel(events = coreState.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { error ->
|
||||
item {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
|
||||
Text(
|
||||
text = error,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun AppFrame(
|
||||
appState: AppUiState,
|
||||
coreState: CoreUiState,
|
||||
windowClass: WindowClass,
|
||||
onDestinationChange: (AppDestination) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.canvas)
|
||||
.safeContentPadding(),
|
||||
color = colors.canvas,
|
||||
) {
|
||||
if (windowClass == WindowClass.Compact) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopBar(coreState = coreState)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ScreenContent(content = content)
|
||||
}
|
||||
BottomNav(selected = appState.destination, onDestinationChange = onDestinationChange)
|
||||
}
|
||||
} else {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
SideNav(
|
||||
selected = appState.destination,
|
||||
coreState = coreState,
|
||||
onDestinationChange = onDestinationChange,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ScreenContent(content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text("Events", style = MaterialTheme.typography.titleMedium)
|
||||
@Composable
|
||||
private fun ScreenContent(content: @Composable () -> Unit) {
|
||||
val colors = LocalVniDropColors.current
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.canvas)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBar(coreState: CoreUiState) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.sidebar)
|
||||
.border(BorderStroke(1.dp, colors.border))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("VniDrop", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
NodeStatus(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SideNav(
|
||||
selected: AppDestination,
|
||||
coreState: CoreUiState,
|
||||
onDestinationChange: (AppDestination) -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(220.dp)
|
||||
.fillMaxHeight()
|
||||
.background(colors.sidebar)
|
||||
.border(BorderStroke(1.dp, colors.border))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("VniDrop", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text("Private file transfer", color = colors.textMuted, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AppDestination.entries.forEach { destination ->
|
||||
NavItem(
|
||||
destination = destination,
|
||||
selected = destination == selected,
|
||||
onClick = { onDestinationChange(destination) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
NodeStatus(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNav(
|
||||
selected: AppDestination,
|
||||
onDestinationChange: (AppDestination) -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.sidebar)
|
||||
.border(BorderStroke(1.dp, colors.border))
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
AppDestination.entries.forEach { destination ->
|
||||
NavItem(
|
||||
destination = destination,
|
||||
selected = destination == selected,
|
||||
onClick = { onDestinationChange(destination) },
|
||||
modifier = Modifier.weight(1f),
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavItem(
|
||||
destination: AppDestination,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val background = if (selected) colors.surfaceMuted else colors.sidebar
|
||||
val border = if (selected) colors.brand.copy(alpha = 0.55f) else colors.border.copy(alpha = 0f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(background)
|
||||
.border(1.dp, border, RoundedCornerShape(8.dp))
|
||||
.selectable(selected = selected, onClick = onClick)
|
||||
.padding(horizontal = if (compact) 6.dp else 12.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
destination.label,
|
||||
style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium,
|
||||
color = if (selected) colors.textPrimary else colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeStatus(coreState: CoreUiState) {
|
||||
StatusPill(
|
||||
label = if (coreState.isInitialized) "Online" else "Offline",
|
||||
tone = if (coreState.isInitialized) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendScreen(
|
||||
coreState: CoreUiState,
|
||||
sendState: SendUiState,
|
||||
onSendStateChange: (SendUiState) -> Unit,
|
||||
onSelectFile: () -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onUseLocally: (String) -> Unit,
|
||||
onRefreshRequests: (ULong) -> Unit,
|
||||
onRespondRequest: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader("Send", "Create a VniDrop ticket and approve receivers when required.")
|
||||
ErrorSection(coreState)
|
||||
AppCard(title = "Source") {
|
||||
if (sendState.selectedSource.isBlank()) {
|
||||
EmptyText("Select a file to start a share. The app keeps bytes in Rust and platform file handles.")
|
||||
} else {
|
||||
MetadataRow("Name", sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
|
||||
MetadataRow("Source", sendState.selectedSource)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton("Select file", onClick = onSelectFile)
|
||||
SecondaryButton(
|
||||
text = "Clear",
|
||||
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
|
||||
enabled = sendState.selectedSource.isNotBlank(),
|
||||
)
|
||||
}
|
||||
}
|
||||
AppCard(title = "Transfer details") {
|
||||
Field(
|
||||
value = sendState.transferName,
|
||||
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
|
||||
label = "Transfer name",
|
||||
)
|
||||
Field(
|
||||
value = sendState.senderName,
|
||||
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
|
||||
label = "Sender name",
|
||||
)
|
||||
PrimaryButton(
|
||||
text = if (sendState.isSharing) "Creating ticket..." else "Create share ticket",
|
||||
onClick = onCreateShare,
|
||||
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
|
||||
)
|
||||
}
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
onCopyTicket = onCopyTicket,
|
||||
onUseLocally = onUseLocally,
|
||||
onRefreshRequests = onRefreshRequests,
|
||||
onRespondRequest = onRespondRequest,
|
||||
)
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareResultCard(
|
||||
share: ShareResult,
|
||||
requests: List<ReceiverRequest>,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onUseLocally: (String) -> Unit,
|
||||
onRefreshRequests: (ULong) -> Unit,
|
||||
onRespondRequest: (String, Boolean) -> Unit,
|
||||
) {
|
||||
AppCard(title = "Share ticket", trailing = {
|
||||
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
||||
}) {
|
||||
MetadataRow("Transfer", share.transferName)
|
||||
MetadataRow("Size", formatBytes(share.totalSize))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = share.ticket,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(LocalVniDropColors.current.surfaceMuted)
|
||||
.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton("Copy", onClick = { onCopyTicket(share.ticket) })
|
||||
SecondaryButton("Use locally", onClick = { onUseLocally(share.ticket) })
|
||||
SecondaryButton("Refresh", onClick = { onRefreshRequests(share.transferId) })
|
||||
}
|
||||
if (requests.isNotEmpty()) {
|
||||
HorizontalDivider(color = LocalVniDropColors.current.border)
|
||||
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiveScreen(
|
||||
coreState: CoreUiState,
|
||||
receiveState: ReceiveUiState,
|
||||
onReceiveStateChange: (ReceiveUiState) -> Unit,
|
||||
onInspect: () -> Unit,
|
||||
onReceive: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader("Receive", "Inspect a ticket, request access, and stream files into the output directory.")
|
||||
ErrorSection(coreState)
|
||||
AppCard(title = "Ticket") {
|
||||
Field(
|
||||
value = receiveState.ticket,
|
||||
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
|
||||
label = "Ticket",
|
||||
minLines = 4,
|
||||
)
|
||||
Field(
|
||||
value = receiveState.outputDirectory,
|
||||
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
|
||||
label = "Output directory",
|
||||
)
|
||||
Field(
|
||||
value = receiveState.receiverName,
|
||||
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
|
||||
label = "Receiver name",
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SecondaryButton(
|
||||
text = "Inspect ticket",
|
||||
onClick = onInspect,
|
||||
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
|
||||
)
|
||||
PrimaryButton(
|
||||
text = if (receiveState.isReceiving) "Receiving..." else "Receive",
|
||||
onClick = onReceive,
|
||||
enabled = coreState.isInitialized &&
|
||||
receiveState.ticket.isNotBlank() &&
|
||||
receiveState.outputDirectory.isNotBlank() &&
|
||||
!receiveState.isReceiving,
|
||||
)
|
||||
}
|
||||
}
|
||||
coreState.lastInspection?.let { TicketInspectionCard(it) }
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TicketInspectionCard(inspection: TicketInspection) {
|
||||
AppCard(title = "Ticket details") {
|
||||
MetadataRow("Kind", inspection.kind)
|
||||
inspection.metadata?.let { metadata ->
|
||||
MetadataRow("Transfer", metadata.transferName)
|
||||
MetadataRow("Sender", metadata.senderName ?: "Unknown")
|
||||
MetadataRow("Files", metadata.fileCount.toString())
|
||||
MetadataRow("Size", formatBytes(metadata.totalSize))
|
||||
MetadataRow("Hash", metadata.contentHash)
|
||||
} ?: EmptyText("This ticket does not include VniDrop metadata.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityScreen(
|
||||
coreState: CoreUiState,
|
||||
onRefresh: () -> Unit,
|
||||
onCancel: (ULong) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader("Activity", "Follow current and recent transfers from the Rust core.")
|
||||
ErrorSection(coreState)
|
||||
AppCard(title = "Transfers", trailing = { SecondaryButton("Refresh", onClick = onRefresh) }) {
|
||||
if (coreState.transfers.isEmpty()) {
|
||||
EmptyText("No transfers yet.")
|
||||
} else {
|
||||
coreState.transfers.forEach { transfer ->
|
||||
TransferRow(transfer = transfer, onCancel = onCancel)
|
||||
}
|
||||
items(state.events, key = { it.id }) { event ->
|
||||
Card {
|
||||
Text(
|
||||
text = "${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}",
|
||||
modifier = Modifier.padding(start = 12.dp, top = 10.dp, end = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = event.dataJson,
|
||||
modifier = Modifier.padding(start = 12.dp, bottom = 10.dp, end = 12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransferRow(transfer: StoredTransfer, onCancel: (ULong) -> Unit) {
|
||||
val status = displayNameForStatus(transfer.status)
|
||||
val tone = when (transfer.status.lowercase()) {
|
||||
"done" -> PillTone.Success
|
||||
"failed" -> PillTone.Destructive
|
||||
"cancelled", "stopped" -> PillTone.Warning
|
||||
"sharing", "receiving" -> PillTone.Brand
|
||||
else -> PillTone.Neutral
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(LocalVniDropColors.current.surfaceRaised)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(transfer.transferName ?: "Transfer ${transfer.transferId}", fontWeight = FontWeight.SemiBold)
|
||||
Text(transferSubtitle(transfer), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
StatusPill(status, tone = tone)
|
||||
}
|
||||
if (transfer.status == "sharing" || transfer.status == "receiving") {
|
||||
QuietButton("Cancel", onClick = { onCancel(transfer.transferId) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestsScreen(
|
||||
requests: List<ReceiverRequest>,
|
||||
lastShare: ShareResult?,
|
||||
onRefresh: (ULong) -> Unit,
|
||||
onRespond: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader("Requests", "Approve or refuse receivers for the current share.")
|
||||
AppCard(title = "Receiver requests", trailing = {
|
||||
lastShare?.let { SecondaryButton("Refresh", onClick = { onRefresh(it.transferId) }) }
|
||||
}) {
|
||||
if (lastShare == null) {
|
||||
EmptyText("Create a share ticket first.")
|
||||
} else if (requests.isEmpty()) {
|
||||
EmptyText("No receiver requests yet.")
|
||||
} else {
|
||||
ReceiverRequestList(requests = requests, onRespondRequest = onRespond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiverRequestList(
|
||||
requests: List<ReceiverRequest>,
|
||||
onRespondRequest: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
requests.forEach { request ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(LocalVniDropColors.current.surfaceRaised)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold)
|
||||
Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral)
|
||||
}
|
||||
request.reason?.let { Text(it, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) }
|
||||
if (request.status == "requested") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SecondaryButton("Refuse", onClick = { onRespondRequest(request.id, false) })
|
||||
PrimaryButton("Approve", onClick = { onRespondRequest(request.id, true) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,18 +646,113 @@ fun App() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoreCard(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
private fun SettingsScreen(
|
||||
platformName: String,
|
||||
appDataDir: String,
|
||||
onAppDataDirChange: (String) -> Unit,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
diagnosticsVisible: Boolean,
|
||||
onDiagnosticsVisibleChange: (Boolean) -> Unit,
|
||||
onInitialize: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
HorizontalDivider()
|
||||
content()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader("Settings", "Configure the local node and app appearance.")
|
||||
ErrorSection(coreState)
|
||||
AppCard(title = "Node") {
|
||||
MetadataRow("Platform", platformName)
|
||||
MetadataRow("Status", coreState.status)
|
||||
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = "Core data directory")
|
||||
PrimaryButton("Initialize core", onClick = onInitialize)
|
||||
}
|
||||
AppCard(title = "Appearance") {
|
||||
ThemeMode.entries.forEach { mode ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.selectable(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
|
||||
Text(mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
AppCard(title = "Diagnostics") {
|
||||
SecondaryButton(
|
||||
text = if (diagnosticsVisible) "Hide event log" else "Show event log",
|
||||
onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) },
|
||||
)
|
||||
EmptyText("Diagnostics are intentionally separate from the primary flow so transfer state stays readable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticsPanel(events: List<CoreEvent>) {
|
||||
AppCard(title = "Event log") {
|
||||
if (events.isEmpty()) {
|
||||
EmptyText("No events have been emitted yet.")
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(280.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
events.forEach { event ->
|
||||
EventRow(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: CoreEvent) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(LocalVniDropColors.current.surfaceRaised)
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(event.dataJson, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressSection(coreState: CoreUiState) {
|
||||
val progress = summarizeProgress(coreState.events)
|
||||
if (progress.isNotEmpty()) {
|
||||
AppCard(title = "Progress") {
|
||||
progress.forEach { item ->
|
||||
ProgressRow(label = item.label, progress = item.progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenHeader(title: String, subtitle: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text(subtitle, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorSection(coreState: CoreUiState) {
|
||||
friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyText(text: String) {
|
||||
Text(text, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.CoreEventSink
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.ShareMetadataInput
|
||||
import uniffi.vnidrop.ShareResult
|
||||
import uniffi.vnidrop.ShareSource
|
||||
import uniffi.vnidrop.SourceKind
|
||||
import uniffi.vnidrop.StoredTransfer
|
||||
import uniffi.vnidrop.TicketInspection
|
||||
import uniffi.vnidrop.VnidropCore
|
||||
|
||||
@@ -21,8 +23,10 @@ data class CoreUiState(
|
||||
val isInitialized: Boolean = false,
|
||||
val status: String = "Not initialized",
|
||||
val events: List<CoreEvent> = emptyList(),
|
||||
val transfers: List<StoredTransfer> = emptyList(),
|
||||
val lastShare: ShareResult? = null,
|
||||
val lastInspection: TicketInspection? = null,
|
||||
val receiverRequests: List<ReceiverRequest> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@@ -46,6 +50,8 @@ class CoreRepository(
|
||||
core?.shutdown()
|
||||
core = VnidropCore.initialize(appDataDir, sink)
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
loadEvents()
|
||||
_state.update { it.copy(isInitialized = true, error = null) }
|
||||
}
|
||||
|
||||
@@ -117,6 +123,7 @@ class CoreRepository(
|
||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
|
||||
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun receiveIntoSecurityScopedDirectory(
|
||||
@@ -128,11 +135,34 @@ class CoreRepository(
|
||||
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
||||
}
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun cancel(transferId: ULong) = runCore {
|
||||
requireCore().cancelTransfer(transferId)
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun refreshReceiverRequests(transferId: ULong) = runCore {
|
||||
val requests = requireCore().listReceiverRequests(transferId)
|
||||
_state.update { it.copy(receiverRequests = requests, error = null) }
|
||||
}
|
||||
|
||||
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null) = runCore {
|
||||
requireCore().respondReceiverRequest(requestId, accepted, reason)
|
||||
state.value.lastShare?.let { share ->
|
||||
val requests = requireCore().listReceiverRequests(share.transferId)
|
||||
_state.update { it.copy(receiverRequests = requests, error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshTransfers() = runCore {
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun refreshEvents() = runCore {
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
suspend fun setError(message: String) {
|
||||
@@ -166,9 +196,10 @@ class CoreRepository(
|
||||
senderName = senderName.ifBlank { null },
|
||||
),
|
||||
)
|
||||
_state.update { it.copy(lastShare = result, error = null) }
|
||||
_state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
|
||||
}
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
private suspend fun <T> withPlatformPathAccess(
|
||||
@@ -196,6 +227,16 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTransfers() {
|
||||
val transfers = core?.listTransfers().orEmpty()
|
||||
_state.update { it.copy(transfers = transfers, error = null) }
|
||||
}
|
||||
|
||||
private fun loadEvents() {
|
||||
val events = core?.listEvents(null).orEmpty()
|
||||
_state.update { it.copy(events = events.take(200), error = null) }
|
||||
}
|
||||
|
||||
private fun nextTransferId(): ULong =
|
||||
Random.nextLong(1, Long.MAX_VALUE).toULong()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.vnidrop.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.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.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
|
||||
@Composable
|
||||
fun AppCard(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.surface),
|
||||
border = BorderStroke(1.dp, colors.border),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
trailing?.invoke()
|
||||
}
|
||||
HorizontalDivider(color = colors.border)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Field(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 44.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = colors.brand, contentColor = Color.White),
|
||||
) {
|
||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 44.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuietButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
|
||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusPill(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
tone: PillTone = PillTone.Neutral,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val color = when (tone) {
|
||||
PillTone.Neutral -> colors.textMuted
|
||||
PillTone.Success -> colors.success
|
||||
PillTone.Warning -> colors.warning
|
||||
PillTone.Destructive -> colors.destructive
|
||||
PillTone.Brand -> colors.brand
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(color.copy(alpha = 0.12f))
|
||||
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Text(label, color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
enum class PillTone {
|
||||
Neutral,
|
||||
Success,
|
||||
Warning,
|
||||
Destructive,
|
||||
Brand,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.destructive.copy(alpha = 0.14f)),
|
||||
border = BorderStroke(1.dp, colors.destructive.copy(alpha = 0.28f)),
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(14.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressRow(
|
||||
label: String,
|
||||
progress: Float?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (progress == null) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(0.35f),
|
||||
color = LocalVniDropColors.current.textMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
modifier = Modifier.weight(0.65f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.vnidrop.app.ui.state
|
||||
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.StoredTransfer
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class AppDestination(
|
||||
val label: String,
|
||||
) {
|
||||
Send("Send"),
|
||||
Receive("Receive"),
|
||||
Activity("Activity"),
|
||||
Requests("Requests"),
|
||||
Settings("Settings"),
|
||||
}
|
||||
|
||||
enum class WindowClass {
|
||||
Compact,
|
||||
Medium,
|
||||
Expanded,
|
||||
}
|
||||
|
||||
fun windowClassFor(widthDp: Float): WindowClass =
|
||||
when {
|
||||
widthDp >= 920f -> WindowClass.Expanded
|
||||
widthDp >= 640f -> WindowClass.Medium
|
||||
else -> WindowClass.Compact
|
||||
}
|
||||
|
||||
data class AppUiState(
|
||||
val destination: AppDestination = AppDestination.Send,
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val diagnosticsVisible: Boolean = false,
|
||||
)
|
||||
|
||||
data class SendUiState(
|
||||
val selectedSource: String = "",
|
||||
val selectedDisplayName: String = "",
|
||||
val transferName: String = "VniDrop transfer",
|
||||
val senderName: String = "",
|
||||
val isSharing: Boolean = false,
|
||||
)
|
||||
|
||||
data class ReceiveUiState(
|
||||
val ticket: String = "",
|
||||
val outputDirectory: String = "",
|
||||
val receiverName: String = "",
|
||||
val isReceiving: Boolean = false,
|
||||
)
|
||||
|
||||
data class TransferProgress(
|
||||
val transferId: ULong?,
|
||||
val phase: String,
|
||||
val label: String,
|
||||
val progress: Float?,
|
||||
)
|
||||
|
||||
fun displayNameForStatus(status: String): String =
|
||||
when (status.lowercase()) {
|
||||
"sharing" -> "Sharing"
|
||||
"receiving" -> "Receiving"
|
||||
"done" -> "Done"
|
||||
"cancelled" -> "Cancelled"
|
||||
"stopped" -> "Stopped"
|
||||
"failed" -> "Failed"
|
||||
else -> status.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
fun activeReceiverRequests(requests: List<ReceiverRequest>): List<ReceiverRequest> =
|
||||
requests.filter { it.status == "requested" }
|
||||
|
||||
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
|
||||
events
|
||||
.filter { event -> event.transferId != null && event.phase in progressPhases }
|
||||
.distinctBy { event -> "${event.transferId}:${event.phase}" }
|
||||
.take(6)
|
||||
.map { event ->
|
||||
TransferProgress(
|
||||
transferId = event.transferId,
|
||||
phase = event.phase,
|
||||
label = eventLabel(event),
|
||||
progress = parseProgress(event.dataJson),
|
||||
)
|
||||
}
|
||||
|
||||
fun transferSubtitle(transfer: StoredTransfer): String {
|
||||
val pieces = listOfNotNull(
|
||||
transfer.transferName,
|
||||
"${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}",
|
||||
formatBytes(transfer.totalSize),
|
||||
)
|
||||
return pieces.joinToString(" | ")
|
||||
}
|
||||
|
||||
fun formatBytes(size: ULong): String {
|
||||
val value = size.toDouble()
|
||||
val units = listOf("B", "KB", "MB", "GB", "TB")
|
||||
var scaled = value
|
||||
var unitIndex = 0
|
||||
while (scaled >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
scaled /= 1024.0
|
||||
unitIndex += 1
|
||||
}
|
||||
return if (unitIndex == 0) {
|
||||
"${size} ${units[unitIndex]}"
|
||||
} else {
|
||||
"${(scaled * 10).roundToInt() / 10.0} ${units[unitIndex]}"
|
||||
}
|
||||
}
|
||||
|
||||
fun friendlyCoreError(raw: String?): String? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
return when {
|
||||
raw.contains("failed to parse transfer ticket", ignoreCase = true) -> "The ticket could not be read. Check that the full ticket was copied."
|
||||
raw.contains("permission", ignoreCase = true) || raw.contains("refused", ignoreCase = true) -> "The transfer is waiting for approval or was refused by the sender."
|
||||
raw.contains("Failed to bind sockets", ignoreCase = true) -> "VniDrop could not open its network sockets on this device."
|
||||
raw.contains("not found", ignoreCase = true) && raw.contains("libvnidrop", ignoreCase = true) -> "The native VniDrop library is missing from this build."
|
||||
else -> raw
|
||||
}
|
||||
}
|
||||
|
||||
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
|
||||
|
||||
private fun eventLabel(event: CoreEvent): String {
|
||||
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
||||
val phase = event.phase.replaceFirstChar { it.uppercase() }
|
||||
val kind = event.kind.replace('-', ' ')
|
||||
return listOfNotNull(direction, phase, kind).joinToString(" - ")
|
||||
}
|
||||
|
||||
private fun parseProgress(json: String): Float? {
|
||||
val transferred = findNumber(json, "transferred") ?: findNumber(json, "downloaded") ?: findNumber(json, "written")
|
||||
val total = findNumber(json, "total") ?: findNumber(json, "total_size")
|
||||
if (transferred == null || total == null || total <= 0.0) return null
|
||||
return (transferred / total).toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun findNumber(json: String, key: String): Double? {
|
||||
val marker = "\"$key\":"
|
||||
val start = json.indexOf(marker)
|
||||
if (start < 0) return null
|
||||
val valueStart = start + marker.length
|
||||
val valueEnd = json.indexOfAny(charArrayOf(',', '}'), valueStart).takeIf { it >= 0 } ?: json.length
|
||||
return json.substring(valueStart, valueEnd).trim().toDoubleOrNull()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.vnidrop.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
enum class ThemeMode {
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean =
|
||||
when (mode) {
|
||||
ThemeMode.System -> systemDark
|
||||
ThemeMode.Light -> false
|
||||
ThemeMode.Dark -> true
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class VniDropColors(
|
||||
val canvas: Color,
|
||||
val sidebar: Color,
|
||||
val surface: Color,
|
||||
val surfaceRaised: Color,
|
||||
val surfaceMuted: Color,
|
||||
val border: Color,
|
||||
val borderStrong: Color,
|
||||
val textPrimary: Color,
|
||||
val textSecondary: Color,
|
||||
val textMuted: Color,
|
||||
val brand: Color,
|
||||
val brandPressed: Color,
|
||||
val warning: Color,
|
||||
val destructive: Color,
|
||||
val success: Color,
|
||||
)
|
||||
|
||||
val LocalVniDropColors = staticCompositionLocalOf { lightVniDropColors }
|
||||
|
||||
private val lightVniDropColors = VniDropColors(
|
||||
canvas = hsl(0f, 0f, 97.3f),
|
||||
sidebar = hsl(0f, 0f, 98.8f),
|
||||
surface = hsl(0f, 0f, 100f),
|
||||
surfaceRaised = hsl(0f, 0f, 98.8f),
|
||||
surfaceMuted = hsl(0f, 0f, 95.3f),
|
||||
border = hsl(0f, 0f, 85.9f),
|
||||
borderStrong = hsl(0f, 0f, 78f),
|
||||
textPrimary = hsl(0f, 0f, 9f),
|
||||
textSecondary = hsl(0f, 0f, 32.2f),
|
||||
textMuted = hsl(0f, 0f, 43.9f),
|
||||
brand = hsl(153.1f, 60.2f, 52.7f),
|
||||
brandPressed = hsl(152.9f, 56.1f, 46.5f),
|
||||
warning = hsl(38.9f, 100f, 57.1f),
|
||||
destructive = hsl(10.2f, 77.9f, 53.9f),
|
||||
success = hsl(153.1f, 60.2f, 40f),
|
||||
)
|
||||
|
||||
private val darkVniDropColors = VniDropColors(
|
||||
canvas = hsl(0f, 0f, 7.1f),
|
||||
sidebar = hsl(0f, 0f, 9f),
|
||||
surface = hsl(0f, 0f, 12.2f),
|
||||
surfaceRaised = hsl(0f, 0f, 14.1f),
|
||||
surfaceMuted = hsl(0f, 0f, 16.1f),
|
||||
border = hsl(0f, 0f, 24.3f),
|
||||
borderStrong = hsl(0f, 0f, 31.4f),
|
||||
textPrimary = hsl(0f, 0f, 98f),
|
||||
textSecondary = hsl(0f, 0f, 70.6f),
|
||||
textMuted = hsl(0f, 0f, 53.7f),
|
||||
brand = hsl(153.1f, 60.2f, 52.7f),
|
||||
brandPressed = hsl(152.9f, 56.1f, 46.5f),
|
||||
warning = hsl(38.9f, 100f, 42.9f),
|
||||
destructive = hsl(10.2f, 77.9f, 53.9f),
|
||||
success = hsl(153.1f, 60.2f, 52.7f),
|
||||
)
|
||||
|
||||
private fun materialScheme(tokens: VniDropColors, dark: Boolean): ColorScheme {
|
||||
val base = if (dark) {
|
||||
darkColorScheme()
|
||||
} else {
|
||||
lightColorScheme()
|
||||
}
|
||||
return base.copy(
|
||||
primary = tokens.brand,
|
||||
onPrimary = if (dark) Color.Black else Color.White,
|
||||
primaryContainer = tokens.surfaceMuted,
|
||||
onPrimaryContainer = tokens.textPrimary,
|
||||
background = tokens.canvas,
|
||||
onBackground = tokens.textPrimary,
|
||||
surface = tokens.surface,
|
||||
onSurface = tokens.textPrimary,
|
||||
surfaceVariant = tokens.surfaceMuted,
|
||||
onSurfaceVariant = tokens.textSecondary,
|
||||
outline = tokens.border,
|
||||
outlineVariant = tokens.border,
|
||||
error = tokens.destructive,
|
||||
errorContainer = tokens.destructive.copy(alpha = if (dark) 0.22f else 0.16f),
|
||||
onErrorContainer = tokens.textPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VniDropTheme(
|
||||
mode: ThemeMode,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dark = resolveDarkTheme(mode, isSystemInDarkTheme())
|
||||
val tokens = if (dark) darkVniDropColors else lightVniDropColors
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) {
|
||||
MaterialTheme(
|
||||
colorScheme = materialScheme(tokens, dark),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hsl(hue: Float, saturation: Float, lightness: Float): Color {
|
||||
val h = ((hue % 360f) + 360f) % 360f / 360f
|
||||
val s = saturation.coerceIn(0f, 100f) / 100f
|
||||
val l = lightness.coerceIn(0f, 100f) / 100f
|
||||
if (s == 0f) {
|
||||
return Color(l, l, l)
|
||||
}
|
||||
val q = if (l < 0.5f) l * (1 + s) else l + s - l * s
|
||||
val p = 2 * l - q
|
||||
return Color(
|
||||
red = hueToRgb(p, q, h + 1f / 3f),
|
||||
green = hueToRgb(p, q, h),
|
||||
blue = hueToRgb(p, q, h - 1f / 3f),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hueToRgb(p: Float, q: Float, input: Float): Float {
|
||||
var t = input
|
||||
if (t < 0f) t += 1f
|
||||
if (t > 1f) t -= 1f
|
||||
return when {
|
||||
t < 1f / 6f -> p + (q - p) * 6f * t
|
||||
t < 1f / 2f -> q
|
||||
t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f
|
||||
else -> p
|
||||
}.let { min(1f, max(0f, it)) }
|
||||
}
|
||||
|
||||
fun Color.contrastAgainst(other: Color): Float =
|
||||
abs(luminanceApproximation() - other.luminanceApproximation())
|
||||
|
||||
private fun Color.luminanceApproximation(): Float =
|
||||
(red * 0.2126f) + (green * 0.7152f) + (blue * 0.0722f)
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.vnidrop.app.ui.state
|
||||
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.ui.theme.resolveDarkTheme
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AppUiModelsTest {
|
||||
@Test
|
||||
fun windowClassUsesCompactMediumExpandedBreakpoints() {
|
||||
assertEquals(WindowClass.Compact, windowClassFor(390f))
|
||||
assertEquals(WindowClass.Medium, windowClassFor(700f))
|
||||
assertEquals(WindowClass.Expanded, windowClassFor(1200f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun themeModeResolvesAgainstSystemOnlyWhenRequested() {
|
||||
assertTrue(resolveDarkTheme(ThemeMode.System, systemDark = true))
|
||||
assertFalse(resolveDarkTheme(ThemeMode.System, systemDark = false))
|
||||
assertFalse(resolveDarkTheme(ThemeMode.Light, systemDark = true))
|
||||
assertTrue(resolveDarkTheme(ThemeMode.Dark, systemDark = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coreErrorsBecomeStableUserMessages() {
|
||||
assertEquals(
|
||||
"The ticket could not be read. Check that the full ticket was copied.",
|
||||
friendlyCoreError("reason=failed to parse transfer ticket"),
|
||||
)
|
||||
assertEquals(
|
||||
"The transfer is waiting for approval or was refused by the sender.",
|
||||
friendlyCoreError("permission denied by sender"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byteFormattingKeepsTransferCardsReadable() {
|
||||
assertEquals("58 B", formatBytes(58UL))
|
||||
assertEquals("1.5 KB", formatBytes(1536UL))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user