From b882d5acac4de32c27a6cdbf756cf5bf12c1dcab Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Wed, 28 Oct 2026 17:34:04 +0100 Subject: [PATCH 1/6] feat: [CONNECT] Connect dialog with local IP addresses and QR codes --- FloconDesktop/composeApp/build.gradle.kts | 1 + .../composeResources/values-fr/strings.xml | 6 + .../composeResources/values/strings.xml | 6 + .../app/ui/view/topbar/MainScreenTopBar.kt | 11 ++ .../ui/view/topbar/actions/TopBarActions.kt | 10 + .../flocondesktop/features/FeaturesModule.kt | 2 + .../features/connect/ConnectViewModel.kt | 34 ++++ .../flocondesktop/features/connect/DI.kt | 8 + .../features/connect/model/ConnectUiModel.kt | 23 +++ .../features/connect/view/ConnectDialog.kt | 174 ++++++++++++++++++ .../features/connect/view/QrCodeImage.kt | 54 ++++++ .../kotlin/com/flocon/data/remote/DI.kt | 2 + .../com/flocon/data/remote/connect/DI.kt | 8 + .../connect/LocalNetworkRepositoryFactory.kt | 5 + .../LocalNetworkRepositoryFactory.desktop.kt | 5 + .../connect/LocalNetworkRepositoryImpl.kt | 30 +++ .../kotlin/io/github/openflocon/domain/DI.kt | 2 + .../io/github/openflocon/domain/connect/DI.kt | 9 + .../connect/models/LocalAddressDomainModel.kt | 6 + .../repository/LocalNetworkRepository.kt | 7 + .../usecase/GetLocalAddressesUseCase.kt | 35 ++++ .../usecase/GetLocalAddressesUseCaseTest.kt | 45 +++++ FloconDesktop/gradle/libs.versions.toml | 2 + docs/setup.md | 4 + 24 files changed, 489 insertions(+) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt create mode 100644 FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt create mode 100644 FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt create mode 100644 FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt create mode 100644 FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt create mode 100644 FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt diff --git a/FloconDesktop/composeApp/build.gradle.kts b/FloconDesktop/composeApp/build.gradle.kts index 383507e30..6d25823fc 100644 --- a/FloconDesktop/composeApp/build.gradle.kts +++ b/FloconDesktop/composeApp/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.core) + implementation(libs.zxing.core) implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.core) diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml index ab2cfc712..24228a004 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -327,6 +327,12 @@ Fermer les onglets à gauche Fermer les autres onglets Fermer les onglets à droite + Connecter un appareil + Connecter un appareil + Votre téléphone et cet ordinateur doivent être sur le même réseau Wi-Fi. Utilisez l\'une de ces adresses dans Flocon.connect(), ou scannez son QR code. + Aucune adresse du réseau local trouvée. Vérifiez que cet ordinateur est connecté à un réseau. + Afficher le QR code + Masquer le QR code Ouvrir le dossier Flocon Enregistrer l’écran Redémarrer l’app diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index efbee6d8c..7c1a0c929 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -400,6 +400,12 @@ Close Tabs to the Left Close Other Tabs Close Tabs to the Right + connect a device + Connect a device + Your phone and this computer must be on the same Wi-Fi network. Use one of these addresses in Flocon.connect(), or scan its QR code. + No local network address found. Check that this computer is connected to a network. + Show QR code + Hide QR code open flocon folder Record restart 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 062c86317..46b4555cc 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 @@ -19,6 +19,10 @@ import androidx.compose.material.icons.outlined.ArrowCircleUp import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,6 +48,7 @@ import io.github.openflocon.flocondesktop.app.ui.model.DevicesStateUiModel import io.github.openflocon.flocondesktop.app.ui.model.RecordVideoStateUiModel import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarActions import io.github.openflocon.flocondesktop.app.version.VersionCheckerViewModel +import io.github.openflocon.flocondesktop.features.connect.view.ConnectDialog import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.floconIsland import org.jetbrains.compose.resources.painterResource @@ -66,6 +71,11 @@ fun MainScreenTopBar( updateChip: VersionCheckerViewModel.UpdateChipUiModel? = null, onUpdateChipClicked: (VersionCheckerViewModel.UpdateChipUiModel) -> Unit = {}, ) { + var showConnectDialog by remember { mutableStateOf(false) } + if (showConnectDialog) { + ConnectDialog(onDismissRequest = { showConnectDialog = false }) + } + Row( modifier = modifier .floconIsland( @@ -98,6 +108,7 @@ fun MainScreenTopBar( onRecordClicked = onRecordClicked, onRestartClicked = onRestartClicked, onOpenFolderClicked = onOpenFolderClicked, + onConnectClicked = { showConnectDialog = true }, devicesState = devicesState, ) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt index 15860d332..a256532e2 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.QrCode2 import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material.icons.outlined.Videocam @@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.topbar_connect import flocondesktop.composeapp.generated.resources.topbar_open_folder import flocondesktop.composeapp.generated.resources.topbar_record import flocondesktop.composeapp.generated.resources.topbar_restart @@ -36,6 +38,7 @@ internal fun TopBarActions( onRecordClicked: () -> Unit, onRestartClicked: () -> Unit, onOpenFolderClicked: () -> Unit, + onConnectClicked: () -> Unit, modifier: Modifier = Modifier, ) { val colors = FloconTheme.colors @@ -86,5 +89,12 @@ internal fun TopBarActions( onClicked = onOpenFolderClicked, isEnabled = true, ) + TopBarButton( + active = false, + imageVector = Icons.Outlined.QrCode2, + contentDescription = stringResource(Res.string.topbar_connect), + onClicked = onConnectClicked, + isEnabled = true, + ) } } 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 c4005054c..4b0f3addb 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 @@ -5,6 +5,7 @@ import io.github.openflocon.flocondesktop.app.ui.settings.settingsModule import io.github.openflocon.flocondesktop.features.accessibility.accessibilityModule import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderModule import io.github.openflocon.flocondesktop.features.analytics.analyticsModule +import io.github.openflocon.flocondesktop.features.connect.connectModule import io.github.openflocon.flocondesktop.features.crashreporter.crashReporterModule import io.github.openflocon.flocondesktop.features.dashboard.dashboardModule import io.github.openflocon.flocondesktop.features.database.databaseModule @@ -22,6 +23,7 @@ import org.koin.dsl.module val featuresModule = module { includes( analyticsModule, + connectModule, featureFlagsModule, accessibilityModule, databaseModule, diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt new file mode 100644 index 000000000..0cc95642e --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt @@ -0,0 +1,34 @@ +package io.github.openflocon.flocondesktop.features.connect + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.openflocon.domain.common.DispatcherProvider +import io.github.openflocon.domain.connect.usecase.GetLocalAddressesUseCase +import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState +import io.github.openflocon.flocondesktop.features.connect.model.LocalAddressUiModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ConnectViewModel( + private val dispatcherProvider: DispatcherProvider, + private val getLocalAddressesUseCase: GetLocalAddressesUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConnectUiState(addresses = persistentListOf())) + val uiState: StateFlow = _uiState.asStateFlow() + + /** The network can change while the app is open, so addresses are read again each time the dialog opens. */ + fun refresh() { + viewModelScope.launch { + val addresses = withContext(dispatcherProvider.viewModel) { + getLocalAddressesUseCase().map { LocalAddressUiModel(interfaceName = it.interfaceName, ip = it.ip) } + } + _uiState.value = ConnectUiState(addresses = addresses.toImmutableList()) + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt new file mode 100644 index 000000000..fadb1a300 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt @@ -0,0 +1,8 @@ +package io.github.openflocon.flocondesktop.features.connect + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val connectModule = module { + viewModelOf(::ConnectViewModel) +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt new file mode 100644 index 000000000..12994f4d6 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt @@ -0,0 +1,23 @@ +package io.github.openflocon.flocondesktop.features.connect.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class LocalAddressUiModel( + val interfaceName: String, + val ip: String, +) + +@Immutable +data class ConnectUiState( + val addresses: ImmutableList, +) + +fun previewConnectUiState() = ConnectUiState( + addresses = persistentListOf( + LocalAddressUiModel(interfaceName = "en0", ip = "192.168.1.20"), + LocalAddressUiModel(interfaceName = "en5", ip = "10.0.0.12"), + ), +) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt new file mode 100644 index 000000000..32b129d05 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt @@ -0,0 +1,174 @@ +package io.github.openflocon.flocondesktop.features.connect.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.outlined.Close +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.connect_empty +import flocondesktop.composeapp.generated.resources.connect_hide_qr +import flocondesktop.composeapp.generated.resources.connect_hint +import flocondesktop.composeapp.generated.resources.connect_show_qr +import flocondesktop.composeapp.generated.resources.connect_title +import io.github.openflocon.flocondesktop.features.connect.ConnectViewModel +import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState +import io.github.openflocon.flocondesktop.features.connect.model.LocalAddressUiModel +import io.github.openflocon.flocondesktop.features.connect.model.previewConnectUiState +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconButton +import io.github.openflocon.library.designsystem.components.FloconDialog +import io.github.openflocon.library.designsystem.components.FloconDialogHeader +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconIconButton +import io.github.openflocon.library.designsystem.components.FloconOutlinedButton +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun ConnectDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: ConnectViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { viewModel.refresh() } + + ConnectDialog( + uiState = uiState, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) +} + +@Composable +private fun ConnectDialog( + uiState: ConnectUiState, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + var qrIp by remember { mutableStateOf(null) } + + FloconDialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + modifier = modifier.width(480.dp), + ) { + Column { + FloconDialogHeader( + title = stringResource(Res.string.connect_title), + trailingContent = { + FloconIconButton(onClick = onDismissRequest) { + FloconIcon(Icons.Outlined.Close) + } + }, + ) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = FloconTheme.spacing.medium) + .padding(bottom = FloconTheme.spacing.medium), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Text( + text = stringResource(Res.string.connect_hint), + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colors.inkMuted, + ) + if (uiState.addresses.isEmpty()) { + Text( + text = stringResource(Res.string.connect_empty), + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colors.ink, + ) + } + uiState.addresses.forEach { address -> + AddressRow( + address = address, + qrVisible = qrIp == address.ip, + onToggleQr = { qrIp = if (qrIp == address.ip) null else address.ip }, + ) + } + } + } + } +} + +@Composable +private fun AddressRow( + address: LocalAddressUiModel, + qrVisible: Boolean, + onToggleQr: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = address.ip, + style = FloconTheme.typography.monoMedium, + color = FloconTheme.colors.ink, + ) + Text( + text = address.interfaceName, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colors.inkMuted, + ) + } + Spacer(Modifier.width(FloconTheme.spacing.small)) + if (qrVisible) { + FloconOutlinedButton(onClick = onToggleQr) { + Text(stringResource(Res.string.connect_hide_qr)) + } + } else { + FloconButton(onClick = onToggleQr) { + Text(stringResource(Res.string.connect_show_qr)) + } + } + } + if (qrVisible) { + // the SDK connects with the bare ip, see Flocon.connect() + QrCodeImage( + content = address.ip, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(220.dp), + ) + } + } +} + +@Preview +@Composable +private fun ConnectDialogPreview() { + FloconTheme { + ConnectDialog( + uiState = previewConnectUiState(), + onDismissRequest = {}, + ) + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt new file mode 100644 index 000000000..dad93181e --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt @@ -0,0 +1,54 @@ +package io.github.openflocon.flocondesktop.features.connect.view + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel + +/** Always drawn black on white, even in dark mode, so that scanners can read it. */ +@Composable +internal fun QrCodeImage( + content: String, + modifier: Modifier = Modifier, +) { + val matrix = remember(content) { + QRCodeWriter().encode( + content, + BarcodeFormat.QR_CODE, + 0, + 0, + mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M), + ) + } + Canvas( + modifier = modifier + .aspectRatio(1f) + .background(Color.White) + .padding(12.dp), + ) { + val cell = size.width / matrix.width + for (x in 0 until matrix.width) { + for (y in 0 until matrix.height) { + if (matrix[x, y]) { + drawRect( + color = Color.Black, + topLeft = Offset(x * cell, y * cell), + // slight overlap avoids hairlines between cells + size = Size(cell + 0.5f, cell + 0.5f), + ) + } + } + } + } +} diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt index 39b887a50..4db4f545f 100644 --- a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt @@ -2,6 +2,7 @@ package com.flocon.data.remote import com.flocon.data.remote.accessibility.accessibilityModule import com.flocon.data.remote.analytics.analyticsModule +import com.flocon.data.remote.connect.connectModule import com.flocon.data.remote.crashreporter.crashReportModule import com.flocon.data.remote.dashboard.dashboardModule import com.flocon.data.remote.database.databaseModule @@ -21,6 +22,7 @@ val dataRemoteModule = module { includes( accessibilityModule, analyticsModule, + connectModule, featureFlagsModule, dashboardModule, databaseModule, diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt new file mode 100644 index 000000000..93c1876cc --- /dev/null +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt @@ -0,0 +1,8 @@ +package com.flocon.data.remote.connect + +import io.github.openflocon.domain.connect.repository.LocalNetworkRepository +import org.koin.dsl.module + +internal val connectModule = module { + single { getLocalNetworkRepository() } +} diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt new file mode 100644 index 000000000..6fb9283de --- /dev/null +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt @@ -0,0 +1,5 @@ +package com.flocon.data.remote.connect + +import io.github.openflocon.domain.connect.repository.LocalNetworkRepository + +expect fun getLocalNetworkRepository(): LocalNetworkRepository diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt new file mode 100644 index 000000000..c7685f430 --- /dev/null +++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt @@ -0,0 +1,5 @@ +package com.flocon.data.remote.connect + +import io.github.openflocon.domain.connect.repository.LocalNetworkRepository + +actual fun getLocalNetworkRepository(): LocalNetworkRepository = LocalNetworkRepositoryImpl() diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt new file mode 100644 index 000000000..7f5f905c1 --- /dev/null +++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.flocon.data.remote.connect + +import io.github.openflocon.domain.connect.models.LocalAddressDomainModel +import io.github.openflocon.domain.connect.repository.LocalNetworkRepository +import java.net.Inet4Address +import java.net.NetworkInterface + +internal class LocalNetworkRepositoryImpl : LocalNetworkRepository { + + override fun getLocalAddresses(): List = try { + NetworkInterface.getNetworkInterfaces() + ?.toList() + .orEmpty() + .filter { it.isUp && !it.isLoopback && !it.isVirtual } + .flatMap { networkInterface -> + networkInterface.inetAddresses.toList() + .filterIsInstance() + .mapNotNull { address -> + address.hostAddress?.let { + LocalAddressDomainModel( + interfaceName = networkInterface.displayName ?: networkInterface.name, + ip = it, + ) + } + } + } + } catch (_: Exception) { + emptyList() + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt index ec858f6d9..d7cd2fb33 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt @@ -4,6 +4,7 @@ import io.github.openflocon.domain.accessibility.accessibilityModule import io.github.openflocon.domain.adb.adbModule import io.github.openflocon.domain.adbcommander.adbCommanderModule import io.github.openflocon.domain.analytics.analyticsModule +import io.github.openflocon.domain.connect.connectModule import io.github.openflocon.domain.crashreporter.crashReporterDomainModule import io.github.openflocon.domain.dashboard.dashboardModule import io.github.openflocon.domain.database.databaseModule @@ -29,6 +30,7 @@ val domainModule = module { adbModule, adbCommanderModule, analyticsModule, + connectModule, featureFlagsModule, dashboardModule, databaseModule, diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt new file mode 100644 index 000000000..4851d392b --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt @@ -0,0 +1,9 @@ +package io.github.openflocon.domain.connect + +import io.github.openflocon.domain.connect.usecase.GetLocalAddressesUseCase +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +internal val connectModule = module { + factoryOf(::GetLocalAddressesUseCase) +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt new file mode 100644 index 000000000..16abae734 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt @@ -0,0 +1,6 @@ +package io.github.openflocon.domain.connect.models + +data class LocalAddressDomainModel( + val interfaceName: String, + val ip: String, +) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt new file mode 100644 index 000000000..62d7c5e1a --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt @@ -0,0 +1,7 @@ +package io.github.openflocon.domain.connect.repository + +import io.github.openflocon.domain.connect.models.LocalAddressDomainModel + +interface LocalNetworkRepository { + fun getLocalAddresses(): List +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt new file mode 100644 index 000000000..62c0a78f6 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt @@ -0,0 +1,35 @@ +package io.github.openflocon.domain.connect.usecase + +import io.github.openflocon.domain.connect.models.LocalAddressDomainModel +import io.github.openflocon.domain.connect.repository.LocalNetworkRepository + +class GetLocalAddressesUseCase( + private val localNetworkRepository: LocalNetworkRepository, +) { + operator fun invoke(): List = cleanLocalAddresses(localNetworkRepository.getLocalAddresses()) +} + +private val ipv4Regex = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""") + +/** + * Keeps the IPv4 addresses a device can reach from the local network: + * no loopback, no link-local (169.254.x.x), no duplicates. Private ranges come first. + */ +internal fun cleanLocalAddresses(addresses: List): List = addresses + .filter { address -> + val match = ipv4Regex.matchEntire(address.ip) ?: return@filter false + val parts = match.groupValues.drop(1).map { it.toInt() } + parts.all { it in 0..255 } && + parts[0] != 127 && + parts[0] != 0 && + !(parts[0] == 169 && parts[1] == 254) + } + .distinctBy { it.ip } + .sortedBy { if (it.ip.isPrivateIpv4()) 0 else 1 } + +private fun String.isPrivateIpv4(): Boolean { + val parts = split(".").map { it.toInt() } + return parts[0] == 10 || + (parts[0] == 172 && parts[1] in 16..31) || + (parts[0] == 192 && parts[1] == 168) +} diff --git a/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt new file mode 100644 index 000000000..b7372e343 --- /dev/null +++ b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt @@ -0,0 +1,45 @@ +package io.github.openflocon.domain.connect.usecase + +import io.github.openflocon.domain.connect.models.LocalAddressDomainModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GetLocalAddressesUseCaseTest { + + private fun address(ip: String, name: String = "en0") = LocalAddressDomainModel(name, ip) + + @Test + fun `empty list stays empty`() { + assertTrue(cleanLocalAddresses(emptyList()).isEmpty()) + } + + @Test + fun `loopback, link-local and non ipv4 addresses are removed`() { + val result = cleanLocalAddresses( + listOf( + address("127.0.0.1", "lo0"), + address("169.254.10.2"), + address("fe80::1"), + address("not an ip"), + address("300.1.1.1"), + address("192.168.1.20"), + ), + ) + assertEquals(listOf("192.168.1.20"), result.map { it.ip }) + } + + @Test + fun `duplicates are removed`() { + val result = cleanLocalAddresses(listOf(address("192.168.1.20", "en0"), address("192.168.1.20", "en1"))) + assertEquals(1, result.size) + } + + @Test + fun `private addresses come first`() { + val result = cleanLocalAddresses( + listOf(address("8.8.4.4"), address("172.20.0.5"), address("10.0.0.2"), address("172.40.0.1")), + ) + assertEquals(listOf("172.20.0.5", "10.0.0.2", "8.8.4.4", "172.40.0.1"), result.map { it.ip }) + } +} diff --git a/FloconDesktop/gradle/libs.versions.toml b/FloconDesktop/gradle/libs.versions.toml index 98ecd98ea..138c9be10 100644 --- a/FloconDesktop/gradle/libs.versions.toml +++ b/FloconDesktop/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +zxing = "3.5.3" about-libraries = "15.0.2" androidx-lifecycle = "2.11.0" buildconfig = "6.0.10" @@ -91,6 +92,7 @@ kotlinx-schema-generator-json = { module = "org.jetbrains.kotlinx:kotlinx-schema kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } # Ktor +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core-jvm", version.ref = "ktor" } diff --git a/docs/setup.md b/docs/setup.md index 17f9184e5..10735c8ab 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -125,6 +125,10 @@ Flocon.connect("192.168.1.20") Until a desktop is reachable, everything the plugins log is kept in memory. `Flocon.connect` is a `suspend` function: it opens the connection, introduces the app to the desktop, sends what was kept in the order it was logged, then sends the next messages right away. It returns a `FloconConnectResult` (`Connected`, `NotStarted`, `InvalidAddress` or `Failed`) instead of throwing; the messages are kept in every case, so you can call it again. Calling it with a new address replaces the previous one. The desktop shows a wifi icon next to a device connected this way. These devices have no adb link, so the features that rely on adb (screenshot, screen recording, deeplinks on Android, ADB Commander and their MCP tools) are disabled for them. Give only the IP address (or host) of the computer, without scheme nor port: the port is always `9023`, anything else returns `InvalidAddress`. +### Connecting with a QR code + +The desktop app has a **Connect** button in the top bar (QR code icon). It opens a dialog listing the IP addresses of the computer on the local network (one per network interface) and, for each one, a button that shows the address as a QR code. The QR code contains only the IP address, the same value as `Flocon.connect("192.168.1.20")`. Scanning it from the SDK is not available yet. + Up to 5000 messages are kept: above that, the oldest are dropped. `connect { bufferCapacity = ... }` changes this size. `connect { ... }` has two more options, all set here with a computer that has a fixed IP: From 950bd06ab4d538c0f45ac83f7ce34809bbfa8b37 Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Wed, 28 Oct 2026 17:48:00 +0100 Subject: [PATCH 2/6] feat: [CONNECT] Split dialog design, laptop button after the actions group, QR shown directly --- .../composeResources/values-fr/strings.xml | 10 +- .../composeResources/values/strings.xml | 10 +- .../app/ui/view/topbar/MainScreenTopBar.kt | 3 +- .../ui/view/topbar/actions/TopBarActions.kt | 10 - .../topbar/actions/TopBarConnectButton.kt | 39 +++ .../features/connect/ConnectViewModel.kt | 5 +- .../features/connect/model/ConnectUiModel.kt | 6 + .../features/connect/view/ConnectDialog.kt | 326 ++++++++++++++---- 8 files changed, 312 insertions(+), 97 deletions(-) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 24228a004..ab70af91b 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -328,11 +328,13 @@ Fermer les autres onglets Fermer les onglets à droite Connecter un appareil - Connecter un appareil - Votre téléphone et cet ordinateur doivent être sur le même réseau Wi-Fi. Utilisez l\'une de ces adresses dans Flocon.connect(), ou scannez son QR code. + Connecter + Scanner pour connecter + Copier l\'adresse + Choisissez le réseau sur lequel est votre téléphone. + Même Wi-Fi que cet ordinateur, puis scannez, ou appelez Flocon.connect(\"%1$s\") Aucune adresse du réseau local trouvée. Vérifiez que cet ordinateur est connecté à un réseau. - Afficher le QR code - Masquer le QR code + port %1$d Ouvrir le dossier Flocon Enregistrer l’écran Redémarrer l’app diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index 7c1a0c929..9fa26f0c1 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -401,11 +401,13 @@ Close Other Tabs Close Tabs to the Right connect a device - Connect a device - Your phone and this computer must be on the same Wi-Fi network. Use one of these addresses in Flocon.connect(), or scan its QR code. + Connect + Scan to connect + Copy address + Pick the network your phone is on. + Same Wi-Fi as this computer, then scan, or call Flocon.connect(\"%1$s\") No local network address found. Check that this computer is connected to a network. - Show QR code - Hide QR code + port %1$d open flocon folder Record restart 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 46b4555cc..59ab2c989 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 @@ -47,6 +47,7 @@ import io.github.openflocon.flocondesktop.app.ui.model.DeviceItemUiModel import io.github.openflocon.flocondesktop.app.ui.model.DevicesStateUiModel import io.github.openflocon.flocondesktop.app.ui.model.RecordVideoStateUiModel import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarActions +import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarConnectButton import io.github.openflocon.flocondesktop.app.version.VersionCheckerViewModel import io.github.openflocon.flocondesktop.features.connect.view.ConnectDialog import io.github.openflocon.library.designsystem.FloconTheme @@ -108,9 +109,9 @@ fun MainScreenTopBar( onRecordClicked = onRecordClicked, onRestartClicked = onRestartClicked, onOpenFolderClicked = onOpenFolderClicked, - onConnectClicked = { showConnectDialog = true }, devicesState = devicesState, ) + TopBarConnectButton(onClicked = { showConnectDialog = true }) } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt index a256532e2..15860d332 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarActions.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.FolderOpen -import androidx.compose.material.icons.outlined.QrCode2 import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material.icons.outlined.Videocam @@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import flocondesktop.composeapp.generated.resources.Res -import flocondesktop.composeapp.generated.resources.topbar_connect import flocondesktop.composeapp.generated.resources.topbar_open_folder import flocondesktop.composeapp.generated.resources.topbar_record import flocondesktop.composeapp.generated.resources.topbar_restart @@ -38,7 +36,6 @@ internal fun TopBarActions( onRecordClicked: () -> Unit, onRestartClicked: () -> Unit, onOpenFolderClicked: () -> Unit, - onConnectClicked: () -> Unit, modifier: Modifier = Modifier, ) { val colors = FloconTheme.colors @@ -89,12 +86,5 @@ internal fun TopBarActions( onClicked = onOpenFolderClicked, isEnabled = true, ) - TopBarButton( - active = false, - imageVector = Icons.Outlined.QrCode2, - contentDescription = stringResource(Res.string.topbar_connect), - onClicked = onConnectClicked, - isEnabled = true, - ) } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt new file mode 100644 index 000000000..c61b32da6 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt @@ -0,0 +1,39 @@ +package io.github.openflocon.flocondesktop.app.ui.view.topbar.actions + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Laptop +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.topbar_connect +import io.github.openflocon.library.designsystem.FloconTheme +import org.jetbrains.compose.resources.stringResource + +/** Standalone button, on purpose not part of the [TopBarActions] group. */ +@Composable +internal fun TopBarConnectButton( + onClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + + Box( + modifier = modifier + .background(colors.bg100, FloconTheme.shapes.medium) + .border(1.dp, colors.line, FloconTheme.shapes.medium) + .padding(FloconTheme.spacing.extraSmall), + ) { + TopBarButton( + active = false, + imageVector = Icons.Outlined.Laptop, + contentDescription = stringResource(Res.string.topbar_connect), + onClicked = onClicked, + isEnabled = true, + ) + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt index 0cc95642e..7e09074b2 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt @@ -2,6 +2,7 @@ package io.github.openflocon.flocondesktop.features.connect import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.github.openflocon.domain.Constant import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.connect.usecase.GetLocalAddressesUseCase import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState @@ -19,7 +20,7 @@ class ConnectViewModel( private val getLocalAddressesUseCase: GetLocalAddressesUseCase, ) : ViewModel() { - private val _uiState = MutableStateFlow(ConnectUiState(addresses = persistentListOf())) + private val _uiState = MutableStateFlow(ConnectUiState(addresses = persistentListOf(), port = Constant.SERVER_WEBSOCKET_PORT)) val uiState: StateFlow = _uiState.asStateFlow() /** The network can change while the app is open, so addresses are read again each time the dialog opens. */ @@ -28,7 +29,7 @@ class ConnectViewModel( val addresses = withContext(dispatcherProvider.viewModel) { getLocalAddressesUseCase().map { LocalAddressUiModel(interfaceName = it.interfaceName, ip = it.ip) } } - _uiState.value = ConnectUiState(addresses = addresses.toImmutableList()) + _uiState.value = ConnectUiState(addresses = addresses.toImmutableList(), port = Constant.SERVER_WEBSOCKET_PORT) } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt index 12994f4d6..226ca8755 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt @@ -3,6 +3,7 @@ package io.github.openflocon.flocondesktop.features.connect.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList @Immutable data class LocalAddressUiModel( @@ -13,11 +14,16 @@ data class LocalAddressUiModel( @Immutable data class ConnectUiState( val addresses: ImmutableList, + val port: Int, ) fun previewConnectUiState() = ConnectUiState( addresses = persistentListOf( LocalAddressUiModel(interfaceName = "en0", ip = "192.168.1.20"), LocalAddressUiModel(interfaceName = "en5", ip = "10.0.0.12"), + LocalAddressUiModel(interfaceName = "bridge0", ip = "172.20.10.4"), ), + port = 9023, ) + +fun previewSingleConnectUiState() = previewConnectUiState().let { it.copy(addresses = it.addresses.take(1).toImmutableList()) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt index 32b129d05..61c8f9da9 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt @@ -1,17 +1,25 @@ package io.github.openflocon.flocondesktop.features.connect.view +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,30 +29,42 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.connect_copy import flocondesktop.composeapp.generated.resources.connect_empty -import flocondesktop.composeapp.generated.resources.connect_hide_qr import flocondesktop.composeapp.generated.resources.connect_hint -import flocondesktop.composeapp.generated.resources.connect_show_qr +import flocondesktop.composeapp.generated.resources.connect_pick_network +import flocondesktop.composeapp.generated.resources.connect_port +import flocondesktop.composeapp.generated.resources.connect_scan import flocondesktop.composeapp.generated.resources.connect_title import io.github.openflocon.flocondesktop.features.connect.ConnectViewModel import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState import io.github.openflocon.flocondesktop.features.connect.model.LocalAddressUiModel import io.github.openflocon.flocondesktop.features.connect.model.previewConnectUiState +import io.github.openflocon.flocondesktop.features.connect.model.previewSingleConnectUiState import io.github.openflocon.library.designsystem.FloconTheme -import io.github.openflocon.library.designsystem.components.FloconButton import io.github.openflocon.library.designsystem.components.FloconDialog -import io.github.openflocon.library.designsystem.components.FloconDialogHeader import io.github.openflocon.library.designsystem.components.FloconIcon import io.github.openflocon.library.designsystem.components.FloconIconButton -import io.github.openflocon.library.designsystem.components.FloconOutlinedButton import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +private val SingleWidth = 420.dp +private val MultiWidth = 680.dp +private val QrSize = 240.dp + @Composable fun ConnectDialog( onDismissRequest: () -> Unit, @@ -68,48 +88,130 @@ private fun ConnectDialog( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { - var qrIp by remember { mutableStateOf(null) } + val multiple = uiState.addresses.size > 1 FloconDialog( onDismissRequest = onDismissRequest, properties = DialogProperties(usePlatformDefaultWidth = false), - modifier = modifier.width(480.dp), + modifier = modifier.width(if (multiple) MultiWidth else SingleWidth), ) { - Column { - FloconDialogHeader( - title = stringResource(Res.string.connect_title), - trailingContent = { - FloconIconButton(onClick = onDismissRequest) { - FloconIcon(Icons.Outlined.Close) - } - }, + if (multiple) { + MultipleAddresses( + uiState = uiState, + onClose = onDismissRequest, + ) + } else { + SingleAddress( + address = uiState.addresses.firstOrNull(), + port = uiState.port, + onClose = onDismissRequest, + ) + } + } +} + +@Composable +private fun SingleAddress( + address: LocalAddressUiModel?, + port: Int, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(FloconTheme.colors.bg000) + .padding(start = FloconTheme.spacing.extraLarge, end = FloconTheme.spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.connect_title), + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = FloconTheme.colors.ink, + modifier = Modifier.weight(1f), + ) + FloconIconButton(onClick = onClose, modifier = Modifier.padding(vertical = FloconTheme.spacing.small)) { + FloconIcon(Icons.Outlined.Close) + } + } + Box(Modifier.fillMaxWidth().height(1.dp).background(FloconTheme.colors.line)) + if (address == null) { + Text( + text = stringResource(Res.string.connect_empty), + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colors.inkMuted, + modifier = Modifier.padding(FloconTheme.spacing.extraLarge), ) - Column( + } else { + AddressPreview( + address = address, + port = port, modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = FloconTheme.spacing.medium) - .padding(bottom = FloconTheme.spacing.medium), - verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), - ) { - Text( - text = stringResource(Res.string.connect_hint), - style = FloconTheme.typography.bodySmall, - color = FloconTheme.colors.inkMuted, + .fillMaxWidth() + .padding(FloconTheme.spacing.extraLarge), + ) + } + } +} + +@Composable +private fun MultipleAddresses( + uiState: ConnectUiState, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + var selectedIp by remember { mutableStateOf(null) } + val selected = uiState.addresses.firstOrNull { it.ip == selectedIp } ?: uiState.addresses.first() + + Row(modifier = modifier.height(IntrinsicSize.Min)) { + Column( + modifier = Modifier + .width(260.dp) + .background(FloconTheme.colors.bg000) + .verticalScroll(rememberScrollState()) + .padding(FloconTheme.spacing.large), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.extraSmall), + ) { + Text( + text = stringResource(Res.string.connect_title), + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = FloconTheme.colors.ink, + modifier = Modifier.padding(horizontal = FloconTheme.spacing.small), + ) + Text( + text = stringResource(Res.string.connect_pick_network), + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colors.inkMuted, + modifier = Modifier.padding(horizontal = FloconTheme.spacing.small).padding(bottom = FloconTheme.spacing.small), + ) + uiState.addresses.forEach { address -> + AddressRow( + address = address, + selected = address.ip == selected.ip, + onClick = { selectedIp = address.ip }, ) - if (uiState.addresses.isEmpty()) { - Text( - text = stringResource(Res.string.connect_empty), - style = FloconTheme.typography.bodyMedium, - color = FloconTheme.colors.ink, - ) - } - uiState.addresses.forEach { address -> - AddressRow( - address = address, - qrVisible = qrIp == address.ip, - onToggleQr = { qrIp = if (qrIp == address.ip) null else address.ip }, - ) - } + } + } + Box(Modifier.fillMaxHeight().width(1.dp).background(FloconTheme.colors.line)) + Box(modifier = Modifier.weight(1f)) { + AddressPreview( + address = selected, + port = uiState.port, + modifier = Modifier + .fillMaxWidth() + .padding(FloconTheme.spacing.extraLarge) + .padding(top = FloconTheme.spacing.large), + ) + FloconIconButton( + onClick = onClose, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(FloconTheme.spacing.small), + ) { + FloconIcon(Icons.Outlined.Close) } } } @@ -118,53 +220,114 @@ private fun ConnectDialog( @Composable private fun AddressRow( address: LocalAddressUiModel, - qrVisible: Boolean, - onToggleQr: () -> Unit, + selected: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + val colors = FloconTheme.colors + val shape = FloconTheme.shapes.medium + + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(if (selected) colors.accentSoft else Color.Transparent) + .border(1.dp, if (selected) colors.accent.copy(alpha = 0.4f) else Color.Transparent, shape) + .clickable(onClick = onClick) + .pointerHoverIcon(PointerIcon.Hand) + .padding(horizontal = FloconTheme.spacing.medium, vertical = FloconTheme.spacing.small), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.medium), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = address.ip, - style = FloconTheme.typography.monoMedium, - color = FloconTheme.colors.ink, - ) - Text( - text = address.interfaceName, - style = FloconTheme.typography.bodySmall, - color = FloconTheme.colors.inkMuted, - ) - } - Spacer(Modifier.width(FloconTheme.spacing.small)) - if (qrVisible) { - FloconOutlinedButton(onClick = onToggleQr) { - Text(stringResource(Res.string.connect_hide_qr)) - } - } else { - FloconButton(onClick = onToggleQr) { - Text(stringResource(Res.string.connect_show_qr)) - } - } + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (selected) colors.accent else colors.lineStrong), + ) + Column { + Text( + text = address.ip, + style = FloconTheme.typography.monoMedium, + fontWeight = FontWeight.SemiBold, + color = colors.ink, + ) + Text( + text = address.interfaceName, + style = FloconTheme.typography.bodySmall, + color = colors.inkMuted, + ) } - if (qrVisible) { - // the SDK connects with the bare ip, see Flocon.connect() - QrCodeImage( - content = address.ip, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .size(220.dp), + } +} + +@Composable +private fun AddressPreview( + address: LocalAddressUiModel, + port: Int, + modifier: Modifier = Modifier, +) { + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.large), + ) { + Text( + text = stringResource(Res.string.connect_scan).uppercase(), + style = FloconTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = FloconTheme.colors.accent, + ) + // the SDK connects with the bare ip, see Flocon.connect() + QrCodeImage( + content = address.ip, + modifier = Modifier + .size(QrSize) + .clip(FloconTheme.shapes.large), + ) + Text( + text = address.ip, + style = FloconTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = FloconTheme.colors.ink, + ) + Text( + text = "${address.interfaceName} · ${stringResource(Res.string.connect_port, port)}", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colors.inkMuted, + ) + Row( + modifier = Modifier + .clip(FloconTheme.shapes.medium) + .background(FloconTheme.colors.accent) + .clickable { clipboardManager.setText(AnnotatedString(address.ip)) } + .pointerHoverIcon(PointerIcon.Hand) + .padding(horizontal = FloconTheme.spacing.large, vertical = FloconTheme.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + FloconIcon(Icons.Outlined.ContentCopy, tint = FloconTheme.colors.accentInk, modifier = Modifier.size(16.dp)) + Text( + text = stringResource(Res.string.connect_copy), + style = FloconTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = FloconTheme.colors.accentInk, ) } + Text( + text = stringResource(Res.string.connect_hint, address.ip), + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colors.inkMuted, + textAlign = TextAlign.Center, + ) } } @Preview @Composable -private fun ConnectDialogPreview() { +private fun ConnectDialogMultiplePreview() { FloconTheme { ConnectDialog( uiState = previewConnectUiState(), @@ -172,3 +335,14 @@ private fun ConnectDialogPreview() { ) } } + +@Preview +@Composable +private fun ConnectDialogSinglePreview() { + FloconTheme { + ConnectDialog( + uiState = previewSingleConnectUiState(), + onDismissRequest = {}, + ) + } +} From d19aeb7652119e297e6a1687f10d68ddf25e9c3d Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Wed, 28 Oct 2026 17:50:42 +0100 Subject: [PATCH 3/6] fix: [CONNECT] Animate the copy button, fix escaped quotes in the hint, add a border to the dialog --- .../composeResources/values-fr/strings.xml | 3 +- .../composeResources/values/strings.xml | 3 +- .../features/connect/view/ConnectDialog.kt | 110 +++++++++++++----- 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml index ab70af91b..8c4533c1f 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -331,8 +331,9 @@ Connecter Scanner pour connecter Copier l\'adresse + Copié Choisissez le réseau sur lequel est votre téléphone. - Même Wi-Fi que cet ordinateur, puis scannez, ou appelez Flocon.connect(\"%1$s\") + Même Wi-Fi que cet ordinateur, puis scannez, ou appelez Flocon.connect("%1$s") Aucune adresse du réseau local trouvée. Vérifiez que cet ordinateur est connecté à un réseau. port %1$d Ouvrir le dossier Flocon diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index 9fa26f0c1..0b9dba361 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -404,8 +404,9 @@ Connect Scan to connect Copy address + Copied Pick the network your phone is on. - Same Wi-Fi as this computer, then scan, or call Flocon.connect(\"%1$s\") + Same Wi-Fi as this computer, then scan, or call Flocon.connect("%1$s") No local network address found. Check that this computer is connected to a network. port %1$d open flocon folder diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt index 61c8f9da9..ee7ba5222 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt @@ -1,8 +1,14 @@ package io.github.openflocon.flocondesktop.features.connect.view +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,8 +22,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.Text @@ -26,10 +34,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon @@ -42,6 +52,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.connect_copied import flocondesktop.composeapp.generated.resources.connect_copy import flocondesktop.composeapp.generated.resources.connect_empty import flocondesktop.composeapp.generated.resources.connect_hint @@ -58,6 +69,8 @@ import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconDialog import io.github.openflocon.library.designsystem.components.FloconIcon import io.github.openflocon.library.designsystem.components.FloconIconButton +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -95,17 +108,20 @@ private fun ConnectDialog( properties = DialogProperties(usePlatformDefaultWidth = false), modifier = modifier.width(if (multiple) MultiWidth else SingleWidth), ) { - if (multiple) { - MultipleAddresses( - uiState = uiState, - onClose = onDismissRequest, - ) - } else { - SingleAddress( - address = uiState.addresses.firstOrNull(), - port = uiState.port, - onClose = onDismissRequest, - ) + // FloconDialog has no border, drawn here over the content so that the window stands out + Box(modifier = Modifier.border(1.dp, FloconTheme.colors.lineStrong, RoundedCornerShape(10.dp))) { + if (multiple) { + MultipleAddresses( + uiState = uiState, + onClose = onDismissRequest, + ) + } else { + SingleAddress( + address = uiState.addresses.firstOrNull(), + port = uiState.port, + onClose = onDismissRequest, + ) + } } } } @@ -267,8 +283,6 @@ private fun AddressPreview( port: Int, modifier: Modifier = Modifier, ) { - val clipboardManager = LocalClipboardManager.current - Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -298,24 +312,7 @@ private fun AddressPreview( style = FloconTheme.typography.bodySmall, color = FloconTheme.colors.inkMuted, ) - Row( - modifier = Modifier - .clip(FloconTheme.shapes.medium) - .background(FloconTheme.colors.accent) - .clickable { clipboardManager.setText(AnnotatedString(address.ip)) } - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = FloconTheme.spacing.large, vertical = FloconTheme.spacing.medium), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), - ) { - FloconIcon(Icons.Outlined.ContentCopy, tint = FloconTheme.colors.accentInk, modifier = Modifier.size(16.dp)) - Text( - text = stringResource(Res.string.connect_copy), - style = FloconTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = FloconTheme.colors.accentInk, - ) - } + CopyButton(ip = address.ip) Text( text = stringResource(Res.string.connect_hint, address.ip), style = FloconTheme.typography.bodySmall, @@ -325,6 +322,57 @@ private fun AddressPreview( } } +@Composable +private fun CopyButton( + ip: String, + modifier: Modifier = Modifier, +) { + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + var copied by remember(ip) { mutableStateOf(false) } + + val scale by animateFloatAsState(if (pressed) 0.94f else 1f) + val background by animateColorAsState(if (copied) FloconTheme.colors.statusSuccess else FloconTheme.colors.accent) + + Row( + modifier = modifier + .scale(scale) + .clip(FloconTheme.shapes.medium) + .background(background) + .clickable(interactionSource = interactionSource, indication = null) { + clipboardManager.setText(AnnotatedString(ip)) + scope.launch { + copied = true + delay(1_500) + copied = false + } + } + .pointerHoverIcon(PointerIcon.Hand) + .padding(horizontal = FloconTheme.spacing.large, vertical = FloconTheme.spacing.medium) + .animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Crossfade(targetState = copied) { isCopied -> + FloconIcon( + imageVector = if (isCopied) Icons.Outlined.Check else Icons.Outlined.ContentCopy, + tint = FloconTheme.colors.accentInk, + modifier = Modifier.size(16.dp), + ) + } + Crossfade(targetState = copied) { isCopied -> + Text( + text = stringResource(if (isCopied) Res.string.connect_copied else Res.string.connect_copy), + style = FloconTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = FloconTheme.colors.accentInk, + ) + } + } +} + @Preview @Composable private fun ConnectDialogMultiplePreview() { From 11efd4873cbd476ee3b2d936167898d87a8aac35 Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Wed, 28 Oct 2026 17:52:13 +0100 Subject: [PATCH 4/6] fix: [CONNECT] Copy button only bounces, no copied state --- .../composeResources/values-fr/strings.xml | 1 - .../composeResources/values/strings.xml | 1 - .../features/connect/view/ConnectDialog.kt | 60 +++++++------------ 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 8c4533c1f..8b3f6e6ba 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -331,7 +331,6 @@ Connecter Scanner pour connecter Copier l\'adresse - Copié Choisissez le réseau sur lequel est votre téléphone. Même Wi-Fi que cet ordinateur, puis scannez, ou appelez Flocon.connect("%1$s") Aucune adresse du réseau local trouvée. Vérifiez que cet ordinateur est connecté à un réseau. diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml index 0b9dba361..f70afe164 100644 --- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml @@ -404,7 +404,6 @@ Connect Scan to connect Copy address - Copied Pick the network your phone is on. Same Wi-Fi as this computer, then scan, or call Flocon.connect("%1$s") No local network address found. Check that this computer is connected to a network. diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt index ee7ba5222..407641858 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt @@ -1,14 +1,12 @@ package io.github.openflocon.flocondesktop.features.connect.view -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,7 +23,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.Text @@ -52,7 +49,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import flocondesktop.composeapp.generated.resources.Res -import flocondesktop.composeapp.generated.resources.connect_copied import flocondesktop.composeapp.generated.resources.connect_copy import flocondesktop.composeapp.generated.resources.connect_empty import flocondesktop.composeapp.generated.resources.connect_hint @@ -69,7 +65,6 @@ import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconDialog import io.github.openflocon.library.designsystem.components.FloconIcon import io.github.openflocon.library.designsystem.components.FloconIconButton -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -329,47 +324,36 @@ private fun CopyButton( ) { val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - var copied by remember(ip) { mutableStateOf(false) } - - val scale by animateFloatAsState(if (pressed) 0.94f else 1f) - val background by animateColorAsState(if (copied) FloconTheme.colors.statusSuccess else FloconTheme.colors.accent) + val scale = remember { Animatable(1f) } Row( modifier = modifier - .scale(scale) + .scale(scale.value) .clip(FloconTheme.shapes.medium) - .background(background) - .clickable(interactionSource = interactionSource, indication = null) { + .background(FloconTheme.colors.accent) + .clickable(interactionSource = null, indication = null) { clipboardManager.setText(AnnotatedString(ip)) scope.launch { - copied = true - delay(1_500) - copied = false + scale.animateTo(0.88f, tween(durationMillis = 70)) + scale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium)) } } .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = FloconTheme.spacing.large, vertical = FloconTheme.spacing.medium) - .animateContentSize(), + .padding(horizontal = FloconTheme.spacing.large, vertical = FloconTheme.spacing.medium), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), ) { - Crossfade(targetState = copied) { isCopied -> - FloconIcon( - imageVector = if (isCopied) Icons.Outlined.Check else Icons.Outlined.ContentCopy, - tint = FloconTheme.colors.accentInk, - modifier = Modifier.size(16.dp), - ) - } - Crossfade(targetState = copied) { isCopied -> - Text( - text = stringResource(if (isCopied) Res.string.connect_copied else Res.string.connect_copy), - style = FloconTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = FloconTheme.colors.accentInk, - ) - } + FloconIcon( + imageVector = Icons.Outlined.ContentCopy, + tint = FloconTheme.colors.accentInk, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringResource(Res.string.connect_copy), + style = FloconTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = FloconTheme.colors.accentInk, + ) } } From 8d90fcbd5b187c0799c803d9c7b488369f396c6c Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Wed, 28 Oct 2026 18:04:54 +0100 Subject: [PATCH 5/6] feat: [CONNECT] Mascot scene in the connect dialog: the dog finds a phone under a rug --- .../features/connect/view/ConnectDialog.kt | 21 +-- .../connect/view/ConnectMascotScene.kt | 132 ++++++++++++++++++ .../components/FloconPixelScene.kt | 19 ++- 3 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt index 407641858..5bd065f25 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt @@ -71,7 +71,7 @@ import org.koin.compose.viewmodel.koinViewModel private val SingleWidth = 420.dp private val MultiWidth = 680.dp -private val QrSize = 240.dp +private val QrSize = 216.dp @Composable fun ConnectDialog( @@ -156,6 +156,7 @@ private fun SingleAddress( modifier = Modifier.padding(FloconTheme.spacing.extraLarge), ) } else { + ConnectMascotScene() AddressPreview( address = address, port = port, @@ -208,14 +209,16 @@ private fun MultipleAddresses( } Box(Modifier.fillMaxHeight().width(1.dp).background(FloconTheme.colors.line)) Box(modifier = Modifier.weight(1f)) { - AddressPreview( - address = selected, - port = uiState.port, - modifier = Modifier - .fillMaxWidth() - .padding(FloconTheme.spacing.extraLarge) - .padding(top = FloconTheme.spacing.large), - ) + Column { + ConnectMascotScene() + AddressPreview( + address = selected, + port = uiState.port, + modifier = Modifier + .fillMaxWidth() + .padding(FloconTheme.spacing.extraLarge), + ) + } FloconIconButton( onClick = onClose, modifier = Modifier diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt new file mode 100644 index 000000000..7820dd7a6 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt @@ -0,0 +1,132 @@ +package io.github.openflocon.flocondesktop.features.connect.view + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import io.github.openflocon.library.designsystem.components.FloconPixelPalette +import io.github.openflocon.library.designsystem.components.FloconPixelScene +import io.github.openflocon.library.designsystem.components.FloconPixelScope +import io.github.openflocon.library.designsystem.components.drawMascotDigging +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +private const val SceneWidth = 105 +private const val SceneHeight = 36 +private const val FrameMs = 400L +private const val FirstFoundFrame = 8 + +private val Night = Color(0xFF232846) +private val NightCloud = Color(0xFF3A4170) +private val GrassTop = Color(0xFF4C9A65) +private val GrassMid = Color(0xFF3A7D4F) +private val GrassDark = Color(0xFF2E6440) +private val RugDark = Color(0xFFC23A3F) + +private val Frames: ImmutableList = (0..14).toList().toImmutableList() +private val DigFrames = setOf(8, 10, 12) + +/** + * The mascot sniffs the ground, digs at a rug and finds a phone hidden under it. + * 15 frames of [FrameMs]: it sniffs (0..7), digs (8..13), then looks up with the phone found. + */ +@Composable +internal fun ConnectMascotScene(modifier: Modifier = Modifier) { + FloconPixelScene( + frames = Frames, + frameMs = FrameMs, + gridWidth = SceneWidth, + gridHeight = SceneHeight, + modifier = modifier + .fillMaxWidth() + .aspectRatio(SceneWidth.toFloat() / SceneHeight), + ) { frame -> + drawNight() + drawPhone(found = frame >= FirstFoundFrame, pulse = frame % 2 == 0) + drawRug(lifted = frame in DigFrames) + val digging = frame in DigFrames + offset(20, 5).drawMascotDigging( + digging = digging, + tailUp = digging, + headY = when { + digging -> 6 + frame % 2 == 1 -> 5 + else -> 2 + }, + ) + when (frame) { + in 2..7 -> drawMark(question = true) + in FirstFoundFrame..13 -> drawMark(question = false) + } + } +} + +private fun FloconPixelScope.drawNight() { + pixels(0, 0, SceneWidth, SceneHeight, Night) + listOf(6 to 4, 30 to 7, 62 to 3, 100 to 6, 72 to 5).forEach { (x, y) -> pixels(x, y, 1, 1, FloconPixelPalette.Spark) } + pixels(10, 9, 8, 2, NightCloud) + pixels(12, 7, 5, 2, NightCloud) + pixels(0, 29, SceneWidth, 7, GrassMid) + pixels(0, 29, SceneWidth, 1, GrassTop) + pixels(0, 33, SceneWidth, 3, GrassDark) +} + +private fun FloconPixelScope.drawPhone(found: Boolean, pulse: Boolean) { + pixels(85, 17, 6, 12, FloconPixelPalette.DeviceDark) + pixels(85, 17, 6, 1, FloconPixelPalette.Device) + pixels(86, 18, 4, 5, FloconPixelPalette.Screen) + if (!found) return + pixels(86, 18, 4, 5, FloconPixelPalette.BlueLight) + pixels(87, 19, 1, 1, FloconPixelPalette.Ink) + pixels(89, 19, 1, 1, FloconPixelPalette.Ink) + pixels(87, 21, 2, 1, FloconPixelPalette.Ink) + // wifi arcs, the far one blinks + pixels(93, 19, 1, 1, FloconPixelPalette.Blue) + pixels(94, 20, 1, 2, FloconPixelPalette.Blue) + pixels(93, 22, 1, 1, FloconPixelPalette.Blue) + if (pulse) { + pixels(96, 17, 1, 1, FloconPixelPalette.BlueLight) + pixels(97, 18, 1, 1, FloconPixelPalette.BlueLight) + pixels(98, 19, 1, 4, FloconPixelPalette.BlueLight) + pixels(97, 23, 1, 1, FloconPixelPalette.BlueLight) + pixels(96, 24, 1, 1, FloconPixelPalette.BlueLight) + } +} + +private fun FloconPixelScope.drawRug(lifted: Boolean) { + val red = FloconPixelPalette.Red + pixels(60, 26, 32, 3, red) + pixels(60, 27, 32, 1, FloconPixelPalette.Folder) + // the rug is humped over the phone + pixels(76, 25, 16, 1, red) + pixels(79, 24, 13, 1, red) + pixels(82, 23, 10, 1, red) + pixels(79, 26, 13, 1, RugDark) + pixels(92, 26, 1, 1, RugDark) + pixels(92, 28, 1, 1, RugDark) + if (lifted) { + pixels(54, 25, 6, 1, red) + pixels(55, 26, 5, 1, red) + pixels(56, 27, 4, 2, FloconPixelPalette.Port) + pixels(54, 24, 3, 1, RugDark) + } else { + pixels(54, 26, 6, 3, red) + pixels(54, 27, 6, 1, FloconPixelPalette.Folder) + pixels(53, 26, 1, 1, RugDark) + pixels(53, 28, 1, 1, RugDark) + } +} + +private fun FloconPixelScope.drawMark(question: Boolean) { + val color = FloconPixelPalette.Spark + if (question) { + pixels(48, 2, 3, 1, color) + pixels(50, 3, 1, 1, color) + pixels(49, 4, 1, 1, color) + pixels(49, 6, 1, 1, color) + } else { + pixels(49, 2, 1, 3, color) + pixels(49, 6, 1, 1, color) + } +} diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt index eef0e8c13..9ab3b1796 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt @@ -30,14 +30,16 @@ class FloconPixelScope internal constructor( private val unit: Float, private val left: Float, private val top: Float, + private val gridWidth: Int = FloconPixelSceneWidth, + private val gridHeight: Int = FloconPixelSceneHeight, private val originX: Int = 0, private val originY: Int = 0, ) { fun pixels(x: Int, y: Int, width: Int, height: Int, color: Color) { val gridX = (originX + x).coerceAtLeast(0) val gridY = (originY + y).coerceAtLeast(0) - val right = (originX + x + width).coerceAtMost(FloconPixelSceneWidth) - val bottom = (originY + y + height).coerceAtMost(FloconPixelSceneHeight) + val right = (originX + x + width).coerceAtMost(gridWidth) + val bottom = (originY + y + height).coerceAtMost(gridHeight) if (right <= gridX || bottom <= gridY) return val l = floor(left + gridX * unit) val t = floor(top + gridY * unit) @@ -49,18 +51,21 @@ class FloconPixelScope internal constructor( } /** Same grid, with its origin moved by ([dx], [dy]). */ - fun offset(dx: Int, dy: Int): FloconPixelScope = FloconPixelScope(drawScope, unit, left, top, originX + dx, originY + dy) + fun offset(dx: Int, dy: Int): FloconPixelScope = FloconPixelScope(drawScope, unit, left, top, gridWidth, gridHeight, originX + dx, originY + dy) } /** * Small looping 8-bit animation on a [FloconPixelSceneWidth] x [FloconPixelSceneHeight] grid, * scaled to fit and centered. [draw] gets the current frame: [frames] are played in a loop, [frameMs] each. + * The grid is [gridWidth] x [gridHeight] pixels, a wider one is for a banner. */ @Composable fun FloconPixelScene( frames: ImmutableList, frameMs: Long, modifier: Modifier = Modifier, + gridWidth: Int = FloconPixelSceneWidth, + gridHeight: Int = FloconPixelSceneHeight, draw: FloconPixelScope.(frame: Int) -> Unit, ) { var step by remember(frames) { mutableIntStateOf(0) } @@ -71,9 +76,9 @@ fun FloconPixelScene( } } Canvas(modifier = modifier) { - val unit = min(size.width / FloconPixelSceneWidth, size.height / FloconPixelSceneHeight) - val left = (size.width - unit * FloconPixelSceneWidth) / 2f - val top = (size.height - unit * FloconPixelSceneHeight) / 2f - FloconPixelScope(this, unit, left, top).draw(frames[step]) + val unit = min(size.width / gridWidth, size.height / gridHeight) + val left = (size.width - unit * gridWidth) / 2f + val top = (size.height - unit * gridHeight) / 2f + FloconPixelScope(this, unit, left, top, gridWidth, gridHeight).draw(frames[step]) } } From 2970187f983f324285e9cdb518401525e11e5307 Mon Sep 17 00:00:00 2001 From: Florent Champigny Date: Wed, 28 Oct 2026 19:09:23 +0100 Subject: [PATCH 6/6] feat: [CONNECT] Mascot peeking over the connect dialog, only when the mascot is enabled --- .../features/connect/ConnectViewModel.kt | 33 ++++- .../features/connect/model/ConnectUiModel.kt | 2 + .../features/connect/view/ConnectDialog.kt | 66 ++++++++- .../connect/view/ConnectMascotPeek.kt | 112 +++++++++++++++ .../connect/view/ConnectMascotScene.kt | 132 ------------------ 5 files changed, 202 insertions(+), 143 deletions(-) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt delete mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt index 7e09074b2..a31d10607 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt @@ -5,31 +5,52 @@ import androidx.lifecycle.viewModelScope import io.github.openflocon.domain.Constant import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.connect.usecase.GetLocalAddressesUseCase +import io.github.openflocon.domain.settings.usecase.ObserveMascotEnabledUseCase +import io.github.openflocon.flocondesktop.common.utils.stateInWhileSubscribed import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState import io.github.openflocon.flocondesktop.features.connect.model.LocalAddressUiModel +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ConnectViewModel( private val dispatcherProvider: DispatcherProvider, private val getLocalAddressesUseCase: GetLocalAddressesUseCase, + observeMascotEnabledUseCase: ObserveMascotEnabledUseCase, ) : ViewModel() { - private val _uiState = MutableStateFlow(ConnectUiState(addresses = persistentListOf(), port = Constant.SERVER_WEBSOCKET_PORT)) - val uiState: StateFlow = _uiState.asStateFlow() + private val addresses = MutableStateFlow>(persistentListOf()) + + val uiState: StateFlow = combine( + addresses, + observeMascotEnabledUseCase(), + ) { addresses, mascotEnabled -> + ConnectUiState( + addresses = addresses, + port = Constant.SERVER_WEBSOCKET_PORT, + mascotEnabled = mascotEnabled, + ) + }.stateInWhileSubscribed( + ConnectUiState( + addresses = persistentListOf(), + port = Constant.SERVER_WEBSOCKET_PORT, + mascotEnabled = false, + ), + ) /** The network can change while the app is open, so addresses are read again each time the dialog opens. */ fun refresh() { viewModelScope.launch { - val addresses = withContext(dispatcherProvider.viewModel) { - getLocalAddressesUseCase().map { LocalAddressUiModel(interfaceName = it.interfaceName, ip = it.ip) } + addresses.value = withContext(dispatcherProvider.viewModel) { + getLocalAddressesUseCase() + .map { LocalAddressUiModel(interfaceName = it.interfaceName, ip = it.ip) } + .toImmutableList() } - _uiState.value = ConnectUiState(addresses = addresses.toImmutableList(), port = Constant.SERVER_WEBSOCKET_PORT) } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt index 226ca8755..c9c5cc1c8 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt @@ -15,6 +15,7 @@ data class LocalAddressUiModel( data class ConnectUiState( val addresses: ImmutableList, val port: Int, + val mascotEnabled: Boolean, ) fun previewConnectUiState() = ConnectUiState( @@ -24,6 +25,7 @@ fun previewConnectUiState() = ConnectUiState( LocalAddressUiModel(interfaceName = "bridge0", ip = "172.20.10.4"), ), port = 9023, + mascotEnabled = true, ) fun previewSingleConnectUiState() = previewConnectUiState().let { it.copy(addresses = it.addresses.take(1).toImmutableList()) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt index 5bd065f25..eb924bf84 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt @@ -18,6 +18,7 @@ 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.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -45,7 +46,9 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import flocondesktop.composeapp.generated.resources.Res @@ -65,6 +68,8 @@ import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconDialog import io.github.openflocon.library.designsystem.components.FloconIcon import io.github.openflocon.library.designsystem.components.FloconIconButton +import io.github.openflocon.library.designsystem.components.FloconSurface +import io.github.openflocon.library.designsystem.components.escape.EscapeHandler import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -73,6 +78,10 @@ private val SingleWidth = 420.dp private val MultiWidth = 680.dp private val QrSize = 216.dp +/** The paws rest on the top edge of the window: the surface starts a bit above their bottom. */ +private val MascotPawsOverlap = 8.dp +private val MascotEndOffset = 56.dp + @Composable fun ConnectDialog( onDismissRequest: () -> Unit, @@ -98,10 +107,11 @@ private fun ConnectDialog( ) { val multiple = uiState.addresses.size > 1 - FloconDialog( + ConnectDialogFrame( onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = false), - modifier = modifier.width(if (multiple) MultiWidth else SingleWidth), + width = if (multiple) MultiWidth else SingleWidth, + mascotEnabled = uiState.mascotEnabled, + modifier = modifier, ) { // FloconDialog has no border, drawn here over the content so that the window stands out Box(modifier = Modifier.border(1.dp, FloconTheme.colors.lineStrong, RoundedCornerShape(10.dp))) { @@ -121,6 +131,54 @@ private fun ConnectDialog( } } +/** + * The window of the dialog. With the mascot, it looks at the window from above: it has to be + * drawn outside of the surface, which clips its content, so the dialog is built here. + */ +@Composable +private fun ConnectDialogFrame( + onDismissRequest: () -> Unit, + width: Dp, + mascotEnabled: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val properties = DialogProperties(usePlatformDefaultWidth = false) + if (!mascotEnabled) { + FloconDialog( + onDismissRequest = onDismissRequest, + properties = properties, + modifier = modifier.width(width), + ) { + content() + } + return + } + Dialog(onDismissRequest = onDismissRequest, properties = properties) { + EscapeHandler { + onDismissRequest() + true + } + Box(modifier = modifier.width(width)) { + FloconSurface( + color = FloconTheme.colors.floating, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .padding(top = ConnectMascotPeekPawsY + MascotPawsOverlap) + .fillMaxWidth() + .wrapContentHeight(), + ) { + content() + } + ConnectMascotPeek( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = MascotEndOffset), + ) + } + } +} + @Composable private fun SingleAddress( address: LocalAddressUiModel?, @@ -156,7 +214,6 @@ private fun SingleAddress( modifier = Modifier.padding(FloconTheme.spacing.extraLarge), ) } else { - ConnectMascotScene() AddressPreview( address = address, port = port, @@ -210,7 +267,6 @@ private fun MultipleAddresses( Box(Modifier.fillMaxHeight().width(1.dp).background(FloconTheme.colors.line)) Box(modifier = Modifier.weight(1f)) { Column { - ConnectMascotScene() AddressPreview( address = selected, port = uiState.port, diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt new file mode 100644 index 000000000..5409e79bf --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt @@ -0,0 +1,112 @@ +package io.github.openflocon.flocondesktop.features.connect.view + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.github.openflocon.library.designsystem.components.FloconPixelPalette +import io.github.openflocon.library.designsystem.components.FloconPixelScene +import io.github.openflocon.library.designsystem.components.FloconPixelScope +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +private const val GridWidth = 19 +private const val GridHeight = 22 +private const val FrameMs = 100L + +/** One pixel of the sprite is 6 dp. */ +private val PixelSize = 6.dp +internal val ConnectMascotPeekWidth: Dp = PixelSize * GridWidth +internal val ConnectMascotPeekHeight: Dp = PixelSize * GridHeight + +/** From the top of the sprite to the top of its paws, the window edge is just below. */ +internal val ConnectMascotPeekPawsY: Dp = PixelSize * 20 + +private const val LoopFrames = 36 +private val Frames: ImmutableList = (0 until LoopFrames).toList().toImmutableList() +private val GlanceAside = 11..21 +private val EarUp = setOf(16, 17, 29, 30) +private const val BlinkFrame = 33 + +/** + * The upper body of the mascot looking at the window from above, facing left: it looks at the + * window, glances aside (a "?" shows up), blinks and twitches an ear. 36 frames of [FrameMs]. + */ +@Composable +internal fun ConnectMascotPeek(modifier: Modifier = Modifier) { + FloconPixelScene( + frames = Frames, + frameMs = FrameMs, + gridWidth = GridWidth, + gridHeight = GridHeight, + modifier = modifier.size(ConnectMascotPeekWidth, ConnectMascotPeekHeight), + ) { frame -> + val aside = frame in GlanceAside + drawBody() + drawHead( + lean = if (aside) 1 else 0, + glance = aside, + blink = frame == BlinkFrame, + earUp = frame in EarUp, + ) + drawPaws() + if (aside) drawQuestionMark() + } +} + +/** The sprite is drawn facing right on this grid (x from 13, y from -8), then flipped. */ +private fun FloconPixelScope.mirrored(x: Int, y: Int, width: Int, height: Int, color: Color) { + pixels(32 - x - width, y + 8, width, height, color) +} + +private fun FloconPixelScope.drawBody() { + mirrored(16, 7, 12, 6, FloconPixelPalette.GoldenBody) + mirrored(19, 8, 6, 5, FloconPixelPalette.GoldenLight) + mirrored(17, 11, 3, 3, FloconPixelPalette.GoldenDark) + mirrored(24, 11, 3, 3, FloconPixelPalette.GoldenDark) +} + +private fun FloconPixelScope.drawHead(lean: Int, glance: Boolean, blink: Boolean, earUp: Boolean) { + val x = lean + val y = -3 + mirrored(17 + x, 2 + y, 12, 11, FloconPixelPalette.Golden) + mirrored(25 + x, 8 + y, 6, 5, FloconPixelPalette.GoldenLight) + mirrored(29 + x, 8 + y, 2, 2, FloconPixelPalette.Ink) + mirrored(21 + x, 10 + y, 2, 1, FloconPixelPalette.Blush) + when { + blink -> mirrored(21 + x, 7 + y, 3, 1, FloconPixelPalette.Ink) + + glance -> { + mirrored(23 + x, 6 + y, 2, 3, FloconPixelPalette.Ink) + mirrored(23 + x, 6 + y, 1, 1, FloconPixelPalette.White) + } + + else -> { + mirrored(22 + x, 5 + y, 2, 3, FloconPixelPalette.Ink) + mirrored(22 + x, 5 + y, 1, 1, FloconPixelPalette.White) + } + } + if (earUp) { + mirrored(15 + x, 3 + y, 3, 7, FloconPixelPalette.Ear) + mirrored(15 + x, 8 + y, 3, 2, FloconPixelPalette.Pink) + } else { + mirrored(15 + x, 4 + y, 3, 7, FloconPixelPalette.Ear) + mirrored(15 + x, 9 + y, 3, 2, FloconPixelPalette.Pink) + } +} + +private fun FloconPixelScope.drawPaws() { + mirrored(18, 12, 4, 2, FloconPixelPalette.GoldenBody) + mirrored(24, 12, 3, 2, FloconPixelPalette.GoldenBody) +} + +private fun FloconPixelScope.drawQuestionMark() { + val color = FloconPixelPalette.Spark + // not flipped: a "?" has to stay readable, it sits next to the muzzle + pixels(2, 1, 3, 1, color) + pixels(4, 2, 1, 1, color) + pixels(3, 3, 1, 1, color) + pixels(3, 5, 1, 1, color) +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt deleted file mode 100644 index 7820dd7a6..000000000 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotScene.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.github.openflocon.flocondesktop.features.connect.view - -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import io.github.openflocon.library.designsystem.components.FloconPixelPalette -import io.github.openflocon.library.designsystem.components.FloconPixelScene -import io.github.openflocon.library.designsystem.components.FloconPixelScope -import io.github.openflocon.library.designsystem.components.drawMascotDigging -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -private const val SceneWidth = 105 -private const val SceneHeight = 36 -private const val FrameMs = 400L -private const val FirstFoundFrame = 8 - -private val Night = Color(0xFF232846) -private val NightCloud = Color(0xFF3A4170) -private val GrassTop = Color(0xFF4C9A65) -private val GrassMid = Color(0xFF3A7D4F) -private val GrassDark = Color(0xFF2E6440) -private val RugDark = Color(0xFFC23A3F) - -private val Frames: ImmutableList = (0..14).toList().toImmutableList() -private val DigFrames = setOf(8, 10, 12) - -/** - * The mascot sniffs the ground, digs at a rug and finds a phone hidden under it. - * 15 frames of [FrameMs]: it sniffs (0..7), digs (8..13), then looks up with the phone found. - */ -@Composable -internal fun ConnectMascotScene(modifier: Modifier = Modifier) { - FloconPixelScene( - frames = Frames, - frameMs = FrameMs, - gridWidth = SceneWidth, - gridHeight = SceneHeight, - modifier = modifier - .fillMaxWidth() - .aspectRatio(SceneWidth.toFloat() / SceneHeight), - ) { frame -> - drawNight() - drawPhone(found = frame >= FirstFoundFrame, pulse = frame % 2 == 0) - drawRug(lifted = frame in DigFrames) - val digging = frame in DigFrames - offset(20, 5).drawMascotDigging( - digging = digging, - tailUp = digging, - headY = when { - digging -> 6 - frame % 2 == 1 -> 5 - else -> 2 - }, - ) - when (frame) { - in 2..7 -> drawMark(question = true) - in FirstFoundFrame..13 -> drawMark(question = false) - } - } -} - -private fun FloconPixelScope.drawNight() { - pixels(0, 0, SceneWidth, SceneHeight, Night) - listOf(6 to 4, 30 to 7, 62 to 3, 100 to 6, 72 to 5).forEach { (x, y) -> pixels(x, y, 1, 1, FloconPixelPalette.Spark) } - pixels(10, 9, 8, 2, NightCloud) - pixels(12, 7, 5, 2, NightCloud) - pixels(0, 29, SceneWidth, 7, GrassMid) - pixels(0, 29, SceneWidth, 1, GrassTop) - pixels(0, 33, SceneWidth, 3, GrassDark) -} - -private fun FloconPixelScope.drawPhone(found: Boolean, pulse: Boolean) { - pixels(85, 17, 6, 12, FloconPixelPalette.DeviceDark) - pixels(85, 17, 6, 1, FloconPixelPalette.Device) - pixels(86, 18, 4, 5, FloconPixelPalette.Screen) - if (!found) return - pixels(86, 18, 4, 5, FloconPixelPalette.BlueLight) - pixels(87, 19, 1, 1, FloconPixelPalette.Ink) - pixels(89, 19, 1, 1, FloconPixelPalette.Ink) - pixels(87, 21, 2, 1, FloconPixelPalette.Ink) - // wifi arcs, the far one blinks - pixels(93, 19, 1, 1, FloconPixelPalette.Blue) - pixels(94, 20, 1, 2, FloconPixelPalette.Blue) - pixels(93, 22, 1, 1, FloconPixelPalette.Blue) - if (pulse) { - pixels(96, 17, 1, 1, FloconPixelPalette.BlueLight) - pixels(97, 18, 1, 1, FloconPixelPalette.BlueLight) - pixels(98, 19, 1, 4, FloconPixelPalette.BlueLight) - pixels(97, 23, 1, 1, FloconPixelPalette.BlueLight) - pixels(96, 24, 1, 1, FloconPixelPalette.BlueLight) - } -} - -private fun FloconPixelScope.drawRug(lifted: Boolean) { - val red = FloconPixelPalette.Red - pixels(60, 26, 32, 3, red) - pixels(60, 27, 32, 1, FloconPixelPalette.Folder) - // the rug is humped over the phone - pixels(76, 25, 16, 1, red) - pixels(79, 24, 13, 1, red) - pixels(82, 23, 10, 1, red) - pixels(79, 26, 13, 1, RugDark) - pixels(92, 26, 1, 1, RugDark) - pixels(92, 28, 1, 1, RugDark) - if (lifted) { - pixels(54, 25, 6, 1, red) - pixels(55, 26, 5, 1, red) - pixels(56, 27, 4, 2, FloconPixelPalette.Port) - pixels(54, 24, 3, 1, RugDark) - } else { - pixels(54, 26, 6, 3, red) - pixels(54, 27, 6, 1, FloconPixelPalette.Folder) - pixels(53, 26, 1, 1, RugDark) - pixels(53, 28, 1, 1, RugDark) - } -} - -private fun FloconPixelScope.drawMark(question: Boolean) { - val color = FloconPixelPalette.Spark - if (question) { - pixels(48, 2, 3, 1, color) - pixels(50, 3, 1, 1, color) - pixels(49, 4, 1, 1, color) - pixels(49, 6, 1, 1, color) - } else { - pixels(49, 2, 1, 3, color) - pixels(49, 6, 1, 1, color) - } -}