Build native UI shell foundation

This commit is contained in:
2026-07-04 10:41:51 +02:00
parent 89a99582a5
commit 6bcfc534a0
34 changed files with 1998 additions and 794 deletions

View File

@@ -10,7 +10,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<activity <activity
android:exported="true" android:exported="true"
android:name=".MainActivity"> android:name=".MainActivity">

View File

@@ -1,8 +1,17 @@
use std::{path::Path, sync::OnceLock}; use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::OnceLock,
};
use anyhow::Result; use anyhow::Result;
use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter}; use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
const LOG_FILE: &str = "vnidrop.log";
const MAX_LOG_BYTES: u64 = 1_048_576;
const MAX_LOG_FILES: usize = 5;
static LOG_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new(); static LOG_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> { pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
@@ -11,9 +20,9 @@ pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
} }
let log_dir = app_data_dir.join("logs"); let log_dir = app_data_dir.join("logs");
std::fs::create_dir_all(&log_dir)?; fs::create_dir_all(&log_dir)?;
let file_appender = tracing_appender::rolling::daily(log_dir, "vnidrop.log"); let writer = SizeRotatingWriter::new(log_dir, MAX_LOG_BYTES, MAX_LOG_FILES);
let (writer, guard) = tracing_appender::non_blocking(file_appender); let (writer, guard) = tracing_appender::non_blocking(writer);
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("vnidrop=debug,iroh=info,iroh_blobs=info,warn")); .unwrap_or_else(|_| EnvFilter::new("vnidrop=debug,iroh=info,iroh_blobs=info,warn"));
@@ -27,3 +36,71 @@ pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
struct SizeRotatingWriter {
log_dir: PathBuf,
max_bytes: u64,
max_files: usize,
}
impl SizeRotatingWriter {
fn new(log_dir: PathBuf, max_bytes: u64, max_files: usize) -> Self {
Self {
log_dir,
max_bytes,
max_files,
}
}
fn active_path(&self) -> PathBuf {
self.log_dir.join(LOG_FILE)
}
fn rotated_path(&self, index: usize) -> PathBuf {
self.log_dir.join(format!("vnidrop.{index}.log"))
}
fn rotate_if_needed(&self, incoming_bytes: usize) -> io::Result<()> {
fs::create_dir_all(&self.log_dir)?;
let active = self.active_path();
let current_size = active
.metadata()
.map(|metadata| metadata.len())
.unwrap_or(0);
if current_size == 0 || current_size + incoming_bytes as u64 <= self.max_bytes {
return Ok(());
}
if self.max_files == 0 {
let _ = fs::remove_file(active);
return Ok(());
}
let _ = fs::remove_file(self.rotated_path(self.max_files));
for index in (1..self.max_files).rev() {
let source = self.rotated_path(index);
if source.exists() {
let _ = fs::rename(source, self.rotated_path(index + 1));
}
}
if active.exists() {
let _ = fs::rename(active, self.rotated_path(1));
}
Ok(())
}
}
impl Write for SizeRotatingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.rotate_if_needed(buf.len())?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(self.active_path())?;
file.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

View File

@@ -3,7 +3,9 @@ package com.vnidrop.app
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
fun main() = application { fun main() {
configureMacOsNativeAppearance()
application {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "vnidrop", title = "vnidrop",
@@ -11,3 +13,11 @@ fun main() = application {
App() App()
} }
} }
}
private fun configureMacOsNativeAppearance() {
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return
// AWT reads this before creating the first native window. Runtime theme
// changes are handled in the JVM platform appearance hook.
System.setProperty("apple.awt.application.appearance", "system")
}

View File

