mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Build native UI shell foundation
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
shared/src/commonMain/composeResources/values/strings.xml
Normal file
66
shared/src/commonMain/composeResources/values/strings.xml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="nav_send">Send</string>
|
||||||
|
<string name="nav_receive">Receive</string>
|
||||||
|
<string name="nav_settings">Settings</string>
|
||||||
|
<string name="send_title">Send</string>
|
||||||
|
<string name="send_subtitle">Create a VniDrop ticket and approve receivers when required.</string>
|
||||||
|
<string name="source_title">Source</string>
|
||||||
|
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
|
||||||
|
<string name="button_select_file">Select file</string>
|
||||||
|
<string name="button_clear">Clear</string>
|
||||||
|
<string name="transfer_details_title">Transfer details</string>
|
||||||
|
<string name="field_transfer_name">Transfer name</string>
|
||||||
|
<string name="field_sender_name">Sender name</string>
|
||||||
|
<string name="button_create_share_ticket">Create share ticket</string>
|
||||||
|
<string name="button_creating_ticket">Creating ticket...</string>
|
||||||
|
<string name="share_ticket_title">Share ticket</string>
|
||||||
|
<string name="receiver_requests_title">Receiver requests</string>
|
||||||
|
<string name="button_copy">Copy</string>
|
||||||
|
<string name="button_use_locally">Use locally</string>
|
||||||
|
<string name="button_refresh">Refresh</string>
|
||||||
|
<string name="button_refuse">Refuse</string>
|
||||||
|
<string name="button_approve">Approve</string>
|
||||||
|
<string name="receive_title">Receive</string>
|
||||||
|
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
|
||||||
|
<string name="ticket_card_title">Ticket</string>
|
||||||
|
<string name="field_ticket">Ticket</string>
|
||||||
|
<string name="field_output_directory">Output directory</string>
|
||||||
|
<string name="field_receiver_name">Receiver name</string>
|
||||||
|
<string name="button_inspect_ticket">Inspect ticket</string>
|
||||||
|
<string name="button_receive">Receive</string>
|
||||||
|
<string name="button_receiving">Receiving...</string>
|
||||||
|
<string name="ticket_details_title">Ticket details</string>
|
||||||
|
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_subtitle">Configure the local node and app appearance.</string>
|
||||||
|
<string name="node_title">Node</string>
|
||||||
|
<string name="appearance_title">Appearance</string>
|
||||||
|
<string name="diagnostics_title">Diagnostics</string>
|
||||||
|
<string name="button_initialize_core">Initialize core</string>
|
||||||
|
<string name="field_core_data_directory">Core data directory</string>
|
||||||
|
<string name="button_hide_event_log">Hide event log</string>
|
||||||
|
<string name="button_show_event_log">Show event log</string>
|
||||||
|
<string name="button_refresh_logs">Refresh logs</string>
|
||||||
|
<string name="diagnostics_hint">Diagnostics stay behind Settings so transfer flows remain focused.</string>
|
||||||
|
<string name="event_log_title">Event log</string>
|
||||||
|
<string name="no_events">No events have been emitted yet.</string>
|
||||||
|
<string name="no_logs">No log files have been written yet.</string>
|
||||||
|
<string name="progress_title">Progress</string>
|
||||||
|
<string name="not_initialized">Not initialized</string>
|
||||||
|
<string name="unknown_sender">Unknown</string>
|
||||||
|
<string name="metadata_name">Name</string>
|
||||||
|
<string name="metadata_source">Source</string>
|
||||||
|
<string name="metadata_transfer">Transfer</string>
|
||||||
|
<string name="metadata_size">Size</string>
|
||||||
|
<string name="metadata_kind">Kind</string>
|
||||||
|
<string name="metadata_sender">Sender</string>
|
||||||
|
<string name="metadata_files">Files</string>
|
||||||
|
<string name="metadata_hash">Hash</string>
|
||||||
|
<string name="metadata_platform">Platform</string>
|
||||||
|
<string name="metadata_status">Status</string>
|
||||||
|
<string name="metadata_log_directory">Log directory</string>
|
||||||
|
<string name="error_invalid_ticket">The ticket could not be read. Check that the full ticket was copied.</string>
|
||||||
|
<string name="error_permission">The transfer is waiting for approval or was refused by the sender.</string>
|
||||||
|
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
|
||||||
|
<string name="error_missing_native_library">The native VniDrop library is missing from this build.</string>
|
||||||
|
</resources>
|
||||||
@@ -1,35 +1,6 @@
|
|||||||
package com.vnidrop.app
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.vnidrop.app.logging
|
||||||
|
|
||||||
|
data class LogRotationPolicy(
|
||||||
|
val maxBytes: Long = 1_048_576,
|
||||||
|
val maxFiles: Int = 5,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(maxBytes > 0) { "maxBytes must be positive" }
|
||||||
|
require(maxFiles >= 0) { "maxFiles must be zero or positive" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldRotate(currentBytes: Long, incomingBytes: Long): Boolean =
|
||||||
|
currentBytes > 0 && currentBytes + incomingBytes > maxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppLogLevel {
|
||||||
|
Debug,
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LogFileInfo(
|
||||||
|
val name: String,
|
||||||
|
val path: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val modifiedAtMillis: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface PlatformLogStore {
|
||||||
|
val logDirectory: String
|
||||||
|
fun append(line: String)
|
||||||
|
fun listLogFiles(): List<LogFileInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
|
||||||
|
|
||||||
|
expect fun platformNowMillis(): Long
|
||||||
|
|
||||||
|
object AppLogger {
|
||||||
|
private var store: PlatformLogStore? = null
|
||||||
|
private var activeDirectory: String? = null
|
||||||
|
|
||||||
|
val logDirectory: String?
|
||||||
|
get() = store?.logDirectory
|
||||||
|
|
||||||
|
fun initialize(appDataDir: String, policy: LogRotationPolicy = LogRotationPolicy()) {
|
||||||
|
if (activeDirectory == appDataDir && store != null) return
|
||||||
|
store = createPlatformLogStore(appDataDir, policy)
|
||||||
|
activeDirectory = appDataDir
|
||||||
|
info("logging", "app logger initialized", mapOf("directory" to (store?.logDirectory ?: "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun debug(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
|
||||||
|
write(AppLogLevel.Debug, scope, message, fields)
|
||||||
|
|
||||||
|
fun info(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
|
||||||
|
write(AppLogLevel.Info, scope, message, fields)
|
||||||
|
|
||||||
|
fun warn(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
|
||||||
|
write(AppLogLevel.Warn, scope, message, fields)
|
||||||
|
|
||||||
|
fun error(scope: String, message: String, throwable: Throwable? = null, fields: Map<String, String> = emptyMap()) {
|
||||||
|
val allFields = if (throwable == null) {
|
||||||
|
fields
|
||||||
|
} else {
|
||||||
|
fields + ("error" to (throwable.message ?: throwable.toString()))
|
||||||
|
}
|
||||||
|
write(AppLogLevel.Error, scope, message, allFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listLogFiles(): List<LogFileInfo> =
|
||||||
|
store?.listLogFiles().orEmpty()
|
||||||
|
|
||||||
|
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
|
||||||
|
val line = buildString {
|
||||||
|
append(platformNowMillis())
|
||||||
|
append(" ")
|
||||||
|
append(level.name.uppercase())
|
||||||
|
append(" [")
|
||||||
|
append(scope)
|
||||||
|
append("] ")
|
||||||
|
append(message)
|
||||||
|
if (fields.isNotEmpty()) {
|
||||||
|
append(" ")
|
||||||
|
append(fields.entries.joinToString(" ") { (key, value) -> "$key=${value.sanitizeLogValue()}" })
|
||||||
|
}
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
store?.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.sanitizeLogValue(): String =
|
||||||
|
replace('\n', ' ').replace('\r', ' ')
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.vnidrop.app.platform
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
expect fun PlatformSystemAppearance(isDarkTheme: Boolean)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.vnidrop.app.platform
|
||||||
|
|
||||||
|
enum class SystemBarIconMode {
|
||||||
|
LightIcons,
|
||||||
|
DarkIcons,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun systemBarIconModeForTheme(isDarkTheme: Boolean): SystemBarIconMode =
|
||||||
|
if (isDarkTheme) SystemBarIconMode.LightIcons else SystemBarIconMode.DarkIcons
|
||||||
@@ -46,8 +46,8 @@ fun AppCard(
|
|||||||
Card(
|
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(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.vnidrop.app.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.nav_receive
|
||||||
|
import vnidrop.shared.generated.resources.nav_send
|
||||||
|
import vnidrop.shared.generated.resources.nav_settings
|
||||||
|
|
||||||
|
enum class AppDestination {
|
||||||
|
Send,
|
||||||
|
Receive,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NavigationItem(
|
||||||
|
val destination: AppDestination,
|
||||||
|
val label: StringResource,
|
||||||
|
val icon: ImageVector,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The route list is intentionally tiny for this phase. Activity, receiver
|
||||||
|
// requests, and diagnostics remain available inside screens instead of being
|
||||||
|
// promoted to top-level navigation.
|
||||||
|
val primaryNavigationItems = listOf(
|
||||||
|
NavigationItem(AppDestination.Send, Res.string.nav_send, VniDropIcons.Send),
|
||||||
|
NavigationItem(AppDestination.Receive, Res.string.nav_receive, VniDropIcons.Receive),
|
||||||
|
NavigationItem(AppDestination.Settings, Res.string.nav_settings, VniDropIcons.Settings),
|
||||||
|
)
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package com.vnidrop.app.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppSidebarNavigation(
|
||||||
|
selected: AppDestination,
|
||||||
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.width(88.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(colors.backgroundSurface200)
|
||||||
|
.border(width = 1.dp, color = colors.borderDefault)
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
primaryNavigationItems.forEach { item ->
|
||||||
|
SidebarNavigationItem(
|
||||||
|
item = item,
|
||||||
|
selected = item.destination == selected,
|
||||||
|
onClick = { onDestinationSelected(item.destination) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBottomNavigation(
|
||||||
|
selected: AppDestination,
|
||||||
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(colors.backgroundSurface200)
|
||||||
|
.border(width = 1.dp, color = colors.borderDefault),
|
||||||
|
) {
|
||||||
|
ActiveBottomIndicator(selected = selected)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
primaryNavigationItems.forEach { item ->
|
||||||
|
BottomNavigationItem(
|
||||||
|
item = item,
|
||||||
|
selected = item.destination == selected,
|
||||||
|
onClick = { onDestinationSelected(item.destination) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SidebarNavigationItem(
|
||||||
|
item: NavigationItem,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
val foreground = if (selected) colors.brandLink else colors.foregroundLight
|
||||||
|
val label = stringResource(item.label)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(selected = selected, onClick = onClick)
|
||||||
|
.background(if (selected) colors.backgroundSurface300 else Color.Transparent)
|
||||||
|
.padding(vertical = 13.dp),
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.size(width = 4.dp, height = 46.dp)
|
||||||
|
.clip(RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp))
|
||||||
|
.background(colors.brandLink),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = foreground,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BottomNavigationItem(
|
||||||
|
item: NavigationItem,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
val foreground = if (selected) colors.brandLink else colors.foregroundLight
|
||||||
|
val label = stringResource(item.label)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.selectable(selected = selected, onClick = onClick)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
|
||||||
|
) {
|
||||||
|
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = foreground,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveBottomIndicator(selected: AppDestination) {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
val index = primaryNavigationItems.indexOfFirst { it.destination == selected }.coerceAtLeast(0)
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
repeat(primaryNavigationItems.size) { itemIndex ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(height = 3.dp, width = 1.dp)
|
||||||
|
.background(if (itemIndex == index) colors.brandLink else Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.vnidrop.app.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathFillType
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object VniDropIcons {
|
||||||
|
val Send: ImageVector by lazy {
|
||||||
|
ImageVector.Builder("Send", 24.dp, 24.dp, 24f, 24f).apply {
|
||||||
|
path(
|
||||||
|
fill = SolidColor(Color.Transparent),
|
||||||
|
stroke = SolidColor(Color.Black),
|
||||||
|
strokeLineWidth = 2f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
|
pathFillType = PathFillType.NonZero,
|
||||||
|
) {
|
||||||
|
moveTo(22f, 2f)
|
||||||
|
lineTo(11f, 13f)
|
||||||
|
moveTo(22f, 2f)
|
||||||
|
lineTo(15f, 22f)
|
||||||
|
lineTo(11f, 13f)
|
||||||
|
lineTo(2f, 9f)
|
||||||
|
lineTo(22f, 2f)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val Receive: ImageVector by lazy {
|
||||||
|
ImageVector.Builder("Receive", 24.dp, 24.dp, 24f, 24f).apply {
|
||||||
|
path(
|
||||||
|
fill = SolidColor(Color.Transparent),
|
||||||
|
stroke = SolidColor(Color.Black),
|
||||||
|
strokeLineWidth = 2f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
|
pathFillType = PathFillType.NonZero,
|
||||||
|
) {
|
||||||
|
moveTo(12f, 3f)
|
||||||
|
lineTo(12f, 15f)
|
||||||
|
moveTo(7f, 10f)
|
||||||
|
lineTo(12f, 15f)
|
||||||
|
lineTo(17f, 10f)
|
||||||
|
moveTo(5f, 21f)
|
||||||
|
lineTo(19f, 21f)
|
||||||
|
moveTo(5f, 17f)
|
||||||
|
lineTo(5f, 21f)
|
||||||
|
moveTo(19f, 17f)
|
||||||
|
lineTo(19f, 21f)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val Settings: ImageVector by lazy {
|
||||||
|
ImageVector.Builder("Settings", 24.dp, 24.dp, 24f, 24f).apply {
|
||||||
|
path(
|
||||||
|
fill = SolidColor(Color.Transparent),
|
||||||
|
stroke = SolidColor(Color.Black),
|
||||||
|
strokeLineWidth = 2f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
|
pathFillType = PathFillType.NonZero,
|
||||||
|
) {
|
||||||
|
moveTo(12f, 15f)
|
||||||
|
arcTo(3f, 3f, 0f, false, false, 12f, 9f)
|
||||||
|
arcTo(3f, 3f, 0f, false, false, 12f, 15f)
|
||||||
|
moveTo(19.4f, 15f)
|
||||||
|
lineTo(20.8f, 17.4f)
|
||||||
|
lineTo(18.4f, 21f)
|
||||||
|
lineTo(15.8f, 20f)
|
||||||
|
moveTo(8.2f, 4f)
|
||||||
|
lineTo(5.6f, 3f)
|
||||||
|
lineTo(3.2f, 6.6f)
|
||||||
|
lineTo(4.6f, 9f)
|
||||||
|
moveTo(15.8f, 4f)
|
||||||
|
lineTo(18.4f, 3f)
|
||||||
|
lineTo(20.8f, 6.6f)
|
||||||
|
lineTo(19.4f, 9f)
|
||||||
|
moveTo(4.6f, 15f)
|
||||||
|
lineTo(3.2f, 17.4f)
|
||||||
|
lineTo(5.6f, 21f)
|
||||||
|
lineTo(8.2f, 20f)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.vnidrop.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.core.CoreUiState
|
||||||
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
|
import com.vnidrop.app.ui.components.Field
|
||||||
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
|
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.button_inspect_ticket
|
||||||
|
import vnidrop.shared.generated.resources.button_receive
|
||||||
|
import vnidrop.shared.generated.resources.button_receiving
|
||||||
|
import vnidrop.shared.generated.resources.field_output_directory
|
||||||
|
import vnidrop.shared.generated.resources.field_receiver_name
|
||||||
|
import vnidrop.shared.generated.resources.field_ticket
|
||||||
|
import vnidrop.shared.generated.resources.receive_subtitle
|
||||||
|
import vnidrop.shared.generated.resources.receive_title
|
||||||
|
import vnidrop.shared.generated.resources.ticket_card_title
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReceiveScreen(
|
||||||
|
coreState: CoreUiState,
|
||||||
|
receiveState: ReceiveUiState,
|
||||||
|
onReceiveStateChange: (ReceiveUiState) -> Unit,
|
||||||
|
onInspect: () -> Unit,
|
||||||
|
onReceive: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
|
||||||
|
ErrorSection(coreState)
|
||||||
|
AppCard(title = stringResource(Res.string.ticket_card_title)) {
|
||||||
|
Field(
|
||||||
|
value = receiveState.ticket,
|
||||||
|
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
|
||||||
|
label = stringResource(Res.string.field_ticket),
|
||||||
|
minLines = 4,
|
||||||
|
)
|
||||||
|
Field(
|
||||||
|
value = receiveState.outputDirectory,
|
||||||
|
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
|
||||||
|
label = stringResource(Res.string.field_output_directory),
|
||||||
|
)
|
||||||
|
Field(
|
||||||
|
value = receiveState.receiverName,
|
||||||
|
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
|
||||||
|
label = stringResource(Res.string.field_receiver_name),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
SecondaryButton(
|
||||||
|
text = stringResource(Res.string.button_inspect_ticket),
|
||||||
|
onClick = onInspect,
|
||||||
|
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
|
||||||
|
)
|
||||||
|
PrimaryButton(
|
||||||
|
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
|
||||||
|
onClick = onReceive,
|
||||||
|
enabled = coreState.isInitialized &&
|
||||||
|
receiveState.ticket.isNotBlank() &&
|
||||||
|
receiveState.outputDirectory.isNotBlank() &&
|
||||||
|
!receiveState.isReceiving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coreState.lastInspection?.let { TicketInspectionCard(it) }
|
||||||
|
ProgressSection(coreState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package com.vnidrop.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.core.CoreUiState
|
||||||
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
|
import com.vnidrop.app.ui.components.ErrorBanner
|
||||||
|
import com.vnidrop.app.ui.components.MetadataRow
|
||||||
|
import com.vnidrop.app.ui.components.PillTone
|
||||||
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.ProgressRow
|
||||||
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
|
import com.vnidrop.app.ui.components.StatusPill
|
||||||
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
|
import com.vnidrop.app.ui.state.friendlyCoreError
|
||||||
|
import com.vnidrop.app.ui.state.summarizeProgress
|
||||||
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import uniffi.vnidrop.CoreEvent
|
||||||
|
import uniffi.vnidrop.ReceiverRequest
|
||||||
|
import uniffi.vnidrop.TicketInspection
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.button_approve
|
||||||
|
import vnidrop.shared.generated.resources.button_refuse
|
||||||
|
import vnidrop.shared.generated.resources.event_log_title
|
||||||
|
import vnidrop.shared.generated.resources.metadata_files
|
||||||
|
import vnidrop.shared.generated.resources.metadata_hash
|
||||||
|
import vnidrop.shared.generated.resources.metadata_kind
|
||||||
|
import vnidrop.shared.generated.resources.metadata_sender
|
||||||
|
import vnidrop.shared.generated.resources.metadata_size
|
||||||
|
import vnidrop.shared.generated.resources.metadata_transfer
|
||||||
|
import vnidrop.shared.generated.resources.no_events
|
||||||
|
import vnidrop.shared.generated.resources.progress_title
|
||||||
|
import vnidrop.shared.generated.resources.ticket_details_title
|
||||||
|
import vnidrop.shared.generated.resources.ticket_no_metadata
|
||||||
|
import vnidrop.shared.generated.resources.unknown_sender
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScreenHeader(title: String, subtitle: String) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(subtitle, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorSection(coreState: CoreUiState) {
|
||||||
|
friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyText(text: String) {
|
||||||
|
Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProgressSection(coreState: CoreUiState) {
|
||||||
|
val progress = summarizeProgress(coreState.events)
|
||||||
|
if (progress.isNotEmpty()) {
|
||||||
|
AppCard(title = stringResource(Res.string.progress_title)) {
|
||||||
|
progress.forEach { item ->
|
||||||
|
ProgressRow(label = item.label, progress = item.progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TicketInspectionCard(inspection: TicketInspection) {
|
||||||
|
AppCard(title = stringResource(Res.string.ticket_details_title)) {
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind)
|
||||||
|
inspection.metadata?.let { metadata ->
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_transfer), metadata.transferName)
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_sender), metadata.senderName ?: stringResource(Res.string.unknown_sender))
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_files), metadata.fileCount.toString())
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(metadata.totalSize))
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_hash), metadata.contentHash)
|
||||||
|
} ?: EmptyText(stringResource(Res.string.ticket_no_metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReceiverRequestList(
|
||||||
|
requests: List<ReceiverRequest>,
|
||||||
|
onRespondRequest: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
requests.forEach { request ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(LocalVniDropColors.current.backgroundSurface100)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral)
|
||||||
|
}
|
||||||
|
request.reason?.let { Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
if (request.status == "requested") {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRespondRequest(request.id, false) })
|
||||||
|
PrimaryButton(stringResource(Res.string.button_approve), onClick = { onRespondRequest(request.id, true) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TicketText(ticket: String) {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
text = ticket,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(LocalVniDropColors.current.backgroundSurface200)
|
||||||
|
.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiagnosticsPanel(events: List<CoreEvent>) {
|
||||||
|
AppCard(title = stringResource(Res.string.event_log_title)) {
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
EmptyText(stringResource(Res.string.no_events))
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(280.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
events.forEach { event -> EventRow(event) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventRow(event: CoreEvent) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(LocalVniDropColors.current.backgroundSurface100)
|
||||||
|
.padding(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
Text(event.dataJson, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SectionDivider() {
|
||||||
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package com.vnidrop.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.core.CoreUiState
|
||||||
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
|
import com.vnidrop.app.ui.components.Field
|
||||||
|
import com.vnidrop.app.ui.components.MetadataRow
|
||||||
|
import com.vnidrop.app.ui.components.PillTone
|
||||||
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
|
import com.vnidrop.app.ui.components.StatusPill
|
||||||
|
import com.vnidrop.app.ui.state.SendUiState
|
||||||
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import uniffi.vnidrop.ReceiverRequest
|
||||||
|
import uniffi.vnidrop.ShareResult
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.button_approve
|
||||||
|
import vnidrop.shared.generated.resources.button_clear
|
||||||
|
import vnidrop.shared.generated.resources.button_copy
|
||||||
|
import vnidrop.shared.generated.resources.button_create_share_ticket
|
||||||
|
import vnidrop.shared.generated.resources.button_creating_ticket
|
||||||
|
import vnidrop.shared.generated.resources.button_refresh
|
||||||
|
import vnidrop.shared.generated.resources.button_refuse
|
||||||
|
import vnidrop.shared.generated.resources.button_select_file
|
||||||
|
import vnidrop.shared.generated.resources.button_use_locally
|
||||||
|
import vnidrop.shared.generated.resources.field_sender_name
|
||||||
|
import vnidrop.shared.generated.resources.field_transfer_name
|
||||||
|
import vnidrop.shared.generated.resources.metadata_name
|
||||||
|
import vnidrop.shared.generated.resources.metadata_size
|
||||||
|
import vnidrop.shared.generated.resources.metadata_source
|
||||||
|
import vnidrop.shared.generated.resources.metadata_transfer
|
||||||
|
import vnidrop.shared.generated.resources.receiver_requests_title
|
||||||
|
import vnidrop.shared.generated.resources.send_source_empty
|
||||||
|
import vnidrop.shared.generated.resources.send_subtitle
|
||||||
|
import vnidrop.shared.generated.resources.send_title
|
||||||
|
import vnidrop.shared.generated.resources.share_ticket_title
|
||||||
|
import vnidrop.shared.generated.resources.source_title
|
||||||
|
import vnidrop.shared.generated.resources.transfer_details_title
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SendScreen(
|
||||||
|
coreState: CoreUiState,
|
||||||
|
sendState: SendUiState,
|
||||||
|
onSendStateChange: (SendUiState) -> Unit,
|
||||||
|
onSelectFile: () -> Unit,
|
||||||
|
onCreateShare: () -> Unit,
|
||||||
|
onCopyTicket: (String) -> Unit,
|
||||||
|
onUseLocally: (String) -> Unit,
|
||||||
|
onRefreshRequests: (ULong) -> Unit,
|
||||||
|
onRespondRequest: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||||
|
ErrorSection(coreState)
|
||||||
|
SendSourceCard(
|
||||||
|
sendState = sendState,
|
||||||
|
onSendStateChange = onSendStateChange,
|
||||||
|
onSelectFile = onSelectFile,
|
||||||
|
)
|
||||||
|
SendDetailsCard(
|
||||||
|
coreState = coreState,
|
||||||
|
sendState = sendState,
|
||||||
|
onSendStateChange = onSendStateChange,
|
||||||
|
onCreateShare = onCreateShare,
|
||||||
|
)
|
||||||
|
coreState.lastShare?.let { share ->
|
||||||
|
ShareResultCard(
|
||||||
|
share = share,
|
||||||
|
requests = coreState.receiverRequests,
|
||||||
|
onCopyTicket = onCopyTicket,
|
||||||
|
onUseLocally = onUseLocally,
|
||||||
|
onRefreshRequests = onRefreshRequests,
|
||||||
|
onRespondRequest = onRespondRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProgressSection(coreState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendSourceCard(
|
||||||
|
sendState: SendUiState,
|
||||||
|
onSendStateChange: (SendUiState) -> Unit,
|
||||||
|
onSelectFile: () -> Unit,
|
||||||
|
) {
|
||||||
|
AppCard(title = stringResource(Res.string.source_title)) {
|
||||||
|
if (sendState.selectedSource.isBlank()) {
|
||||||
|
EmptyText(stringResource(Res.string.send_source_empty))
|
||||||
|
} else {
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_name), sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
|
||||||
|
SecondaryButton(
|
||||||
|
text = stringResource(Res.string.button_clear),
|
||||||
|
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
|
||||||
|
enabled = sendState.selectedSource.isNotBlank(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendDetailsCard(
|
||||||
|
coreState: CoreUiState,
|
||||||
|
sendState: SendUiState,
|
||||||
|
onSendStateChange: (SendUiState) -> Unit,
|
||||||
|
onCreateShare: () -> Unit,
|
||||||
|
) {
|
||||||
|
AppCard(title = stringResource(Res.string.transfer_details_title)) {
|
||||||
|
Field(
|
||||||
|
value = sendState.transferName,
|
||||||
|
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
|
||||||
|
label = stringResource(Res.string.field_transfer_name),
|
||||||
|
)
|
||||||
|
Field(
|
||||||
|
value = sendState.senderName,
|
||||||
|
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
|
||||||
|
label = stringResource(Res.string.field_sender_name),
|
||||||
|
)
|
||||||
|
PrimaryButton(
|
||||||
|
text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket),
|
||||||
|
onClick = onCreateShare,
|
||||||
|
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareResultCard(
|
||||||
|
share: ShareResult,
|
||||||
|
requests: List<ReceiverRequest>,
|
||||||
|
onCopyTicket: (String) -> Unit,
|
||||||
|
onUseLocally: (String) -> Unit,
|
||||||
|
onRefreshRequests: (ULong) -> Unit,
|
||||||
|
onRespondRequest: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
AppCard(title = stringResource(Res.string.share_ticket_title), trailing = {
|
||||||
|
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
||||||
|
}) {
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
|
||||||
|
TicketText(share.ticket)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
|
||||||
|
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseLocally(share.ticket) })
|
||||||
|
SecondaryButton(stringResource(Res.string.button_refresh), onClick = { onRefreshRequests(share.transferId) })
|
||||||
|
}
|
||||||
|
if (requests.isNotEmpty()) {
|
||||||
|
SectionDivider()
|
||||||
|
Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold)
|
||||||
|
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.vnidrop.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.core.CoreUiState
|
||||||
|
import com.vnidrop.app.logging.LogFileInfo
|
||||||
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
|
import com.vnidrop.app.ui.components.Field
|
||||||
|
import com.vnidrop.app.ui.components.MetadataRow
|
||||||
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.appearance_title
|
||||||
|
import vnidrop.shared.generated.resources.button_hide_event_log
|
||||||
|
import vnidrop.shared.generated.resources.button_initialize_core
|
||||||
|
import vnidrop.shared.generated.resources.button_refresh_logs
|
||||||
|
import vnidrop.shared.generated.resources.button_show_event_log
|
||||||
|
import vnidrop.shared.generated.resources.diagnostics_hint
|
||||||
|
import vnidrop.shared.generated.resources.diagnostics_title
|
||||||
|
import vnidrop.shared.generated.resources.field_core_data_directory
|
||||||
|
import vnidrop.shared.generated.resources.metadata_log_directory
|
||||||
|
import vnidrop.shared.generated.resources.metadata_platform
|
||||||
|
import vnidrop.shared.generated.resources.metadata_status
|
||||||
|
import vnidrop.shared.generated.resources.no_logs
|
||||||
|
import vnidrop.shared.generated.resources.node_title
|
||||||
|
import vnidrop.shared.generated.resources.not_initialized
|
||||||
|
import vnidrop.shared.generated.resources.settings_subtitle
|
||||||
|
import vnidrop.shared.generated.resources.settings_title
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
platformName: String,
|
||||||
|
appDataDir: String,
|
||||||
|
onAppDataDirChange: (String) -> Unit,
|
||||||
|
coreState: CoreUiState,
|
||||||
|
themeMode: ThemeMode,
|
||||||
|
onThemeModeChange: (ThemeMode) -> Unit,
|
||||||
|
diagnosticsVisible: Boolean,
|
||||||
|
onDiagnosticsVisibleChange: (Boolean) -> Unit,
|
||||||
|
logDirectory: String?,
|
||||||
|
logFiles: List<LogFileInfo>,
|
||||||
|
onRefreshLogs: () -> Unit,
|
||||||
|
onInitialize: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
ScreenHeader(stringResource(Res.string.settings_title), stringResource(Res.string.settings_subtitle))
|
||||||
|
ErrorSection(coreState)
|
||||||
|
AppCard(title = stringResource(Res.string.node_title)) {
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_platform), platformName)
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_status), coreState.status)
|
||||||
|
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = stringResource(Res.string.field_core_data_directory))
|
||||||
|
PrimaryButton(stringResource(Res.string.button_initialize_core), onClick = onInitialize)
|
||||||
|
}
|
||||||
|
AppCard(title = stringResource(Res.string.appearance_title)) {
|
||||||
|
ThemeMode.entries.forEach { mode ->
|
||||||
|
ThemeModeRow(
|
||||||
|
mode = mode,
|
||||||
|
selected = themeMode == mode,
|
||||||
|
onSelected = { onThemeModeChange(mode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppCard(title = stringResource(Res.string.diagnostics_title)) {
|
||||||
|
SecondaryButton(
|
||||||
|
text = if (diagnosticsVisible) stringResource(Res.string.button_hide_event_log) else stringResource(Res.string.button_show_event_log),
|
||||||
|
onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) },
|
||||||
|
)
|
||||||
|
SecondaryButton(text = stringResource(Res.string.button_refresh_logs), onClick = onRefreshLogs)
|
||||||
|
MetadataRow(stringResource(Res.string.metadata_log_directory), logDirectory ?: stringResource(Res.string.not_initialized))
|
||||||
|
if (logFiles.isEmpty()) {
|
||||||
|
EmptyText(stringResource(Res.string.no_logs))
|
||||||
|
} else {
|
||||||
|
logFiles.forEach { file ->
|
||||||
|
MetadataRow(file.name, "${file.sizeBytes} bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EmptyText(stringResource(Res.string.diagnostics_hint))
|
||||||
|
}
|
||||||
|
if (diagnosticsVisible) {
|
||||||
|
DiagnosticsPanel(events = coreState.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeModeRow(
|
||||||
|
mode: ThemeMode,
|
||||||
|
selected: Boolean,
|
||||||
|
onSelected: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.selectable(selected = selected, onClick = onSelected)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = onSelected)
|
||||||
|
Text(mode.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.vnidrop.app.ui.shell
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vnidrop.app.ui.navigation.AppBottomNavigation
|
||||||
|
import com.vnidrop.app.ui.navigation.AppDestination
|
||||||
|
import com.vnidrop.app.ui.navigation.AppSidebarNavigation
|
||||||
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
|
import com.vnidrop.app.ui.state.useBottomNavigation
|
||||||
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppShell(
|
||||||
|
selectedDestination: AppDestination,
|
||||||
|
windowClass: WindowClass,
|
||||||
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(colors.backgroundDashCanvas),
|
||||||
|
color = colors.backgroundDashCanvas,
|
||||||
|
) {
|
||||||
|
if (useBottomNavigation(windowClass)) {
|
||||||
|
PhoneShell(
|
||||||
|
selectedDestination = selectedDestination,
|
||||||
|
onDestinationSelected = onDestinationSelected,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
WideShell(
|
||||||
|
selectedDestination = selectedDestination,
|
||||||
|
onDestinationSelected = onDestinationSelected,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WideShell(
|
||||||
|
selectedDestination: AppDestination,
|
||||||
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AppSidebarNavigation(
|
||||||
|
selected = selectedDestination,
|
||||||
|
onDestinationSelected = onDestinationSelected,
|
||||||
|
)
|
||||||
|
ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhoneShell(
|
||||||
|
selectedDestination: AppDestination,
|
||||||
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
|
||||||
|
AppBottomNavigation(
|
||||||
|
selected = selectedDestination,
|
||||||
|
onDestinationSelected = onDestinationSelected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScreenScrollContainer(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,27 @@
|
|||||||
package com.vnidrop.app.ui.state
|
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 }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user