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 <junie@jetbrains.com>
This commit is contained in:
2026-07-07 16:55:33 +02:00
parent 8c84314528
commit 51a7734b38
3 changed files with 578 additions and 103 deletions

View File

@@ -17,7 +17,6 @@ import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.core.sharePickedFile import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.logging.LogFileInfo
import com.vnidrop.app.platform.PlatformSystemAppearance import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.screens.ReceiveScreen import com.vnidrop.app.ui.screens.ReceiveScreen
@@ -39,11 +38,10 @@ fun App() {
val repository = remember { CoreRepository() } val repository = remember { CoreRepository() }
val coreState by repository.state.collectAsState() val coreState by repository.state.collectAsState()
var appState by remember { mutableStateOf(AppUiState()) } var appState by remember { mutableStateOf(AppUiState()) }
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } val appDataDir = platform.defaultCoreDataDir
var sendState by remember { mutableStateOf(SendUiState()) } var sendState by remember { mutableStateOf(SendUiState()) }
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) }
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) } var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
var logFiles by remember { mutableStateOf<List<LogFileInfo>>(emptyList()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker( val picker = rememberShareFilePicker(
@@ -51,19 +49,18 @@ fun App() {
AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName))
selectedFile = file selectedFile = file
sendState = sendState.withSelectedFile(file) sendState = sendState.withSelectedFile(file)
logFiles = AppLogger.listLogFiles()
}, },
onError = { error -> onError = { error ->
AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error)) AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error))
scope.launch { repository.setError(error) } scope.launch { repository.setError(error) }
logFiles = AppLogger.listLogFiles()
}, },
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AppLogger.initialize(appDataDir) AppLogger.initialize(appDataDir)
AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name)) 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) { LaunchedEffect(coreState.lastShare?.transferId) {
@@ -74,14 +71,14 @@ fun App() {
PlatformSystemAppearance(isDarkTheme) PlatformSystemAppearance(isDarkTheme)
LaunchedEffect(isDarkTheme) { LaunchedEffect(isDarkTheme) {
AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString())) AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
logFiles = AppLogger.listLogFiles()
} }
VniDropTheme(isDarkTheme = isDarkTheme) { VniDropTheme(isDarkTheme = isDarkTheme) {
BoxWithConstraints { BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value)
AppShell( AppShell(
selectedDestination = appState.destination, selectedDestination = appState.destination,
windowClass = windowClassFor(maxWidth.value), windowClass = windowClass,
onDestinationSelected = { appState = appState.copy(destination = it) }, onDestinationSelected = { appState = appState.copy(destination = it) },
) { ) {
when (appState.destination) { when (appState.destination) {
@@ -92,7 +89,6 @@ fun App() {
onSelectFile = { onSelectFile = {
AppLogger.info("file-picker", "open share file picker") AppLogger.info("file-picker", "open share file picker")
picker.pickFile() picker.pickFile()
logFiles = AppLogger.listLogFiles()
}, },
onCreateShare = { onCreateShare = {
scope.launch { scope.launch {
@@ -105,13 +101,11 @@ fun App() {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName) sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
} }
sendState = sendState.copy(isSharing = false) sendState = sendState.copy(isSharing = false)
logFiles = AppLogger.listLogFiles()
} }
}, },
onCopyTicket = { ticket -> onCopyTicket = { ticket ->
AppLogger.info("send", "ticket copied") AppLogger.info("send", "ticket copied")
clipboard.setText(AnnotatedString(ticket)) clipboard.setText(AnnotatedString(ticket))
logFiles = AppLogger.listLogFiles()
}, },
onUseLocally = { ticket -> onUseLocally = { ticket ->
receiveState = receiveState.copy(ticket = ticket) receiveState = receiveState.copy(ticket = ticket)
@@ -139,33 +133,17 @@ fun App() {
receiveState = receiveState.copy(isReceiving = true) receiveState = receiveState.copy(isReceiving = true)
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
receiveState = receiveState.copy(isReceiving = false) receiveState = receiveState.copy(isReceiving = false)
logFiles = AppLogger.listLogFiles()
} }
}, },
) )
AppDestination.Settings -> SettingsScreen( AppDestination.Settings -> SettingsScreen(
platformName = platform.name, deviceInfo = platform.deviceInfo,
appDataDir = appDataDir,
onAppDataDirChange = { appDataDir = it },
coreState = coreState, coreState = coreState,
themeMode = appState.themeMode, themeMode = appState.themeMode,
windowClass = windowClass,
onThemeModeChange = { onThemeModeChange = {
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name)) AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name))
appState = appState.copy(themeMode = it) 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()
}
}, },
) )
} }