@@ -2,9 +2,64 @@ import Shared
import SwiftUI import SwiftUI
import UIKit import UIKit
// Compose renders the app content, but UIKit owns the status bar style. This
// host listens for theme changes from shared Kotlin code and asks iOS to
// recompute the status bar contrast.
final class VniDropHostViewController: UIViewController {
private let composeController: UIViewController
private var usesDarkTheme: Bool
init(composeController: UIViewController) {
self.composeController = composeController
self.usesDarkTheme = UITraitCollection.current.userInterfaceStyle == .dark
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle {
usesDarkTheme ? .lightContent : .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()
addChild(composeController)
view.addSubview(composeController.view)
composeController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
composeController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
composeController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
composeController.view.topAnchor.constraint(equalTo: view.topAnchor),
composeController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
composeController.didMove(toParent: self)
NotificationCenter.default.addObserver(
self,
selector: #selector(themeDidChange(_:)),
name: Notification.Name("VniDropThemeChanged"),
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func themeDidChange(_ notification: Notification) {
guard let isDark = notification.userInfo?["isDark"] as? String else { return }
usesDarkTheme = isDark == "true"
setNeedsStatusBarAppearanceUpdate()
}
}
struct ComposeView: UIViewControllerRepresentable { struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Self.Context) -> UIViewController { func makeUIViewController(context: Self.Context) -> UIViewController {
MainViewControllerKt.MainViewController() VniDropHostViewController(composeController: MainViewControllerKt.MainViewController())
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}

View File

@@ -4,5 +4,7 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,56 @@
package com.vnidrop.app.logging
import java.io.File
import java.nio.charset.StandardCharsets
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
AndroidPlatformLogStore(appDataDir, policy)
actual fun platformNowMillis(): Long = System.currentTimeMillis()
private class AndroidPlatformLogStore(
appDataDir: String,
private val policy: LogRotationPolicy,
) : PlatformLogStore {
private val directory = File(appDataDir, "logs")
private val activeFile = File(directory, "app.log")
override val logDirectory: String = directory.absolutePath
@Synchronized
override fun append(line: String) {
directory.mkdirs()
val bytes = line.toByteArray(StandardCharsets.UTF_8)
if (policy.shouldRotate(activeFile.length(), bytes.size.toLong())) {
rotate()
}
activeFile.appendBytes(bytes)
}
@Synchronized
override fun listLogFiles(): List<LogFileInfo> {
directory.mkdirs()
return directory
.listFiles { file -> file.isFile && file.name.startsWith("app") && file.name.endsWith(".log") }
.orEmpty()
.sortedByDescending { it.lastModified() }
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
}
private fun rotate() {
if (policy.maxFiles == 0) {
activeFile.delete()
return
}
File(directory, "app.${policy.maxFiles}.log").delete()
for (index in policy.maxFiles - 1 downTo 1) {
val source = File(directory, "app.$index.log")
if (source.exists()) {
source.renameTo(File(directory, "app.${index + 1}.log"))
}
}
if (activeFile.exists()) {
activeFile.renameTo(File(directory, "app.1.log"))
}
}
}

View File

@@ -0,0 +1,40 @@
package com.vnidrop.app.platform
import android.app.Activity
import android.os.Build
import android.view.View
import android.view.WindowInsetsController
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
val view = LocalView.current
if (view.isInEditMode) return
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
val useDarkIcons = systemBarIconModeForTheme(isDarkTheme) == SystemBarIconMode.DarkIcons
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val lightBars = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
window.insetsController?.setSystemBarsAppearance(if (useDarkIcons) lightBars else 0, lightBars)
} else {
var flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (useDarkIcons) {
flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
flags = flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
}
}
window.decorView.systemUiVisibility = flags
}
}
}

View 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>

View File

