diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index bcf94b0df..eb554de41 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -87,7 +87,7 @@ Success, file saved at %1$s Search Please setup ADB first, this field is mandatory - ADB configuraton is valid + ADB configuration is valid Font Size Multiplier : %1$sx Theme Dark diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/AppWindow.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/AppWindow.kt index 18821305e..2cfac6fcd 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/AppWindow.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/AppWindow.kt @@ -21,10 +21,15 @@ import io.github.openflocon.flocondesktop.app.AppScreen import io.github.openflocon.flocondesktop.app.di.appModule import io.github.openflocon.flocondesktop.common.di.commonModule import io.github.openflocon.flocondesktop.core.di.coreModule +import io.github.openflocon.domain.settings.repository.SettingsRepository +import io.github.openflocon.domain.device.repository.DevicesRepository +import io.github.openflocon.flocondesktop.features.onboarding.OnboardingRoutes import io.github.openflocon.flocondesktop.features.featuresModule import io.github.openflocon.flocondesktop.features.network.NetworkRoutes import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.navigation.MainFloconNavigationState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.koin.compose.KoinApplication import org.koin.compose.koinInject import org.koin.core.module.dsl.singleOf @@ -51,7 +56,28 @@ fun App() { // scope { // scoped { MainFloconNavigationState(MainRoutes.Main) } // } - single { MainFloconNavigationState(NetworkRoutes.Main) } + single { + val repository = get() + val devicesRepository = get() + val hasDevices = try { + runBlocking { + devicesRepository.devices.first().isNotEmpty() + } + } catch (e: Exception) { + false + } + if (hasDevices && !repository.isOnboardingCompleted()) { + runBlocking { + repository.setOnboardingCompleted(true) + } + } + val startRoute = if (repository.isOnboardingCompleted() || hasDevices) { + NetworkRoutes.Main + } else { + OnboardingRoutes.Main + } + MainFloconNavigationState(startRoute) + } singleOf(::AdbRepositoryImpl) bind AdbRepository::class }, ) 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 2e9556ce0..ec5bbe497 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 @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.scene.SinglePaneSceneStrategy import io.github.openflocon.flocondesktop.app.ui.settings.settingsRoutes +import io.github.openflocon.flocondesktop.app.ui.settings.SettingsRoutes 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 @@ -23,6 +24,7 @@ import io.github.openflocon.flocondesktop.features.deeplinks.deeplinkRoutes import io.github.openflocon.flocondesktop.features.files.filesRoutes import io.github.openflocon.flocondesktop.features.images.imageRoutes import io.github.openflocon.flocondesktop.features.network.networkRoutes +import io.github.openflocon.flocondesktop.features.onboarding.onboardingRoutes import io.github.openflocon.flocondesktop.features.sharedpreferences.sharedPreferencesRoutes import io.github.openflocon.flocondesktop.features.table.tableRoutes import io.github.openflocon.library.designsystem.FloconTheme @@ -38,10 +40,14 @@ import org.koin.compose.viewmodel.koinViewModel fun AppScreen() { val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val adbError by viewModel.adbErrorState.collectAsStateWithLifecycle() + val serverError by viewModel.serverError.collectAsStateWithLifecycle() Box(modifier = Modifier.fillMaxSize()) { Content( uiState = uiState, + adbError = adbError, + serverError = serverError, navigationState = viewModel.navigationState, onAction = viewModel::onAction ) @@ -53,6 +59,8 @@ fun AppScreen() { @Composable private fun Content( uiState: AppUiState, + adbError: AdbErrorType, + serverError: String?, navigationState: MainFloconNavigationState, onAction: (AppAction) -> Unit ) { @@ -90,7 +98,10 @@ private fun Content( onAppSelected = { onAction(AppAction.SelectApp(it)) }, onRecordClicked = { onAction(AppAction.Record) }, onRestartClicked = { onAction(AppAction.Restart) }, - onTakeScreenshotClicked = { onAction(AppAction.Screenshoot) } + onTakeScreenshotClicked = { onAction(AppAction.Screenshoot) }, + adbError = adbError, + serverError = serverError, + onFixAdbClicked = { navigationState.navigate(SettingsRoutes.Main) } ) } ) @@ -111,5 +122,6 @@ private fun Content( tableRoutes() settingsRoutes() crashReporterRoutes() + onboardingRoutes() } } 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 ec086a369..7356d5afe 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 @@ -36,12 +36,16 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString +enum class AdbErrorType { NONE, SETUP_REQUIRED, PORT_FORWARD_ERROR, SERVER_ERROR } + internal class AppViewModel( - messagesServerDelegate: MessagesServerDelegate, + private val messagesServerDelegate: MessagesServerDelegate, initAdbPathUseCase: InitAdbPathUseCase, startAdbForwardUseCase: StartAdbForwardUseCase, val navigationState: MainFloconNavigationState, @@ -56,6 +60,25 @@ internal class AppViewModel( private val settingsRepository: SettingsRepository, ) : ViewModel(messagesServerDelegate) { + val serverError = messagesServerDelegate.serverError + + val adbErrorState = combine( + initialSetupStateHolder.needsAdbSetup, + settingsRepository.adbForwardStatus, + messagesServerDelegate.serverError + ) { needsSetup, forwardStatus, serverErrorMsg -> + when { + serverErrorMsg != null -> AdbErrorType.SERVER_ERROR + needsSetup -> AdbErrorType.SETUP_REQUIRED + forwardStatus == AdbForwardStatus.NOK -> AdbErrorType.PORT_FORWARD_ERROR + else -> AdbErrorType.NONE + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = AdbErrorType.NONE + ) + private val contentState = MutableStateFlow( ContentUiState( current = SubScreen.Network 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 d403fac7d..6320e3b8f 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 @@ -73,6 +73,11 @@ 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.FloconIcon +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import io.github.openflocon.flocondesktop.common.utils.pickAdbFile +import io.github.openflocon.library.designsystem.components.FloconIconButton +import androidx.compose.material.icons.outlined.FolderOpen import io.github.openflocon.library.designsystem.components.FloconSlider import io.github.openflocon.library.designsystem.components.FloconSurface import io.github.openflocon.library.designsystem.components.FloconTextFieldWithoutM3 @@ -116,6 +121,8 @@ fun SettingsScreen( onAction = viewModel::onAction, onClearLogs = viewModel::clearLogs, needsAdbSetup = needsAdbSetup, + onLaunchOnboarding = viewModel::launchOnboarding, + onRelaunchAdbAndServer = viewModel::relaunchAdbAndServer, ) } @@ -133,6 +140,8 @@ private fun SettingsScreen( needsAdbSetup: Boolean, onAction: (SettingsAction) -> Unit, onClearLogs: () -> Unit, + onLaunchOnboarding: () -> Unit, + onRelaunchAdbAndServer: () -> Unit, modifier: Modifier = Modifier, ) { var selectedTab by remember { mutableStateOf(SettingsTab.Adb) } @@ -174,6 +183,8 @@ private fun SettingsScreen( testAdbPath = testAdbPath, needsAdbSetup = needsAdbSetup, adbForwardStatus = uiState.adbForwardStatus, + serverError = uiState.serverError, + onRelaunchAdbAndServer = onRelaunchAdbAndServer, ) SettingsTab.Appearance -> AppearancePane( @@ -187,7 +198,9 @@ private fun SettingsScreen( onClearLogs = onClearLogs, ) - SettingsTab.About -> AboutPane() + SettingsTab.About -> AboutPane( + onLaunchOnboarding = onLaunchOnboarding + ) } } } @@ -323,8 +336,11 @@ private fun AdbPane( testAdbPath: () -> Unit, needsAdbSetup: Boolean, adbForwardStatus: AdbForwardStatus, + serverError: String?, + onRelaunchAdbAndServer: () -> Unit, modifier: Modifier = Modifier, ) { + val scope = rememberCoroutineScope() Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier @@ -334,7 +350,49 @@ private fun AdbPane( 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 + Text( + text = "ADB Executable Path", + style = FloconTheme.typography.labelSmall, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f) + ) + + FloconTextFieldWithoutM3( + value = adbPathText, + onValueChange = onAdbPathChanged, + placeholder = defaultPlaceHolder("Eg: /Users/youruser/Library/Android/sdk/platform-tools/adb"), + containerColor = FloconTheme.colorPalette.secondary, + contentPadding = PaddingValues(12.dp), + trailingComponent = { + FloconIconButton( + onClick = { + scope.launch { + pickAdbFile()?.let { onAdbPathChanged(it) } + } + } + ) { + FloconIcon( + imageVector = Icons.Outlined.FolderOpen, + tint = FloconTheme.colorPalette.onSecondary + ) + } + }, + 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, + ) + } + + Spacer(Modifier.height(4.dp)) + + // Setup alert or status at the bottom if (needsAdbSetup) { Row( modifier = Modifier @@ -380,74 +438,110 @@ private fun AdbPane( ) } } - - Spacer(Modifier.height(4.dp)) - - Text( - text = "ADB Executable Path", - style = FloconTheme.typography.labelSmall, - color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.6f) - ) - - 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", + title = "ADB Health Status", 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)." + description = "Flocon runs a local server and uses ADB reverse port forwarding to transfer data (logs, preferences, screenshots) from your Android device." ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier - .fillMaxWidth() - .clip(FloconTheme.shapes.small) - .background(FloconTheme.colorPalette.secondary) - .padding(12.dp) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() ) { - AdbForwardStatusBadge(status = adbForwardStatus) + // Status 1: Local Server + val serverStarted = serverError == null + val serverBgColor = if (serverStarted) FloconTheme.colorPalette.secondary else FloconTheme.colorPalette.error.copy(alpha = 0.12f) + val serverTextColor = if (serverStarted) FloconTheme.colorPalette.onPrimary else FloconTheme.colorPalette.error + val serverBadgeBgColor = if (serverStarted) FloconTheme.colorPalette.accent.copy(alpha = 0.2f) else FloconTheme.colorPalette.error.copy(alpha = 0.2f) + val serverBadgeTextColor = if (serverStarted) FloconTheme.colorPalette.onAccent else FloconTheme.colorPalette.error - Text( - 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) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(FloconTheme.shapes.small) + .background(serverBgColor) + .padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(FloconTheme.shapes.small) + .background(serverBadgeBgColor) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + FloconIcon( + imageVector = if (serverStarted) Icons.Outlined.Check else Icons.Outlined.ErrorOutline, + tint = serverBadgeTextColor, + modifier = Modifier.size(14.dp) + ) + Text( + text = if (serverStarted) "STARTED" else "FAILED", + color = serverBadgeTextColor, + style = FloconTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = if (serverStarted) "Local WebSocket server is running successfully on port 9023." else serverError ?: "Local server failed to start.", + color = serverTextColor, + style = FloconTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + } + + // Status 2: ADB Reverse Port Forwarding + val forwardBgColor = when (adbForwardStatus) { + AdbForwardStatus.NOK -> FloconTheme.colorPalette.error.copy(alpha = 0.12f) + else -> FloconTheme.colorPalette.secondary + } + val forwardTextColor = when (adbForwardStatus) { + AdbForwardStatus.NOK -> FloconTheme.colorPalette.error + AdbForwardStatus.UNKNOWN -> FloconTheme.colorPalette.onSecondary.copy(alpha = 0.8f) + else -> FloconTheme.colorPalette.onPrimary + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clip(FloconTheme.shapes.small) + .background(forwardBgColor) + .padding(12.dp) + ) { + AdbForwardStatusBadge(status = adbForwardStatus) + + Text( + 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 = forwardTextColor, + style = FloconTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + } + + // Relaunch / Retry Button + FloconButton( + onClick = onRelaunchAdbAndServer, + modifier = Modifier.align(Alignment.End) + ) { + Text( + text = "Relaunch Services", + style = FloconTheme.typography.labelMedium + ) + } } } } } -private data class BadgeTheme( - val label: String, - val bgColor: Color, - val textColor: Color, - val icon: ImageVector -) - @Composable private fun AdbForwardStatusBadge( status: AdbForwardStatus, @@ -657,14 +751,44 @@ private fun LogsPane( } } +private data class BadgeTheme( + val label: String, + val bgColor: Color, + val textColor: Color, + val icon: ImageVector +) + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + @Composable private fun AboutPane( + onLaunchOnboarding: () -> Unit, modifier: Modifier = Modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = modifier.fillMaxSize() ) { + SettingsCard( + title = "Setup Guide", + icon = Icons.Outlined.Info, + description = "Click below to relaunch the initial onboarding and step-by-step setup guide." + ) { + FloconButton( + onClick = onLaunchOnboarding, + containerColor = FloconTheme.colorPalette.secondary + ) { + Text( + text = "Launch Onboarding", + color = FloconTheme.colorPalette.onSecondary + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( text = "Open Source Licenses", style = FloconTheme.typography.titleMedium, @@ -845,7 +969,9 @@ private fun SettingsScreenPreview() { modifier = Modifier.fillMaxSize(), onAction = {}, onClearLogs = {}, + onLaunchOnboarding = {}, needsAdbSetup = false, + onRelaunchAdbAndServer = {}, ) } } @@ -864,7 +990,9 @@ private fun SettingsScreenPreview_needsAdbSetup() { modifier = Modifier.fillMaxSize(), onAction = {}, onClearLogs = {}, + onLaunchOnboarding = {}, needsAdbSetup = true, + onRelaunchAdbAndServer = {}, ) } } @@ -882,7 +1010,9 @@ private fun SettingsScreen_LogsPreview() { modifier = Modifier.fillMaxSize(), onAction = {}, onClearLogs = {}, + onLaunchOnboarding = {}, needsAdbSetup = false, + onRelaunchAdbAndServer = {}, ) } } 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 393425b0e..e0b75fbda 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 @@ -11,7 +11,8 @@ data class SettingsUiState( val fontSizeMultiplier: Float, val logs: List, val adbForwardStatus: AdbForwardStatus, - val theme: ThemeSetting + val theme: ThemeSetting, + val serverError: String? = null ) fun previewSettingsUiState() = SettingsUiState( 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 1ecd360d4..b4d45d307 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 @@ -25,6 +25,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import io.github.openflocon.navigation.MainFloconNavigationState +import io.github.openflocon.flocondesktop.features.onboarding.OnboardingRoutes +import io.github.openflocon.domain.settings.usecase.StartAdbForwardUseCase +import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate import org.jetbrains.compose.resources.getString class SettingsViewModel( @@ -38,6 +42,9 @@ class SettingsViewModel( private val initialSetupStateHolder: InitialSetupStateHolder, private val dispatcherProvider: DispatcherProvider, private val logManager: LogManager, + private val navigationState: MainFloconNavigationState, + private val startAdbForwardUseCase: StartAdbForwardUseCase, + private val messagesServerDelegate: MessagesServerDelegate, ) : ViewModel() { private val _adbPathInput = MutableStateFlow("") @@ -49,12 +56,14 @@ class SettingsViewModel( observeThemeUseCase(), logManager.logs, settingsRepository.adbForwardStatus, - ) { multiplier, theme, logs, forwardStatus -> + messagesServerDelegate.serverError, + ) { multiplier, theme, logs, forwardStatus, serverErrorMsg -> SettingsUiState( fontSizeMultiplier = multiplier, theme = theme, logs = logs.map { it.toUiModel() }, adbForwardStatus = forwardStatus, + serverError = serverErrorMsg, ) } .stateIn( @@ -64,15 +73,25 @@ class SettingsViewModel( fontSizeMultiplier = 1f, theme = ThemeSetting.DEFAULT, logs = emptyList(), - adbForwardStatus = AdbForwardStatus.UNKNOWN + adbForwardStatus = AdbForwardStatus.UNKNOWN, + serverError = null ) ) init { viewModelScope.launch { - // Utiliser GlobalScope ici pour la simplicité de l'exemple, mais préférez un scope dédié settingsRepository.adbPath.collect { path -> - path?.let { _adbPathInput.value = it } + path?.let { + _adbPathInput.value = it + testAdbUseCase(it).fold( + doOnFailure = { + initialSetupStateHolder.setRequiresInitialSetup() + }, + doOnSuccess = { + initialSetupStateHolder.setAdbIsWorking() + } + ) + } } } } @@ -102,7 +121,19 @@ class SettingsViewModel( fun saveAdbPath() { viewModelScope.launch(dispatcherProvider.viewModel) { - saveAdb() + val path = adbPathInput.value + testAdbUseCase(path).fold( + doOnFailure = { + feedbackDisplayer.displayMessage( + message = "Cannot save: ADB path is invalid.", + type = FeedbackDisplayer.MessageType.Error + ) + }, + doOnSuccess = { + saveAdb() + feedbackDisplayer.displayMessage("ADB path saved successfully!") + } + ) } } @@ -115,11 +146,10 @@ class SettingsViewModel( 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( + testAdbUseCase(path).fold( doOnFailure = { val msg = "ADB test failed: ${it.message}" Logger.e(TAG, it) { msg } @@ -135,15 +165,43 @@ class SettingsViewModel( logManager.d(TAG, "ADB test succeeded") feedbackDisplayer.displayMessage(getString(Res.string.general_success)) initialSetupStateHolder.setAdbIsWorking() + saveAdb() }, ) } } + fun launchOnboarding() { + viewModelScope.launch { + settingsRepository.setOnboardingCompleted(false) + navigationState.navigate(OnboardingRoutes.Main) + } + } + fun clearLogs() { logManager.clear() } + fun relaunchAdbAndServer() { + viewModelScope.launch(dispatcherProvider.viewModel) { + logManager.d(TAG, "User requested relaunch of ADB Server connection & websocket server") + messagesServerDelegate.relaunchServer() + startAdbForwardUseCase().fold( + doOnSuccess = { + settingsRepository.setAdbForwardStatus(AdbForwardStatus.OK) + feedbackDisplayer.displayMessage("Services relaunched successfully") + }, + doOnFailure = { + settingsRepository.setAdbForwardStatus(AdbForwardStatus.NOK) + feedbackDisplayer.displayMessage( + message = "ADB Port Forward failed: ${it.message}", + type = FeedbackDisplayer.MessageType.Error + ) + } + ) + } + } + companion object { private const val TAG = "SettingsViewModel" } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt index 4a5d43c46..99db97821 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt @@ -28,6 +28,14 @@ import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarActio import io.github.openflocon.library.designsystem.FloconTheme import org.jetbrains.compose.resources.painterResource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Warning +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconTextButton +import androidx.compose.ui.graphics.Color + +import io.github.openflocon.flocondesktop.app.AdbErrorType + @Composable fun MainScreenTopBar( modifier: Modifier = Modifier, @@ -41,6 +49,9 @@ fun MainScreenTopBar( recordState: RecordVideoStateUiModel, onRecordClicked: () -> Unit, onRestartClicked: () -> Unit, + adbError: AdbErrorType, + serverError: String?, + onFixAdbClicked: () -> Unit, ) { Row( modifier = modifier @@ -50,14 +61,54 @@ fun MainScreenTopBar( ) { Title() Spacer(modifier = Modifier.width(18.dp)) - TopBarDeviceAndAppView( - devicesState = devicesState, - appsState = appsState, - onDeviceSelected = onDeviceSelected, - onAppSelected = onAppSelected, - deleteDevice = deleteDevice, - deleteApp = deleteApp, - ) + if (adbError != AdbErrorType.NONE) { + val errorMessage = when (adbError) { + AdbErrorType.SERVER_ERROR -> serverError ?: "Server Error" + AdbErrorType.SETUP_REQUIRED -> "ADB Error: Setup is required" + AdbErrorType.PORT_FORWARD_ERROR -> "ADB Error: Port forwarding failed" + else -> "" + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.error.copy(alpha = 0.15f)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + FloconIcon( + imageVector = Icons.Outlined.Warning, + tint = FloconTheme.colorPalette.error, + modifier = Modifier.size(16.dp) + ) + Text( + text = errorMessage, + color = FloconTheme.colorPalette.error, + style = FloconTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + FloconTextButton( + onClick = onFixAdbClicked, + containerColor = FloconTheme.colorPalette.error + ) { + Text( + text = "Configure", + color = FloconTheme.colorPalette.onError, + style = FloconTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + } + } + } else { + TopBarDeviceAndAppView( + devicesState = devicesState, + appsState = appsState, + onDeviceSelected = onDeviceSelected, + onAppSelected = onAppSelected, + deleteDevice = deleteDevice, + deleteApp = deleteApp, + ) + } Spacer(modifier = Modifier.weight(1f)) TopBarActions( onTakeScreenshotClicked = onTakeScreenshotClicked, diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/FilePicker.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/FilePicker.kt new file mode 100644 index 000000000..e90e0c5b8 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/FilePicker.kt @@ -0,0 +1,3 @@ +package io.github.openflocon.flocondesktop.common.utils + +expect suspend fun pickAdbFile(): String? 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 143382897..116fd8380 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 @@ -49,4 +49,10 @@ internal class SettingsRepositoryImpl( override suspend fun setTheme(value: ThemeSetting) { localSettingsDataSource.setTheme(value) } + + override fun isOnboardingCompleted(): Boolean = localSettingsDataSource.isOnboardingCompleted() + + override suspend fun setOnboardingCompleted(completed: Boolean) { + localSettingsDataSource.setOnboardingCompleted(completed) + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt index b3ed91d61..237486948 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSource.kt @@ -15,6 +15,9 @@ internal interface SettingsDataSource { suspend fun setFontSizeMultiplier(value: Float) suspend fun setTheme(value: ThemeSetting) + fun isOnboardingCompleted(): Boolean + suspend fun setOnboardingCompleted(completed: Boolean) + val adbPath: Flow val fontSizeMultiplier: StateFlow val theme: StateFlow diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt index 6b7fd83bf..2f8bd2f01 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/datasource/local/SettingsDataSourcePrefs.kt @@ -66,6 +66,12 @@ internal class SettingsDataSourcePrefs( settings.putString(THEME, value.name) } + override fun isOnboardingCompleted(): Boolean = settings.getBoolean(ONBOARDING_COMPLETED, false) + + override suspend fun setOnboardingCompleted(completed: Boolean) { + settings.putBoolean(ONBOARDING_COMPLETED, completed) + } + private fun String?.toThemeSetting(): ThemeSetting = this ?.let { name -> ThemeSetting.entries.firstOrNull { it.name == name } } ?: ThemeSetting.DEFAULT @@ -102,6 +108,7 @@ internal class SettingsDataSourcePrefs( private const val ADB_PATH = "adb_path" private const val FONT_SIZE_MULTIPLIER = "font_size_multiplier" private const val THEME = "theme" + private const val ONBOARDING_COMPLETED = "onboarding_completed" private const val NETWORK_SETTINGS = "network_settings" } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt index 372722c51..71e2dda4b 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt @@ -10,6 +10,7 @@ import io.github.openflocon.flocondesktop.features.deeplinks.deeplinkModule import io.github.openflocon.flocondesktop.features.files.filesModule import io.github.openflocon.flocondesktop.features.images.imagesModule import io.github.openflocon.flocondesktop.features.network.networkModule +import io.github.openflocon.flocondesktop.features.onboarding.onboardingModule import io.github.openflocon.flocondesktop.features.sharedpreferences.sharedPreferencesModule import io.github.openflocon.flocondesktop.features.table.tableModule import io.github.openflocon.flocondesktop.messages.di.messagesModule @@ -30,5 +31,6 @@ val featuresModule = module { adbCommanderModule, settingsModule, crashReporterModule, + onboardingModule, ) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt index 311f509f6..bd00fe0df 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt @@ -12,7 +12,6 @@ import io.github.openflocon.flocondesktop.features.network.mock.processor.Export import io.github.openflocon.flocondesktop.features.network.mock.processor.ImportMocksProcessor import io.github.openflocon.flocondesktop.features.network.search.NetworkSearchViewModel import io.github.openflocon.flocondesktop.features.network.websocket.NetworkWebsocketMockViewModel -import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -21,7 +20,6 @@ internal val networkModule = module { viewModelOf(::NetworkViewModel) viewModelOf(::NetworkDetailViewModel) - factoryOf(::MessagesServerDelegate) factoryOf(::HeaderDelegate) factoryOf(::OpenBodyDelegate) factoryOf(::NetworkDetailDelegate) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/DI.kt new file mode 100644 index 000000000..ba6d4c83b --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/DI.kt @@ -0,0 +1,8 @@ +package io.github.openflocon.flocondesktop.features.onboarding + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +internal val onboardingModule = module { + viewModelOf(::OnboardingViewModel) +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/Navigation.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/Navigation.kt new file mode 100644 index 000000000..2fc3c167e --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/Navigation.kt @@ -0,0 +1,18 @@ +package io.github.openflocon.flocondesktop.features.onboarding + +import androidx.navigation3.runtime.EntryProviderScope +import io.github.openflocon.flocondesktop.features.onboarding.view.OnboardingScreen +import io.github.openflocon.navigation.FloconRoute +import kotlinx.serialization.Serializable + +sealed interface OnboardingRoutes : FloconRoute { + + @Serializable + data object Main : OnboardingRoutes +} + +fun EntryProviderScope.onboardingRoutes() { + entry { + OnboardingScreen() + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/OnboardingViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/OnboardingViewModel.kt new file mode 100644 index 000000000..e3249a1eb --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/OnboardingViewModel.kt @@ -0,0 +1,140 @@ +package io.github.openflocon.flocondesktop.features.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.openflocon.domain.settings.repository.SettingsRepository +import io.github.openflocon.domain.settings.usecase.TestAdbUseCase +import io.github.openflocon.flocondesktop.app.ui.delegates.DevicesDelegate +import io.github.openflocon.flocondesktop.app.ui.model.AppsStateUiModel +import io.github.openflocon.flocondesktop.features.network.NetworkRoutes +import io.github.openflocon.navigation.MainFloconNavigationState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class OnboardingStep { + AdbConfig, + FloconSetup, + LaunchApp, + Success +} + +class OnboardingViewModel( + private val settingsRepository: SettingsRepository, + private val testAdbUseCase: TestAdbUseCase, + private val devicesDelegate: DevicesDelegate, + private val navigationState: MainFloconNavigationState, +) : ViewModel() { + + private val _currentStep = MutableStateFlow(OnboardingStep.AdbConfig) + val currentStep: StateFlow = _currentStep.asStateFlow() + + private val _adbPathInput = MutableStateFlow("") + val adbPathInput: StateFlow = _adbPathInput.asStateFlow() + + private val _adbValid = MutableStateFlow(null) + val adbValid: StateFlow = _adbValid.asStateFlow() + + private val _isAdbTesting = MutableStateFlow(false) + val isAdbTesting: StateFlow = _isAdbTesting.asStateFlow() + + private val _appDetected = MutableStateFlow(false) + val appDetected: StateFlow = _appDetected.asStateFlow() + + private val _successCountdown = MutableStateFlow(5) + val successCountdown: StateFlow = _successCountdown.asStateFlow() + + init { + viewModelScope.launch { + val path = settingsRepository.getAdbPath() ?: "" + _adbPathInput.value = path + if (path.isNotEmpty()) { + _isAdbTesting.value = true + testAdbUseCase(path).fold( + doOnFailure = { _adbValid.value = false }, + doOnSuccess = { _adbValid.value = true } + ) + _isAdbTesting.value = false + } + } + + viewModelScope.launch { + devicesDelegate.appsState.collect { state -> + val detected = state is AppsStateUiModel.WithApps && state.apps.isNotEmpty() + _appDetected.value = detected + if (detected && _currentStep.value == OnboardingStep.LaunchApp) { + goToStep(OnboardingStep.Success) + } + } + } + } + + fun onAdbPathChanged(newPath: String) { + _adbPathInput.value = newPath + _adbValid.value = null // reset validation state on edit + } + + fun testAdbPath() { + val path = adbPathInput.value + viewModelScope.launch { + _isAdbTesting.value = true + testAdbUseCase(path).fold( + doOnFailure = { + _adbValid.value = false + }, + doOnSuccess = { + _adbValid.value = true + settingsRepository.setAdbPath(path) + } + ) + _isAdbTesting.value = false + } + } + + fun goToStep(step: OnboardingStep) { + _currentStep.value = step + if (step == OnboardingStep.Success) { + startSuccessCountdown() + } + } + + fun nextStep() { + val next = when (_currentStep.value) { + OnboardingStep.AdbConfig -> OnboardingStep.FloconSetup + OnboardingStep.FloconSetup -> OnboardingStep.LaunchApp + OnboardingStep.LaunchApp -> OnboardingStep.Success + OnboardingStep.Success -> OnboardingStep.Success + } + goToStep(next) + } + + fun previousStep() { + val prev = when (_currentStep.value) { + OnboardingStep.AdbConfig -> OnboardingStep.AdbConfig + OnboardingStep.FloconSetup -> OnboardingStep.AdbConfig + OnboardingStep.LaunchApp -> OnboardingStep.FloconSetup + OnboardingStep.Success -> OnboardingStep.LaunchApp + } + goToStep(prev) + } + + fun skipOnboarding() { + viewModelScope.launch { + settingsRepository.setOnboardingCompleted(true) + navigationState.menu(NetworkRoutes.Main) + } + } + + private fun startSuccessCountdown() { + viewModelScope.launch { + settingsRepository.setOnboardingCompleted(true) + for (i in 5 downTo 1) { + _successCountdown.value = i + delay(1000) + } + navigationState.menu(NetworkRoutes.Main) + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/view/OnboardingScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/view/OnboardingScreen.kt new file mode 100644 index 000000000..8ae7ffa47 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/onboarding/view/OnboardingScreen.kt @@ -0,0 +1,591 @@ +package io.github.openflocon.flocondesktop.features.onboarding.view + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.github.openflocon.flocondesktop.features.onboarding.OnboardingStep +import io.github.openflocon.flocondesktop.features.onboarding.OnboardingViewModel +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconButton +import io.github.openflocon.library.designsystem.components.FloconCircularProgressIndicator +import io.github.openflocon.library.designsystem.components.FloconCodeBlock +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconOutlinedButton +import io.github.openflocon.library.designsystem.components.FloconSurface +import io.github.openflocon.library.designsystem.components.FloconTextField +import io.github.openflocon.library.designsystem.components.FloconTextButton +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.material.icons.outlined.FolderOpen +import io.github.openflocon.flocondesktop.common.utils.pickAdbFile +import io.github.openflocon.library.designsystem.components.FloconIconButton +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween + +@Composable +fun OnboardingScreen() { + val viewModel = koinViewModel() + val currentStep by viewModel.currentStep.collectAsState() + val adbPathInput by viewModel.adbPathInput.collectAsState() + val adbValid by viewModel.adbValid.collectAsState() + val isAdbTesting by viewModel.isAdbTesting.collectAsState() + val successCountdown by viewModel.successCountdown.collectAsState() + + Box( + modifier = Modifier + .fillMaxSize() + .background(FloconTheme.colorPalette.surface), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .width(600.dp) + .padding(32.dp) + ) { + // Header Title + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Welcome to Flocon", + style = FloconTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = FloconTheme.colorPalette.onAccent + ) + ) + + if (currentStep != OnboardingStep.Success) { + FloconTextButton( + onClick = viewModel::skipOnboarding, + containerColor = Color.Transparent + ) { + Text( + text = "Close", + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.6f) + ) + } + } + } + + // Step indicator + StepIndicator(currentStep = currentStep) + + // Main wizard Card + FloconSurface( + color = FloconTheme.colorPalette.primary, + shape = FloconTheme.shapes.large, + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = 300)) + ) { + AnimatedContent( + targetState = currentStep, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + (slideInHorizontally(animationSpec = tween(300)) { width -> width } + fadeIn(animationSpec = tween(300))) togetherWith + (slideOutHorizontally(animationSpec = tween(300)) { width -> -width } + fadeOut(animationSpec = tween(300))) + } else { + (slideInHorizontally(animationSpec = tween(300)) { width -> -width } + fadeIn(animationSpec = tween(300))) togetherWith + (slideOutHorizontally(animationSpec = tween(300)) { width -> width } + fadeOut(animationSpec = tween(300))) + }.using( + SizeTransform(clip = false) + ) + } + ) { step -> + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + when (step) { + OnboardingStep.AdbConfig -> AdbConfigStep( + adbPathInput = adbPathInput, + adbValid = adbValid, + isAdbTesting = isAdbTesting, + onPathChange = viewModel::onAdbPathChanged, + onTest = viewModel::testAdbPath, + onNext = viewModel::nextStep + ) + OnboardingStep.FloconSetup -> FloconSetupStep( + onBack = viewModel::previousStep, + onNext = viewModel::nextStep + ) + OnboardingStep.LaunchApp -> LaunchAppStep( + onBack = viewModel::previousStep + ) + OnboardingStep.Success -> SuccessStep( + countdown = successCountdown + ) + } + } + } + } + } + } +} + +@Composable +private fun StepIndicator(currentStep: OnboardingStep) { + val steps = listOf( + "ADB Setup" to OnboardingStep.AdbConfig, + "SDK Setup" to OnboardingStep.FloconSetup, + "Launch App" to OnboardingStep.LaunchApp, + "Connected" to OnboardingStep.Success + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + steps.forEachIndexed { index, (label, step) -> + val isActive = currentStep == step + val isCompleted = currentStep.ordinal > step.ordinal + + val circleColor by animateColorAsState( + targetValue = when { + isActive -> FloconTheme.colorPalette.accent + isCompleted -> FloconTheme.colorPalette.secondary.copy(alpha = 0.5f) + else -> FloconTheme.colorPalette.secondary.copy(alpha = 0.2f) + }, + animationSpec = tween(durationMillis = 300) + ) + + val textColor by animateColorAsState( + targetValue = if (isActive) FloconTheme.colorPalette.onAccent else FloconTheme.colorPalette.onSecondary, + animationSpec = tween(durationMillis = 300) + ) + + val labelColor by animateColorAsState( + targetValue = if (isActive) FloconTheme.colorPalette.onSurface else FloconTheme.colorPalette.onSurface.copy(alpha = 0.6f), + animationSpec = tween(durationMillis = 300) + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(28.dp) + .clip(MaterialTheme.shapes.small) + .background(circleColor) + ) { + if (isCompleted) { + FloconIcon( + imageVector = Icons.Default.Check, + tint = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.size(16.dp) + ) + } else { + Text( + text = (index + 1).toString(), + style = FloconTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + color = textColor + ) + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + style = FloconTheme.typography.bodySmall.copy( + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = labelColor + ), + textAlign = TextAlign.Center + ) + } + if (index < steps.size - 1) { + val lineColor by animateColorAsState( + targetValue = if (currentStep.ordinal > step.ordinal) FloconTheme.colorPalette.secondary.copy(alpha = 0.5f) + else FloconTheme.colorPalette.secondary.copy(alpha = 0.2f), + animationSpec = tween(durationMillis = 300) + ) + Box( + modifier = Modifier + .height(2.dp) + .weight(0.5f) + .background(lineColor) + ) + } + } + } +} + +@Composable +private fun AdbConfigStep( + adbPathInput: String, + adbValid: Boolean?, + isAdbTesting: Boolean, + onPathChange: (String) -> Unit, + onTest: () -> Unit, + onNext: () -> Unit +) { + val scope = rememberCoroutineScope() + + Text( + text = "1. ADB Configuration", + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "Flocon communicates with Android devices using the Android Debug Bridge (ADB). Please configure the path to your adb binary to proceed.", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "ADB Executable Path", + style = FloconTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + + FloconTextField( + value = adbPathInput, + onValueChange = onPathChange, + placeholder = { Text("Eg: /Users/youruser/Library/Android/sdk/platform-tools/adb") }, + trailingComponent = { + FloconIconButton( + onClick = { + scope.launch { + pickAdbFile()?.let { onPathChange(it) } + } + } + ) { + FloconIcon( + imageVector = Icons.Outlined.FolderOpen, + tint = FloconTheme.colorPalette.onSecondary + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FloconButton( + onClick = onTest, + containerColor = FloconTheme.colorPalette.secondary, + modifier = Modifier.height(36.dp) + ) { + if (isAdbTesting) { + FloconCircularProgressIndicator( + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Testing...") + } else { + Text("Test Connection") + } + } + + when (adbValid) { + true -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + FloconIcon( + imageVector = Icons.Default.CheckCircle, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(20.dp) + ) + Text( + text = "Connection Successful", + style = FloconTheme.typography.bodyMedium.copy(color = Color(0xFF4CAF50)) + ) + } + } + false -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + FloconIcon( + imageVector = Icons.Default.Error, + tint = FloconTheme.colorPalette.error, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Validation Failed", + style = FloconTheme.typography.bodyMedium.copy(color = FloconTheme.colorPalette.error) + ) + } + } + null -> {} + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + FloconButton( + onClick = { if (adbValid == true) onNext() }, + containerColor = if (adbValid == true) FloconTheme.colorPalette.accent else FloconTheme.colorPalette.secondary.copy(alpha = 0.5f), + modifier = Modifier.width(100.dp) + ) { + Text( + text = "Next", + color = if (adbValid == true) FloconTheme.colorPalette.onAccent else FloconTheme.colorPalette.onSecondary.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.width(4.dp)) + FloconIcon( + imageVector = Icons.Default.ChevronRight, + tint = if (adbValid == true) FloconTheme.colorPalette.onAccent else FloconTheme.colorPalette.onSecondary.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) + } + } +} + +@Composable +private fun FloconSetupStep( + onBack: () -> Unit, + onNext: () -> Unit +) { + Text( + text = "2. Integrate Flocon SDK", + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "Add Flocon inside your Android app to start intercepting network requests, databases, and logs.", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f) + ) + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Step A: Add Dependency", + style = FloconTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + FloconCodeBlock( + code = """ + // In your build.gradle.kts (app module): + dependencies { + debugImplementation("io.github.openflocon:flocon:1.4.0") + releaseImplementation("io.github.openflocon:flocon-no-op:1.4.0") + } + """.trimIndent(), + modifier = Modifier.fillMaxWidth(), + containerColor = FloconTheme.colorPalette.secondary + ) + + Text( + text = "Step B: Initialize in Application", + style = FloconTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + FloconCodeBlock( + code = """ + // In your Application or MainActivity onCreate(): + import io.github.openflocon.flocon.Flocon + + Flocon.initialize(context) + """.trimIndent(), + modifier = Modifier.fillMaxWidth(), + containerColor = FloconTheme.colorPalette.secondary + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + FloconOutlinedButton( + onClick = onBack, + modifier = Modifier.width(100.dp) + ) { + FloconIcon( + imageVector = Icons.Default.ChevronLeft, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Back") + } + + FloconButton( + onClick = onNext, + containerColor = FloconTheme.colorPalette.accent, + modifier = Modifier.width(100.dp) + ) { + Text( + text = "Next", + color = FloconTheme.colorPalette.onAccent + ) + Spacer(modifier = Modifier.width(4.dp)) + FloconIcon( + imageVector = Icons.Default.ChevronRight, + tint = FloconTheme.colorPalette.onAccent, + modifier = Modifier.size(16.dp) + ) + } + } +} + +@Composable +private fun LaunchAppStep( + onBack: () -> Unit +) { + Text( + text = "3. Launch Your App", + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "Run your Android application on an emulator or a connected physical device.", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + FloconCircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Text( + text = "Waiting for Flocon app connection...", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.7f) + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + FloconOutlinedButton( + onClick = onBack, + modifier = Modifier.width(100.dp) + ) { + FloconIcon( + imageVector = Icons.Default.ChevronLeft, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Back") + } + } +} + +@Composable +private fun SuccessStep( + countdown: Int +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + FloconIcon( + imageVector = Icons.Default.CheckCircle, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(64.dp) + ) + Text( + text = "Successfully Connected!", + style = FloconTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color(0xFF4CAF50) + ) + Text( + text = "Flocon has successfully detected your application and is ready to capture network requests, databases, and logs.", + style = FloconTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Redirecting to network menu in $countdown seconds...", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.6f) + ) + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/MessagesServerDelegate.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/MessagesServerDelegate.kt index 921fcd42a..a67938cc6 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/MessagesServerDelegate.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/MessagesServerDelegate.kt @@ -12,6 +12,8 @@ import io.github.openflocon.domain.messages.usecase.StartServerUseCase import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableDelegate import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableScoped import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -26,6 +28,9 @@ class MessagesServerDelegate( private val dispatcherProvider: DispatcherProvider, ) : CloseableScoped by closeableDelegate { + private val _serverError = MutableStateFlow(null) + val serverError = _serverError.asStateFlow() + fun initialize() { coroutineScope.launch { handleIncomingMessagesUseCase() @@ -44,6 +49,7 @@ class MessagesServerDelegate( while (isActive) { startServer().fold( doOnSuccess = { + _serverError.value = null delay(20.seconds) }, doOnFailure = { @@ -54,20 +60,31 @@ class MessagesServerDelegate( } } + fun relaunchServer() { + coroutineScope.launch { + startServer().fold( + doOnSuccess = { + _serverError.value = null + }, + doOnFailure = { + // error is set inside startServer + } + ) + } + } + private fun startServer(): Either = try { startServerUseCase() Success(Unit) } catch (t: Throwable) { - feedbackDisplayer.displayMessage( - buildString { - append("Cannot start server on port ${Constant.SERVER_WEBSOCKET_PORT}") - t.message?.let { - append(" : ") - append(it) - } - }, - type = FeedbackDisplayer.MessageType.Error, - ) + val errorMsg = buildString { + append("Cannot start server on port ${Constant.SERVER_WEBSOCKET_PORT}") + t.message?.let { + append(" : ") + append(it) + } + } + _serverError.value = errorMsg Failure(t) } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/di/MessagesUiModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/di/MessagesUiModule.kt index c2ef0fb79..0f14754b1 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/di/MessagesUiModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/messages/ui/di/MessagesUiModule.kt @@ -1,10 +1,10 @@ package io.github.openflocon.flocondesktop.messages.ui.di import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate -import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val messagesUiModule = module { - factoryOf(::MessagesServerDelegate) + singleOf(::MessagesServerDelegate) } diff --git a/FloconDesktop/composeApp/src/desktopMain/kotlin/io/github/openflocon/flocondesktop/common/utils/FilePicker.desktop.kt b/FloconDesktop/composeApp/src/desktopMain/kotlin/io/github/openflocon/flocondesktop/common/utils/FilePicker.desktop.kt new file mode 100644 index 000000000..4e0e7ea09 --- /dev/null +++ b/FloconDesktop/composeApp/src/desktopMain/kotlin/io/github/openflocon/flocondesktop/common/utils/FilePicker.desktop.kt @@ -0,0 +1,18 @@ +package io.github.openflocon.flocondesktop.common.utils + +import java.awt.FileDialog +import java.awt.Frame +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +actual suspend fun pickAdbFile(): String? = withContext(Dispatchers.IO) { + val fileDialog = FileDialog(null as Frame?, "Select ADB Executable", FileDialog.LOAD) + fileDialog.isVisible = true + val directory = fileDialog.directory + val file = fileDialog.file + if (directory != null && file != null) { + "$directory$file" + } else { + null + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adb/ExecuteAdbCommandUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adb/ExecuteAdbCommandUseCase.kt index 24bd3840b..6a2487b7a 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adb/ExecuteAdbCommandUseCase.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/adb/ExecuteAdbCommandUseCase.kt @@ -13,8 +13,9 @@ class ExecuteAdbCommandUseCase( suspend operator fun invoke( target: AdbCommandTargetDomainModel, command: String, + adbPathOverride: String? = null, ): Either { - val adbPath = settingsRepository.getAdbPath() ?: return Failure(Throwable("No adb path")) + val adbPath = adbPathOverride ?: settingsRepository.getAdbPath() ?: return Failure(Throwable("No adb path")) val deviceSerial = when (target) { is AdbCommandTargetDomainModel.Device -> adbRepository.getAdbSerial(target.deviceId) 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 d170a0d74..465d22a09 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 @@ -19,6 +19,9 @@ interface SettingsRepository { suspend fun setTheme(value: ThemeSetting) + fun isOnboardingCompleted(): Boolean + suspend fun setOnboardingCompleted(completed: Boolean) + val adbPath: Flow val fontSizeMultiplier: StateFlow val theme: StateFlow diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/usecase/TestAdbUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/usecase/TestAdbUseCase.kt index af2298fdd..1b42cc165 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/usecase/TestAdbUseCase.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/settings/usecase/TestAdbUseCase.kt @@ -7,8 +7,9 @@ import io.github.openflocon.domain.common.Either class TestAdbUseCase( private val executeAdbCommandUseCase: ExecuteAdbCommandUseCase, ) { - suspend operator fun invoke(): Either = executeAdbCommandUseCase( + suspend operator fun invoke(adbPathOverride: String? = null): Either = executeAdbCommandUseCase( command = "start-server", target = AdbCommandTargetDomainModel.AllDevices, + adbPathOverride = adbPathOverride, ).mapSuccess { Unit } }