View File

@@ -1,116 +1,614 @@
package com.vnidrop.app.ui.screens 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.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.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.logging.LogFileInfo import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.theme.LocalVniDropColors
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 com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res 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.appearance_title
import vnidrop.shared.generated.resources.button_hide_event_log import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.button_initialize_core import vnidrop.shared.generated.resources.core_status_ready
import vnidrop.shared.generated.resources.button_refresh_logs import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.button_show_event_log import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.diagnostics_hint import vnidrop.shared.generated.resources.network_title
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.node_title
import vnidrop.shared.generated.resources.not_initialized 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.settings_title
import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title
private enum class SettingsPane {
Overview,
Appearance,
About,
}
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
platformName: String, deviceInfo: DeviceInfo,
appDataDir: String, coreState: CoreUiState,
onAppDataDirChange: (String) -> Unit, 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, coreState: CoreUiState,
themeMode: ThemeMode, themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit, onThemeModeChange: (ThemeMode) -> Unit,
diagnosticsVisible: Boolean,
onDiagnosticsVisibleChange: (Boolean) -> Unit,
logDirectory: String?,
logFiles: List<LogFileInfo>,
onRefreshLogs: () -> Unit,
onInitialize: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ScreenHeader(stringResource(Res.string.settings_title), stringResource(Res.string.settings_subtitle))
ErrorSection(coreState) ErrorSection(coreState)
AppCard(title = stringResource(Res.string.node_title)) { when (pane) {
MetadataRow(stringResource(Res.string.metadata_platform), platformName) SettingsPane.Overview -> SettingsOverview(
MetadataRow(stringResource(Res.string.metadata_status), coreState.status) coreState = coreState,
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = stringResource(Res.string.field_core_data_directory)) themeMode = themeMode,
PrimaryButton(stringResource(Res.string.button_initialize_core), onClick = onInitialize) onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
} onOpenAbout = { onPaneSelected(SettingsPane.About) },
AppCard(title = stringResource(Res.string.appearance_title)) { largeTitle = true,
ThemeMode.entries.forEach { mode -> )
ThemeModeRow( SettingsPane.Appearance -> AppearanceSettings(
mode = mode, themeMode = themeMode,
selected = themeMode == mode, onThemeModeChange = onThemeModeChange,
onSelected = { onThemeModeChange(mode) }, onBack = { onPaneSelected(SettingsPane.Overview) },
) showBack = true,
} )
} SettingsPane.About -> AboutSettings(
AppCard(title = stringResource(Res.string.diagnostics_title)) { deviceInfo = deviceInfo,
SecondaryButton( coreState = coreState,
text = if (diagnosticsVisible) stringResource(Res.string.button_hide_event_log) else stringResource(Res.string.button_show_event_log), onBack = { onPaneSelected(SettingsPane.Overview) },
onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) }, 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 @Composable
private fun ThemeModeRow( private fun DesktopSettings(
mode: ThemeMode, selectedPane: SettingsPane,
selected: Boolean, onPaneSelected: (SettingsPane) -> Unit,
onSelected: () -> 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .height(60.dp)
.selectable(selected = selected, onClick = onSelected) .background(if (selected) colors.backgroundSurface300 else Color.Transparent)
.padding(vertical = 8.dp), .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
RadioButton(selected = selected, onClick = onSelected) Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
Text(mode.name) 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)
}

View File

@@ -25,7 +25,6 @@ fun useBottomNavigation(windowClass: WindowClass): Boolean =
data class AppUiState( data class AppUiState(
val destination: AppDestination = AppDestination.Send, val destination: AppDestination = AppDestination.Send,
val themeMode: ThemeMode = ThemeMode.System, val themeMode: ThemeMode = ThemeMode.System,
val diagnosticsVisible: Boolean = false,
) )
data class SendUiState( data class SendUiState(