Build Rust core handshake and Compose UI foundation

This commit is contained in:
2026-07-04 08:59:23 +02:00
parent 68363fb680
commit 89a99582a5
19 changed files with 2212 additions and 253 deletions

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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,
)
}
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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))
}
}