From 6bcfc534a0c06524888690a195099c18f66f80b3 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sat, 4 Jul 2026 10:41:51 +0200 Subject: [PATCH] Build native UI shell foundation --- androidApp/src/main/AndroidManifest.xml | 2 +- crates/vnidrop/src/logging.rs | 85 +- .../src/main/kotlin/com/vnidrop/app/main.kt | 22 +- iosApp/iosApp/ContentView.swift | 57 +- iosApp/iosApp/Info.plist | 10 +- .../app/logging/PlatformLogStore.android.kt | 56 ++ .../PlatformSystemAppearance.android.kt | 40 + .../composeResources/values/strings.xml | 66 ++ .../commonMain/kotlin/com/vnidrop/app/App.kt | 738 ++---------------- .../com/vnidrop/app/logging/AppLogger.kt | 95 +++ .../app/platform/PlatformSystemAppearance.kt | 6 + .../vnidrop/app/platform/SystemBarIconMode.kt | 9 + .../app/ui/components/VniDropComponents.kt | 24 +- .../app/ui/navigation/AppDestination.kt | 29 + .../app/ui/navigation/AppNavigation.kt | 186 +++++ .../vnidrop/app/ui/navigation/VniDropIcons.kt | 91 +++ .../vnidrop/app/ui/screens/ReceiveScreen.kt | 73 ++ .../vnidrop/app/ui/screens/ScreenSections.kt | 186 +++++ .../com/vnidrop/app/ui/screens/SendScreen.kt | 163 ++++ .../vnidrop/app/ui/screens/SettingsScreen.kt | 116 +++ .../com/vnidrop/app/ui/shell/AppShell.kt | 99 +++ .../com/vnidrop/app/ui/state/AppUiModels.kt | 30 +- .../com/vnidrop/app/ui/theme/VniDropTheme.kt | 262 +++++-- .../com/vnidrop/app/logging/AppLoggerTest.kt | 16 + .../app/platform/SystemBarIconModeTest.kt | 12 + .../app/ui/navigation/NavigationModelTest.kt | 15 + .../vnidrop/app/ui/state/AppUiModelsTest.kt | 15 +- .../vnidrop/app/ui/theme/I18nResourceTest.kt | 30 + .../app/ui/theme/VniDropThemeTokenTest.kt | 20 + .../app/logging/PlatformLogStore.ios.kt | 95 +++ .../platform/PlatformSystemAppearance.ios.kt | 19 + .../app/logging/PlatformLogStore.jvm.kt | 56 ++ .../platform/PlatformSystemAppearance.jvm.kt | 51 ++ .../platform/DesktopSystemAppearanceTest.kt | 18 + 34 files changed, 1998 insertions(+), 794 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt create mode 100644 shared/src/androidMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.android.kt create mode 100644 shared/src/commonMain/composeResources/values/strings.xml create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/platform/SystemBarIconMode.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/logging/AppLoggerTest.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/platform/SystemBarIconModeTest.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/I18nResourceTest.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/VniDropThemeTokenTest.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt create mode 100644 shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 12b0c7d..542f6f8 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:theme="@android:style/Theme.Material.NoActionBar"> diff --git a/crates/vnidrop/src/logging.rs b/crates/vnidrop/src/logging.rs index 4dca45e..975715e 100644 --- a/crates/vnidrop/src/logging.rs +++ b/crates/vnidrop/src/logging.rs @@ -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 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 = OnceLock::new(); 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"); - std::fs::create_dir_all(&log_dir)?; - let file_appender = tracing_appender::rolling::daily(log_dir, "vnidrop.log"); - let (writer, guard) = tracing_appender::non_blocking(file_appender); + fs::create_dir_all(&log_dir)?; + let writer = SizeRotatingWriter::new(log_dir, MAX_LOG_BYTES, MAX_LOG_FILES); + let (writer, guard) = tracing_appender::non_blocking(writer); let filter = EnvFilter::try_from_default_env() .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(()) } + +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 { + 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(()) + } +} diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 66d930d..7db1794 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -3,11 +3,21 @@ package com.vnidrop.app import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - title = "vnidrop", - ) { - App() +fun main() { + configureMacOsNativeAppearance() + application { + Window( + onCloseRequest = ::exitApplication, + title = "vnidrop", + ) { + 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") +} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index dac51c3..646ca93 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -2,9 +2,64 @@ import Shared import SwiftUI 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 { func makeUIViewController(context: Self.Context) -> UIViewController { - MainViewControllerKt.MainViewController() + VniDropHostViewController(composeController: MainViewControllerKt.MainViewController()) } func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index e1e7881..df2d283 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -1,8 +1,10 @@ - - CADisableMinimumFrameDurationOnPhone - - + + CADisableMinimumFrameDurationOnPhone + + UIViewControllerBasedStatusBarAppearance + + diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt new file mode 100644 index 0000000..59134ef --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.android.kt @@ -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 { + 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")) + } + } +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.android.kt new file mode 100644 index 0000000..090d91c --- /dev/null +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.android.kt @@ -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 + } + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..d9659ee --- /dev/null +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,66 @@ + + Send + Receive + Settings + Send + Create a VniDrop ticket and approve receivers when required. + Source + Select a file to start a share. The app keeps bytes in Rust and platform file handles. + Select file + Clear + Transfer details + Transfer name + Sender name + Create share ticket + Creating ticket... + Share ticket + Receiver requests + Copy + Use locally + Refresh + Refuse + Approve + Receive + Inspect a ticket, request access, and stream files into the output directory. + Ticket + Ticket + Output directory + Receiver name + Inspect ticket + Receive + Receiving... + Ticket details + This ticket does not include VniDrop metadata. + Settings + Configure the local node and app appearance. + Node + Appearance + Diagnostics + Initialize core + Core data directory + Hide event log + Show event log + Refresh logs + Diagnostics stay behind Settings so transfer flows remain focused. + Event log + No events have been emitted yet. + No log files have been written yet. + Progress + Not initialized + Unknown + Name + Source + Transfer + Size + Kind + Sender + Files + Hash + Platform + Status + Log directory + The ticket could not be read. Check that the full ticket was copied. + The transfer is waiting for approval or was refused by the sender. + VniDrop could not open its network sockets on this device. + The native VniDrop library is missing from this build. + diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 4e4782e..ac16394 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -1,35 +1,6 @@ package com.vnidrop.app -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -38,51 +9,28 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreRepository -import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.sharePickedFile -import com.vnidrop.app.ui.components.AppCard -import com.vnidrop.app.ui.components.ErrorBanner -import com.vnidrop.app.ui.components.Field -import com.vnidrop.app.ui.components.MetadataRow -import com.vnidrop.app.ui.components.PillTone -import com.vnidrop.app.ui.components.PrimaryButton -import com.vnidrop.app.ui.components.ProgressRow -import com.vnidrop.app.ui.components.QuietButton -import com.vnidrop.app.ui.components.SecondaryButton -import com.vnidrop.app.ui.components.StatusPill -import com.vnidrop.app.ui.state.AppDestination +import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.logging.LogFileInfo +import com.vnidrop.app.platform.PlatformSystemAppearance +import com.vnidrop.app.ui.navigation.AppDestination +import com.vnidrop.app.ui.screens.ReceiveScreen +import com.vnidrop.app.ui.screens.SendScreen +import com.vnidrop.app.ui.screens.SettingsScreen +import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.state.AppUiState import com.vnidrop.app.ui.state.ReceiveUiState import com.vnidrop.app.ui.state.SendUiState -import com.vnidrop.app.ui.state.WindowClass -import com.vnidrop.app.ui.state.activeReceiverRequests -import com.vnidrop.app.ui.state.displayNameForStatus -import com.vnidrop.app.ui.state.formatBytes -import com.vnidrop.app.ui.state.friendlyCoreError -import com.vnidrop.app.ui.state.summarizeProgress -import com.vnidrop.app.ui.state.transferSubtitle import com.vnidrop.app.ui.state.windowClassFor -import com.vnidrop.app.ui.theme.LocalVniDropColors -import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.VniDropTheme +import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import kotlinx.coroutines.launch -import uniffi.vnidrop.CoreEvent -import uniffi.vnidrop.ReceiverRequest -import uniffi.vnidrop.ShareResult -import uniffi.vnidrop.StoredTransfer -import uniffi.vnidrop.TicketInspection @Composable @Preview @@ -95,63 +43,89 @@ fun App() { var sendState by remember { mutableStateOf(SendUiState()) } var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } var selectedFile by remember { mutableStateOf(null) } + var logFiles by remember { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() val clipboard = LocalClipboardManager.current val picker = rememberShareFilePicker( onFilePicked = { file -> + AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) selectedFile = file - sendState = sendState.copy( - selectedSource = file.value, - selectedDisplayName = file.displayName, - transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) { - file.displayName - } else { - sendState.transferName - }, - ) + sendState = sendState.withSelectedFile(file) + logFiles = AppLogger.listLogFiles() + }, + onError = { error -> + AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error)) + scope.launch { repository.setError(error) } + logFiles = AppLogger.listLogFiles() }, - onError = { error -> scope.launch { repository.setError(error) } }, ) + LaunchedEffect(Unit) { + AppLogger.initialize(appDataDir) + AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name)) + logFiles = AppLogger.listLogFiles() + } + LaunchedEffect(coreState.lastShare?.transferId) { coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) } } - VniDropTheme(mode = appState.themeMode) { + val isDarkTheme = rememberResolvedDarkTheme(appState.themeMode) + PlatformSystemAppearance(isDarkTheme) + LaunchedEffect(isDarkTheme) { + AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString())) + logFiles = AppLogger.listLogFiles() + } + + VniDropTheme(isDarkTheme = isDarkTheme) { BoxWithConstraints { - val windowClass = windowClassFor(maxWidth.value) - AppFrame( - appState = appState, - coreState = coreState, - windowClass = windowClass, - onDestinationChange = { appState = appState.copy(destination = it) }, + AppShell( + selectedDestination = appState.destination, + windowClass = windowClassFor(maxWidth.value), + onDestinationSelected = { appState = appState.copy(destination = it) }, ) { when (appState.destination) { AppDestination.Send -> SendScreen( coreState = coreState, sendState = sendState, onSendStateChange = { sendState = it }, - onSelectFile = { picker.pickFile() }, + onSelectFile = { + AppLogger.info("file-picker", "open share file picker") + picker.pickFile() + logFiles = AppLogger.listLogFiles() + }, onCreateShare = { scope.launch { + AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource)) sendState = sendState.copy(isSharing = true) val file = selectedFile - if (file != null) { - sharePickedFile(repository, file, sendState.transferName, sendState.senderName) - } else { + if (file == null) { repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) + } else { + sharePickedFile(repository, file, sendState.transferName, sendState.senderName) } sendState = sendState.copy(isSharing = false) + logFiles = AppLogger.listLogFiles() } }, - onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) }, + onCopyTicket = { ticket -> + AppLogger.info("send", "ticket copied") + clipboard.setText(AnnotatedString(ticket)) + logFiles = AppLogger.listLogFiles() + }, onUseLocally = { ticket -> receiveState = receiveState.copy(ticket = ticket) appState = appState.copy(destination = AppDestination.Receive) }, onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, onRespondRequest = { requestId, accepted -> - scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") } + scope.launch { + repository.respondReceiverRequest( + requestId = requestId, + accepted = accepted, + reason = if (accepted) null else "sender-refused", + ) + } }, ) AppDestination.Receive -> ReceiveScreen( @@ -161,598 +135,48 @@ fun App() { onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } }, onReceive = { scope.launch { + AppLogger.info("receive", "receive requested") receiveState = receiveState.copy(isReceiving = true) repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) receiveState = receiveState.copy(isReceiving = false) + logFiles = AppLogger.listLogFiles() } }, ) - AppDestination.Activity -> ActivityScreen( - coreState = coreState, - onRefresh = { - scope.launch { - repository.refreshTransfers() - repository.refreshEvents() - } - }, - onCancel = { transferId -> scope.launch { repository.cancel(transferId) } }, - ) - AppDestination.Requests -> RequestsScreen( - requests = coreState.receiverRequests, - lastShare = coreState.lastShare, - onRefresh = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, - onRespond = { requestId, accepted -> - scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") } - }, - ) AppDestination.Settings -> SettingsScreen( platformName = platform.name, appDataDir = appDataDir, onAppDataDirChange = { appDataDir = it }, coreState = coreState, themeMode = appState.themeMode, - onThemeModeChange = { appState = appState.copy(themeMode = it) }, + onThemeModeChange = { + AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name)) + appState = appState.copy(themeMode = it) + logFiles = AppLogger.listLogFiles() + }, diagnosticsVisible = appState.diagnosticsVisible, onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) }, - onInitialize = { scope.launch { repository.initialize(appDataDir) } }, + logDirectory = AppLogger.logDirectory, + logFiles = logFiles, + onRefreshLogs = { logFiles = AppLogger.listLogFiles() }, + onInitialize = { + scope.launch { + AppLogger.initialize(appDataDir) + AppLogger.info("core", "initialize requested", mapOf("appDataDir" to appDataDir)) + repository.initialize(appDataDir) + logFiles = AppLogger.listLogFiles() + } + }, ) } - - if (appState.diagnosticsVisible) { - DiagnosticsPanel(events = coreState.events) - } } } } } -@Composable -private fun AppFrame( - appState: AppUiState, - coreState: CoreUiState, - windowClass: WindowClass, - onDestinationChange: (AppDestination) -> Unit, - content: @Composable () -> Unit, -) { - val colors = LocalVniDropColors.current - Surface( - modifier = Modifier - .fillMaxSize() - .background(colors.canvas) - .safeContentPadding(), - color = colors.canvas, - ) { - if (windowClass == WindowClass.Compact) { - Column(modifier = Modifier.fillMaxSize()) { - TopBar(coreState = coreState) - Box(modifier = Modifier.weight(1f)) { - ScreenContent(content = content) - } - BottomNav(selected = appState.destination, onDestinationChange = onDestinationChange) - } - } else { - Row(modifier = Modifier.fillMaxSize()) { - SideNav( - selected = appState.destination, - coreState = coreState, - onDestinationChange = onDestinationChange, - ) - Box(modifier = Modifier.weight(1f)) { - ScreenContent(content = content) - } - } - } - } -} - -@Composable -private fun ScreenContent(content: @Composable () -> Unit) { - val colors = LocalVniDropColors.current - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(colors.canvas) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item { - content() - } - } -} - -@Composable -private fun TopBar(coreState: CoreUiState) { - val colors = LocalVniDropColors.current - Row( - modifier = Modifier - .fillMaxWidth() - .background(colors.sidebar) - .border(BorderStroke(1.dp, colors.border)) - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("VniDrop", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - NodeStatus(coreState) - } -} - -@Composable -private fun SideNav( - selected: AppDestination, - coreState: CoreUiState, - onDestinationChange: (AppDestination) -> Unit, -) { - val colors = LocalVniDropColors.current - Column( - modifier = Modifier - .width(220.dp) - .fillMaxHeight() - .background(colors.sidebar) - .border(BorderStroke(1.dp, colors.border)) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text("VniDrop", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text("Private file transfer", color = colors.textMuted, style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(12.dp)) - AppDestination.entries.forEach { destination -> - NavItem( - destination = destination, - selected = destination == selected, - onClick = { onDestinationChange(destination) }, - ) - } - Spacer(Modifier.weight(1f)) - NodeStatus(coreState) - } -} - -@Composable -private fun BottomNav( - selected: AppDestination, - onDestinationChange: (AppDestination) -> Unit, -) { - val colors = LocalVniDropColors.current - Row( - modifier = Modifier - .fillMaxWidth() - .background(colors.sidebar) - .border(BorderStroke(1.dp, colors.border)) - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - AppDestination.entries.forEach { destination -> - NavItem( - destination = destination, - selected = destination == selected, - onClick = { onDestinationChange(destination) }, - modifier = Modifier.weight(1f), - compact = true, - ) - } - } -} - -@Composable -private fun NavItem( - destination: AppDestination, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - compact: Boolean = false, -) { - val colors = LocalVniDropColors.current - val background = if (selected) colors.surfaceMuted else colors.sidebar - val border = if (selected) colors.brand.copy(alpha = 0.55f) else colors.border.copy(alpha = 0f) - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(background) - .border(1.dp, border, RoundedCornerShape(8.dp)) - .selectable(selected = selected, onClick = onClick) - .padding(horizontal = if (compact) 6.dp else 12.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text( - destination.label, - style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium, - color = if (selected) colors.textPrimary else colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun NodeStatus(coreState: CoreUiState) { - StatusPill( - label = if (coreState.isInitialized) "Online" else "Offline", - tone = if (coreState.isInitialized) PillTone.Success else PillTone.Neutral, +private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState = + copy( + selectedSource = file.value, + selectedDisplayName = file.displayName, + transferName = if (transferName == "VniDrop transfer" || transferName.isBlank()) file.displayName else transferName, ) -} - -@Composable -private fun SendScreen( - coreState: CoreUiState, - sendState: SendUiState, - onSendStateChange: (SendUiState) -> Unit, - onSelectFile: () -> Unit, - onCreateShare: () -> Unit, - onCopyTicket: (String) -> Unit, - onUseLocally: (String) -> Unit, - onRefreshRequests: (ULong) -> Unit, - onRespondRequest: (String, Boolean) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - ScreenHeader("Send", "Create a VniDrop ticket and approve receivers when required.") - ErrorSection(coreState) - AppCard(title = "Source") { - if (sendState.selectedSource.isBlank()) { - EmptyText("Select a file to start a share. The app keeps bytes in Rust and platform file handles.") - } else { - MetadataRow("Name", sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') }) - MetadataRow("Source", sendState.selectedSource) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton("Select file", onClick = onSelectFile) - SecondaryButton( - text = "Clear", - onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) }, - enabled = sendState.selectedSource.isNotBlank(), - ) - } - } - AppCard(title = "Transfer details") { - Field( - value = sendState.transferName, - onValueChange = { onSendStateChange(sendState.copy(transferName = it)) }, - label = "Transfer name", - ) - Field( - value = sendState.senderName, - onValueChange = { onSendStateChange(sendState.copy(senderName = it)) }, - label = "Sender name", - ) - PrimaryButton( - text = if (sendState.isSharing) "Creating ticket..." else "Create share ticket", - onClick = onCreateShare, - enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing, - ) - } - coreState.lastShare?.let { share -> - ShareResultCard( - share = share, - requests = coreState.receiverRequests, - onCopyTicket = onCopyTicket, - onUseLocally = onUseLocally, - onRefreshRequests = onRefreshRequests, - onRespondRequest = onRespondRequest, - ) - } - ProgressSection(coreState) - } -} - -@Composable -private fun ShareResultCard( - share: ShareResult, - requests: List, - 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, - 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, - 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) { - 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) -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt new file mode 100644 index 0000000..b372dba --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/logging/AppLogger.kt @@ -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 +} + +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 = emptyMap()) = + write(AppLogLevel.Debug, scope, message, fields) + + fun info(scope: String, message: String, fields: Map = emptyMap()) = + write(AppLogLevel.Info, scope, message, fields) + + fun warn(scope: String, message: String, fields: Map = emptyMap()) = + write(AppLogLevel.Warn, scope, message, fields) + + fun error(scope: String, message: String, throwable: Throwable? = null, fields: Map = emptyMap()) { + val allFields = if (throwable == null) { + fields + } else { + fields + ("error" to (throwable.message ?: throwable.toString())) + } + write(AppLogLevel.Error, scope, message, allFields) + } + + fun listLogFiles(): List = + store?.listLogFiles().orEmpty() + + private fun write(level: AppLogLevel, scope: String, message: String, fields: Map) { + 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', ' ') diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.kt new file mode 100644 index 0000000..9f674ed --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.kt @@ -0,0 +1,6 @@ +package com.vnidrop.app.platform + +import androidx.compose.runtime.Composable + +@Composable +expect fun PlatformSystemAppearance(isDarkTheme: Boolean) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/platform/SystemBarIconMode.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/SystemBarIconMode.kt new file mode 100644 index 0000000..0ed6a98 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/platform/SystemBarIconMode.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt index bf2a8f6..468a832 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt @@ -46,8 +46,8 @@ fun AppCard( Card( modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = colors.surface), - border = BorderStroke(1.dp, colors.border), + colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75), + border = BorderStroke(1.dp, colors.borderDefault), ) { Column( modifier = Modifier.padding(16.dp), @@ -61,7 +61,7 @@ fun AppCard( Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) trailing?.invoke() } - HorizontalDivider(color = colors.border) + HorizontalDivider(color = colors.borderDefault) content() } } @@ -100,7 +100,7 @@ fun PrimaryButton( enabled = enabled, modifier = modifier.heightIn(min = 44.dp), shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.buttonColors(containerColor = colors.brand, contentColor = Color.White), + colors = ButtonDefaults.buttonColors(containerColor = colors.brandButton, contentColor = Color.White), ) { Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -143,11 +143,11 @@ fun StatusPill( ) { val colors = LocalVniDropColors.current val color = when (tone) { - PillTone.Neutral -> colors.textMuted - PillTone.Success -> colors.success - PillTone.Warning -> colors.warning - PillTone.Destructive -> colors.destructive - PillTone.Brand -> colors.brand + PillTone.Neutral -> colors.foregroundLighter + PillTone.Success -> colors.brandLink + PillTone.Warning -> colors.warningDefault + PillTone.Destructive -> colors.destructiveDefault + PillTone.Brand -> colors.brandLink } Row( modifier = modifier @@ -182,8 +182,8 @@ fun ErrorBanner(message: String, modifier: Modifier = Modifier) { Card( modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = colors.destructive.copy(alpha = 0.14f)), - border = BorderStroke(1.dp, colors.destructive.copy(alpha = 0.28f)), + colors = CardDefaults.cardColors(containerColor = colors.destructive200), + border = BorderStroke(1.dp, colors.destructive400), ) { Text( text = message, @@ -220,7 +220,7 @@ fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) { Text( text = label, modifier = Modifier.weight(0.35f), - color = LocalVniDropColors.current.textMuted, + color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall, ) Text( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt new file mode 100644 index 0000000..cfa21cf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppDestination.kt @@ -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), +) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..861301f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt @@ -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), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt new file mode 100644 index 0000000..2f8acf0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt @@ -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() + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt new file mode 100644 index 0000000..d0b4beb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt @@ -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) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt new file mode 100644 index 0000000..ca11183 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt @@ -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, + 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) { + 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) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt new file mode 100644 index 0000000..a5c0ee5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt @@ -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, + 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) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..b13ae83 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt @@ -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, + 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) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt new file mode 100644 index 0000000..d90230c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt @@ -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() + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index c26a275..ad3fd5c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -1,34 +1,27 @@ package com.vnidrop.app.ui.state import com.vnidrop.app.ui.theme.ThemeMode +import com.vnidrop.app.ui.navigation.AppDestination import uniffi.vnidrop.CoreEvent -import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.StoredTransfer import kotlin.math.roundToInt -enum class AppDestination( - val label: String, -) { - Send("Send"), - Receive("Receive"), - Activity("Activity"), - Requests("Requests"), - Settings("Settings"), -} - enum class WindowClass { - Compact, - Medium, - Expanded, + Phone, + Tablet, + Desktop, } fun windowClassFor(widthDp: Float): WindowClass = when { - widthDp >= 920f -> WindowClass.Expanded - widthDp >= 640f -> WindowClass.Medium - else -> WindowClass.Compact + widthDp >= 920f -> WindowClass.Desktop + widthDp >= 600f -> WindowClass.Tablet + else -> WindowClass.Phone } +fun useBottomNavigation(windowClass: WindowClass): Boolean = + windowClass == WindowClass.Phone + data class AppUiState( val destination: AppDestination = AppDestination.Send, val themeMode: ThemeMode = ThemeMode.System, @@ -68,9 +61,6 @@ fun displayNameForStatus(status: String): String = else -> status.replaceFirstChar { it.uppercase() } } -fun activeReceiverRequests(requests: List): List = - requests.filter { it.status == "requested" } - fun summarizeProgress(events: List): List = events .filter { event -> event.transferId != null && event.phase in progressPhases } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt index cc3bd7d..b4b4ac6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -26,85 +25,156 @@ fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean = ThemeMode.Dark -> true } +@Composable +fun rememberResolvedDarkTheme(mode: ThemeMode): Boolean = + resolveDarkTheme(mode, isSystemInDarkTheme()) + @Immutable data class VniDropColors( - val canvas: Color, - val sidebar: Color, - val surface: Color, - val surfaceRaised: Color, - val surfaceMuted: Color, - val border: Color, + val backgroundDefault: Color, + val backgroundDashCanvas: Color, + val backgroundDashSidebar: Color, + val backgroundSurface75: Color, + val backgroundSurface100: Color, + val backgroundSurface200: Color, + val backgroundSurface300: Color, + val backgroundSurface400: Color, + val backgroundMuted: Color, + val backgroundControl: Color, + val backgroundSelection: Color, + val backgroundButton: Color, + val backgroundOverlayHover: Color, + val backgroundDialog: Color, + val borderDefault: Color, val borderStrong: Color, - val textPrimary: Color, - val textSecondary: Color, - val textMuted: Color, - val brand: Color, - val brandPressed: Color, - val warning: Color, - val destructive: Color, - val success: Color, + val borderStronger: Color, + val borderMuted: Color, + val borderControl: Color, + val foregroundDefault: Color, + val foregroundLight: Color, + val foregroundLighter: Color, + val foregroundMuted: Color, + val foregroundContrast: Color, + val brandLink: Color, + val brandButton: Color, + val brandDefault: Color, + val brand600: Color, + val brand500: Color, + val brand400: Color, + val brand300: Color, + val brand200: Color, + val warningDefault: Color, + val warning200: Color, + val warning300: Color, + val warning400: Color, + val warning500: Color, + val warning600: Color, + val destructiveDefault: Color, + val destructive200: Color, + val destructive300: Color, + val destructive400: Color, + val destructive500: Color, + val destructive600: Color, ) -val LocalVniDropColors = staticCompositionLocalOf { lightVniDropColors } +val LocalVniDropColors = staticCompositionLocalOf { VniDropThemeTokens.light } -private val lightVniDropColors = VniDropColors( - canvas = hsl(0f, 0f, 97.3f), - sidebar = hsl(0f, 0f, 98.8f), - surface = hsl(0f, 0f, 100f), - surfaceRaised = hsl(0f, 0f, 98.8f), - surfaceMuted = hsl(0f, 0f, 95.3f), - border = hsl(0f, 0f, 85.9f), - borderStrong = hsl(0f, 0f, 78f), - textPrimary = hsl(0f, 0f, 9f), - textSecondary = hsl(0f, 0f, 32.2f), - textMuted = hsl(0f, 0f, 43.9f), - brand = hsl(153.1f, 60.2f, 52.7f), - brandPressed = hsl(152.9f, 56.1f, 46.5f), - warning = hsl(38.9f, 100f, 57.1f), - destructive = hsl(10.2f, 77.9f, 53.9f), - success = hsl(153.1f, 60.2f, 40f), -) +object VniDropThemeTokens { + // These values are a direct Compose port of the legacy Tauri theme tokens. + // The app uses these semantic tokens directly because Material3's ColorScheme + // cannot represent the full surface, border, and foreground stack. + val light = VniDropColors( + backgroundDefault = hsl(0f, 0f, 98.8f), + backgroundDashCanvas = hsl(0f, 0f, 97.3f), + backgroundDashSidebar = hsl(0f, 0f, 98.8f), + backgroundSurface75 = hsl(0f, 0f, 100f), + backgroundSurface100 = hsl(0f, 0f, 98.8f), + backgroundSurface200 = hsl(0f, 0f, 95.3f), + backgroundSurface300 = hsl(0f, 0f, 92.9f), + backgroundSurface400 = hsl(0f, 0f, 89.8f), + backgroundMuted = hsl(0f, 0f, 96.9f), + backgroundControl = hsl(0f, 0f, 95.3f), + backgroundSelection = hsl(0f, 0f, 92.9f), + backgroundButton = hsl(0f, 0f, 91f), + backgroundOverlayHover = hsl(0f, 0f, 95.3f), + backgroundDialog = hsl(0f, 0f, 100f), + borderDefault = hsl(0f, 0f, 87.5f), + borderStrong = hsl(0f, 0f, 83.1f), + borderStronger = hsl(0f, 0f, 56.1f), + borderMuted = hsl(0f, 0f, 92.9f), + borderControl = hsl(0f, 0f, 78f), + foregroundDefault = hsl(0f, 0f, 9f), + foregroundLight = hsl(0f, 0f, 32.2f), + foregroundLighter = hsl(0f, 0f, 43.9f), + foregroundMuted = hsl(0f, 0f, 69.8f), + foregroundContrast = hsl(0f, 0f, 98.4f), + brandLink = hsl(271f, 91f, 65f), + brandButton = hsl(270f, 95f, 75f), + brandDefault = hsl(271f, 91f, 65f), + brand600 = hsl(271f, 81f, 56f), + brand500 = hsl(271f, 91f, 65f), + brand400 = hsl(270f, 95f, 75f), + brand300 = hsl(269f, 97f, 85f), + brand200 = hsl(269f, 100f, 92f), + warningDefault = hsl(38.9f, 100f, 57.1f), + warning600 = hsl(30.3f, 80.3f, 47.8f), + warning500 = hsl(36.3f, 85.7f, 67.1f), + warning400 = hsl(41.9f, 100f, 81.8f), + warning300 = hsl(44.3f, 100f, 91.8f), + warning200 = hsl(40f, 81.8f, 97.8f), + destructiveDefault = hsl(10.2f, 77.9f, 53.9f), + destructive600 = hsl(9.9f, 82f, 43.5f), + destructive500 = hsl(10.4f, 77.1f, 79.4f), + destructive400 = hsl(7.1f, 91.3f, 91f), + destructive300 = hsl(7.1f, 100f, 96.7f), + destructive200 = hsl(0f, 100f, 99.4f), + ) -private val darkVniDropColors = VniDropColors( - canvas = hsl(0f, 0f, 7.1f), - sidebar = hsl(0f, 0f, 9f), - surface = hsl(0f, 0f, 12.2f), - surfaceRaised = hsl(0f, 0f, 14.1f), - surfaceMuted = hsl(0f, 0f, 16.1f), - border = hsl(0f, 0f, 24.3f), - borderStrong = hsl(0f, 0f, 31.4f), - textPrimary = hsl(0f, 0f, 98f), - textSecondary = hsl(0f, 0f, 70.6f), - textMuted = hsl(0f, 0f, 53.7f), - brand = hsl(153.1f, 60.2f, 52.7f), - brandPressed = hsl(152.9f, 56.1f, 46.5f), - warning = hsl(38.9f, 100f, 42.9f), - destructive = hsl(10.2f, 77.9f, 53.9f), - success = hsl(153.1f, 60.2f, 52.7f), -) - -private fun materialScheme(tokens: VniDropColors, dark: Boolean): ColorScheme { - val base = if (dark) { - darkColorScheme() - } else { - lightColorScheme() - } - return base.copy( - primary = tokens.brand, - onPrimary = if (dark) Color.Black else Color.White, - primaryContainer = tokens.surfaceMuted, - onPrimaryContainer = tokens.textPrimary, - background = tokens.canvas, - onBackground = tokens.textPrimary, - surface = tokens.surface, - onSurface = tokens.textPrimary, - surfaceVariant = tokens.surfaceMuted, - onSurfaceVariant = tokens.textSecondary, - outline = tokens.border, - outlineVariant = tokens.border, - error = tokens.destructive, - errorContainer = tokens.destructive.copy(alpha = if (dark) 0.22f else 0.16f), - onErrorContainer = tokens.textPrimary, + val dark = VniDropColors( + backgroundDefault = hsl(0f, 0f, 7.1f), + backgroundDashCanvas = hsl(0f, 0f, 7.1f), + backgroundDashSidebar = hsl(0f, 0f, 9f), + backgroundSurface75 = hsl(0f, 0f, 9f), + backgroundSurface100 = hsl(0f, 0f, 12.2f), + backgroundSurface200 = hsl(0f, 0f, 12.9f), + backgroundSurface300 = hsl(0f, 0f, 16.1f), + backgroundSurface400 = hsl(0f, 0f, 16.1f), + backgroundMuted = hsl(0f, 0f, 14.1f), + backgroundControl = hsl(0f, 0f, 14.1f), + backgroundSelection = hsl(0f, 0f, 19.2f), + backgroundButton = hsl(0f, 0f, 18f), + backgroundOverlayHover = hsl(0f, 0f, 18f), + backgroundDialog = hsl(0f, 0f, 7.1f), + borderDefault = hsl(0f, 0f, 18f), + borderStrong = hsl(0f, 0f, 21.2f), + borderStronger = hsl(0f, 0f, 27.1f), + borderMuted = hsl(0f, 0f, 14.1f), + borderControl = hsl(0f, 0f, 22.4f), + foregroundDefault = hsl(0f, 0f, 98f), + foregroundLight = hsl(0f, 0f, 70.6f), + foregroundLighter = hsl(0f, 0f, 53.7f), + foregroundMuted = hsl(0f, 0f, 30.2f), + foregroundContrast = hsl(0f, 0f, 8.6f), + brandLink = hsl(270f, 95f, 75f), + brandButton = hsl(271f, 81f, 56f), + brandDefault = hsl(270f, 95f, 75f), + brand600 = hsl(271f, 91f, 65f), + brand500 = hsl(271f, 81f, 56f), + brand400 = hsl(273f, 67f, 39f), + brand300 = hsl(274f, 66f, 32f), + brand200 = hsl(274f, 87f, 21f), + warningDefault = hsl(38.9f, 100f, 42.9f), + warning600 = hsl(38.9f, 100f, 42.9f), + warning500 = hsl(34.8f, 90.9f, 21.6f), + warning400 = hsl(33.2f, 100f, 14.5f), + warning300 = hsl(32.3f, 100f, 10.2f), + warning200 = hsl(36.6f, 100f, 8f), + destructiveDefault = hsl(10.2f, 77.9f, 53.9f), + destructive600 = hsl(9.7f, 85.2f, 62.9f), + destructive500 = hsl(7.9f, 71.6f, 29f), + destructive400 = hsl(6.7f, 60f, 20.6f), + destructive300 = hsl(7.5f, 51.3f, 15.3f), + destructive200 = hsl(10.9f, 23.4f, 9.2f), ) } @@ -113,23 +183,51 @@ fun VniDropTheme( mode: ThemeMode, content: @Composable () -> Unit, ) { - val dark = resolveDarkTheme(mode, isSystemInDarkTheme()) - val tokens = if (dark) darkVniDropColors else lightVniDropColors + VniDropTheme(isDarkTheme = rememberResolvedDarkTheme(mode), content = content) +} + +@Composable +fun VniDropTheme( + isDarkTheme: Boolean, + content: @Composable () -> Unit, +) { + val tokens = if (isDarkTheme) VniDropThemeTokens.dark else VniDropThemeTokens.light androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) { MaterialTheme( - colorScheme = materialScheme(tokens, dark), + colorScheme = tokens.toMaterialColorScheme(isDarkTheme), content = content, ) } } +private fun VniDropColors.toMaterialColorScheme(isDark: Boolean): ColorScheme { + val base = if (isDark) darkColorScheme() else lightColorScheme() + return base.copy( + primary = brandDefault, + onPrimary = if (isDark) Color.Black else Color.White, + secondary = brandLink, + background = backgroundDashCanvas, + onBackground = foregroundDefault, + surface = backgroundSurface75, + onSurface = foregroundDefault, + surfaceVariant = backgroundSurface200, + onSurfaceVariant = foregroundLight, + outline = borderDefault, + outlineVariant = borderMuted, + error = destructiveDefault, + errorContainer = destructive200, + onErrorContainer = if (isDark) destructive600 else destructiveDefault, + ) +} + +fun hslColorForTest(hue: Float, saturation: Float, lightness: Float): Color = + hsl(hue, saturation, lightness) + private fun hsl(hue: Float, saturation: Float, lightness: Float): Color { val h = ((hue % 360f) + 360f) % 360f / 360f val s = saturation.coerceIn(0f, 100f) / 100f val l = lightness.coerceIn(0f, 100f) / 100f - if (s == 0f) { - return Color(l, l, l) - } + if (s == 0f) return Color(l, l, l) val q = if (l < 0.5f) l * (1 + s) else l + s - l * s val p = 2 * l - q return Color( @@ -150,9 +248,3 @@ private fun hueToRgb(p: Float, q: Float, input: Float): Float { else -> p }.let { min(1f, max(0f, it)) } } - -fun Color.contrastAgainst(other: Color): Float = - abs(luminanceApproximation() - other.luminanceApproximation()) - -private fun Color.luminanceApproximation(): Float = - (red * 0.2126f) + (green * 0.7152f) + (blue * 0.0722f) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/logging/AppLoggerTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/logging/AppLoggerTest.kt new file mode 100644 index 0000000..05e9569 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/logging/AppLoggerTest.kt @@ -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)) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/platform/SystemBarIconModeTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/platform/SystemBarIconModeTest.kt new file mode 100644 index 0000000..9aaf4d6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/platform/SystemBarIconModeTest.kt @@ -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)) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt new file mode 100644 index 0000000..1d1107b --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt @@ -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) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index e575512..639c111 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -9,10 +9,17 @@ import kotlin.test.assertTrue class AppUiModelsTest { @Test - fun windowClassUsesCompactMediumExpandedBreakpoints() { - assertEquals(WindowClass.Compact, windowClassFor(390f)) - assertEquals(WindowClass.Medium, windowClassFor(700f)) - assertEquals(WindowClass.Expanded, windowClassFor(1200f)) + fun windowClassUsesPhoneTabletDesktopBreakpoints() { + assertEquals(WindowClass.Phone, windowClassFor(390f)) + assertEquals(WindowClass.Tablet, windowClassFor(700f)) + assertEquals(WindowClass.Desktop, windowClassFor(1200f)) + } + + @Test + fun bottomNavigationIsReservedForPhoneWidth() { + assertTrue(useBottomNavigation(WindowClass.Phone)) + assertFalse(useBottomNavigation(WindowClass.Tablet)) + assertFalse(useBottomNavigation(WindowClass.Desktop)) } @Test diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/I18nResourceTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/I18nResourceTest.kt new file mode 100644 index 0000000..6c2cae6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/I18nResourceTest.kt @@ -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 = 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) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/VniDropThemeTokenTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/VniDropThemeTokenTest.kt new file mode 100644 index 0000000..329d58d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/theme/VniDropThemeTokenTest.kt @@ -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) + } +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt new file mode 100644 index 0000000..c91d1a0 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt @@ -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 { + ensureDirectory() + val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() + .filterIsInstance() + .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() + } +} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt new file mode 100644 index 0000000..aaf3445 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt @@ -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"), + ) + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt new file mode 100644 index 0000000..6abf999 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.jvm.kt @@ -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 { + 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")) + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt new file mode 100644 index 0000000..511f37a --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt @@ -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) +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt new file mode 100644 index 0000000..d82df4d --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt @@ -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) + } +}