mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Build native UI shell foundation
This commit is contained in:
66
shared/src/commonMain/composeResources/values/strings.xml
Normal file
66
shared/src/commonMain/composeResources/values/strings.xml
Normal file
@@ -0,0 +1,66 @@
|
||||
<resources>
|
||||
<string name="nav_send">Send</string>
|
||||
<string name="nav_receive">Receive</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="send_title">Send</string>
|
||||
<string name="send_subtitle">Create a VniDrop ticket and approve receivers when required.</string>
|
||||
<string name="source_title">Source</string>
|
||||
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
|
||||
<string name="button_select_file">Select file</string>
|
||||
<string name="button_clear">Clear</string>
|
||||
<string name="transfer_details_title">Transfer details</string>
|
||||
<string name="field_transfer_name">Transfer name</string>
|
||||
<string name="field_sender_name">Sender name</string>
|
||||
<string name="button_create_share_ticket">Create share ticket</string>
|
||||
<string name="button_creating_ticket">Creating ticket...</string>
|
||||
<string name="share_ticket_title">Share ticket</string>
|
||||
<string name="receiver_requests_title">Receiver requests</string>
|
||||
<string name="button_copy">Copy</string>
|
||||
<string name="button_use_locally">Use locally</string>
|
||||
<string name="button_refresh">Refresh</string>
|
||||
<string name="button_refuse">Refuse</string>
|
||||
<string name="button_approve">Approve</string>
|
||||
<string name="receive_title">Receive</string>
|
||||
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
|
||||
<string name="ticket_card_title">Ticket</string>
|
||||
<string name="field_ticket">Ticket</string>
|
||||
<string name="field_output_directory">Output directory</string>
|
||||
<string name="field_receiver_name">Receiver name</string>
|
||||
<string name="button_inspect_ticket">Inspect ticket</string>
|
||||
<string name="button_receive">Receive</string>
|
||||
<string name="button_receiving">Receiving...</string>
|
||||
<string name="ticket_details_title">Ticket details</string>
|
||||
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_subtitle">Configure the local node and app appearance.</string>
|
||||
<string name="node_title">Node</string>
|
||||
<string name="appearance_title">Appearance</string>
|
||||
<string name="diagnostics_title">Diagnostics</string>
|
||||
<string name="button_initialize_core">Initialize core</string>
|
||||
<string name="field_core_data_directory">Core data directory</string>
|
||||
<string name="button_hide_event_log">Hide event log</string>
|
||||
<string name="button_show_event_log">Show event log</string>
|
||||
<string name="button_refresh_logs">Refresh logs</string>
|
||||
<string name="diagnostics_hint">Diagnostics stay behind Settings so transfer flows remain focused.</string>
|
||||
<string name="event_log_title">Event log</string>
|
||||
<string name="no_events">No events have been emitted yet.</string>
|
||||
<string name="no_logs">No log files have been written yet.</string>
|
||||
<string name="progress_title">Progress</string>
|
||||
<string name="not_initialized">Not initialized</string>
|
||||
<string name="unknown_sender">Unknown</string>
|
||||
<string name="metadata_name">Name</string>
|
||||
<string name="metadata_source">Source</string>
|
||||
<string name="metadata_transfer">Transfer</string>
|
||||
<string name="metadata_size">Size</string>
|
||||
<string name="metadata_kind">Kind</string>
|
||||
<string name="metadata_sender">Sender</string>
|
||||
<string name="metadata_files">Files</string>
|
||||
<string name="metadata_hash">Hash</string>
|
||||
<string name="metadata_platform">Platform</string>
|
||||
<string name="metadata_status">Status</string>
|
||||
<string name="metadata_log_directory">Log directory</string>
|
||||
<string name="error_invalid_ticket">The ticket could not be read. Check that the full ticket was copied.</string>
|
||||
<string name="error_permission">The transfer is waiting for approval or was refused by the sender.</string>
|
||||
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
|
||||
<string name="error_missing_native_library">The native VniDrop library is missing from this build.</string>
|
||||
</resources>
|
||||
@@ -1,35 +1,6 @@
|
||||
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.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.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -38,51 +9,28 @@ 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.logging.AppLogger
|
||||
import com.vnidrop.app.logging.LogFileInfo
|
||||
import com.vnidrop.app.platform.PlatformSystemAppearance
|
||||
import com.vnidrop.app.ui.navigation.AppDestination
|
||||
import com.vnidrop.app.ui.screens.ReceiveScreen
|
||||
import com.vnidrop.app.ui.screens.SendScreen
|
||||
import com.vnidrop.app.ui.screens.SettingsScreen
|
||||
import com.vnidrop.app.ui.shell.AppShell
|
||||
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 com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||
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
|
||||
@@ -95,63 +43,89 @@ fun App() {
|
||||
var sendState by remember { mutableStateOf(SendUiState()) }
|
||||
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) }
|
||||
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
|
||||
var logFiles by remember { mutableStateOf<List<LogFileInfo>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val picker = rememberShareFilePicker(
|
||||
onFilePicked = { file ->
|
||||
AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName))
|
||||
selectedFile = file
|
||||
sendState = sendState.copy(
|
||||
selectedSource = file.value,
|
||||
selectedDisplayName = file.displayName,
|
||||
transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) {
|
||||
file.displayName
|
||||
} else {
|
||||
sendState.transferName
|
||||
},
|
||||
)
|
||||
sendState = sendState.withSelectedFile(file)
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
},
|
||||
onError = { error ->
|
||||
AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error))
|
||||
scope.launch { repository.setError(error) }
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
},
|
||||
onError = { error -> scope.launch { repository.setError(error) } },
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
AppLogger.initialize(appDataDir)
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name))
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
}
|
||||
|
||||
LaunchedEffect(coreState.lastShare?.transferId) {
|
||||
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) }
|
||||
}
|
||||
|
||||
VniDropTheme(mode = appState.themeMode) {
|
||||
val isDarkTheme = rememberResolvedDarkTheme(appState.themeMode)
|
||||
PlatformSystemAppearance(isDarkTheme)
|
||||
LaunchedEffect(isDarkTheme) {
|
||||
AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
}
|
||||
|
||||
VniDropTheme(isDarkTheme = isDarkTheme) {
|
||||
BoxWithConstraints {
|
||||
val windowClass = windowClassFor(maxWidth.value)
|
||||
AppFrame(
|
||||
appState = appState,
|
||||
coreState = coreState,
|
||||
windowClass = windowClass,
|
||||
onDestinationChange = { appState = appState.copy(destination = it) },
|
||||
AppShell(
|
||||
selectedDestination = appState.destination,
|
||||
windowClass = windowClassFor(maxWidth.value),
|
||||
onDestinationSelected = { appState = appState.copy(destination = it) },
|
||||
) {
|
||||
when (appState.destination) {
|
||||
AppDestination.Send -> SendScreen(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onSendStateChange = { sendState = it },
|
||||
onSelectFile = { picker.pickFile() },
|
||||
onSelectFile = {
|
||||
AppLogger.info("file-picker", "open share file picker")
|
||||
picker.pickFile()
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
},
|
||||
onCreateShare = {
|
||||
scope.launch {
|
||||
AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource))
|
||||
sendState = sendState.copy(isSharing = true)
|
||||
val file = selectedFile
|
||||
if (file != null) {
|
||||
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
|
||||
} else {
|
||||
if (file == null) {
|
||||
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
|
||||
} else {
|
||||
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
|
||||
}
|
||||
sendState = sendState.copy(isSharing = false)
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
}
|
||||
},
|
||||
onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) },
|
||||
onCopyTicket = { ticket ->
|
||||
AppLogger.info("send", "ticket copied")
|
||||
clipboard.setText(AnnotatedString(ticket))
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
},
|
||||
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") }
|
||||
scope.launch {
|
||||
repository.respondReceiverRequest(
|
||||
requestId = requestId,
|
||||
accepted = accepted,
|
||||
reason = if (accepted) null else "sender-refused",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
AppDestination.Receive -> ReceiveScreen(
|
||||
@@ -161,598 +135,48 @@ fun App() {
|
||||
onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } },
|
||||
onReceive = {
|
||||
scope.launch {
|
||||
AppLogger.info("receive", "receive requested")
|
||||
receiveState = receiveState.copy(isReceiving = true)
|
||||
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
|
||||
receiveState = receiveState.copy(isReceiving = false)
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
}
|
||||
},
|
||||
)
|
||||
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) },
|
||||
onThemeModeChange = {
|
||||
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name))
|
||||
appState = appState.copy(themeMode = it)
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
},
|
||||
diagnosticsVisible = appState.diagnosticsVisible,
|
||||
onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) },
|
||||
onInitialize = { scope.launch { repository.initialize(appDataDir) } },
|
||||
logDirectory = AppLogger.logDirectory,
|
||||
logFiles = logFiles,
|
||||
onRefreshLogs = { logFiles = AppLogger.listLogFiles() },
|
||||
onInitialize = {
|
||||
scope.launch {
|
||||
AppLogger.initialize(appDataDir)
|
||||
AppLogger.info("core", "initialize requested", mapOf("appDataDir" to appDataDir))
|
||||
repository.initialize(appDataDir)
|
||||
logFiles = AppLogger.listLogFiles()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (appState.diagnosticsVisible) {
|
||||
DiagnosticsPanel(events = coreState.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =
|
||||
copy(
|
||||
selectedSource = file.value,
|
||||
selectedDisplayName = file.displayName,
|
||||
transferName = if (transferName == "VniDrop transfer" || transferName.isBlank()) file.displayName else transferName,
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(
|
||||
platformName: String,
|
||||
appDataDir: String,
|
||||
onAppDataDirChange: (String) -> Unit,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
diagnosticsVisible: Boolean,
|
||||
onDiagnosticsVisibleChange: (Boolean) -> Unit,
|
||||
onInitialize: () -> Unit,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.vnidrop.app.logging
|
||||
|
||||
data class LogRotationPolicy(
|
||||
val maxBytes: Long = 1_048_576,
|
||||
val maxFiles: Int = 5,
|
||||
) {
|
||||
init {
|
||||
require(maxBytes > 0) { "maxBytes must be positive" }
|
||||
require(maxFiles >= 0) { "maxFiles must be zero or positive" }
|
||||
}
|
||||
|
||||
fun shouldRotate(currentBytes: Long, incomingBytes: Long): Boolean =
|
||||
currentBytes > 0 && currentBytes + incomingBytes > maxBytes
|
||||
}
|
||||
|
||||
enum class AppLogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class LogFileInfo(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val sizeBytes: Long,
|
||||
val modifiedAtMillis: Long,
|
||||
)
|
||||
|
||||
interface PlatformLogStore {
|
||||
val logDirectory: String
|
||||
fun append(line: String)
|
||||
fun listLogFiles(): List<LogFileInfo>
|
||||
}
|
||||
|
||||
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
|
||||
|
||||
expect fun platformNowMillis(): Long
|
||||
|
||||
object AppLogger {
|
||||
private var store: PlatformLogStore? = null
|
||||
private var activeDirectory: String? = null
|
||||
|
||||
val logDirectory: String?
|
||||
get() = store?.logDirectory
|
||||
|
||||
fun initialize(appDataDir: String, policy: LogRotationPolicy = LogRotationPolicy()) {
|
||||
if (activeDirectory == appDataDir && store != null) return
|
||||
store = createPlatformLogStore(appDataDir, policy)
|
||||
activeDirectory = appDataDir
|
||||
info("logging", "app logger initialized", mapOf("directory" to (store?.logDirectory ?: "")))
|
||||
}
|
||||
|
||||
fun debug(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
|
||||
write(AppLogLevel.Debug, scope, message, fields)
|
||||
|
||||
fun info(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
|
||||
write(AppLogLevel.Info, scope, message, fields)
|
||||
|
||||
fun warn(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
|
||||
write(AppLogLevel.Warn, scope, message, fields)
|
||||
|
||||
fun error(scope: String, message: String, throwable: Throwable? = null, fields: Map<String, String> = emptyMap()) {
|
||||
val allFields = if (throwable == null) {
|
||||
fields
|
||||
} else {
|
||||
fields + ("error" to (throwable.message ?: throwable.toString()))
|
||||
}
|
||||
write(AppLogLevel.Error, scope, message, allFields)
|
||||
}
|
||||
|
||||
fun listLogFiles(): List<LogFileInfo> =
|
||||
store?.listLogFiles().orEmpty()
|
||||
|
||||
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
|
||||
val line = buildString {
|
||||
append(platformNowMillis())
|
||||
append(" ")
|
||||
append(level.name.uppercase())
|
||||
append(" [")
|
||||
append(scope)
|
||||
append("] ")
|
||||
append(message)
|
||||
if (fields.isNotEmpty()) {
|
||||
append(" ")
|
||||
append(fields.entries.joinToString(" ") { (key, value) -> "$key=${value.sanitizeLogValue()}" })
|
||||
}
|
||||
append("\n")
|
||||
}
|
||||
store?.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sanitizeLogValue(): String =
|
||||
replace('\n', ' ').replace('\r', ' ')
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vnidrop.app.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
expect fun PlatformSystemAppearance(isDarkTheme: Boolean)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.vnidrop.app.platform
|
||||
|
||||
enum class SystemBarIconMode {
|
||||
LightIcons,
|
||||
DarkIcons,
|
||||
}
|
||||
|
||||
fun systemBarIconModeForTheme(isDarkTheme: Boolean): SystemBarIconMode =
|
||||
if (isDarkTheme) SystemBarIconMode.LightIcons else SystemBarIconMode.DarkIcons
|
||||
@@ -46,8 +46,8 @@ fun AppCard(
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.surface),
|
||||
border = BorderStroke(1.dp, colors.border),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
|
||||
border = BorderStroke(1.dp, colors.borderDefault),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -61,7 +61,7 @@ fun AppCard(
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
trailing?.invoke()
|
||||
}
|
||||
HorizontalDivider(color = colors.border)
|
||||
HorizontalDivider(color = colors.borderDefault)
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ fun PrimaryButton(
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 44.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = colors.brand, contentColor = Color.White),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = colors.brandButton, contentColor = Color.White),
|
||||
) {
|
||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
@@ -143,11 +143,11 @@ fun StatusPill(
|
||||
) {
|
||||
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
|
||||
PillTone.Neutral -> colors.foregroundLighter
|
||||
PillTone.Success -> colors.brandLink
|
||||
PillTone.Warning -> colors.warningDefault
|
||||
PillTone.Destructive -> colors.destructiveDefault
|
||||
PillTone.Brand -> colors.brandLink
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -182,8 +182,8 @@ fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
|
||||
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)),
|
||||
colors = CardDefaults.cardColors(containerColor = colors.destructive200),
|
||||
border = BorderStroke(1.dp, colors.destructive400),
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
@@ -220,7 +220,7 @@ fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(0.35f),
|
||||
color = LocalVniDropColors.current.textMuted,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vnidrop.app.ui.navigation
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.nav_receive
|
||||
import vnidrop.shared.generated.resources.nav_send
|
||||
import vnidrop.shared.generated.resources.nav_settings
|
||||
|
||||
enum class AppDestination {
|
||||
Send,
|
||||
Receive,
|
||||
Settings,
|
||||
}
|
||||
|
||||
data class NavigationItem(
|
||||
val destination: AppDestination,
|
||||
val label: StringResource,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
// The route list is intentionally tiny for this phase. Activity, receiver
|
||||
// requests, and diagnostics remain available inside screens instead of being
|
||||
// promoted to top-level navigation.
|
||||
val primaryNavigationItems = listOf(
|
||||
NavigationItem(AppDestination.Send, Res.string.nav_send, VniDropIcons.Send),
|
||||
NavigationItem(AppDestination.Receive, Res.string.nav_receive, VniDropIcons.Receive),
|
||||
NavigationItem(AppDestination.Settings, Res.string.nav_settings, VniDropIcons.Settings),
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.vnidrop.app.ui.navigation
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun AppSidebarNavigation(
|
||||
selected: AppDestination,
|
||||
onDestinationSelected: (AppDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(88.dp)
|
||||
.fillMaxHeight()
|
||||
.background(colors.backgroundSurface200)
|
||||
.border(width = 1.dp, color = colors.borderDefault)
|
||||
.padding(vertical = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
primaryNavigationItems.forEach { item ->
|
||||
SidebarNavigationItem(
|
||||
item = item,
|
||||
selected = item.destination == selected,
|
||||
onClick = { onDestinationSelected(item.destination) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBottomNavigation(
|
||||
selected: AppDestination,
|
||||
onDestinationSelected: (AppDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.backgroundSurface200)
|
||||
.border(width = 1.dp, color = colors.borderDefault),
|
||||
) {
|
||||
ActiveBottomIndicator(selected = selected)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
primaryNavigationItems.forEach { item ->
|
||||
BottomNavigationItem(
|
||||
item = item,
|
||||
selected = item.destination == selected,
|
||||
onClick = { onDestinationSelected(item.destination) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarNavigationItem(
|
||||
item: NavigationItem,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val foreground = if (selected) colors.brandLink else colors.foregroundLight
|
||||
val label = stringResource(item.label)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(selected = selected, onClick = onClick)
|
||||
.background(if (selected) colors.backgroundSurface300 else Color.Transparent)
|
||||
.padding(vertical = 13.dp),
|
||||
) {
|
||||
if (selected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.size(width = 4.dp, height = 46.dp)
|
||||
.clip(RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp))
|
||||
.background(colors.brandLink),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = foreground,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNavigationItem(
|
||||
item: NavigationItem,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val foreground = if (selected) colors.brandLink else colors.foregroundLight
|
||||
val label = stringResource(item.label)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.selectable(selected = selected, onClick = onClick)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = foreground,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveBottomIndicator(selected: AppDestination) {
|
||||
val colors = LocalVniDropColors.current
|
||||
val index = primaryNavigationItems.indexOfFirst { it.destination == selected }.coerceAtLeast(0)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
repeat(primaryNavigationItems.size) { itemIndex ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(height = 3.dp, width = 1.dp)
|
||||
.background(if (itemIndex == index) colors.brandLink else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.vnidrop.app.ui.navigation
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object VniDropIcons {
|
||||
val Send: ImageVector by lazy {
|
||||
ImageVector.Builder("Send", 24.dp, 24.dp, 24f, 24f).apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Transparent),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(22f, 2f)
|
||||
lineTo(11f, 13f)
|
||||
moveTo(22f, 2f)
|
||||
lineTo(15f, 22f)
|
||||
lineTo(11f, 13f)
|
||||
lineTo(2f, 9f)
|
||||
lineTo(22f, 2f)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
val Receive: ImageVector by lazy {
|
||||
ImageVector.Builder("Receive", 24.dp, 24.dp, 24f, 24f).apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Transparent),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(12f, 3f)
|
||||
lineTo(12f, 15f)
|
||||
moveTo(7f, 10f)
|
||||
lineTo(12f, 15f)
|
||||
lineTo(17f, 10f)
|
||||
moveTo(5f, 21f)
|
||||
lineTo(19f, 21f)
|
||||
moveTo(5f, 17f)
|
||||
lineTo(5f, 21f)
|
||||
moveTo(19f, 17f)
|
||||
lineTo(19f, 21f)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
val Settings: ImageVector by lazy {
|
||||
ImageVector.Builder("Settings", 24.dp, 24.dp, 24f, 24f).apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Transparent),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round,
|
||||
pathFillType = PathFillType.NonZero,
|
||||
) {
|
||||
moveTo(12f, 15f)
|
||||
arcTo(3f, 3f, 0f, false, false, 12f, 9f)
|
||||
arcTo(3f, 3f, 0f, false, false, 12f, 15f)
|
||||
moveTo(19.4f, 15f)
|
||||
lineTo(20.8f, 17.4f)
|
||||
lineTo(18.4f, 21f)
|
||||
lineTo(15.8f, 20f)
|
||||
moveTo(8.2f, 4f)
|
||||
lineTo(5.6f, 3f)
|
||||
lineTo(3.2f, 6.6f)
|
||||
lineTo(4.6f, 9f)
|
||||
moveTo(15.8f, 4f)
|
||||
lineTo(18.4f, 3f)
|
||||
lineTo(20.8f, 6.6f)
|
||||
lineTo(19.4f, 9f)
|
||||
moveTo(4.6f, 15f)
|
||||
lineTo(3.2f, 17.4f)
|
||||
lineTo(5.6f, 21f)
|
||||
lineTo(8.2f, 20f)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.vnidrop.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_inspect_ticket
|
||||
import vnidrop.shared.generated.resources.button_receive
|
||||
import vnidrop.shared.generated.resources.button_receiving
|
||||
import vnidrop.shared.generated.resources.field_output_directory
|
||||
import vnidrop.shared.generated.resources.field_receiver_name
|
||||
import vnidrop.shared.generated.resources.field_ticket
|
||||
import vnidrop.shared.generated.resources.receive_subtitle
|
||||
import vnidrop.shared.generated.resources.receive_title
|
||||
import vnidrop.shared.generated.resources.ticket_card_title
|
||||
|
||||
@Composable
|
||||
fun ReceiveScreen(
|
||||
coreState: CoreUiState,
|
||||
receiveState: ReceiveUiState,
|
||||
onReceiveStateChange: (ReceiveUiState) -> Unit,
|
||||
onInspect: () -> Unit,
|
||||
onReceive: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
|
||||
ErrorSection(coreState)
|
||||
AppCard(title = stringResource(Res.string.ticket_card_title)) {
|
||||
Field(
|
||||
value = receiveState.ticket,
|
||||
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
|
||||
label = stringResource(Res.string.field_ticket),
|
||||
minLines = 4,
|
||||
)
|
||||
Field(
|
||||
value = receiveState.outputDirectory,
|
||||
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
|
||||
label = stringResource(Res.string.field_output_directory),
|
||||
)
|
||||
Field(
|
||||
value = receiveState.receiverName,
|
||||
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
|
||||
label = stringResource(Res.string.field_receiver_name),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SecondaryButton(
|
||||
text = stringResource(Res.string.button_inspect_ticket),
|
||||
onClick = onInspect,
|
||||
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
|
||||
)
|
||||
PrimaryButton(
|
||||
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
|
||||
onClick = onReceive,
|
||||
enabled = coreState.isInitialized &&
|
||||
receiveState.ticket.isNotBlank() &&
|
||||
receiveState.outputDirectory.isNotBlank() &&
|
||||
!receiveState.isReceiving,
|
||||
)
|
||||
}
|
||||
}
|
||||
coreState.lastInspection?.let { TicketInspectionCard(it) }
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.vnidrop.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.ErrorBanner
|
||||
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.SecondaryButton
|
||||
import com.vnidrop.app.ui.components.StatusPill
|
||||
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.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.TicketInspection
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_approve
|
||||
import vnidrop.shared.generated.resources.button_refuse
|
||||
import vnidrop.shared.generated.resources.event_log_title
|
||||
import vnidrop.shared.generated.resources.metadata_files
|
||||
import vnidrop.shared.generated.resources.metadata_hash
|
||||
import vnidrop.shared.generated.resources.metadata_kind
|
||||
import vnidrop.shared.generated.resources.metadata_sender
|
||||
import vnidrop.shared.generated.resources.metadata_size
|
||||
import vnidrop.shared.generated.resources.metadata_transfer
|
||||
import vnidrop.shared.generated.resources.no_events
|
||||
import vnidrop.shared.generated.resources.progress_title
|
||||
import vnidrop.shared.generated.resources.ticket_details_title
|
||||
import vnidrop.shared.generated.resources.ticket_no_metadata
|
||||
import vnidrop.shared.generated.resources.unknown_sender
|
||||
|
||||
@Composable
|
||||
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.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorSection(coreState: CoreUiState) {
|
||||
friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyText(text: String) {
|
||||
Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressSection(coreState: CoreUiState) {
|
||||
val progress = summarizeProgress(coreState.events)
|
||||
if (progress.isNotEmpty()) {
|
||||
AppCard(title = stringResource(Res.string.progress_title)) {
|
||||
progress.forEach { item ->
|
||||
ProgressRow(label = item.label, progress = item.progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TicketInspectionCard(inspection: TicketInspection) {
|
||||
AppCard(title = stringResource(Res.string.ticket_details_title)) {
|
||||
MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind)
|
||||
inspection.metadata?.let { metadata ->
|
||||
MetadataRow(stringResource(Res.string.metadata_transfer), metadata.transferName)
|
||||
MetadataRow(stringResource(Res.string.metadata_sender), metadata.senderName ?: stringResource(Res.string.unknown_sender))
|
||||
MetadataRow(stringResource(Res.string.metadata_files), metadata.fileCount.toString())
|
||||
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(metadata.totalSize))
|
||||
MetadataRow(stringResource(Res.string.metadata_hash), metadata.contentHash)
|
||||
} ?: EmptyText(stringResource(Res.string.ticket_no_metadata))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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.backgroundSurface100)
|
||||
.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.foregroundLighter, 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.foregroundLighter, style = MaterialTheme.typography.bodySmall) }
|
||||
if (request.status == "requested") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRespondRequest(request.id, false) })
|
||||
PrimaryButton(stringResource(Res.string.button_approve), onClick = { onRespondRequest(request.id, true) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TicketText(ticket: String) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = ticket,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(LocalVniDropColors.current.backgroundSurface200)
|
||||
.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiagnosticsPanel(events: List<CoreEvent>) {
|
||||
AppCard(title = stringResource(Res.string.event_log_title)) {
|
||||
if (events.isEmpty()) {
|
||||
EmptyText(stringResource(Res.string.no_events))
|
||||
} 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.backgroundSurface100)
|
||||
.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.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionDivider() {
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.vnidrop.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
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.SecondaryButton
|
||||
import com.vnidrop.app.ui.components.StatusPill
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.ShareResult
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_approve
|
||||
import vnidrop.shared.generated.resources.button_clear
|
||||
import vnidrop.shared.generated.resources.button_copy
|
||||
import vnidrop.shared.generated.resources.button_create_share_ticket
|
||||
import vnidrop.shared.generated.resources.button_creating_ticket
|
||||
import vnidrop.shared.generated.resources.button_refresh
|
||||
import vnidrop.shared.generated.resources.button_refuse
|
||||
import vnidrop.shared.generated.resources.button_select_file
|
||||
import vnidrop.shared.generated.resources.button_use_locally
|
||||
import vnidrop.shared.generated.resources.field_sender_name
|
||||
import vnidrop.shared.generated.resources.field_transfer_name
|
||||
import vnidrop.shared.generated.resources.metadata_name
|
||||
import vnidrop.shared.generated.resources.metadata_size
|
||||
import vnidrop.shared.generated.resources.metadata_source
|
||||
import vnidrop.shared.generated.resources.metadata_transfer
|
||||
import vnidrop.shared.generated.resources.receiver_requests_title
|
||||
import vnidrop.shared.generated.resources.send_source_empty
|
||||
import vnidrop.shared.generated.resources.send_subtitle
|
||||
import vnidrop.shared.generated.resources.send_title
|
||||
import vnidrop.shared.generated.resources.share_ticket_title
|
||||
import vnidrop.shared.generated.resources.source_title
|
||||
import vnidrop.shared.generated.resources.transfer_details_title
|
||||
|
||||
@Composable
|
||||
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(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||
ErrorSection(coreState)
|
||||
SendSourceCard(
|
||||
sendState = sendState,
|
||||
onSendStateChange = onSendStateChange,
|
||||
onSelectFile = onSelectFile,
|
||||
)
|
||||
SendDetailsCard(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onSendStateChange = onSendStateChange,
|
||||
onCreateShare = onCreateShare,
|
||||
)
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
onCopyTicket = onCopyTicket,
|
||||
onUseLocally = onUseLocally,
|
||||
onRefreshRequests = onRefreshRequests,
|
||||
onRespondRequest = onRespondRequest,
|
||||
)
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendSourceCard(
|
||||
sendState: SendUiState,
|
||||
onSendStateChange: (SendUiState) -> Unit,
|
||||
onSelectFile: () -> Unit,
|
||||
) {
|
||||
AppCard(title = stringResource(Res.string.source_title)) {
|
||||
if (sendState.selectedSource.isBlank()) {
|
||||
EmptyText(stringResource(Res.string.send_source_empty))
|
||||
} else {
|
||||
MetadataRow(stringResource(Res.string.metadata_name), sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
|
||||
MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
|
||||
SecondaryButton(
|
||||
text = stringResource(Res.string.button_clear),
|
||||
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
|
||||
enabled = sendState.selectedSource.isNotBlank(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendDetailsCard(
|
||||
coreState: CoreUiState,
|
||||
sendState: SendUiState,
|
||||
onSendStateChange: (SendUiState) -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
) {
|
||||
AppCard(title = stringResource(Res.string.transfer_details_title)) {
|
||||
Field(
|
||||
value = sendState.transferName,
|
||||
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
|
||||
label = stringResource(Res.string.field_transfer_name),
|
||||
)
|
||||
Field(
|
||||
value = sendState.senderName,
|
||||
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
|
||||
label = stringResource(Res.string.field_sender_name),
|
||||
)
|
||||
PrimaryButton(
|
||||
text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket),
|
||||
onClick = onCreateShare,
|
||||
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareResultCard(
|
||||
share: ShareResult,
|
||||
requests: List<ReceiverRequest>,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onUseLocally: (String) -> Unit,
|
||||
onRefreshRequests: (ULong) -> Unit,
|
||||
onRespondRequest: (String, Boolean) -> Unit,
|
||||
) {
|
||||
AppCard(title = stringResource(Res.string.share_ticket_title), trailing = {
|
||||
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
||||
}) {
|
||||
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
||||
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
|
||||
TicketText(share.ticket)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
|
||||
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseLocally(share.ticket) })
|
||||
SecondaryButton(stringResource(Res.string.button_refresh), onClick = { onRefreshRequests(share.transferId) })
|
||||
}
|
||||
if (requests.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold)
|
||||
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.vnidrop.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.logging.LogFileInfo
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.MetadataRow
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.appearance_title
|
||||
import vnidrop.shared.generated.resources.button_hide_event_log
|
||||
import vnidrop.shared.generated.resources.button_initialize_core
|
||||
import vnidrop.shared.generated.resources.button_refresh_logs
|
||||
import vnidrop.shared.generated.resources.button_show_event_log
|
||||
import vnidrop.shared.generated.resources.diagnostics_hint
|
||||
import vnidrop.shared.generated.resources.diagnostics_title
|
||||
import vnidrop.shared.generated.resources.field_core_data_directory
|
||||
import vnidrop.shared.generated.resources.metadata_log_directory
|
||||
import vnidrop.shared.generated.resources.metadata_platform
|
||||
import vnidrop.shared.generated.resources.metadata_status
|
||||
import vnidrop.shared.generated.resources.no_logs
|
||||
import vnidrop.shared.generated.resources.node_title
|
||||
import vnidrop.shared.generated.resources.not_initialized
|
||||
import vnidrop.shared.generated.resources.settings_subtitle
|
||||
import vnidrop.shared.generated.resources.settings_title
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
platformName: String,
|
||||
appDataDir: String,
|
||||
onAppDataDirChange: (String) -> Unit,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
diagnosticsVisible: Boolean,
|
||||
onDiagnosticsVisibleChange: (Boolean) -> Unit,
|
||||
logDirectory: String?,
|
||||
logFiles: List<LogFileInfo>,
|
||||
onRefreshLogs: () -> Unit,
|
||||
onInitialize: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader(stringResource(Res.string.settings_title), stringResource(Res.string.settings_subtitle))
|
||||
ErrorSection(coreState)
|
||||
AppCard(title = stringResource(Res.string.node_title)) {
|
||||
MetadataRow(stringResource(Res.string.metadata_platform), platformName)
|
||||
MetadataRow(stringResource(Res.string.metadata_status), coreState.status)
|
||||
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = stringResource(Res.string.field_core_data_directory))
|
||||
PrimaryButton(stringResource(Res.string.button_initialize_core), onClick = onInitialize)
|
||||
}
|
||||
AppCard(title = stringResource(Res.string.appearance_title)) {
|
||||
ThemeMode.entries.forEach { mode ->
|
||||
ThemeModeRow(
|
||||
mode = mode,
|
||||
selected = themeMode == mode,
|
||||
onSelected = { onThemeModeChange(mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
AppCard(title = stringResource(Res.string.diagnostics_title)) {
|
||||
SecondaryButton(
|
||||
text = if (diagnosticsVisible) stringResource(Res.string.button_hide_event_log) else stringResource(Res.string.button_show_event_log),
|
||||
onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) },
|
||||
)
|
||||
SecondaryButton(text = stringResource(Res.string.button_refresh_logs), onClick = onRefreshLogs)
|
||||
MetadataRow(stringResource(Res.string.metadata_log_directory), logDirectory ?: stringResource(Res.string.not_initialized))
|
||||
if (logFiles.isEmpty()) {
|
||||
EmptyText(stringResource(Res.string.no_logs))
|
||||
} else {
|
||||
logFiles.forEach { file ->
|
||||
MetadataRow(file.name, "${file.sizeBytes} bytes")
|
||||
}
|
||||
}
|
||||
EmptyText(stringResource(Res.string.diagnostics_hint))
|
||||
}
|
||||
if (diagnosticsVisible) {
|
||||
DiagnosticsPanel(events = coreState.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeModeRow(
|
||||
mode: ThemeMode,
|
||||
selected: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.selectable(selected = selected, onClick = onSelected)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onSelected)
|
||||
Text(mode.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.vnidrop.app.ui.shell
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.navigation.AppBottomNavigation
|
||||
import com.vnidrop.app.ui.navigation.AppDestination
|
||||
import com.vnidrop.app.ui.navigation.AppSidebarNavigation
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.useBottomNavigation
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
|
||||
@Composable
|
||||
fun AppShell(
|
||||
selectedDestination: AppDestination,
|
||||
windowClass: WindowClass,
|
||||
onDestinationSelected: (AppDestination) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.backgroundDashCanvas),
|
||||
color = colors.backgroundDashCanvas,
|
||||
) {
|
||||
if (useBottomNavigation(windowClass)) {
|
||||
PhoneShell(
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected,
|
||||
content = content,
|
||||
)
|
||||
} else {
|
||||
WideShell(
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WideShell(
|
||||
selectedDestination: AppDestination,
|
||||
onDestinationSelected: (AppDestination) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
AppSidebarNavigation(
|
||||
selected = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected,
|
||||
)
|
||||
ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneShell(
|
||||
selectedDestination: AppDestination,
|
||||
onDestinationSelected: (AppDestination) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
|
||||
AppBottomNavigation(
|
||||
selected = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenScrollContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
) {
|
||||
item {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,27 @@
|
||||
package com.vnidrop.app.ui.state
|
||||
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.ui.navigation.AppDestination
|
||||
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,
|
||||
Phone,
|
||||
Tablet,
|
||||
Desktop,
|
||||
}
|
||||
|
||||
fun windowClassFor(widthDp: Float): WindowClass =
|
||||
when {
|
||||
widthDp >= 920f -> WindowClass.Expanded
|
||||
widthDp >= 640f -> WindowClass.Medium
|
||||
else -> WindowClass.Compact
|
||||
widthDp >= 920f -> WindowClass.Desktop
|
||||
widthDp >= 600f -> WindowClass.Tablet
|
||||
else -> WindowClass.Phone
|
||||
}
|
||||
|
||||
fun useBottomNavigation(windowClass: WindowClass): Boolean =
|
||||
windowClass == WindowClass.Phone
|
||||
|
||||
data class AppUiState(
|
||||
val destination: AppDestination = AppDestination.Send,
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
@@ -68,9 +61,6 @@ fun displayNameForStatus(status: String): String =
|
||||
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 }
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
|
||||
@@ -26,85 +25,156 @@ fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean =
|
||||
ThemeMode.Dark -> true
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberResolvedDarkTheme(mode: ThemeMode): Boolean =
|
||||
resolveDarkTheme(mode, isSystemInDarkTheme())
|
||||
|
||||
@Immutable
|
||||
data class VniDropColors(
|
||||
val canvas: Color,
|
||||
val sidebar: Color,
|
||||
val surface: Color,
|
||||
val surfaceRaised: Color,
|
||||
val surfaceMuted: Color,
|
||||
val border: Color,
|
||||
val backgroundDefault: Color,
|
||||
val backgroundDashCanvas: Color,
|
||||
val backgroundDashSidebar: Color,
|
||||
val backgroundSurface75: Color,
|
||||
val backgroundSurface100: Color,
|
||||
val backgroundSurface200: Color,
|
||||
val backgroundSurface300: Color,
|
||||
val backgroundSurface400: Color,
|
||||
val backgroundMuted: Color,
|
||||
val backgroundControl: Color,
|
||||
val backgroundSelection: Color,
|
||||
val backgroundButton: Color,
|
||||
val backgroundOverlayHover: Color,
|
||||
val backgroundDialog: Color,
|
||||
val borderDefault: 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 borderStronger: Color,
|
||||
val borderMuted: Color,
|
||||
val borderControl: Color,
|
||||
val foregroundDefault: Color,
|
||||
val foregroundLight: Color,
|
||||
val foregroundLighter: Color,
|
||||
val foregroundMuted: Color,
|
||||
val foregroundContrast: Color,
|
||||
val brandLink: Color,
|
||||
val brandButton: Color,
|
||||
val brandDefault: Color,
|
||||
val brand600: Color,
|
||||
val brand500: Color,
|
||||
val brand400: Color,
|
||||
val brand300: Color,
|
||||
val brand200: Color,
|
||||
val warningDefault: Color,
|
||||
val warning200: Color,
|
||||
val warning300: Color,
|
||||
val warning400: Color,
|
||||
val warning500: Color,
|
||||
val warning600: Color,
|
||||
val destructiveDefault: Color,
|
||||
val destructive200: Color,
|
||||
val destructive300: Color,
|
||||
val destructive400: Color,
|
||||
val destructive500: Color,
|
||||
val destructive600: Color,
|
||||
)
|
||||
|
||||
val LocalVniDropColors = staticCompositionLocalOf { lightVniDropColors }
|
||||
val LocalVniDropColors = staticCompositionLocalOf { VniDropThemeTokens.light }
|
||||
|
||||
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),
|
||||
)
|
||||
object VniDropThemeTokens {
|
||||
// These values are a direct Compose port of the legacy Tauri theme tokens.
|
||||
// The app uses these semantic tokens directly because Material3's ColorScheme
|
||||
// cannot represent the full surface, border, and foreground stack.
|
||||
val light = VniDropColors(
|
||||
backgroundDefault = hsl(0f, 0f, 98.8f),
|
||||
backgroundDashCanvas = hsl(0f, 0f, 97.3f),
|
||||
backgroundDashSidebar = hsl(0f, 0f, 98.8f),
|
||||
backgroundSurface75 = hsl(0f, 0f, 100f),
|
||||
backgroundSurface100 = hsl(0f, 0f, 98.8f),
|
||||
backgroundSurface200 = hsl(0f, 0f, 95.3f),
|
||||
backgroundSurface300 = hsl(0f, 0f, 92.9f),
|
||||
backgroundSurface400 = hsl(0f, 0f, 89.8f),
|
||||
backgroundMuted = hsl(0f, 0f, 96.9f),
|
||||
backgroundControl = hsl(0f, 0f, 95.3f),
|
||||
backgroundSelection = hsl(0f, 0f, 92.9f),
|
||||
backgroundButton = hsl(0f, 0f, 91f),
|
||||
backgroundOverlayHover = hsl(0f, 0f, 95.3f),
|
||||
backgroundDialog = hsl(0f, 0f, 100f),
|
||||
borderDefault = hsl(0f, 0f, 87.5f),
|
||||
borderStrong = hsl(0f, 0f, 83.1f),
|
||||
borderStronger = hsl(0f, 0f, 56.1f),
|
||||
borderMuted = hsl(0f, 0f, 92.9f),
|
||||
borderControl = hsl(0f, 0f, 78f),
|
||||
foregroundDefault = hsl(0f, 0f, 9f),
|
||||
foregroundLight = hsl(0f, 0f, 32.2f),
|
||||
foregroundLighter = hsl(0f, 0f, 43.9f),
|
||||
foregroundMuted = hsl(0f, 0f, 69.8f),
|
||||
foregroundContrast = hsl(0f, 0f, 98.4f),
|
||||
brandLink = hsl(271f, 91f, 65f),
|
||||
brandButton = hsl(270f, 95f, 75f),
|
||||
brandDefault = hsl(271f, 91f, 65f),
|
||||
brand600 = hsl(271f, 81f, 56f),
|
||||
brand500 = hsl(271f, 91f, 65f),
|
||||
brand400 = hsl(270f, 95f, 75f),
|
||||
brand300 = hsl(269f, 97f, 85f),
|
||||
brand200 = hsl(269f, 100f, 92f),
|
||||
warningDefault = hsl(38.9f, 100f, 57.1f),
|
||||
warning600 = hsl(30.3f, 80.3f, 47.8f),
|
||||
warning500 = hsl(36.3f, 85.7f, 67.1f),
|
||||
warning400 = hsl(41.9f, 100f, 81.8f),
|
||||
warning300 = hsl(44.3f, 100f, 91.8f),
|
||||
warning200 = hsl(40f, 81.8f, 97.8f),
|
||||
destructiveDefault = hsl(10.2f, 77.9f, 53.9f),
|
||||
destructive600 = hsl(9.9f, 82f, 43.5f),
|
||||
destructive500 = hsl(10.4f, 77.1f, 79.4f),
|
||||
destructive400 = hsl(7.1f, 91.3f, 91f),
|
||||
destructive300 = hsl(7.1f, 100f, 96.7f),
|
||||
destructive200 = hsl(0f, 100f, 99.4f),
|
||||
)
|
||||
|
||||
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,
|
||||
val dark = VniDropColors(
|
||||
backgroundDefault = hsl(0f, 0f, 7.1f),
|
||||
backgroundDashCanvas = hsl(0f, 0f, 7.1f),
|
||||
backgroundDashSidebar = hsl(0f, 0f, 9f),
|
||||
backgroundSurface75 = hsl(0f, 0f, 9f),
|
||||
backgroundSurface100 = hsl(0f, 0f, 12.2f),
|
||||
backgroundSurface200 = hsl(0f, 0f, 12.9f),
|
||||
backgroundSurface300 = hsl(0f, 0f, 16.1f),
|
||||
backgroundSurface400 = hsl(0f, 0f, 16.1f),
|
||||
backgroundMuted = hsl(0f, 0f, 14.1f),
|
||||
backgroundControl = hsl(0f, 0f, 14.1f),
|
||||
backgroundSelection = hsl(0f, 0f, 19.2f),
|
||||
backgroundButton = hsl(0f, 0f, 18f),
|
||||
backgroundOverlayHover = hsl(0f, 0f, 18f),
|
||||
backgroundDialog = hsl(0f, 0f, 7.1f),
|
||||
borderDefault = hsl(0f, 0f, 18f),
|
||||
borderStrong = hsl(0f, 0f, 21.2f),
|
||||
borderStronger = hsl(0f, 0f, 27.1f),
|
||||
borderMuted = hsl(0f, 0f, 14.1f),
|
||||
borderControl = hsl(0f, 0f, 22.4f),
|
||||
foregroundDefault = hsl(0f, 0f, 98f),
|
||||
foregroundLight = hsl(0f, 0f, 70.6f),
|
||||
foregroundLighter = hsl(0f, 0f, 53.7f),
|
||||
foregroundMuted = hsl(0f, 0f, 30.2f),
|
||||
foregroundContrast = hsl(0f, 0f, 8.6f),
|
||||
brandLink = hsl(270f, 95f, 75f),
|
||||
brandButton = hsl(271f, 81f, 56f),
|
||||
brandDefault = hsl(270f, 95f, 75f),
|
||||
brand600 = hsl(271f, 91f, 65f),
|
||||
brand500 = hsl(271f, 81f, 56f),
|
||||
brand400 = hsl(273f, 67f, 39f),
|
||||
brand300 = hsl(274f, 66f, 32f),
|
||||
brand200 = hsl(274f, 87f, 21f),
|
||||
warningDefault = hsl(38.9f, 100f, 42.9f),
|
||||
warning600 = hsl(38.9f, 100f, 42.9f),
|
||||
warning500 = hsl(34.8f, 90.9f, 21.6f),
|
||||
warning400 = hsl(33.2f, 100f, 14.5f),
|
||||
warning300 = hsl(32.3f, 100f, 10.2f),
|
||||
warning200 = hsl(36.6f, 100f, 8f),
|
||||
destructiveDefault = hsl(10.2f, 77.9f, 53.9f),
|
||||
destructive600 = hsl(9.7f, 85.2f, 62.9f),
|
||||
destructive500 = hsl(7.9f, 71.6f, 29f),
|
||||
destructive400 = hsl(6.7f, 60f, 20.6f),
|
||||
destructive300 = hsl(7.5f, 51.3f, 15.3f),
|
||||
destructive200 = hsl(10.9f, 23.4f, 9.2f),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,23 +183,51 @@ fun VniDropTheme(
|
||||
mode: ThemeMode,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dark = resolveDarkTheme(mode, isSystemInDarkTheme())
|
||||
val tokens = if (dark) darkVniDropColors else lightVniDropColors
|
||||
VniDropTheme(isDarkTheme = rememberResolvedDarkTheme(mode), content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VniDropTheme(
|
||||
isDarkTheme: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val tokens = if (isDarkTheme) VniDropThemeTokens.dark else VniDropThemeTokens.light
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) {
|
||||
MaterialTheme(
|
||||
colorScheme = materialScheme(tokens, dark),
|
||||
colorScheme = tokens.toMaterialColorScheme(isDarkTheme),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VniDropColors.toMaterialColorScheme(isDark: Boolean): ColorScheme {
|
||||
val base = if (isDark) darkColorScheme() else lightColorScheme()
|
||||
return base.copy(
|
||||
primary = brandDefault,
|
||||
onPrimary = if (isDark) Color.Black else Color.White,
|
||||
secondary = brandLink,
|
||||
background = backgroundDashCanvas,
|
||||
onBackground = foregroundDefault,
|
||||
surface = backgroundSurface75,
|
||||
onSurface = foregroundDefault,
|
||||
surfaceVariant = backgroundSurface200,
|
||||
onSurfaceVariant = foregroundLight,
|
||||
outline = borderDefault,
|
||||
outlineVariant = borderMuted,
|
||||
error = destructiveDefault,
|
||||
errorContainer = destructive200,
|
||||
onErrorContainer = if (isDark) destructive600 else destructiveDefault,
|
||||
)
|
||||
}
|
||||
|
||||
fun hslColorForTest(hue: Float, saturation: Float, lightness: Float): Color =
|
||||
hsl(hue, saturation, lightness)
|
||||
|
||||
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)
|
||||
}
|
||||
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(
|
||||
@@ -150,9 +248,3 @@ private fun hueToRgb(p: Float, q: Float, input: Float): Float {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user