From 51a7734b389b16db4983602efdf9a56b2ab2e661 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 7 Jul 2026 16:55:33 +0200 Subject: [PATCH] feat(ui): overhaul settings screen with device info and theme selection Refactored SettingsScreen to display device information and allow user to choose between System, Light, and Dark themes. Updated AppUiState to remove unused diagnostics visibility flag. Co-authored-by: Junie --- .../commonMain/kotlin/com/vnidrop/app/App.kt | 36 +- .../vnidrop/app/ui/screens/SettingsScreen.kt | 644 ++++++++++++++++-- .../com/vnidrop/app/ui/state/AppUiModels.kt | 1 - 3 files changed, 578 insertions(+), 103 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index ac16394..71af790 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -17,7 +17,6 @@ import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.sharePickedFile 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 @@ -39,11 +38,10 @@ fun App() { val repository = remember { CoreRepository() } val coreState by repository.state.collectAsState() var appState by remember { mutableStateOf(AppUiState()) } - var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } + val appDataDir = platform.defaultCoreDataDir 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( @@ -51,19 +49,18 @@ fun App() { AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) selectedFile = file 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() }, ) LaunchedEffect(Unit) { AppLogger.initialize(appDataDir) AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name)) - logFiles = AppLogger.listLogFiles() + AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir)) + repository.initialize(appDataDir) } LaunchedEffect(coreState.lastShare?.transferId) { @@ -74,14 +71,14 @@ fun App() { 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) AppShell( selectedDestination = appState.destination, - windowClass = windowClassFor(maxWidth.value), + windowClass = windowClass, onDestinationSelected = { appState = appState.copy(destination = it) }, ) { when (appState.destination) { @@ -92,7 +89,6 @@ fun App() { onSelectFile = { AppLogger.info("file-picker", "open share file picker") picker.pickFile() - logFiles = AppLogger.listLogFiles() }, onCreateShare = { scope.launch { @@ -105,13 +101,11 @@ fun App() { sharePickedFile(repository, file, sendState.transferName, sendState.senderName) } sendState = sendState.copy(isSharing = false) - logFiles = AppLogger.listLogFiles() } }, onCopyTicket = { ticket -> AppLogger.info("send", "ticket copied") clipboard.setText(AnnotatedString(ticket)) - logFiles = AppLogger.listLogFiles() }, onUseLocally = { ticket -> receiveState = receiveState.copy(ticket = ticket) @@ -139,33 +133,17 @@ fun App() { receiveState = receiveState.copy(isReceiving = true) repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) receiveState = receiveState.copy(isReceiving = false) - logFiles = AppLogger.listLogFiles() } }, ) AppDestination.Settings -> SettingsScreen( - platformName = platform.name, - appDataDir = appDataDir, - onAppDataDirChange = { appDataDir = it }, + deviceInfo = platform.deviceInfo, coreState = coreState, themeMode = appState.themeMode, + windowClass = windowClass, 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) }, - 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() - } }, ) } 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 index b13ae83..88c1b67 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SettingsScreen.kt @@ -1,116 +1,614 @@ package com.vnidrop.app.ui.screens +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.RadioButton +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vnidrop.app.DeviceInfo 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.state.WindowClass +import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.ThemeMode import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.about_bug_report +import vnidrop.shared.generated.resources.about_privacy +import vnidrop.shared.generated.resources.about_title +import vnidrop.shared.generated.resources.appearance_auto_description +import vnidrop.shared.generated.resources.appearance_dark_mode +import vnidrop.shared.generated.resources.appearance_light_mode +import vnidrop.shared.generated.resources.appearance_mode_title +import vnidrop.shared.generated.resources.appearance_system_mode 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.battery_level_title +import vnidrop.shared.generated.resources.core_status_ready +import vnidrop.shared.generated.resources.device_model_title +import vnidrop.shared.generated.resources.device_name_title +import vnidrop.shared.generated.resources.network_title 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.os_version_title import vnidrop.shared.generated.resources.settings_title +import vnidrop.shared.generated.resources.value_unavailable +import vnidrop.shared.generated.resources.version_title + +private enum class SettingsPane { + Overview, + Appearance, + About, +} @Composable fun SettingsScreen( - platformName: String, - appDataDir: String, - onAppDataDirChange: (String) -> Unit, + deviceInfo: DeviceInfo, + coreState: CoreUiState, + themeMode: ThemeMode, + windowClass: WindowClass, + onThemeModeChange: (ThemeMode) -> Unit, +) { + var pane by remember { mutableStateOf(SettingsPane.Overview) } + + when (windowClass) { + WindowClass.Desktop -> DesktopSettings( + selectedPane = pane, + onPaneSelected = { pane = it }, + deviceInfo = deviceInfo, + coreState = coreState, + themeMode = themeMode, + onThemeModeChange = onThemeModeChange, + ) + else -> MobileSettings( + pane = pane, + onPaneSelected = { pane = it }, + deviceInfo = deviceInfo, + coreState = coreState, + themeMode = themeMode, + onThemeModeChange = onThemeModeChange, + ) + } +} + +@Composable +private fun MobileSettings( + pane: SettingsPane, + onPaneSelected: (SettingsPane) -> Unit, + deviceInfo: DeviceInfo, 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)) + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { 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) }, + when (pane) { + SettingsPane.Overview -> SettingsOverview( + coreState = coreState, + themeMode = themeMode, + onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, + onOpenAbout = { onPaneSelected(SettingsPane.About) }, + largeTitle = true, + ) + SettingsPane.Appearance -> AppearanceSettings( + themeMode = themeMode, + onThemeModeChange = onThemeModeChange, + onBack = { onPaneSelected(SettingsPane.Overview) }, + showBack = true, + ) + SettingsPane.About -> AboutSettings( + deviceInfo = deviceInfo, + coreState = coreState, + onBack = { onPaneSelected(SettingsPane.Overview) }, + showBack = true, ) - 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, +private fun DesktopSettings( + selectedPane: SettingsPane, + onPaneSelected: (SettingsPane) -> Unit, + deviceInfo: DeviceInfo, + coreState: CoreUiState, + themeMode: ThemeMode, + onThemeModeChange: (ThemeMode) -> Unit, ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.widthIn(min = 280.dp, max = 340.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + SettingsOverview( + coreState = coreState, + themeMode = themeMode, + onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) }, + onOpenAbout = { onPaneSelected(SettingsPane.About) }, + largeTitle = false, + selectedPane = selectedPane, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + when (selectedPane) { + SettingsPane.Overview, + SettingsPane.Appearance -> AppearanceSettings( + themeMode = themeMode, + onThemeModeChange = onThemeModeChange, + onBack = {}, + showBack = false, + ) + SettingsPane.About -> AboutSettings( + deviceInfo = deviceInfo, + coreState = coreState, + onBack = {}, + showBack = false, + ) + } + } + } +} + +@Composable +private fun SettingsOverview( + coreState: CoreUiState, + themeMode: ThemeMode, + onOpenAppearance: () -> Unit, + onOpenAbout: () -> Unit, + largeTitle: Boolean, + selectedPane: SettingsPane = SettingsPane.Overview, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (largeTitle) { + SettingsLargeTitle(stringResource(Res.string.settings_title)) + } else { + Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + } + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Sun, + title = stringResource(Res.string.appearance_title), + value = themeMode.displayName(), + selected = selectedPane == SettingsPane.Appearance, + onClick = onOpenAppearance, + ) + } + SettingsGroup { + SettingsRow( + icon = SettingsIcons.Node, + title = stringResource(Res.string.node_title), + value = if (coreState.isInitialized) stringResource(Res.string.core_status_ready) else stringResource(Res.string.not_initialized), + iconTone = IconTone.Neutral, + ) + SettingsRow( + icon = SettingsIcons.Info, + title = stringResource(Res.string.about_title), + selected = selectedPane == SettingsPane.About, + onClick = onOpenAbout, + ) + } + } +} + +@Composable +private fun AppearanceSettings( + themeMode: ThemeMode, + onThemeModeChange: (ThemeMode) -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(title = stringResource(Res.string.appearance_title), onBack = onBack, showBack = showBack) + Text( + text = stringResource(Res.string.appearance_mode_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + ThemeChoice( + icon = SettingsIcons.Device, + title = stringResource(Res.string.appearance_system_mode), + description = stringResource(Res.string.appearance_auto_description), + selected = themeMode == ThemeMode.System, + onClick = { onThemeModeChange(ThemeMode.System) }, + ) + ThemeChoice( + icon = SettingsIcons.Moon, + title = stringResource(Res.string.appearance_dark_mode), + selected = themeMode == ThemeMode.Dark, + onClick = { onThemeModeChange(ThemeMode.Dark) }, + ) + ThemeChoice( + icon = SettingsIcons.Sun, + title = stringResource(Res.string.appearance_light_mode), + selected = themeMode == ThemeMode.Light, + onClick = { onThemeModeChange(ThemeMode.Light) }, + ) + } +} + +@Composable +private fun AboutSettings( + deviceInfo: DeviceInfo, + coreState: CoreUiState, + onBack: () -> Unit, + showBack: Boolean, +) { + val unavailable = stringResource(Res.string.value_unavailable) + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(title = stringResource(Res.string.about_title), onBack = onBack, showBack = showBack) + SettingsGroup { + SettingsRow(icon = SettingsIcons.Document, title = stringResource(Res.string.about_privacy), iconTone = IconTone.Neutral) + SettingsRow(icon = SettingsIcons.Bug, title = stringResource(Res.string.about_bug_report), iconTone = IconTone.Neutral) + } + PlainInfoSection { + PlainInfoItem(stringResource(Res.string.version_title), "0.1.0") + PlainInfoItem(stringResource(Res.string.device_name_title), deviceInfo.deviceName.orUnavailable(unavailable)) + PlainInfoItem(stringResource(Res.string.device_model_title), deviceInfo.deviceModel.orUnavailable(unavailable)) + PlainInfoItem(stringResource(Res.string.os_version_title), deviceInfo.operatingSystem) + PlainInfoItem(stringResource(Res.string.network_title), deviceInfo.network.orUnavailable(unavailable)) + PlainInfoItem(stringResource(Res.string.battery_level_title), deviceInfo.batteryLevel.orUnavailable(unavailable)) + PlainInfoItem( + title = stringResource(Res.string.node_title), + value = if (coreState.isInitialized) { + stringResource(Res.string.core_status_ready) + } else { + stringResource(Res.string.not_initialized) + }, + ) + } + } +} + +@Composable +private fun SettingsLargeTitle(title: String) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) +} + +@Composable +private fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (showBack) { + Icon( + imageVector = SettingsIcons.Back, + contentDescription = null, + tint = LocalVniDropColors.current.foregroundDefault, + modifier = Modifier + .size(30.dp) + .clickable(onClick = onBack) + .padding(3.dp), + ) + } + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) { + val colors = LocalVniDropColors.current + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp), content = content) + } +} + +@Composable +private fun PlainInfoSection(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + content = content, + ) +} + +@Composable +private fun PlainInfoItem(title: String, value: String) { + val colors = LocalVniDropColors.current + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + color = colors.foregroundLighter, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = value, + color = colors.foregroundDefault, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +private fun String?.orUnavailable(fallback: String): String = + this?.takeIf { it.isNotBlank() } ?: fallback + +@Composable +private fun SettingsRow( + icon: ImageVector, + title: String, + value: String? = null, + selected: Boolean = false, + iconTone: IconTone = IconTone.Brand, + onClick: (() -> Unit)? = null, +) { + val colors = LocalVniDropColors.current + val iconColor = when (iconTone) { + IconTone.Brand -> colors.brandLink + IconTone.Neutral -> colors.foregroundLighter + } Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .selectable(selected = selected, onClick = onSelected) - .padding(vertical = 8.dp), + .height(60.dp) + .background(if (selected) colors.backgroundSurface300 else Color.Transparent) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - RadioButton(selected = selected, onClick = onSelected) - Text(mode.name) + Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(14.dp)) + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + value?.let { + Text( + text = it, + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(10.dp)) + } + if (onClick != null) { + Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(20.dp)) + } } } + +@Composable +private fun ThemeChoice( + icon: ImageVector, + title: String, + selected: Boolean, + onClick: () -> Unit, + description: String? = null, +) { + val colors = LocalVniDropColors.current + val shape = RoundedCornerShape(18.dp) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(62.dp) + .background(colors.backgroundSurface200, shape) + .border( + border = if (selected) BorderStroke(1.5.dp, colors.brandLink) else BorderStroke(1.dp, Color.Transparent), + shape = shape, + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(14.dp)) + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + if (selected) { + Icon(SettingsIcons.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp)) + } + } + description?.let { + Text( + text = it, + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun ThemeMode.displayName(): String = + when (this) { + ThemeMode.System -> stringResource(Res.string.appearance_system_mode) + ThemeMode.Light -> stringResource(Res.string.appearance_light_mode) + ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode) + } + +private enum class IconTone { + Brand, + Neutral, +} + +private object SettingsIcons { + val ChevronRight = lineIcon("ChevronRight") { + moveTo(9f, 18f) + lineTo(15f, 12f) + lineTo(9f, 6f) + } + val Back = lineIcon("Back") { + moveTo(19f, 12f) + lineTo(5f, 12f) + moveTo(12f, 19f) + lineTo(5f, 12f) + lineTo(12f, 5f) + } + val Check = lineIcon("Check") { + moveTo(20f, 6f) + lineTo(9f, 17f) + lineTo(4f, 12f) + } + val Sun = lineIcon("Sun") { + moveTo(12f, 4f) + lineTo(12f, 2f) + moveTo(12f, 22f) + lineTo(12f, 20f) + moveTo(4.93f, 4.93f) + lineTo(6.34f, 6.34f) + moveTo(17.66f, 17.66f) + lineTo(19.07f, 19.07f) + moveTo(2f, 12f) + lineTo(4f, 12f) + moveTo(20f, 12f) + lineTo(22f, 12f) + moveTo(4.93f, 19.07f) + lineTo(6.34f, 17.66f) + moveTo(17.66f, 6.34f) + lineTo(19.07f, 4.93f) + moveTo(16f, 12f) + arcTo(4f, 4f, 0f, true, true, 8f, 12f) + arcTo(4f, 4f, 0f, true, true, 16f, 12f) + } + val Moon = lineIcon("Moon") { + moveTo(21f, 12.79f) + arcTo(9f, 9f, 0f, true, true, 11.21f, 3f) + arcTo(7f, 7f, 0f, false, false, 21f, 12.79f) + } + val Device = lineIcon("Device") { + roundRect(7f, 2f, 10f, 20f, 2.5f) + moveTo(11f, 18f) + lineTo(13f, 18f) + } + val Info = lineIcon("Info") { + moveTo(12f, 16f) + lineTo(12f, 12f) + moveTo(12f, 8f) + lineTo(12.01f, 8f) + moveTo(21f, 12f) + arcTo(9f, 9f, 0f, true, true, 3f, 12f) + arcTo(9f, 9f, 0f, true, true, 21f, 12f) + } + val Bug = lineIcon("Bug") { + moveTo(8f, 2f) + lineTo(9.88f, 3.88f) + moveTo(16f, 2f) + lineTo(14.12f, 3.88f) + roundRect(7f, 6f, 10f, 14f, 5f) + moveTo(3f, 10f) + lineTo(7f, 10f) + moveTo(17f, 10f) + lineTo(21f, 10f) + moveTo(3f, 16f) + lineTo(7f, 16f) + moveTo(17f, 16f) + lineTo(21f, 16f) + moveTo(12f, 6f) + lineTo(12f, 20f) + } + val Document = lineIcon("Document") { + moveTo(14f, 2f) + lineTo(6f, 2f) + arcTo(2f, 2f, 0f, false, false, 4f, 4f) + lineTo(4f, 20f) + arcTo(2f, 2f, 0f, false, false, 6f, 22f) + lineTo(18f, 22f) + arcTo(2f, 2f, 0f, false, false, 20f, 20f) + lineTo(20f, 8f) + lineTo(14f, 2f) + moveTo(14f, 2f) + lineTo(14f, 8f) + lineTo(20f, 8f) + moveTo(8f, 13f) + lineTo(16f, 13f) + moveTo(8f, 17f) + lineTo(16f, 17f) + } + val Node = lineIcon("Node") { + roundRect(4f, 4f, 16f, 16f, 3f) + moveTo(9f, 9f) + lineTo(15f, 9f) + moveTo(9f, 13f) + lineTo(15f, 13f) + moveTo(9f, 17f) + lineTo(12f, 17f) + } +} + +private fun lineIcon(name: String, block: androidx.compose.ui.graphics.vector.PathBuilder.() -> Unit): ImageVector = + ImageVector.Builder(name, 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, + pathBuilder = block, + ) + }.build() + +private fun androidx.compose.ui.graphics.vector.PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) { + moveTo(x + radius, y) + lineTo(x + width - radius, y) + arcTo(radius, radius, 0f, false, true, x + width, y + radius) + lineTo(x + width, y + height - radius) + arcTo(radius, radius, 0f, false, true, x + width - radius, y + height) + lineTo(x + radius, y + height) + arcTo(radius, radius, 0f, false, true, x, y + height - radius) + lineTo(x, y + radius) + arcTo(radius, radius, 0f, false, true, x + radius, y) +} 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 ad3fd5c..8f4bcaa 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 @@ -25,7 +25,6 @@ fun useBottomNavigation(windowClass: WindowClass): Boolean = data class AppUiState( val destination: AppDestination = AppDestination.Send, val themeMode: ThemeMode = ThemeMode.System, - val diagnosticsVisible: Boolean = false, ) data class SendUiState(