@@ -1,35 +1,6 @@
package com.vnidrop.app 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.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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -38,51 +9,28 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.core.sharePickedFile import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.ui.components.ErrorBanner import com.vnidrop.app.logging.LogFileInfo
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.components.MetadataRow import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.screens.ReceiveScreen
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.screens.SendScreen
import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.screens.SettingsScreen
import com.vnidrop.app.ui.components.QuietButton import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.AppDestination
import com.vnidrop.app.ui.state.AppUiState import com.vnidrop.app.ui.state.AppUiState
import com.vnidrop.app.ui.state.ReceiveUiState import com.vnidrop.app.ui.state.ReceiveUiState
import com.vnidrop.app.ui.state.SendUiState 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.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.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.launch 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 @Composable
@Preview @Preview
@@ -95,63 +43,89 @@ fun App() {
var sendState by remember { mutableStateOf(SendUiState()) } var sendState by remember { mutableStateOf(SendUiState()) }
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) }
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) } var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
var logFiles by remember { mutableStateOf<List<LogFileInfo>>(emptyList()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker( val picker = rememberShareFilePicker(
onFilePicked = { file -> onFilePicked = { file ->
AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName))
selectedFile = file selectedFile = file
sendState = sendState.copy( sendState = sendState.withSelectedFile(file)
selectedSource = file.value, logFiles = AppLogger.listLogFiles()
selectedDisplayName = file.displayName, },
transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) { onError = { error ->
file.displayName AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error))
} else { scope.launch { repository.setError(error) }
sendState.transferName 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) { LaunchedEffect(coreState.lastShare?.transferId) {
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.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 { BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value) AppShell(
AppFrame( selectedDestination = appState.destination,
appState = appState, windowClass = windowClassFor(maxWidth.value),
coreState = coreState, onDestinationSelected = { appState = appState.copy(destination = it) },
windowClass = windowClass,
onDestinationChange = { appState = appState.copy(destination = it) },
) { ) {
when (appState.destination) { when (appState.destination) {
AppDestination.Send -> SendScreen( AppDestination.Send -> SendScreen(
coreState = coreState, coreState = coreState,
sendState = sendState, sendState = sendState,
onSendStateChange = { sendState = it }, onSendStateChange = { sendState = it },
onSelectFile = { picker.pickFile() }, onSelectFile = {
AppLogger.info("file-picker", "open share file picker")
picker.pickFile()
logFiles = AppLogger.listLogFiles()
},
onCreateShare = { onCreateShare = {
scope.launch { scope.launch {
AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource))
sendState = sendState.copy(isSharing = true) sendState = sendState.copy(isSharing = true)
val file = selectedFile val file = selectedFile
if (file != null) { if (file == null) {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
} else {
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
} else {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
} }
sendState = sendState.copy(isSharing = false) 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 -> onUseLocally = { ticket ->
receiveState = receiveState.copy(ticket = ticket) receiveState = receiveState.copy(ticket = ticket)
appState = appState.copy(destination = AppDestination.Receive) appState = appState.copy(destination = AppDestination.Receive)
}, },
onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
onRespondRequest = { requestId, accepted -> 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( AppDestination.Receive -> ReceiveScreen(
@@ -161,598 +135,48 @@ fun App() {
onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } }, onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } },
onReceive = { onReceive = {
scope.launch { scope.launch {
AppLogger.info("receive", "receive requested")
receiveState = receiveState.copy(isReceiving = true) receiveState = receiveState.copy(isReceiving = true)
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
receiveState = receiveState.copy(isReceiving = false) 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( AppDestination.Settings -> SettingsScreen(
platformName = platform.name, platformName = platform.name,
appDataDir = appDataDir, appDataDir = appDataDir,
onAppDataDirChange = { appDataDir = it }, onAppDataDirChange = { appDataDir = it },
coreState = coreState, coreState = coreState,
themeMode = appState.themeMode, 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, diagnosticsVisible = appState.diagnosticsVisible,
onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) }, 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 SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =
private fun NavItem( copy(
destination: AppDestination, selectedSource = file.value,
selected: Boolean, selectedDisplayName = file.displayName,
onClick: () -> Unit, transferName = if (transferName == "VniDrop transfer" || transferName.isBlank()) file.displayName else transferName,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
val colors = LocalVniDropColors.current
val background = if (selected) colors.surfaceMuted else colors.sidebar
val border = if (selected) colors.brand.copy(alpha = 0.55f) else colors.border.copy(alpha = 0f)
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.border(1.dp, border, RoundedCornerShape(8.dp))
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = if (compact) 6.dp else 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
destination.label,
style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium,
color = if (selected) colors.textPrimary else colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
}
}
@Composable
private fun NodeStatus(coreState: CoreUiState) {
StatusPill(
label = if (coreState.isInitialized) "Online" else "Offline",
tone = if (coreState.isInitialized) PillTone.Success else PillTone.Neutral,
)
}
@Composable
private fun SendScreen(
coreState: CoreUiState,
sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit,
onSelectFile: () -> Unit,
onCreateShare: () -> Unit,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Send", "Create a VniDrop ticket and approve receivers when required.")
ErrorSection(coreState)
AppCard(title = "Source") {
if (sendState.selectedSource.isBlank()) {
EmptyText("Select a file to start a share. The app keeps bytes in Rust and platform file handles.")
} else {
MetadataRow("Name", sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
MetadataRow("Source", sendState.selectedSource)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton("Select file", onClick = onSelectFile)
SecondaryButton(
text = "Clear",
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
enabled = sendState.selectedSource.isNotBlank(),
)
}
}
AppCard(title = "Transfer details") {
Field(
value = sendState.transferName,
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
label = "Transfer name",
)
Field(
value = sendState.senderName,
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
label = "Sender name",
)
PrimaryButton(
text = if (sendState.isSharing) "Creating ticket..." else "Create share ticket",
onClick = onCreateShare,
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
)
}
coreState.lastShare?.let { share ->
ShareResultCard(
share = share,
requests = coreState.receiverRequests,
onCopyTicket = onCopyTicket,
onUseLocally = onUseLocally,
onRefreshRequests = onRefreshRequests,
onRespondRequest = onRespondRequest,
)
}
ProgressSection(coreState)
}
}
@Composable
private fun ShareResultCard(
share: ShareResult,
requests: List<ReceiverRequest>,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
AppCard(title = "Share ticket", trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
}) {
MetadataRow("Transfer", share.transferName)
MetadataRow("Size", formatBytes(share.totalSize))
SelectionContainer {
Text(
text = share.ticket,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceMuted)
.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton("Copy", onClick = { onCopyTicket(share.ticket) })
SecondaryButton("Use locally", onClick = { onUseLocally(share.ticket) })
SecondaryButton("Refresh", onClick = { onRefreshRequests(share.transferId) })
}
if (requests.isNotEmpty()) {
HorizontalDivider(color = LocalVniDropColors.current.border)
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
}
}
}
@Composable
private fun ReceiveScreen(
coreState: CoreUiState,
receiveState: ReceiveUiState,
onReceiveStateChange: (ReceiveUiState) -> Unit,
onInspect: () -> Unit,
onReceive: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Receive", "Inspect a ticket, request access, and stream files into the output directory.")
ErrorSection(coreState)
AppCard(title = "Ticket") {
Field(
value = receiveState.ticket,
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
label = "Ticket",
minLines = 4,
)
Field(
value = receiveState.outputDirectory,
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
label = "Output directory",
)
Field(
value = receiveState.receiverName,
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
label = "Receiver name",
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
text = "Inspect ticket",
onClick = onInspect,
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
)
PrimaryButton(
text = if (receiveState.isReceiving) "Receiving..." else "Receive",
onClick = onReceive,
enabled = coreState.isInitialized &&
receiveState.ticket.isNotBlank() &&
receiveState.outputDirectory.isNotBlank() &&
!receiveState.isReceiving,
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
}
@Composable
private fun TicketInspectionCard(inspection: TicketInspection) {
AppCard(title = "Ticket details") {
MetadataRow("Kind", inspection.kind)
inspection.metadata?.let { metadata ->
MetadataRow("Transfer", metadata.transferName)
MetadataRow("Sender", metadata.senderName ?: "Unknown")
MetadataRow("Files", metadata.fileCount.toString())
MetadataRow("Size", formatBytes(metadata.totalSize))
MetadataRow("Hash", metadata.contentHash)
} ?: EmptyText("This ticket does not include VniDrop metadata.")
}
}
@Composable
private fun ActivityScreen(
coreState: CoreUiState,
onRefresh: () -> Unit,
onCancel: (ULong) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Activity", "Follow current and recent transfers from the Rust core.")
ErrorSection(coreState)
AppCard(title = "Transfers", trailing = { SecondaryButton("Refresh", onClick = onRefresh) }) {
if (coreState.transfers.isEmpty()) {
EmptyText("No transfers yet.")
} else {
coreState.transfers.forEach { transfer ->
TransferRow(transfer = transfer, onCancel = onCancel)
}
}
}
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)
}

View File

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

View File

@@ -0,0 +1,6 @@
package com.vnidrop.app.platform
import androidx.compose.runtime.Composable
@Composable
expect fun PlatformSystemAppearance(isDarkTheme: Boolean)

View File

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

View File

@@ -46,8 +46,8 @@ fun AppCard(
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.surface), colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
border = BorderStroke(1.dp, colors.border), border = BorderStroke(1.dp, colors.borderDefault),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -61,7 +61,7 @@ fun AppCard(
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
trailing?.invoke() trailing?.invoke()
} }
HorizontalDivider(color = colors.border) HorizontalDivider(color = colors.borderDefault)
content() content()
} }
} }
@@ -100,7 +100,7 @@ fun PrimaryButton(
enabled = enabled, enabled = enabled,
modifier = modifier.heightIn(min = 44.dp), modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.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) Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
@@ -143,11 +143,11 @@ fun StatusPill(
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val color = when (tone) { val color = when (tone) {
PillTone.Neutral -> colors.textMuted PillTone.Neutral -> colors.foregroundLighter
PillTone.Success -> colors.success PillTone.Success -> colors.brandLink
PillTone.Warning -> colors.warning PillTone.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructive PillTone.Destructive -> colors.destructiveDefault
PillTone.Brand -> colors.brand PillTone.Brand -> colors.brandLink
} }
Row( Row(
modifier = modifier modifier = modifier
@@ -182,8 +182,8 @@ fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.destructive.copy(alpha = 0.14f)), colors = CardDefaults.cardColors(containerColor = colors.destructive200),
border = BorderStroke(1.dp, colors.destructive.copy(alpha = 0.28f)), border = BorderStroke(1.dp, colors.destructive400),
) { ) {
Text( Text(
text = message, text = message,
@@ -220,7 +220,7 @@ fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
Text( Text(
text = label, text = label,
modifier = Modifier.weight(0.35f), modifier = Modifier.weight(0.35f),
color = LocalVniDropColors.current.textMuted, color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,27 @@
package com.vnidrop.app.ui.state package com.vnidrop.app.ui.state
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.navigation.AppDestination
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.StoredTransfer
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class AppDestination(
val label: String,
) {
Send("Send"),
Receive("Receive"),
Activity("Activity"),
Requests("Requests"),
Settings("Settings"),
}
enum class WindowClass { enum class WindowClass {
Compact, Phone,
Medium, Tablet,
Expanded, Desktop,
} }
fun windowClassFor(widthDp: Float): WindowClass = fun windowClassFor(widthDp: Float): WindowClass =
when { when {
widthDp >= 920f -> WindowClass.Expanded widthDp >= 920f -> WindowClass.Desktop
widthDp >= 640f -> WindowClass.Medium widthDp >= 600f -> WindowClass.Tablet
else -> WindowClass.Compact else -> WindowClass.Phone
} }
fun useBottomNavigation(windowClass: WindowClass): Boolean =
windowClass == WindowClass.Phone
data class AppUiState( data class AppUiState(
val destination: AppDestination = AppDestination.Send, val destination: AppDestination = AppDestination.Send,
val themeMode: ThemeMode = ThemeMode.System, val themeMode: ThemeMode = ThemeMode.System,
@@ -68,9 +61,6 @@ fun displayNameForStatus(status: String): String =
else -> status.replaceFirstChar { it.uppercase() } else -> status.replaceFirstChar { it.uppercase() }
} }
fun activeReceiverRequests(requests: List<ReceiverRequest>): List<ReceiverRequest> =
requests.filter { it.status == "requested" }
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> = fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
events events
.filter { event -> event.transferId != null && event.phase in progressPhases } .filter { event -> event.transferId != null && event.phase in progressPhases }

View File

@@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -26,85 +25,156 @@ fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean =
ThemeMode.Dark -> true ThemeMode.Dark -> true
} }
@Composable
fun rememberResolvedDarkTheme(mode: ThemeMode): Boolean =
resolveDarkTheme(mode, isSystemInDarkTheme())
@Immutable @Immutable
data class VniDropColors( data class VniDropColors(
val canvas: Color, val backgroundDefault: Color,
val sidebar: Color, val backgroundDashCanvas: Color,
val surface: Color, val backgroundDashSidebar: Color,
val surfaceRaised: Color, val backgroundSurface75: Color,
val surfaceMuted: Color, val backgroundSurface100: Color,
val border: 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 borderStrong: Color,
val textPrimary: Color, val borderStronger: Color,
val textSecondary: Color, val borderMuted: Color,
val textMuted: Color, val borderControl: Color,
val brand: Color, val foregroundDefault: Color,
val brandPressed: Color, val foregroundLight: Color,
val warning: Color, val foregroundLighter: Color,
val destructive: Color, val foregroundMuted: Color,
val success: 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( object VniDropThemeTokens {
canvas = hsl(0f, 0f, 97.3f), // These values are a direct Compose port of the legacy Tauri theme tokens.
sidebar = hsl(0f, 0f, 98.8f), // The app uses these semantic tokens directly because Material3's ColorScheme
surface = hsl(0f, 0f, 100f), // cannot represent the full surface, border, and foreground stack.
surfaceRaised = hsl(0f, 0f, 98.8f), val light = VniDropColors(
surfaceMuted = hsl(0f, 0f, 95.3f), backgroundDefault = hsl(0f, 0f, 98.8f),
border = hsl(0f, 0f, 85.9f), backgroundDashCanvas = hsl(0f, 0f, 97.3f),
borderStrong = hsl(0f, 0f, 78f), backgroundDashSidebar = hsl(0f, 0f, 98.8f),
textPrimary = hsl(0f, 0f, 9f), backgroundSurface75 = hsl(0f, 0f, 100f),
textSecondary = hsl(0f, 0f, 32.2f), backgroundSurface100 = hsl(0f, 0f, 98.8f),
textMuted = hsl(0f, 0f, 43.9f), backgroundSurface200 = hsl(0f, 0f, 95.3f),
brand = hsl(153.1f, 60.2f, 52.7f), backgroundSurface300 = hsl(0f, 0f, 92.9f),
brandPressed = hsl(152.9f, 56.1f, 46.5f), backgroundSurface400 = hsl(0f, 0f, 89.8f),
warning = hsl(38.9f, 100f, 57.1f), backgroundMuted = hsl(0f, 0f, 96.9f),
destructive = hsl(10.2f, 77.9f, 53.9f), backgroundControl = hsl(0f, 0f, 95.3f),
success = hsl(153.1f, 60.2f, 40f), 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( val dark = VniDropColors(
canvas = hsl(0f, 0f, 7.1f), backgroundDefault = hsl(0f, 0f, 7.1f),
sidebar = hsl(0f, 0f, 9f), backgroundDashCanvas = hsl(0f, 0f, 7.1f),
surface = hsl(0f, 0f, 12.2f), backgroundDashSidebar = hsl(0f, 0f, 9f),
surfaceRaised = hsl(0f, 0f, 14.1f), backgroundSurface75 = hsl(0f, 0f, 9f),
surfaceMuted = hsl(0f, 0f, 16.1f), backgroundSurface100 = hsl(0f, 0f, 12.2f),
border = hsl(0f, 0f, 24.3f), backgroundSurface200 = hsl(0f, 0f, 12.9f),
borderStrong = hsl(0f, 0f, 31.4f), backgroundSurface300 = hsl(0f, 0f, 16.1f),
textPrimary = hsl(0f, 0f, 98f), backgroundSurface400 = hsl(0f, 0f, 16.1f),
textSecondary = hsl(0f, 0f, 70.6f), backgroundMuted = hsl(0f, 0f, 14.1f),
textMuted = hsl(0f, 0f, 53.7f), backgroundControl = hsl(0f, 0f, 14.1f),
brand = hsl(153.1f, 60.2f, 52.7f), backgroundSelection = hsl(0f, 0f, 19.2f),
brandPressed = hsl(152.9f, 56.1f, 46.5f), backgroundButton = hsl(0f, 0f, 18f),
warning = hsl(38.9f, 100f, 42.9f), backgroundOverlayHover = hsl(0f, 0f, 18f),
destructive = hsl(10.2f, 77.9f, 53.9f), backgroundDialog = hsl(0f, 0f, 7.1f),
success = hsl(153.1f, 60.2f, 52.7f), borderDefault = hsl(0f, 0f, 18f),
) borderStrong = hsl(0f, 0f, 21.2f),
borderStronger = hsl(0f, 0f, 27.1f),
private fun materialScheme(tokens: VniDropColors, dark: Boolean): ColorScheme { borderMuted = hsl(0f, 0f, 14.1f),
val base = if (dark) { borderControl = hsl(0f, 0f, 22.4f),
darkColorScheme() foregroundDefault = hsl(0f, 0f, 98f),
} else { foregroundLight = hsl(0f, 0f, 70.6f),
lightColorScheme() foregroundLighter = hsl(0f, 0f, 53.7f),
} foregroundMuted = hsl(0f, 0f, 30.2f),
return base.copy( foregroundContrast = hsl(0f, 0f, 8.6f),
primary = tokens.brand, brandLink = hsl(270f, 95f, 75f),
onPrimary = if (dark) Color.Black else Color.White, brandButton = hsl(271f, 81f, 56f),
primaryContainer = tokens.surfaceMuted, brandDefault = hsl(270f, 95f, 75f),
onPrimaryContainer = tokens.textPrimary, brand600 = hsl(271f, 91f, 65f),
background = tokens.canvas, brand500 = hsl(271f, 81f, 56f),
onBackground = tokens.textPrimary, brand400 = hsl(273f, 67f, 39f),
surface = tokens.surface, brand300 = hsl(274f, 66f, 32f),
onSurface = tokens.textPrimary, brand200 = hsl(274f, 87f, 21f),
surfaceVariant = tokens.surfaceMuted, warningDefault = hsl(38.9f, 100f, 42.9f),
onSurfaceVariant = tokens.textSecondary, warning600 = hsl(38.9f, 100f, 42.9f),
outline = tokens.border, warning500 = hsl(34.8f, 90.9f, 21.6f),
outlineVariant = tokens.border, warning400 = hsl(33.2f, 100f, 14.5f),
error = tokens.destructive, warning300 = hsl(32.3f, 100f, 10.2f),
errorContainer = tokens.destructive.copy(alpha = if (dark) 0.22f else 0.16f), warning200 = hsl(36.6f, 100f, 8f),
onErrorContainer = tokens.textPrimary, 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, mode: ThemeMode,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val dark = resolveDarkTheme(mode, isSystemInDarkTheme()) VniDropTheme(isDarkTheme = rememberResolvedDarkTheme(mode), content = content)
val tokens = if (dark) darkVniDropColors else lightVniDropColors }
@Composable
fun VniDropTheme(
isDarkTheme: Boolean,
content: @Composable () -> Unit,
) {
val tokens = if (isDarkTheme) VniDropThemeTokens.dark else VniDropThemeTokens.light
androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) { androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) {
MaterialTheme( MaterialTheme(
colorScheme = materialScheme(tokens, dark), colorScheme = tokens.toMaterialColorScheme(isDarkTheme),
content = content, 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 { private fun hsl(hue: Float, saturation: Float, lightness: Float): Color {
val h = ((hue % 360f) + 360f) % 360f / 360f val h = ((hue % 360f) + 360f) % 360f / 360f
val s = saturation.coerceIn(0f, 100f) / 100f val s = saturation.coerceIn(0f, 100f) / 100f
val l = lightness.coerceIn(0f, 100f) / 100f val l = lightness.coerceIn(0f, 100f) / 100f
if (s == 0f) { if (s == 0f) return Color(l, l, l)
return Color(l, l, l)
}
val q = if (l < 0.5f) l * (1 + s) else l + s - l * s val q = if (l < 0.5f) l * (1 + s) else l + s - l * s
val p = 2 * l - q val p = 2 * l - q
return Color( return Color(
@@ -150,9 +248,3 @@ private fun hueToRgb(p: Float, q: Float, input: Float): Float {
else -> p else -> p
}.let { min(1f, max(0f, it)) } }.let { min(1f, max(0f, it)) }
} }
fun Color.contrastAgainst(other: Color): Float =
abs(luminanceApproximation() - other.luminanceApproximation())
private fun Color.luminanceApproximation(): Float =
(red * 0.2126f) + (green * 0.7152f) + (blue * 0.0722f)

View File

@@ -0,0 +1,16 @@
package com.vnidrop.app.logging
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppLoggerTest {
@Test
fun rotationPolicyRotatesOnlyWhenActiveFileWouldExceedLimit() {
val policy = LogRotationPolicy(maxBytes = 10, maxFiles = 2)
assertFalse(policy.shouldRotate(currentBytes = 0, incomingBytes = 20))
assertFalse(policy.shouldRotate(currentBytes = 4, incomingBytes = 6))
assertTrue(policy.shouldRotate(currentBytes = 5, incomingBytes = 6))
}
}

View File

@@ -0,0 +1,12 @@
package com.vnidrop.app.platform
import kotlin.test.Test
import kotlin.test.assertEquals
class SystemBarIconModeTest {
@Test
fun darkThemeUsesLightSystemBarIcons() {
assertEquals(SystemBarIconMode.LightIcons, systemBarIconModeForTheme(isDarkTheme = true))
assertEquals(SystemBarIconMode.DarkIcons, systemBarIconModeForTheme(isDarkTheme = false))
}
}

View File

@@ -0,0 +1,15 @@
package com.vnidrop.app.ui.navigation
import kotlin.test.Test
import kotlin.test.assertEquals
class NavigationModelTest {
@Test
fun primaryNavigationContainsOnlyProductDestinations() {
assertEquals(
listOf(AppDestination.Send, AppDestination.Receive, AppDestination.Settings),
primaryNavigationItems.map { it.destination },
)
assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size)
}
}

View File

@@ -9,10 +9,17 @@ import kotlin.test.assertTrue
class AppUiModelsTest { class AppUiModelsTest {
@Test @Test
fun windowClassUsesCompactMediumExpandedBreakpoints() { fun windowClassUsesPhoneTabletDesktopBreakpoints() {
assertEquals(WindowClass.Compact, windowClassFor(390f)) assertEquals(WindowClass.Phone, windowClassFor(390f))
assertEquals(WindowClass.Medium, windowClassFor(700f)) assertEquals(WindowClass.Tablet, windowClassFor(700f))
assertEquals(WindowClass.Expanded, windowClassFor(1200f)) assertEquals(WindowClass.Desktop, windowClassFor(1200f))
}
@Test
fun bottomNavigationIsReservedForPhoneWidth() {
assertTrue(useBottomNavigation(WindowClass.Phone))
assertFalse(useBottomNavigation(WindowClass.Tablet))
assertFalse(useBottomNavigation(WindowClass.Desktop))
} }
@Test @Test

View File

@@ -0,0 +1,30 @@
package com.vnidrop.app.ui.theme
import kotlin.test.Test
import kotlin.test.assertEquals
import org.jetbrains.compose.resources.StringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.error_invalid_ticket
import vnidrop.shared.generated.resources.nav_receive
import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.nav_settings
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.settings_title
class I18nResourceTest {
@Test
fun primaryNavigationScreenAndErrorKeysExist() {
val resources: List<StringResource> = listOf(
Res.string.nav_send,
Res.string.nav_receive,
Res.string.nav_settings,
Res.string.send_title,
Res.string.receive_title,
Res.string.settings_title,
Res.string.error_invalid_ticket,
)
assertEquals(7, resources.size)
}
}

View File

@@ -0,0 +1,20 @@
package com.vnidrop.app.ui.theme
import kotlin.test.Test
import kotlin.test.assertEquals
class VniDropThemeTokenTest {
@Test
fun lightPaletteUsesTauriPurpleBrandAndSurfaceTokens() {
assertEquals(hslColorForTest(271f, 91f, 65f), VniDropThemeTokens.light.brandLink)
assertEquals(hslColorForTest(0f, 0f, 97.3f), VniDropThemeTokens.light.backgroundDashCanvas)
assertEquals(hslColorForTest(0f, 0f, 87.5f), VniDropThemeTokens.light.borderDefault)
}
@Test
fun darkPaletteUsesTauriPurpleBrandAndSurfaceTokens() {
assertEquals(hslColorForTest(270f, 95f, 75f), VniDropThemeTokens.dark.brandLink)
assertEquals(hslColorForTest(0f, 0f, 7.1f), VniDropThemeTokens.dark.backgroundDashCanvas)
assertEquals(hslColorForTest(0f, 0f, 18f), VniDropThemeTokens.dark.borderDefault)
}
}

View File

@@ -0,0 +1,95 @@
package com.vnidrop.app.logging
import platform.Foundation.NSData
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSDate
import platform.Foundation.NSFileManager
import platform.Foundation.NSFileModificationDate
import platform.Foundation.NSFileSize
import platform.Foundation.NSNumber
import platform.Foundation.timeIntervalSince1970
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fwrite
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
IosPlatformLogStore(appDataDir, policy)
actual fun platformNowMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
@OptIn(ExperimentalForeignApi::class)
private class IosPlatformLogStore(
appDataDir: String,
private val policy: LogRotationPolicy,
) : PlatformLogStore {
private val fileManager = NSFileManager.defaultManager
private val directory = appDataDir.trimEnd('/') + "/logs"
private val activePath = "$directory/app.log"
override val logDirectory: String = directory
override fun append(line: String) {
ensureDirectory()
val bytes = line.encodeToByteArray()
if (policy.shouldRotate(fileSize(activePath), bytes.size.toLong())) {
rotate()
}
val file = fopen(activePath, "ab") ?: return
try {
bytes.usePinned { pinned ->
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
}
} finally {
fclose(file)
}
}
override fun listLogFiles(): List<LogFileInfo> {
ensureDirectory()
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
.filterIsInstance<String>()
.filter { it.startsWith("app") && it.endsWith(".log") }
return names
.map { name ->
val path = "$directory/$name"
LogFileInfo(name, path, fileSize(path), modifiedAt(path))
}
.sortedByDescending { it.modifiedAtMillis }
}
private fun rotate() {
if (policy.maxFiles == 0) {
fileManager.removeItemAtPath(activePath, null)
return
}
fileManager.removeItemAtPath("$directory/app.${policy.maxFiles}.log", null)
for (index in policy.maxFiles - 1 downTo 1) {
val source = "$directory/app.$index.log"
if (fileManager.fileExistsAtPath(source)) {
fileManager.moveItemAtPath(source, "$directory/app.${index + 1}.log", null)
}
}
if (fileManager.fileExistsAtPath(activePath)) {
fileManager.moveItemAtPath(activePath, "$directory/app.1.log", null)
}
}
private fun ensureDirectory() {
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
}
private fun fileSize(path: String): Long {
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
return (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
}
private fun modifiedAt(path: String): Long {
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
return (date.timeIntervalSince1970 * 1000.0).toLong()
}
}

View File

@@ -0,0 +1,19 @@
package com.vnidrop.app.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import platform.Foundation.NSNotificationCenter
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
SideEffect {
// The Swift host owns the actual UIKit status bar style. Compose publishes
// the resolved theme here so the wrapper can update without coupling common
// UI code to iOS-specific view controller APIs.
NSNotificationCenter.defaultCenter.postNotificationName(
aName = "VniDropThemeChanged",
`object` = null,
userInfo = mapOf("isDark" to if (isDarkTheme) "true" else "false"),
)
}
}

View File

@@ -0,0 +1,56 @@
package com.vnidrop.app.logging
import java.io.File
import java.nio.charset.StandardCharsets
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
JvmPlatformLogStore(appDataDir, policy)
actual fun platformNowMillis(): Long = System.currentTimeMillis()
private class JvmPlatformLogStore(
appDataDir: String,
private val policy: LogRotationPolicy,
) : PlatformLogStore {
private val directory = File(appDataDir, "logs")
private val activeFile = File(directory, "app.log")
override val logDirectory: String = directory.absolutePath
@Synchronized
override fun append(line: String) {
directory.mkdirs()
val bytes = line.toByteArray(StandardCharsets.UTF_8)
if (policy.shouldRotate(activeFile.length(), bytes.size.toLong())) {
rotate()
}
activeFile.appendBytes(bytes)
}
@Synchronized
override fun listLogFiles(): List<LogFileInfo> {
directory.mkdirs()
return directory
.listFiles { file -> file.isFile && file.name.startsWith("app") && file.name.endsWith(".log") }
.orEmpty()
.sortedByDescending { it.lastModified() }
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
}
private fun rotate() {
if (policy.maxFiles == 0) {
activeFile.delete()
return
}
File(directory, "app.${policy.maxFiles}.log").delete()
for (index in policy.maxFiles - 1 downTo 1) {
val source = File(directory, "app.$index.log")
if (source.exists()) {
source.renameTo(File(directory, "app.${index + 1}.log"))
}
}
if (activeFile.exists()) {
activeFile.renameTo(File(directory, "app.1.log"))
}
}
}

View File

@@ -0,0 +1,51 @@
package com.vnidrop.app.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import java.awt.Color
import java.awt.EventQueue
import java.awt.Window
import javax.swing.JFrame
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
SideEffect {
DesktopSystemAppearance.apply(isDarkTheme)
}
}
internal object DesktopSystemAppearance {
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
fun apply(isDarkTheme: Boolean) {
if (!isMacOs()) return
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
EventQueue.invokeLater {
Window.getWindows().forEach { window ->
applyWindowChrome(window, isDarkTheme)
}
}
}
internal fun macOsAppearanceName(isDarkTheme: Boolean): String =
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8)
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
val background = titlebarBackground(isDarkTheme)
window.background = background
(window as? JFrame)?.rootPane?.let { rootPane ->
// Keep the native macOS controls and drag behavior, but let the
// decorated titlebar blend with the app's resolved light/dark surface.
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, true)
rootPane.background = background
rootPane.contentPane.background = background
}
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
}

View File

@@ -0,0 +1,18 @@
package com.vnidrop.app.platform
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopSystemAppearanceTest {
@Test
fun macOsAppearanceNamesMatchResolvedTheme() {
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
assertEquals("NSAppearanceNameAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = false))
}
@Test
fun titlebarBackgroundUsesLightAndDarkSurfaces() {
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
}
}