From 293ae093384ea5129081e93104bca3648f924cec Mon Sep 17 00:00:00 2001 From: Raphael TEYSSANDIER Date: Wed, 8 Jul 2026 09:57:49 +0200 Subject: [PATCH 1/5] feat: add logs tab to settings and improve ADB initialization reporting Introduces a centralized LogManager to track application events, exposed through a new "Logs" tab in the Settings screen. Additionally, updates the Settings UI to use a side-drawer layout and improves ADB process feedback by logging success and error states. --- .../flocondesktop/app/AppViewModel.kt | 62 ++- .../flocondesktop/app/di/AppModule.kt | 2 + .../app/ui/delegates/DevicesDelegate.kt | 9 + .../app/ui/settings/Navigation.kt | 5 +- .../app/ui/settings/SettingsScreen.kt | 417 +++++++++++++----- .../app/ui/settings/SettingsUiState.kt | 12 +- .../app/ui/settings/SettingsViewModel.kt | 30 +- .../flocondesktop/common/log/LogManager.kt | 41 ++ FloconDesktop/gradle/libs.versions.toml | 6 +- .../navigation/scene/BigDialogScene.kt | 35 +- 10 files changed, 483 insertions(+), 136 deletions(-) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt index 694988003..b3c82e219 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt @@ -15,12 +15,13 @@ import io.github.openflocon.flocondesktop.app.ui.delegates.RecordVideoDelegate import io.github.openflocon.flocondesktop.app.ui.model.SubScreen import io.github.openflocon.flocondesktop.app.ui.model.leftpanel.buildMenu import io.github.openflocon.flocondesktop.app.ui.settings.SettingsRoutes +import io.github.openflocon.flocondesktop.common.log.LogManager import io.github.openflocon.flocondesktop.common.utils.stateInWhileSubscribed +import io.github.openflocon.flocondesktop.features.adbcommander.AdbCommanderRoutes import io.github.openflocon.flocondesktop.features.analytics.AnalyticsRoutes import io.github.openflocon.flocondesktop.features.crashreporter.CrashReporterRoutes import io.github.openflocon.flocondesktop.features.dashboard.DashboardRoutes import io.github.openflocon.flocondesktop.features.database.DatabaseRoutes -import io.github.openflocon.flocondesktop.features.adbcommander.AdbCommanderRoutes import io.github.openflocon.flocondesktop.features.deeplinks.DeeplinkRoutes import io.github.openflocon.flocondesktop.features.files.FilesRoutes import io.github.openflocon.flocondesktop.features.images.ImageRoutes @@ -49,6 +50,7 @@ internal class AppViewModel( private val restartAppUseCase: RestartAppUseCase, private val recordVideoDelegate: RecordVideoDelegate, private val feedbackDisplayer: FeedbackDisplayer, + private val logManager: LogManager, ) : ViewModel(messagesServerDelegate) { private val contentState = MutableStateFlow( @@ -87,9 +89,14 @@ internal class AppViewModel( init { viewModelScope.launch(dispatcherProvider.viewModel) { - initAdbPathUseCase().alsoFailure { - initialSetupStateHolder.setRequiresInitialSetup() - } + initAdbPathUseCase() + .alsoFailure { + logManager.e(TAG, "ADB init failed", it) + initialSetupStateHolder.setRequiresInitialSetup() + } + .alsoSuccess { + logManager.d(TAG, "ADB init OK") + } messagesServerDelegate.initialize() @@ -97,6 +104,8 @@ internal class AppViewModel( while (isActive) { // ensure we have the forward enabled startAdbForwardUseCase() + .alsoFailure { logManager.e(TAG, "ADB forward failed", it) } + .alsoSuccess { logManager.d(TAG, "ADB forward OK") } delay(1_500) } } @@ -117,23 +126,30 @@ internal class AppViewModel( } private fun onSelectMenu(action: AppAction.SelectMenu) { - contentState.update { it.copy(current = action.menu) } - navigationState.menu( - when (action.menu) { - SubScreen.Analytics -> AnalyticsRoutes.Main - SubScreen.Dashboard -> DashboardRoutes.Main - SubScreen.Database -> DatabaseRoutes.Main - SubScreen.Deeplinks -> DeeplinkRoutes.Main - SubScreen.AdbCommander -> AdbCommanderRoutes.Main - SubScreen.Files -> FilesRoutes.Main - SubScreen.Images -> ImageRoutes.Main - SubScreen.Network -> NetworkRoutes.Main - SubScreen.Settings -> SettingsRoutes.Main - SubScreen.SharedPreferences -> SharedPreferencesRoutes.Main - SubScreen.Tables -> TableRoutes.Main - SubScreen.CrashReporter -> CrashReporterRoutes.Main - } - ) + if (action.menu != SubScreen.Settings) { + contentState.update { it.copy(current = action.menu) } + } + + val route = when (action.menu) { + SubScreen.Analytics -> AnalyticsRoutes.Main + SubScreen.Dashboard -> DashboardRoutes.Main + SubScreen.Database -> DatabaseRoutes.Main + SubScreen.Deeplinks -> DeeplinkRoutes.Main + SubScreen.AdbCommander -> AdbCommanderRoutes.Main + SubScreen.Files -> FilesRoutes.Main + SubScreen.Images -> ImageRoutes.Main + SubScreen.Network -> NetworkRoutes.Main + SubScreen.Settings -> SettingsRoutes.Main + SubScreen.SharedPreferences -> SharedPreferencesRoutes.Main + SubScreen.Tables -> TableRoutes.Main + SubScreen.CrashReporter -> CrashReporterRoutes.Main + } + + if (route !is SettingsRoutes) { + navigationState.menu(route) + } else { + navigationState.navigate(route) + } } private fun onDeviceSelected(action: AppAction.SelectDevice) { @@ -184,4 +200,8 @@ internal class AppViewModel( ) } } + + companion object { + private const val TAG = "AppViewModel" + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt index bfff605d9..782e533e6 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt @@ -3,6 +3,7 @@ package io.github.openflocon.flocondesktop.app.di import io.github.openflocon.domain.feedback.FeedbackDisplayer import io.github.openflocon.domain.feedback.FeedbackDisplayerHandler import io.github.openflocon.flocondesktop.app.InitialSetupStateHolder +import io.github.openflocon.flocondesktop.common.log.LogManager import io.github.openflocon.flocondesktop.common.ui.feedback.FeedbackDisplayerImpl import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.singleOf @@ -18,4 +19,5 @@ val appModule = } singleOf(::InitialSetupStateHolder) + singleOf(::LogManager) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/delegates/DevicesDelegate.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/delegates/DevicesDelegate.kt index 6f45f703e..0d7b87445 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/delegates/DevicesDelegate.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/delegates/DevicesDelegate.kt @@ -15,6 +15,7 @@ import io.github.openflocon.flocondesktop.app.ui.model.AppsStateUiModel import io.github.openflocon.flocondesktop.app.ui.model.DevicesStateUiModel import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableDelegate import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableScoped +import io.github.openflocon.flocondesktop.common.log.LogManager import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -36,6 +37,7 @@ class DevicesDelegate( private val deleteDeviceApplicationUseCase: DeleteDeviceApplicationUseCase, private val closeableDelegate: CloseableDelegate, private val observeCurrentDeviceCapabilitiesUseCase: ObserveCurrentDeviceCapabilitiesUseCase, + private val logManager: LogManager, ) : CloseableScoped by closeableDelegate { val devicesState: StateFlow = @@ -51,6 +53,7 @@ class DevicesDelegate( val current = devices.firstOrNull { it.deviceId == currentDeviceId } if (current == null) { val firstDevice = devices.first() + logManager.d(TAG, "No selected device found, auto-selecting: ${firstDevice.deviceId}") select(firstDevice.deviceId) DevicesStateUiModel.WithDevices( devices = mapListToUi( @@ -86,8 +89,10 @@ class DevicesDelegate( // do this only if we have 1 unique active device observeActiveDevicesUseCase().distinctUntilChanged().onEach { activeDevices -> val currentDeviceId = getCurrentDeviceIdAndPackageNameUseCase()?.deviceId + logManager.d(TAG, "Active devices changed: ${activeDevices.map { it.deviceId }}") if (activeDevices.size == 1 && currentDeviceId !in activeDevices.map { it.deviceId }) { val firstActiveDevice = activeDevices.first() + logManager.d(TAG, "Auto-selecting active device: ${firstActiveDevice.deviceId}") select(firstActiveDevice.deviceId) } }.launchIn(coroutineScope) @@ -142,4 +147,8 @@ class DevicesDelegate( packageName = packageName, ) } + + companion object { + private const val TAG = "DevicesDelegate" + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/Navigation.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/Navigation.kt index 1d7e00e04..314cd9d62 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/Navigation.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/Navigation.kt @@ -1,8 +1,9 @@ package io.github.openflocon.flocondesktop.app.ui.settings import androidx.navigation3.runtime.EntryProviderScope -import io.github.openflocon.flocondesktop.app.MenuSceneStrategy import io.github.openflocon.navigation.FloconRoute +import io.github.openflocon.navigation.scene.BigDialogProperties +import io.github.openflocon.navigation.scene.BigDialogSceneStrategy import kotlinx.serialization.Serializable sealed interface SettingsRoutes : FloconRoute { @@ -13,7 +14,7 @@ sealed interface SettingsRoutes : FloconRoute { fun EntryProviderScope.settingsRoutes() { entry( - metadata = MenuSceneStrategy.menu() + metadata = BigDialogSceneStrategy.bigDialog(BigDialogProperties("Settings")) ) { SettingsScreen() } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt index 51636490a..2ede41a9f 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt @@ -2,16 +2,29 @@ package io.github.openflocon.flocondesktop.app.ui.settings import androidx.compose.foundation.background 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.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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cable import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.TextFields import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,36 +33,46 @@ 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.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.general_save -import flocondesktop.composeapp.generated.resources.settings_about_title import flocondesktop.composeapp.generated.resources.settings_adb_setup_title import flocondesktop.composeapp.generated.resources.settings_adb_valid import flocondesktop.composeapp.generated.resources.settings_font_size_multiplier -import flocondesktop.composeapp.generated.resources.settings_licenses import flocondesktop.composeapp.generated.resources.settings_test -import flocondesktop.composeapp.generated.resources.settings_theme -import flocondesktop.composeapp.generated.resources.settings_theme_dark -import flocondesktop.composeapp.generated.resources.settings_theme_light -import flocondesktop.composeapp.generated.resources.settings_theme_system -import io.github.openflocon.domain.models.settings.ThemeSetting -import io.github.openflocon.flocondesktop.common.ui.window.FloconWindow -import io.github.openflocon.flocondesktop.common.ui.window.createFloconWindowState +import io.github.openflocon.flocondesktop.common.log.LogEntryUiModel +import io.github.openflocon.flocondesktop.common.log.LogLevel import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconButton -import io.github.openflocon.library.designsystem.components.FloconFeature import io.github.openflocon.library.designsystem.components.FloconIcon -import io.github.openflocon.library.designsystem.components.FloconSection import io.github.openflocon.library.designsystem.components.FloconSlider +import io.github.openflocon.library.designsystem.components.FloconSurface import io.github.openflocon.library.designsystem.components.FloconTextFieldWithoutM3 +import io.github.openflocon.library.designsystem.components.FloconVerticalDivider import io.github.openflocon.library.designsystem.components.defaultPlaceHolder import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel +// --------------------------------------------------------------------------- +// Tabs +// --------------------------------------------------------------------------- + +private enum class SettingsTab(val label: String, val icon: ImageVector) { + Adb("Adb", Icons.Outlined.Cable), + Appearance("Appearance", Icons.Outlined.TextFields), + Logs("Logs", Icons.Outlined.List), + About("About", Icons.Outlined.Info), +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + @Composable fun SettingsScreen( modifier: Modifier = Modifier, @@ -67,10 +90,15 @@ fun SettingsScreen( saveAdbPath = viewModel::saveAdbPath, testAdbPath = viewModel::testAdbPath, onAction = viewModel::onAction, + onClearLogs = viewModel::clearLogs, needsAdbSetup = needsAdbSetup, ) } +// --------------------------------------------------------------------------- +// Main layout — Permanent drawer + content pane +// --------------------------------------------------------------------------- + @Composable private fun SettingsScreen( uiState: SettingsUiState, @@ -80,85 +108,143 @@ private fun SettingsScreen( testAdbPath: () -> Unit, needsAdbSetup: Boolean, onAction: (SettingsAction) -> Unit, + onClearLogs: () -> Unit, modifier: Modifier = Modifier, ) { - var showLicenses by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(SettingsTab.Adb) } - FloconFeature( - modifier = modifier.fillMaxSize() - ) { - FloconSection( - title = "Adb Path", - initialValue = true + Row(modifier = modifier) { + // ── Drawer ────────────────────────────────────────────────────────── + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .width(180.dp) + .fillMaxHeight() + .padding(vertical = 8.dp, horizontal = 4.dp) ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .padding(8.dp) - .clip(FloconTheme.shapes.medium) - .background(FloconTheme.colorPalette.primary) - .padding(all = 8.dp) - ) { - if (needsAdbSetup) { - Text( - text = stringResource(Res.string.settings_adb_setup_title), - color = FloconTheme.colorPalette.onError, - style = FloconTheme.typography.bodySmall, - ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - FloconIcon( - imageVector = Icons.Outlined.Check, - tint = FloconTheme.colorPalette.onAccent, - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(Res.string.settings_adb_valid), - color = FloconTheme.colorPalette.onAccent, - style = FloconTheme.typography.bodySmall - ) - } - } - FloconTextFieldWithoutM3( - value = adbPathText, - onValueChange = onAdbPathChanged, - placeholder = defaultPlaceHolder("Eg: /Users/youruser/Library/Android/sdk/platform-tools/adb"), - containerColor = FloconTheme.colorPalette.secondary, - modifier = Modifier.fillMaxWidth() + SettingsTab.entries.forEach { tab -> + DrawerItem( + tab = tab, + selected = tab == selectedTab, + onClick = { selectedTab = tab }, ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - SettingsButton( - text = stringResource(Res.string.general_save), - onClick = saveAdbPath - ) - SettingsButton( - onClick = testAdbPath, - text = stringResource(Res.string.settings_test), - ) - } } } - FloconSection( - title = stringResource(Res.string.settings_font_size_multiplier, uiState.fontSizeMultiplier), - initialValue = true + + FloconVerticalDivider( + modifier = Modifier.fillMaxHeight(), + color = FloconTheme.colorPalette.secondary, + ) + + // ── Content pane ──────────────────────────────────────────────────── + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) ) { - Column( - modifier = Modifier - .padding(8.dp) - .clip(FloconTheme.shapes.medium) - .background(FloconTheme.colorPalette.primary) - .padding(all = 8.dp) + when (selectedTab) { + SettingsTab.Adb -> AdbPane( + adbPathText = adbPathText, + onAdbPathChanged = onAdbPathChanged, + saveAdbPath = saveAdbPath, + testAdbPath = testAdbPath, + needsAdbSetup = needsAdbSetup, + ) + + SettingsTab.Appearance -> AppearancePane( + fontSizeMultiplier = uiState.fontSizeMultiplier, + onAction = onAction, + ) + + SettingsTab.Logs -> LogsPane( + logs = uiState.logs, + onClearLogs = onClearLogs, + ) + + SettingsTab.About -> AboutPane() + } + } + } +} + +// --------------------------------------------------------------------------- +// Drawer item +// --------------------------------------------------------------------------- + +@Composable +private fun DrawerItem( + tab: SettingsTab, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = if (selected) FloconTheme.colorPalette.secondary else FloconTheme.colorPalette.primary + val contentColor = if (selected) FloconTheme.colorPalette.onSecondary else FloconTheme.colorPalette.onPrimary + + FloconSurface( + onClick = onClick, + color = bgColor, + contentColor = contentColor, + shape = FloconTheme.shapes.small, + modifier = modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) { + FloconIcon( + imageVector = tab.icon, + tint = contentColor, + modifier = Modifier.size(16.dp) + ) + Text( + text = tab.label, + style = FloconTheme.typography.bodyMedium, + color = contentColor, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Content panes +// --------------------------------------------------------------------------- + +@Composable +private fun AdbPane( + adbPathText: String, + onAdbPathChanged: (String) -> Unit, + saveAdbPath: () -> Unit, + testAdbPath: () -> Unit, + needsAdbSetup: Boolean, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + // Status row + if (needsAdbSetup) { + Text( + text = stringResource(Res.string.settings_adb_setup_title), + color = FloconTheme.colorPalette.onError, + style = FloconTheme.typography.bodySmall, + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - FloconSlider( - value = uiState.fontSizeMultiplier, - onValueChange = { onAction(SettingsAction.FontSizeMultiplierChange(it)) }, - valueRange = 1f..2f, - modifier = Modifier.fillMaxWidth() + FloconIcon( + imageVector = Icons.Outlined.Check, + tint = FloconTheme.colorPalette.onAccent, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(Res.string.settings_adb_valid), + color = FloconTheme.colorPalette.onAccent, + style = FloconTheme.typography.bodySmall, ) } } @@ -188,20 +274,106 @@ private fun SettingsScreen( initialValue = true ) { SettingsButton( - onClick = { showLicenses = true }, - text = stringResource(Res.string.settings_licenses), - modifier = Modifier.padding(8.dp) + text = stringResource(Res.string.general_save), + onClick = saveAdbPath, + ) + SettingsButton( + text = stringResource(Res.string.settings_test), + onClick = testAdbPath, ) } } +} - if (showLicenses) { - LicensesWindow( - onCloseRequest = { showLicenses = false } +@Composable +private fun AppearancePane( + fontSizeMultiplier: Float, + onAction: (SettingsAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + Text( + text = stringResource(Res.string.settings_font_size_multiplier, fontSizeMultiplier), + style = FloconTheme.typography.titleMedium, + color = FloconTheme.colorPalette.onPrimary, + ) + FloconSlider( + value = fontSizeMultiplier, + onValueChange = { onAction(SettingsAction.FontSizeMultiplierChange(it)) }, + valueRange = 1f..2f, + modifier = Modifier.fillMaxWidth() ) } } +@Composable +private fun LogsPane( + logs: List, + onClearLogs: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxSize() + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Console (${logs.size})", + style = FloconTheme.typography.titleMedium, + color = FloconTheme.colorPalette.onPrimary, + ) + if (logs.isNotEmpty()) { + SettingsButton( + text = "Clear", + onClick = onClearLogs, + ) + } + } + + if (logs.isEmpty()) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = "No logs yet", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.4f), + ) + } + } else { + ConsoleLogPanel( + logs = logs, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + } +} + +@Composable +private fun AboutPane( + modifier: Modifier = Modifier, +) { + AboutScreen( + modifier = modifier + .fillMaxSize() + .background(FloconTheme.colorPalette.primary), + ) +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + @Composable private fun SettingsButton( onClick: () -> Unit, @@ -252,21 +424,40 @@ private fun ThemeButton( @Composable private fun LicensesWindow( onCloseRequest: () -> Unit +private fun ConsoleLogPanel( + logs: List, + modifier: Modifier = Modifier, ) { - FloconWindow( - title = "Licenses", - state = createFloconWindowState(), - alwaysOnTop = true, - onCloseRequest = onCloseRequest, + val listState = rememberLazyListState() + LaunchedEffect(logs.size) { + if (logs.isNotEmpty()) listState.animateScrollToItem(logs.lastIndex) + } + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = modifier + .clip(FloconTheme.shapes.medium) + .background(FloconTheme.colorPalette.secondary) + .padding(8.dp) ) { - AboutScreen( - modifier = Modifier - .fillMaxSize() - .background(FloconTheme.colorPalette.primary), - ) + items(logs) { entry -> + val color = when (entry.level) { + LogLevel.ERROR -> FloconTheme.colorPalette.onError + LogLevel.DEBUG -> FloconTheme.colorPalette.onAccent + } + Text( + text = "[${entry.level.name}] ${entry.message}", + color = color, + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } } } +// --------------------------------------------------------------------------- +// Previews +// --------------------------------------------------------------------------- + @Preview @Composable private fun SettingsScreenPreview() { @@ -276,14 +467,11 @@ private fun SettingsScreenPreview() { uiState = previewSettingsUiState(), adbPathText = adbPath, onAdbPathChanged = { adbPath = it }, - saveAdbPath = { - Logger.d { "Save ADB FilePathDomainModel: $adbPath" } - }, - testAdbPath = { - Logger.d { "Test ADB FilePathDomainModel: $adbPath" } - }, + saveAdbPath = { Logger.d { "Save ADB: $adbPath" } }, + testAdbPath = { Logger.d { "Test ADB: $adbPath" } }, modifier = Modifier.fillMaxSize(), onAction = {}, + onClearLogs = {}, needsAdbSetup = false, ) } @@ -298,11 +486,30 @@ private fun SettingsScreenPreview_needsAdbSetup() { uiState = previewSettingsUiState(), adbPathText = adbPath, onAdbPathChanged = { adbPath = it }, - saveAdbPath = { Logger.d { "Save ADB FilePathDomainModel: $adbPath" } }, - testAdbPath = { Logger.d { "Test ADB FilePathDomainModel: $adbPath" } }, + saveAdbPath = { Logger.d { "Save ADB: $adbPath" } }, + testAdbPath = { Logger.d { "Test ADB: $adbPath" } }, modifier = Modifier.fillMaxSize(), onAction = {}, + onClearLogs = {}, needsAdbSetup = true, ) } } + +@Preview +@Composable +private fun SettingsScreen_LogsPreview() { + FloconTheme { + SettingsScreen( + uiState = previewSettingsUiState(), + adbPathText = "/usr/local/bin/adb", + onAdbPathChanged = {}, + saveAdbPath = {}, + testAdbPath = {}, + modifier = Modifier.fillMaxSize(), + onAction = {}, + onClearLogs = {}, + needsAdbSetup = false, + ) + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt index 2f6ab94f5..4352f7c43 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt @@ -1,15 +1,21 @@ package io.github.openflocon.flocondesktop.app.ui.settings -import androidx.compose.runtime.Immutable -import io.github.openflocon.domain.models.settings.ThemeSetting +import io.github.openflocon.flocondesktop.common.log.LogEntryUiModel +import io.github.openflocon.flocondesktop.common.log.LogLevel -@Immutable data class SettingsUiState( val fontSizeMultiplier: Float, + val logs: List = emptyList(), val theme: ThemeSetting ) fun previewSettingsUiState() = SettingsUiState( fontSizeMultiplier = 1f, + logs = listOf( + LogEntryUiModel(LogLevel.DEBUG, "ADB path saved: /usr/local/bin/adb"), + LogEntryUiModel(LogLevel.ERROR, "ADB test failed: No such file or directory"), + LogEntryUiModel(LogLevel.DEBUG, "ADB test succeeded"), + ), theme = ThemeSetting.DEFAULT ) + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt index 940a8e807..1693111d0 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt @@ -2,6 +2,7 @@ package io.github.openflocon.flocondesktop.app.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.general_success import flocondesktop.composeapp.generated.resources.settings_test_failure @@ -15,6 +16,8 @@ import io.github.openflocon.domain.settings.usecase.SetFontSizeMultiplierUseCase import io.github.openflocon.domain.settings.usecase.SetThemeUseCase import io.github.openflocon.domain.settings.usecase.TestAdbUseCase import io.github.openflocon.flocondesktop.app.InitialSetupStateHolder +import io.github.openflocon.flocondesktop.common.log.LogManager +import io.github.openflocon.flocondesktop.common.log.toUiModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow @@ -33,6 +36,7 @@ class SettingsViewModel( private val feedbackDisplayer: FeedbackDisplayer, private val initialSetupStateHolder: InitialSetupStateHolder, private val dispatcherProvider: DispatcherProvider, + private val logManager: LogManager, ) : ViewModel() { private val _adbPathInput = MutableStateFlow("") @@ -47,6 +51,11 @@ class SettingsViewModel( fontSizeMultiplier = fontSizeMultiplier, theme = theme, ) + val uiState = combine(fontSizeMultiplierUseCase(), logManager.logs) { multiplier, logs -> + SettingsUiState( + fontSizeMultiplier = multiplier, + logs = logs.map { it.toUiModel() }, + ) } .stateIn( viewModelScope, @@ -96,14 +105,23 @@ class SettingsViewModel( } private suspend fun saveAdb() { - settingsRepository.setAdbPath(adbPathInput.value) + val path = adbPathInput.value + Logger.d(TAG) { "Saving ADB path: $path" } + settingsRepository.setAdbPath(path) + logManager.d(TAG, "Saving ADB path: $path") } fun testAdbPath() { viewModelScope.launch(dispatcherProvider.viewModel) { saveAdb() + val path = adbPathInput.value + Logger.d(TAG) { "Testing ADB path: $path" } + logManager.d(TAG, "Testing ADB path: $path") testAdbUseCase().fold( doOnFailure = { + val msg = "ADB test failed: ${it.message}" + Logger.e(TAG, it) { msg } + logManager.e(TAG, "ADB test failed", it) feedbackDisplayer.displayMessage( message = getString(Res.string.settings_test_failure, it.localizedMessage), type = FeedbackDisplayer.MessageType.Error @@ -111,10 +129,20 @@ class SettingsViewModel( initialSetupStateHolder.setRequiresInitialSetup() }, doOnSuccess = { + Logger.d(TAG) { "ADB test succeeded" } + logManager.d(TAG, "ADB test succeeded") feedbackDisplayer.displayMessage(getString(Res.string.general_success)) initialSetupStateHolder.setAdbIsWorking() }, ) } } + + fun clearLogs() { + logManager.clear() + } + + companion object { + private const val TAG = "SettingsViewModel" + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt new file mode 100644 index 000000000..156d3efd4 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt @@ -0,0 +1,41 @@ +package io.github.openflocon.flocondesktop.common.log + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +enum class LogLevel { DEBUG, ERROR } + +data class LogEntry(val level: LogLevel, val message: String) + +data class LogEntryUiModel(val level: LogLevel, val message: String) + +fun LogEntry.toUiModel() = LogEntryUiModel(level = level, message = message) + +class LogManager { + + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs.asStateFlow() + + fun d(tag: String, message: String) { + append(LogLevel.DEBUG, "[$tag] $message") + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + val suffix = throwable?.message?.let { ": $it" } ?: "" + append(LogLevel.ERROR, "[$tag] $message$suffix") + } + + fun clear() { + _logs.value = emptyList() + } + + private fun append(level: LogLevel, message: String) { + _logs.update { (it + LogEntry(level, message)).takeLast(MAX_ENTRIES) } + } + + companion object { + private const val MAX_ENTRIES = 200 + } +} diff --git a/FloconDesktop/gradle/libs.versions.toml b/FloconDesktop/gradle/libs.versions.toml index 490002d35..6320e569f 100644 --- a/FloconDesktop/gradle/libs.versions.toml +++ b/FloconDesktop/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] about-libraries = "15.0.2" agp = "9.2.0" -androidx-lifecycle = "2.11.0-beta01" +androidx-lifecycle = "2.11.0-rc01" buildconfig = "6.0.10" coil = "3.5.0" compose-multiplatform = "1.11.1" @@ -16,9 +16,9 @@ ksp = "2.3.9" ktlint = "14.0.1" ktor = "3.5.0" logback = "1.5.18" -material3-adaptive = "1.3.0-alpha07" +material3-adaptive = "1.3.0-beta02" multiplatform-settings = "1.3.0" -navigation3 = "1.1.1" +navigation3 = "1.2.0-alpha02" opencsv = "5.12.0" other-jsontree = "2.7.0" paging = "3.5.0" diff --git a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/BigDialogScene.kt b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/BigDialogScene.kt index 1ade93d99..852a889b2 100644 --- a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/BigDialogScene.kt +++ b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/BigDialogScene.kt @@ -2,6 +2,9 @@ package io.github.openflocon.navigation.scene +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOutCubic +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,12 +22,13 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavEntry @@ -36,6 +40,9 @@ import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconIcon import io.github.openflocon.library.designsystem.components.FloconIconButton import io.github.openflocon.navigation.FloconRoute +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope @Immutable private data class BigDialogScene( @@ -46,13 +53,26 @@ private data class BigDialogScene( private val onBack: () -> Unit, ) : OverlayScene { + private val spec = tween(durationMillis = 300, easing = EaseInOutCubic) + private val alpha = Animatable(initialValue = 0f) + private val scale = Animatable(initialValue = 0.9f) + override val key: Any = BigDialogSceneStrategy.BIG_DIALOG override val entries: List> = listOf(entry) override val content: @Composable (() -> Unit) = { + LaunchedEffect(Unit) { + val alphaTask = async { alpha.animateTo(1f, spec) } + val scaleTask = async { scale.animateTo(1f, spec) } + + awaitAll(alphaTask, scaleTask) + } Column( modifier = Modifier .fillMaxSize() + .graphicsLayer { + this.alpha = this@BigDialogScene.alpha.value + } .background(FloconTheme.colorPalette.primary.copy(alpha = 0.7f)) .clickable( onClick = onBack, @@ -60,6 +80,10 @@ private data class BigDialogScene( interactionSource = null ) .padding(64.dp) + .graphicsLayer { + this.scaleX = scale.value + this.scaleY = scale.value + } .dropShadow( shape = RoundedCornerShape(12.dp), shadow = Shadow( @@ -101,6 +125,15 @@ private data class BigDialogScene( } } } + + override suspend fun onRemove() { + coroutineScope { + val alphaTask = async { alpha.animateTo(0f, spec) } + val scaleTask = async { scale.animateTo(0.9f, spec) } + + awaitAll(alphaTask, scaleTask) + } + } } data class BigDialogProperties( From f043f59480dea1686f0f8236b606d0023a736b76 Mon Sep 17 00:00:00 2001 From: Raphael TEYSSANDIER Date: Wed, 8 Jul 2026 10:21:15 +0200 Subject: [PATCH 2/5] chore: downgrade navigation3 version to 1.1.1 Reverts the navigation3 library to the stable 1.1.1 release to resolve compatibility or stability issues encountered with the alpha version. --- FloconDesktop/gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FloconDesktop/gradle/libs.versions.toml b/FloconDesktop/gradle/libs.versions.toml index 6320e569f..518fcee2d 100644 --- a/FloconDesktop/gradle/libs.versions.toml +++ b/FloconDesktop/gradle/libs.versions.toml @@ -18,7 +18,7 @@ ktor = "3.5.0" logback = "1.5.18" material3-adaptive = "1.3.0-beta02" multiplatform-settings = "1.3.0" -navigation3 = "1.2.0-alpha02" +navigation3 = "1.1.1" opencsv = "5.12.0" other-jsontree = "2.7.0" paging = "3.5.0" From 464e44c889ba465917e22185c340ee0ace77996f Mon Sep 17 00:00:00 2001 From: Raphael TEYSSANDIER Date: Mon, 20 Jul 2026 14:32:01 +0200 Subject: [PATCH 3/5] feat: add ADB forward status monitoring to Settings screen Introduces an `AdbForwardStatus` to track the health of reverse port forwarding in the `SettingsRepository`. Updates the Settings UI to display the current connection status and refines log entries with timestamps for better debugging. --- .../openflocon/flocondesktop/app/AppScreen.kt | 15 +- .../flocondesktop/app/AppViewModel.kt | 12 +- .../flocondesktop/app/di/AppModule.kt | 19 +- .../app/ui/settings/SettingsScreen.kt | 485 +++++++++++++++--- .../app/ui/settings/SettingsUiState.kt | 11 +- .../app/ui/settings/SettingsViewModel.kt | 13 +- .../flocondesktop/common/log/LogManager.kt | 28 +- .../core/data/di/CoreDataModule.kt | 2 +- .../data/settings/SettingsRepositoryImpl.kt | 9 + .../settings/repository/SettingsRepository.kt | 5 + 10 files changed, 506 insertions(+), 93 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt index 0081d3b70..2e9556ce0 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.scene.SinglePaneSceneStrategy @@ -13,11 +14,11 @@ import io.github.openflocon.flocondesktop.app.ui.view.leftpannel.LeftPanelView import io.github.openflocon.flocondesktop.app.ui.view.topbar.MainScreenTopBar import io.github.openflocon.flocondesktop.app.version.VersionCheckerView import io.github.openflocon.flocondesktop.common.ui.feedback.FeedbackDisplayerView +import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderRoutes import io.github.openflocon.flocondesktop.features.analytics.analyticsRoutes import io.github.openflocon.flocondesktop.features.crashreporter.crashReporterRoutes import io.github.openflocon.flocondesktop.features.dashboard.dashboardRoutes import io.github.openflocon.flocondesktop.features.database.databaseRoutes -import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderRoutes import io.github.openflocon.flocondesktop.features.deeplinks.deeplinkRoutes import io.github.openflocon.flocondesktop.features.files.filesRoutes import io.github.openflocon.flocondesktop.features.images.imageRoutes @@ -55,15 +56,19 @@ private fun Content( navigationState: MainFloconNavigationState, onAction: (AppAction) -> Unit ) { - FloconNavigation( - navigationState = navigationState, - sceneStrategies = listOf( + val sceneStrategies = remember { + listOf( PanelSceneStrategy(), WindowSceneStrategy(), DialogSceneStrategy(), BigDialogSceneStrategy(), SinglePaneSceneStrategy() - ), + ) + } + + FloconNavigation( + navigationState = navigationState, + sceneStrategies = sceneStrategies, sceneDecoratorStrategies = listOf( MenuSceneStrategy( menuContent = { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt index b3c82e219..ec086a369 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/AppViewModel.kt @@ -10,6 +10,8 @@ import io.github.openflocon.domain.device.usecase.TakeScreenshotUseCase import io.github.openflocon.domain.feedback.FeedbackDisplayer import io.github.openflocon.domain.settings.usecase.InitAdbPathUseCase import io.github.openflocon.domain.settings.usecase.StartAdbForwardUseCase +import io.github.openflocon.domain.settings.repository.AdbForwardStatus +import io.github.openflocon.domain.settings.repository.SettingsRepository import io.github.openflocon.flocondesktop.app.ui.delegates.DevicesDelegate import io.github.openflocon.flocondesktop.app.ui.delegates.RecordVideoDelegate import io.github.openflocon.flocondesktop.app.ui.model.SubScreen @@ -51,6 +53,7 @@ internal class AppViewModel( private val recordVideoDelegate: RecordVideoDelegate, private val feedbackDisplayer: FeedbackDisplayer, private val logManager: LogManager, + private val settingsRepository: SettingsRepository, ) : ViewModel(messagesServerDelegate) { private val contentState = MutableStateFlow( @@ -104,8 +107,13 @@ internal class AppViewModel( while (isActive) { // ensure we have the forward enabled startAdbForwardUseCase() - .alsoFailure { logManager.e(TAG, "ADB forward failed", it) } - .alsoSuccess { logManager.d(TAG, "ADB forward OK") } + .alsoFailure { + settingsRepository.setAdbForwardStatus(AdbForwardStatus.NOK) + logManager.e(TAG, "ADB forward failed", it) + } + .alsoSuccess { + settingsRepository.setAdbForwardStatus(AdbForwardStatus.OK) + } delay(1_500) } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt index 782e533e6..0da855b3b 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/di/AppModule.kt @@ -9,15 +9,14 @@ import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.singleOf import org.koin.dsl.module -val appModule = - module { - includes(appUiModule) +val appModule = module { + includes(appUiModule) - singleOf(::FeedbackDisplayerImpl) { - bind() - bind() - } - - singleOf(::InitialSetupStateHolder) - singleOf(::LogManager) + singleOf(::FeedbackDisplayerImpl) { + bind() + bind() } + + singleOf(::InitialSetupStateHolder) + singleOf(::LogManager) +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt index 2ede41a9f..6b5b5ec98 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt @@ -1,10 +1,15 @@ package io.github.openflocon.flocondesktop.app.ui.settings +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.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -16,12 +21,19 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cable import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -33,8 +45,12 @@ 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.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger @@ -44,6 +60,7 @@ import flocondesktop.composeapp.generated.resources.settings_adb_setup_title import flocondesktop.composeapp.generated.resources.settings_adb_valid import flocondesktop.composeapp.generated.resources.settings_font_size_multiplier import flocondesktop.composeapp.generated.resources.settings_test +import io.github.openflocon.domain.settings.repository.AdbForwardStatus import io.github.openflocon.flocondesktop.common.log.LogEntryUiModel import io.github.openflocon.flocondesktop.common.log.LogLevel import io.github.openflocon.library.designsystem.FloconTheme @@ -116,7 +133,7 @@ private fun SettingsScreen( Row(modifier = modifier) { // ── Drawer ────────────────────────────────────────────────────────── Column( - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .width(180.dp) .fillMaxHeight() @@ -149,6 +166,7 @@ private fun SettingsScreen( saveAdbPath = saveAdbPath, testAdbPath = testAdbPath, needsAdbSetup = needsAdbSetup, + adbForwardStatus = uiState.adbForwardStatus, ) SettingsTab.Appearance -> AppearancePane( @@ -178,20 +196,42 @@ private fun DrawerItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val bgColor = if (selected) FloconTheme.colorPalette.secondary else FloconTheme.colorPalette.primary - val contentColor = if (selected) FloconTheme.colorPalette.onSecondary else FloconTheme.colorPalette.onPrimary + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val shape = FloconTheme.shapes.medium + + val bgColor = when { + selected -> FloconTheme.colorPalette.secondary + hovered -> FloconTheme.colorPalette.secondary.copy(alpha = 0.5f) + else -> FloconTheme.colorPalette.primary.copy(alpha = 0f) + } + + val contentColor = when { + selected -> FloconTheme.colorPalette.onSecondary + hovered -> FloconTheme.colorPalette.onSecondary.copy(alpha = 0.8f) + else -> FloconTheme.colorPalette.onPrimary + } + + val borderColor = if (selected) { + FloconTheme.colorPalette.accent + } else { + Color.Transparent + } FloconSurface( onClick = onClick, color = bgColor, contentColor = contentColor, - shape = FloconTheme.shapes.small, - modifier = modifier.fillMaxWidth() + shape = shape, + border = BorderStroke(1.dp, borderColor), + modifier = modifier + .fillMaxWidth() + .height(40.dp) ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + modifier = Modifier.padding(horizontal = 12.dp) ) { FloconIcon( imageVector = tab.icon, @@ -201,12 +241,68 @@ private fun DrawerItem( Text( text = tab.label, style = FloconTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, color = contentColor, ) } } } +// --------------------------------------------------------------------------- +// Reusable Settings Card +// --------------------------------------------------------------------------- + +@Composable +private fun SettingsCard( + title: String, + icon: ImageVector, + modifier: Modifier = Modifier, + description: String? = null, + headerActions: @Composable (RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { + FloconSurface( + color = FloconTheme.colorPalette.primary, + shape = FloconTheme.shapes.medium, + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FloconIcon( + imageVector = icon, + tint = FloconTheme.colorPalette.onAccent, + modifier = Modifier.size(20.dp) + ) + Text( + text = title, + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = FloconTheme.colorPalette.onPrimary, + modifier = Modifier.weight(1f) + ) + headerActions?.invoke(this) + } + + if (description != null) { + Text( + text = description, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.7f), + ) + } + + content() + } + } +} + // --------------------------------------------------------------------------- // Content panes // --------------------------------------------------------------------------- @@ -218,33 +314,119 @@ private fun AdbPane( saveAdbPath: () -> Unit, testAdbPath: () -> Unit, needsAdbSetup: Boolean, + adbForwardStatus: AdbForwardStatus, modifier: Modifier = Modifier, ) { Column( - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier ) { - // Status row - if (needsAdbSetup) { + SettingsCard( + title = "ADB Configuration", + icon = Icons.Outlined.Settings, + description = "Flocon communicates with Android devices using the Android Debug Bridge (ADB). Set the path to your adb binary below." + ) { + // Setup alert or status + if (needsAdbSetup) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.error.copy(alpha = 0.15f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FloconIcon( + imageVector = Icons.Outlined.Warning, + tint = FloconTheme.colorPalette.error, + modifier = Modifier.size(18.dp) + ) + Text( + text = stringResource(Res.string.settings_adb_setup_title), + color = FloconTheme.colorPalette.error, + style = FloconTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.accent.copy(alpha = 0.15f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FloconIcon( + imageVector = Icons.Outlined.Check, + tint = FloconTheme.colorPalette.onAccent, + modifier = Modifier.size(18.dp) + ) + Text( + text = stringResource(Res.string.settings_adb_valid), + color = FloconTheme.colorPalette.onAccent, + style = FloconTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + } + + Spacer(Modifier.height(4.dp)) + Text( - text = stringResource(Res.string.settings_adb_setup_title), - color = FloconTheme.colorPalette.onError, - style = FloconTheme.typography.bodySmall, + text = "ADB Executable Path", + style = FloconTheme.typography.labelSmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f) ) - } else { + + FloconTextFieldWithoutM3( + value = adbPathText, + onValueChange = onAdbPathChanged, + placeholder = defaultPlaceHolder("Eg: /Users/youruser/Library/Android/sdk/platform-tools/adb"), + containerColor = FloconTheme.colorPalette.secondary, + contentPadding = PaddingValues(12.dp), + modifier = Modifier.fillMaxWidth() + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsButton( + text = stringResource(Res.string.general_save), + onClick = saveAdbPath, + ) + SettingsButton( + text = stringResource(Res.string.settings_test), + onClick = testAdbPath, + ) + } + } + + SettingsCard( + title = "ADB Reverse Port Forwarding", + icon = Icons.Outlined.Cable, + description = "Flocon runs a local server that communicates with the daemon on the device. Reverse port forwarding enables high-throughput data transfer (logs, preferences, screenshots)." + ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.secondary) + .padding(12.dp) ) { - FloconIcon( - imageVector = Icons.Outlined.Check, - tint = FloconTheme.colorPalette.onAccent, - modifier = Modifier.size(16.dp) - ) + AdbForwardStatusBadge(status = adbForwardStatus) + Text( - text = stringResource(Res.string.settings_adb_valid), - color = FloconTheme.colorPalette.onAccent, + text = when (adbForwardStatus) { + AdbForwardStatus.OK -> "Reverse port forwarding is active and healthy." + AdbForwardStatus.NOK -> "Connection failed. Please ensure ADB is configured correctly and your device is connected." + AdbForwardStatus.UNKNOWN -> "Status unknown. Waiting for device or forwarding loop to initialize." + }, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.8f), style = FloconTheme.typography.bodySmall, + modifier = Modifier.weight(1f) ) } } @@ -285,6 +467,61 @@ private fun AdbPane( } } +private data class BadgeTheme( + val label: String, + val bgColor: Color, + val textColor: Color, + val icon: ImageVector +) + +@Composable +private fun AdbForwardStatusBadge( + status: AdbForwardStatus, + modifier: Modifier = Modifier, +) { + val theme = when (status) { + AdbForwardStatus.OK -> BadgeTheme( + "ACTIVE", + FloconTheme.colorPalette.accent.copy(alpha = 0.2f), + FloconTheme.colorPalette.onAccent, + Icons.Outlined.Check + ) + AdbForwardStatus.NOK -> BadgeTheme( + "FAILED", + FloconTheme.colorPalette.error.copy(alpha = 0.2f), + FloconTheme.colorPalette.error, + Icons.Outlined.ErrorOutline + ) + AdbForwardStatus.UNKNOWN -> BadgeTheme( + "PENDING", + FloconTheme.colorPalette.secondary.copy(alpha = 0.5f), + FloconTheme.colorPalette.onSecondary, + Icons.Outlined.Info + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clip(FloconTheme.shapes.small) + .background(theme.bgColor) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + FloconIcon( + imageVector = theme.icon, + tint = theme.textColor, + modifier = Modifier.size(12.dp) + ) + Text( + text = theme.label, + color = theme.textColor, + style = FloconTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } +} + @Composable private fun AppearancePane( fontSizeMultiplier: Float, @@ -292,20 +529,64 @@ private fun AppearancePane( modifier: Modifier = Modifier, ) { Column( - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier ) { - Text( - text = stringResource(Res.string.settings_font_size_multiplier, fontSizeMultiplier), - style = FloconTheme.typography.titleMedium, - color = FloconTheme.colorPalette.onPrimary, - ) - FloconSlider( - value = fontSizeMultiplier, - onValueChange = { onAction(SettingsAction.FontSizeMultiplierChange(it)) }, - valueRange = 1f..2f, - modifier = Modifier.fillMaxWidth() - ) + SettingsCard( + title = "Text Scaling", + icon = Icons.Outlined.TextFields, + description = "Increase or decrease the font size multiplier to scale all application labels, logs, and values." + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + FloconSlider( + value = fontSizeMultiplier, + onValueChange = { onAction(SettingsAction.FontSizeMultiplierChange(it)) }, + valueRange = 1f..2f, + modifier = Modifier.weight(1f) + ) + + Text( + text = "${(fontSizeMultiplier * 100).toInt()}%", + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = FloconTheme.colorPalette.onPrimary, + modifier = Modifier.width(48.dp) + ) + } + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Live Preview", + style = FloconTheme.typography.labelSmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f) + ) + + // Scaled text preview container + Column( + modifier = Modifier + .fillMaxWidth() + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.secondary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "The quick brown fox jumps over the lazy dog.", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onPrimary + ) + Text( + text = "[12:00:00 DEBUG] [AppViewModel] Live layout preview active.", + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = FloconTheme.colorPalette.onAccent + ) + } + } } } @@ -315,25 +596,60 @@ private fun LogsPane( onClearLogs: () -> Unit, modifier: Modifier = Modifier, ) { + val clipboardManager = LocalClipboardManager.current + Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier.fillMaxSize() ) { Row( - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "Console (${logs.size})", + text = "Flocon System Logs", style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, color = FloconTheme.colorPalette.onPrimary, + modifier = Modifier.weight(1f) ) + if (logs.isNotEmpty()) { - SettingsButton( - text = "Clear", + FloconButton( + onClick = { + val text = logs.joinToString("\n") { "[${it.timestamp} ${it.level.name}] ${it.message}" } + clipboardManager.setText(AnnotatedString(text)) + }, + containerColor = FloconTheme.colorPalette.secondary, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + FloconIcon( + imageVector = Icons.Outlined.ContentCopy, + modifier = Modifier.size(14.dp) + ) + Text("Copy All", style = FloconTheme.typography.bodySmall) + } + } + + FloconButton( onClick = onClearLogs, - ) + containerColor = FloconTheme.colorPalette.secondary, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + FloconIcon( + imageVector = Icons.Outlined.Delete, + modifier = Modifier.size(14.dp) + ) + Text("Clear", style = FloconTheme.typography.bodySmall) + } + } } } @@ -343,17 +659,31 @@ private fun LogsPane( modifier = Modifier .fillMaxWidth() .weight(1f) + .clip(FloconTheme.shapes.medium) + .background(FloconTheme.colorPalette.primary) ) { - Text( - text = "No logs yet", - style = FloconTheme.typography.bodySmall, - color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.4f), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FloconIcon( + imageVector = Icons.Outlined.Info, + tint = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.3f), + modifier = Modifier.size(36.dp) + ) + Text( + text = "No system logs generated yet", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.4f), + ) + } } } else { ConsoleLogPanel( logs = logs, - modifier = Modifier.fillMaxWidth().weight(1f), + modifier = Modifier + .fillMaxWidth() + .weight(1f), ) } } @@ -363,11 +693,29 @@ private fun LogsPane( private fun AboutPane( modifier: Modifier = Modifier, ) { - AboutScreen( - modifier = modifier - .fillMaxSize() - .background(FloconTheme.colorPalette.primary), - ) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.fillMaxSize() + ) { + Text( + text = "Open Source Licenses", + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = FloconTheme.colorPalette.onPrimary, + ) + Text( + text = "Flocon is built using open source software. The licenses of libraries used in this project are listed below.", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.7f), + ) + AboutScreen( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(FloconTheme.shapes.medium) + .background(FloconTheme.colorPalette.primary), + ) + } } // --------------------------------------------------------------------------- @@ -432,24 +780,45 @@ private fun ConsoleLogPanel( LaunchedEffect(logs.size) { if (logs.isNotEmpty()) listState.animateScrollToItem(logs.lastIndex) } + LazyColumn( state = listState, - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .clip(FloconTheme.shapes.medium) .background(FloconTheme.colorPalette.secondary) - .padding(8.dp) + .border(1.dp, FloconTheme.colorPalette.primary, FloconTheme.shapes.medium) + .padding(12.dp) ) { items(logs) { entry -> val color = when (entry.level) { LogLevel.ERROR -> FloconTheme.colorPalette.onError LogLevel.DEBUG -> FloconTheme.colorPalette.onAccent } - Text( - text = "[${entry.level.name}] ${entry.message}", - color = color, - style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "[${entry.timestamp}]", + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.4f), + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + Text( + text = entry.level.name, + color = color, + fontWeight = FontWeight.Bold, + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.width(48.dp) + ) + Text( + text = entry.message, + color = FloconTheme.colorPalette.onPrimary, + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.weight(1f) + ) + } } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt index 4352f7c43..c0a4417a1 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt @@ -1,20 +1,25 @@ package io.github.openflocon.flocondesktop.app.ui.settings +import androidx.compose.runtime.Immutable +import io.github.openflocon.domain.settings.repository.AdbForwardStatus import io.github.openflocon.flocondesktop.common.log.LogEntryUiModel import io.github.openflocon.flocondesktop.common.log.LogLevel +@Immutable data class SettingsUiState( val fontSizeMultiplier: Float, val logs: List = emptyList(), + val adbForwardStatus: AdbForwardStatus = AdbForwardStatus.UNKNOWN, val theme: ThemeSetting ) fun previewSettingsUiState() = SettingsUiState( fontSizeMultiplier = 1f, + adbForwardStatus = AdbForwardStatus.OK, logs = listOf( - LogEntryUiModel(LogLevel.DEBUG, "ADB path saved: /usr/local/bin/adb"), - LogEntryUiModel(LogLevel.ERROR, "ADB test failed: No such file or directory"), - LogEntryUiModel(LogLevel.DEBUG, "ADB test succeeded"), + LogEntryUiModel(LogLevel.DEBUG, "ADB path saved: /usr/local/bin/adb", "12:00:00"), + LogEntryUiModel(LogLevel.ERROR, "ADB test failed: No such file or directory", "12:00:01"), + LogEntryUiModel(LogLevel.DEBUG, "ADB test succeeded", "12:00:02"), ), theme = ThemeSetting.DEFAULT ) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt index 1693111d0..c27c0785a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt @@ -45,20 +45,17 @@ class SettingsViewModel( val uiState = combine( fontSizeMultiplierUseCase(), - observeThemeUseCase(), - ) { fontSizeMultiplier, theme -> - SettingsUiState( - fontSizeMultiplier = fontSizeMultiplier, - theme = theme, - ) - val uiState = combine(fontSizeMultiplierUseCase(), logManager.logs) { multiplier, logs -> + logManager.logs, + settingsRepository.adbForwardStatus, + ) { multiplier, logs, forwardStatus -> SettingsUiState( fontSizeMultiplier = multiplier, logs = logs.map { it.toUiModel() }, + adbForwardStatus = forwardStatus, ) } .stateIn( - viewModelScope, + scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = SettingsUiState( fontSizeMultiplier = 1f, diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt index 156d3efd4..5d34ce65c 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/log/LogManager.kt @@ -4,14 +4,30 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock enum class LogLevel { DEBUG, ERROR } -data class LogEntry(val level: LogLevel, val message: String) - -data class LogEntryUiModel(val level: LogLevel, val message: String) - -fun LogEntry.toUiModel() = LogEntryUiModel(level = level, message = message) +data class LogEntry( + val level: LogLevel, + val message: String, + val timestamp: Instant, +) + +data class LogEntryUiModel( + val level: LogLevel, + val message: String, + val timestamp: String, +) + +fun LogEntry.toUiModel(): LogEntryUiModel { + val local = timestamp.toLocalDateTime(TimeZone.currentSystemDefault()) + val formatted = "%02d:%02d:%02d".format(local.hour, local.minute, local.second) + return LogEntryUiModel(level = level, message = message, timestamp = formatted) +} class LogManager { @@ -32,7 +48,7 @@ class LogManager { } private fun append(level: LogLevel, message: String) { - _logs.update { (it + LogEntry(level, message)).takeLast(MAX_ENTRIES) } + _logs.update { (it + LogEntry(level, message, Clock.System.now())).takeLast(MAX_ENTRIES) } } companion object { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/di/CoreDataModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/di/CoreDataModule.kt index 44ce51db9..692b70b36 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/di/CoreDataModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/di/CoreDataModule.kt @@ -14,7 +14,7 @@ import org.koin.dsl.module val coreDataModule = module { - factoryOf(::SettingsRepositoryImpl) { + singleOf(::SettingsRepositoryImpl) { bind() } singleOf(::SettingsDataSourcePrefs) { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt index 518751bef..143382897 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/SettingsRepositoryImpl.kt @@ -2,11 +2,13 @@ package io.github.openflocon.flocondesktop.core.data.settings import io.github.openflocon.domain.models.settings.NetworkSettings import io.github.openflocon.domain.models.settings.ThemeSetting +import io.github.openflocon.domain.settings.repository.AdbForwardStatus import io.github.openflocon.domain.settings.repository.SettingsRepository import io.github.openflocon.flocondesktop.core.data.settings.datasource.local.SettingsDataSource import io.github.openflocon.flocondesktop.core.data.settings.models.toDomain import io.github.openflocon.flocondesktop.core.data.settings.models.toLocal import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapLatest @@ -19,6 +21,13 @@ internal class SettingsRepositoryImpl( override val fontSizeMultiplier: StateFlow = localSettingsDataSource.fontSizeMultiplier override val theme: StateFlow = localSettingsDataSource.theme + private val _adbForwardStatus = MutableStateFlow(AdbForwardStatus.UNKNOWN) + override val adbForwardStatus: StateFlow = _adbForwardStatus + + override fun setAdbForwardStatus(status: AdbForwardStatus) { + _adbForwardStatus.value = status + } + override var networkSettings: NetworkSettings get() = localSettingsDataSource.networkSettings.toDomain() set(value) { diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt index 54b391754..d170a0d74 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/repository/SettingsRepository.kt @@ -5,6 +5,8 @@ import io.github.openflocon.domain.models.settings.ThemeSetting import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +enum class AdbForwardStatus { UNKNOWN, OK, NOK } + interface SettingsRepository { var networkSettings: NetworkSettings val networkSettingsFlow: Flow @@ -20,4 +22,7 @@ interface SettingsRepository { val adbPath: Flow val fontSizeMultiplier: StateFlow val theme: StateFlow + val adbForwardStatus: StateFlow + + fun setAdbForwardStatus(status: AdbForwardStatus) } From f5cea09a4905d49dbcba8afa97690fd0647ef3c9 Mon Sep 17 00:00:00 2001 From: Raphael TEYSSANDIER Date: Mon, 20 Jul 2026 14:42:45 +0200 Subject: [PATCH 4/5] feat: redesign settings appearance pane and theme selection Refactor the appearance settings to use a card-based layout and update the theme selector with improved visual feedback, including hover states and descriptive icons for each theme option. --- .../app/ui/settings/SettingsScreen.kt | 145 +++++++++++------- .../app/ui/settings/SettingsUiState.kt | 5 +- .../app/ui/settings/SettingsViewModel.kt | 7 +- 3 files changed, 98 insertions(+), 59 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt index 6b5b5ec98..a319b478f 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt @@ -3,6 +3,8 @@ package io.github.openflocon.flocondesktop.app.ui.settings import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,16 +23,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cable import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Computer import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.LightMode import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.ModeNight import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.TextFields import androidx.compose.material.icons.outlined.Warning @@ -58,8 +61,12 @@ import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.general_save import flocondesktop.composeapp.generated.resources.settings_adb_setup_title import flocondesktop.composeapp.generated.resources.settings_adb_valid -import flocondesktop.composeapp.generated.resources.settings_font_size_multiplier import flocondesktop.composeapp.generated.resources.settings_test +import flocondesktop.composeapp.generated.resources.settings_theme +import flocondesktop.composeapp.generated.resources.settings_theme_dark +import flocondesktop.composeapp.generated.resources.settings_theme_light +import flocondesktop.composeapp.generated.resources.settings_theme_system +import io.github.openflocon.domain.models.settings.ThemeSetting import io.github.openflocon.domain.settings.repository.AdbForwardStatus import io.github.openflocon.flocondesktop.common.log.LogEntryUiModel import io.github.openflocon.flocondesktop.common.log.LogLevel @@ -171,6 +178,7 @@ private fun SettingsScreen( SettingsTab.Appearance -> AppearancePane( fontSizeMultiplier = uiState.fontSizeMultiplier, + currentTheme = uiState.theme, onAction = onAction, ) @@ -430,40 +438,6 @@ private fun AdbPane( ) } } - FloconSection( - title = stringResource(Res.string.settings_theme), - initialValue = true - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .padding(8.dp) - .clip(FloconTheme.shapes.medium) - .background(FloconTheme.colorPalette.primary) - .padding(all = 8.dp) - ) { - ThemeSetting.entries.forEach { theme -> - ThemeButton( - theme = theme, - selected = uiState.theme == theme, - onClick = { onAction(SettingsAction.ThemeChange(theme)) }, - ) - } - } - } - FloconSection( - title = stringResource(Res.string.settings_about_title), - initialValue = true - ) { - SettingsButton( - text = stringResource(Res.string.general_save), - onClick = saveAdbPath, - ) - SettingsButton( - text = stringResource(Res.string.settings_test), - onClick = testAdbPath, - ) - } } } @@ -486,12 +460,14 @@ private fun AdbForwardStatusBadge( FloconTheme.colorPalette.onAccent, Icons.Outlined.Check ) + AdbForwardStatus.NOK -> BadgeTheme( "FAILED", FloconTheme.colorPalette.error.copy(alpha = 0.2f), FloconTheme.colorPalette.error, Icons.Outlined.ErrorOutline ) + AdbForwardStatus.UNKNOWN -> BadgeTheme( "PENDING", FloconTheme.colorPalette.secondary.copy(alpha = 0.5f), @@ -525,6 +501,7 @@ private fun AdbForwardStatusBadge( @Composable private fun AppearancePane( fontSizeMultiplier: Float, + currentTheme: ThemeSetting, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier, ) { @@ -532,6 +509,26 @@ private fun AppearancePane( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier ) { + SettingsCard( + title = stringResource(Res.string.settings_theme), + icon = Icons.Outlined.ModeNight, + description = "Choose the color scheme of Flocon. Light mode uses bright backgrounds, Dark mode uses dark backgrounds, and System automatically matches your operating system." + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + ThemeSetting.entries.forEach { theme -> + ThemeButton( + theme = theme, + selected = currentTheme == theme, + onClick = { onAction(SettingsAction.ThemeChange(theme)) }, + modifier = Modifier.weight(1f) + ) + } + } + } + SettingsCard( title = "Text Scaling", icon = Icons.Outlined.TextFields, @@ -747,31 +744,67 @@ private fun ThemeButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - FloconButton( + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val shape = FloconTheme.shapes.medium + + val bgColor = when { + selected -> FloconTheme.colorPalette.accent + hovered -> FloconTheme.colorPalette.secondary + else -> FloconTheme.colorPalette.secondary.copy(alpha = 0.5f) + } + + val contentColor = when { + selected -> FloconTheme.colorPalette.onAccent + else -> FloconTheme.colorPalette.onPrimary + } + + val borderColor = if (selected) { + FloconTheme.colorPalette.onAccent.copy(alpha = 0.5f) + } else { + Color.Transparent + } + + val icon = when (theme) { + ThemeSetting.Dark -> Icons.Outlined.ModeNight + ThemeSetting.Light -> Icons.Outlined.LightMode + ThemeSetting.System -> Icons.Outlined.Computer + } + + FloconSurface( onClick = onClick, - containerColor = if (selected) { - FloconTheme.colorPalette.accent - } else { - FloconTheme.colorPalette.secondary - }, - modifier = modifier + color = bgColor, + contentColor = contentColor, + shape = shape, + border = BorderStroke(1.dp, borderColor), + modifier = modifier.height(40.dp), + interactionSource = interactionSource ) { - Text( - text = stringResource( - when (theme) { - ThemeSetting.Dark -> Res.string.settings_theme_dark - ThemeSetting.Light -> Res.string.settings_theme_light - ThemeSetting.System -> Res.string.settings_theme_system - } - ), - style = FloconTheme.typography.bodySmall - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + FloconIcon( + imageVector = icon, + tint = contentColor, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource( + when (theme) { + ThemeSetting.Dark -> Res.string.settings_theme_dark + ThemeSetting.Light -> Res.string.settings_theme_light + ThemeSetting.System -> Res.string.settings_theme_system + } + ), + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + style = FloconTheme.typography.bodySmall + ) + } } } @Composable -private fun LicensesWindow( - onCloseRequest: () -> Unit private fun ConsoleLogPanel( logs: List, modifier: Modifier = Modifier, diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt index c0a4417a1..393425b0e 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsUiState.kt @@ -1,6 +1,7 @@ package io.github.openflocon.flocondesktop.app.ui.settings import androidx.compose.runtime.Immutable +import io.github.openflocon.domain.models.settings.ThemeSetting import io.github.openflocon.domain.settings.repository.AdbForwardStatus import io.github.openflocon.flocondesktop.common.log.LogEntryUiModel import io.github.openflocon.flocondesktop.common.log.LogLevel @@ -8,8 +9,8 @@ import io.github.openflocon.flocondesktop.common.log.LogLevel @Immutable data class SettingsUiState( val fontSizeMultiplier: Float, - val logs: List = emptyList(), - val adbForwardStatus: AdbForwardStatus = AdbForwardStatus.UNKNOWN, + val logs: List, + val adbForwardStatus: AdbForwardStatus, val theme: ThemeSetting ) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt index c27c0785a..1ecd360d4 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import flocondesktop.composeapp.generated.resources.settings_test_failure import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.feedback.FeedbackDisplayer import io.github.openflocon.domain.models.settings.ThemeSetting +import io.github.openflocon.domain.settings.repository.AdbForwardStatus import io.github.openflocon.domain.settings.repository.SettingsRepository import io.github.openflocon.domain.settings.usecase.ObserveFontSizeMultiplierUseCase import io.github.openflocon.domain.settings.usecase.ObserveThemeUseCase @@ -45,11 +46,13 @@ class SettingsViewModel( val uiState = combine( fontSizeMultiplierUseCase(), + observeThemeUseCase(), logManager.logs, settingsRepository.adbForwardStatus, - ) { multiplier, logs, forwardStatus -> + ) { multiplier, theme, logs, forwardStatus -> SettingsUiState( fontSizeMultiplier = multiplier, + theme = theme, logs = logs.map { it.toUiModel() }, adbForwardStatus = forwardStatus, ) @@ -60,6 +63,8 @@ class SettingsViewModel( initialValue = SettingsUiState( fontSizeMultiplier = 1f, theme = ThemeSetting.DEFAULT, + logs = emptyList(), + adbForwardStatus = AdbForwardStatus.UNKNOWN ) ) From 16ca7b479a5caccc56bbdade0bf85c3339e11165 Mon Sep 17 00:00:00 2001 From: doTTTTT Date: Wed, 29 Jul 2026 19:08:30 +0200 Subject: [PATCH 5/5] refactor: remove live preview from appearance settings The live preview section, which included a mock log entry, is no longer needed in the Appearance pane. A dedicated Logs tab has been introduced to centralize application logging. --- .../app/ui/settings/SettingsScreen.kt | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt index a319b478f..d403fac7d 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/settings/SettingsScreen.kt @@ -554,35 +554,6 @@ private fun AppearancePane( modifier = Modifier.width(48.dp) ) } - - Spacer(Modifier.height(8.dp)) - - Text( - text = "Live Preview", - style = FloconTheme.typography.labelSmall, - color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f) - ) - - // Scaled text preview container - Column( - modifier = Modifier - .fillMaxWidth() - .clip(FloconTheme.shapes.small) - .background(FloconTheme.colorPalette.secondary) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "The quick brown fox jumps over the lazy dog.", - style = FloconTheme.typography.bodyMedium, - color = FloconTheme.colorPalette.onPrimary - ) - Text( - text = "[12:00:00 DEBUG] [AppViewModel] Live layout preview active.", - style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = FloconTheme.colorPalette.onAccent - ) - } } } }