From c99a68534979aed1cc4f8c34135c4f3b88ff0686 Mon Sep 17 00:00:00 2001 From: CodePandaaAI Date: Thu, 1 Oct 2026 16:50:09 +0530 Subject: [PATCH] feat(ui): clarify nearby sharing for 0.6.1 - separate device discovery on Send from visibility on Receive - show failures and retry actions on their relevant screens - add navigation badges for discovery and advertising failures - redesign the Receive identity, transfer-code, and visibility UI - show advertised device names across Android, Desktop, and iOS - keep Desktop hostname resolution off the UI thread - prepare Android, Desktop, and iOS packages as 0.6.1 --- CHANGELOG.md | 14 +++ androidApp/build.gradle.kts | 4 +- desktopApp/build.gradle.kts | 2 +- docs/DEVELOPMENT.md | 8 +- iosApp/Configuration/Config.xcconfig | 4 +- .../components/YouWillAppearAs.android.kt | 47 ++++++++ .../kotlin/com/liftley/sync360/Sync360Root.kt | 65 ++++++++--- .../com/liftley/sync360/core/di/Koin.kt | 2 +- .../sync360/data/NetworkServicesController.kt | 2 +- .../app/components/FileReceiveCodeCard.kt | 36 +++--- .../presentation/receive/ReceiveScreen.kt | 7 +- .../receive/ReceiveScreenViewModel.kt | 17 ++- .../receive/components/IdleReceiveStateUi.kt | 38 +++--- .../components/NearbyVisibilityCard.kt | 74 ++++++++++++ .../receive/components/YouWillAppearAs.kt | 6 + .../sync360/presentation/send/SendScreen.kt | 7 +- .../presentation/send/SendScreenViewModel.kt | 16 +-- .../send/components/NearbyDevicesSection.kt | 109 +++++++++++------- .../send/model/SendScreenState.kt | 4 +- .../receive/components/YouWillAppearAs.ios.kt | 47 ++++++++ .../receive/components/YouWillAppearAs.jvm.kt | 59 ++++++++++ 21 files changed, 442 insertions(+), 126 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.android.kt create mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/NearbyVisibilityCard.kt create mode 100644 shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.kt create mode 100644 shared/src/iosMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.ios.kt create mode 100644 shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.jvm.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b771f8b..6c415c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.6.1] - 2026-10-01 + +### Changed + +- Separate nearby-network presentation by user intent: Send now reports device search, while Receive reports whether this device is visible to nearby peers. +- Keep the shared network-service start, stop, and retry behavior while presenting discovery and advertising failures on their relevant screens. +- Add failure badges to the Send and Receive navigation items so a problem on the other screen remains visible without mixing both statuses into one card. +- Redesign the idle Receive screen around the advertised device name, file-transfer code, and nearby-visibility control. +- Show the advertised device name on Android, Desktop, and iOS, and keep the Desktop hostname lookup off the UI thread. +- Refine the Send discovery presentation with clearer transition controls and expressive search progress. +- Prepare Android, Desktop, and iOS packages as `0.6.1`; Android and iOS build numbers are `11`. + +Release builds and cross-platform manual validation have not yet been recorded for `0.6.1`. + ## [0.6.0] - 2026-09-30 ### Changed diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 81d9494..2df2ce7 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -36,8 +36,8 @@ android { applicationId = "com.liftley.sync360" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 10 - versionName = "0.6.0" + versionCode = 11 + versionName = "0.6.1" } buildFeatures { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 7507d00..1548bfc 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -37,7 +37,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Sync360" - packageVersion = "0.6.0" + packageVersion = "0.6.1" appResourcesRootDir.set( project.layout.projectDirectory.dir("packaging/app-resources") ) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 16793b5..2a0a281 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -58,7 +58,7 @@ The Desktop `hotRun` task is configured to use a Java 23 toolchain when it is a ## Preparing public packages -The current package version is `0.6.0`. +The current package version is `0.6.1`. Android release APKs must use the maintainer's permanent private signing key. Copy `keystore.properties.example` to the ignored `keystore.properties` file and set: @@ -156,8 +156,8 @@ Automated coverage is still minimal. Add focused tests for pure Kotlin logic whe There is no stable release yet. Treat current builds as development software. -## 0.6.0 validation +## 0.6.1 validation -Before publishing the final packages, check the grouped Nearby devices header, rows, and footer with zero, one, and multiple devices. Check discovery off, scanning, and failure/retry states; light and dark themes; compact and wide windows; and the intentionally limited empty-state text at larger font sizes. Check media/document selection and adding more files. The shared default surface shape changed, so inspect other screens that use its default corners too. +Before publishing the final packages, check the Send screen with zero, one, and multiple devices and confirm that it reports only device-search status. Check the Receive screen's displayed device name, file-transfer code, and nearby-visibility states on each supported platform. Force discovery and registration failures separately and confirm that only the corresponding Send or Receive navigation item shows its warning badge and retry UI. Check light and dark themes, compact and wide windows, and larger font sizes. -Build and device validation for 0.6.0 have not been recorded in this preparation task. +Build and device validation for 0.6.1 have not been recorded in this preparation task. diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 867f51d..162e8a6 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -3,5 +3,5 @@ TEAM_ID= PRODUCT_NAME=Sync360 PRODUCT_BUNDLE_IDENTIFIER=com.liftley.sync360.Sync360$(TEAM_ID) -CURRENT_PROJECT_VERSION=10 -MARKETING_VERSION=0.6.0 +CURRENT_PROJECT_VERSION=11 +MARKETING_VERSION=0.6.1 diff --git a/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.android.kt b/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.android.kt new file mode 100644 index 0000000..da405c5 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.android.kt @@ -0,0 +1,47 @@ +package com.liftley.sync360.presentation.receive.components + +import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.liftley.sync360.presentation.app.components.Sync360Surface + +@Composable +actual fun YouWillAppearAs() { + val manufacturer = Build.MANUFACTURER.trim().replaceFirstChar { it.titlecase() } + val model = Build.MODEL.trim() + val name = if (model.startsWith( + manufacturer, + ignoreCase = true + ) + ) model else "$manufacturer $model" + + + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Text("You'll appear as") + + Spacer(modifier = Modifier.height(16.dp)) + + Sync360Surface(modifier = Modifier.fillMaxWidth()) { + Text( + name, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(16.dp) + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt index 063a52e..0fcda19 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/Sync360Root.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -34,6 +36,8 @@ import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import com.liftley.sync360.core.designsystem.icons.Download import com.liftley.sync360.core.designsystem.icons.Send +import com.liftley.sync360.domain.model.DiscoveryStatus +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.presentation.navigation.NavScreen import com.liftley.sync360.presentation.navigation.NavigationViewModel import com.liftley.sync360.presentation.receive.ReceiveScreen @@ -53,6 +57,7 @@ fun Sync360Root() { val sendScreenViewModel = koinInject() val receiveScreenState by receiveScreenViewModel.screenState.collectAsStateWithLifecycle() + val registrationStatus by receiveScreenViewModel.registrationStatus.collectAsStateWithLifecycle() val sendScreenState by sendScreenViewModel.sendScreenState.collectAsStateWithLifecycle() val currentScreen = navigationViewModel.currentScreen() @@ -66,6 +71,10 @@ fun Sync360Root() { val isReceivingFiles = receiveScreenState is ReceiveState.ReceivingFiles val receivedText = receiveScreenState as? ReceiveState.ReceivedText + val sendNeedsAttention = sendScreenState.discoveryStatus == DiscoveryStatus.FailedToStart || + sendScreenState.discoveryStatus == DiscoveryStatus.CleanupFailed + val receiveNeedsAttention = registrationStatus == RegistrationStatus.FailedToStart || + registrationStatus == RegistrationStatus.CleanupFailed val receiveTitle = when (receiveScreenState) { is ReceiveState.Idle -> "Sync360" @@ -114,10 +123,16 @@ fun Sync360Root() { ) }, icon = { - Icon( - imageVector = Download, - contentDescription = null - ) + BadgedBox( + badge = { + if (receiveNeedsAttention) Badge() + } + ) { + Icon( + imageVector = Download, + contentDescription = null + ) + } } ) NavigationBarItem( @@ -133,10 +148,16 @@ fun Sync360Root() { ) }, icon = { - Icon( - imageVector = Send, - contentDescription = null - ) + BadgedBox( + badge = { + if (sendNeedsAttention) Badge() + } + ) { + Icon( + imageVector = Send, + contentDescription = null + ) + } } ) @@ -185,10 +206,16 @@ fun Sync360Root() { selected = currentScreen == NavScreen.ReceiveScreen, onClick = { navigationViewModel.navigateTo(NavScreen.ReceiveScreen) }, icon = { - Icon( - imageVector = Download, - contentDescription = null - ) + BadgedBox( + badge = { + if (receiveNeedsAttention) Badge() + } + ) { + Icon( + imageVector = Download, + contentDescription = null + ) + } }, label = { Text("Receive") } ) @@ -198,10 +225,16 @@ fun Sync360Root() { selected = currentScreen == NavScreen.SendScreen, onClick = { navigationViewModel.navigateTo(NavScreen.SendScreen) }, icon = { - Icon( - imageVector = Send, - contentDescription = null - ) + BadgedBox( + badge = { + if (sendNeedsAttention) Badge() + } + ) { + Icon( + imageVector = Send, + contentDescription = null + ) + } }, label = { Text("Send") } ) diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt index 74eb5ac..4a21e8f 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/core/di/Koin.kt @@ -15,7 +15,7 @@ import org.koin.dsl.KoinAppDeclaration import org.koin.dsl.module val appModule = module { - single { ReceiveScreenViewModel(get(), get(), get()) } + single { ReceiveScreenViewModel(get(), get(), get(), get()) } single { SendScreenViewModel(get(), get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt index 32cad05..a90303c 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/data/NetworkServicesController.kt @@ -37,7 +37,7 @@ class NetworkServicesController( } } - fun retryNearbySharing() { + fun retryNetworkServices() { controllerScope.launch { if (hasCleanupFailure()) { stopNearbySharing() diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt index 9f69a90..0ae2ad4 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/app/components/FileReceiveCodeCard.kt @@ -2,43 +2,37 @@ package com.liftley.sync360.presentation.app.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable fun FileReceiveCodeCard( - modifier: Modifier, fileReceiveCode: String ) { - Sync360Surface( - containerColor = MaterialTheme.colorScheme.surface + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start ) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "Your file transfer code", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text("Your file transfer code") + + Spacer(modifier = Modifier.height(16.dp)) + + Sync360Surface(modifier = Modifier.fillMaxWidth(), containerColor = MaterialTheme.colorScheme.primaryContainer) { Text( fileReceiveCode, - style = MaterialTheme.typography.displayMedium - ) - Text( - "Share this code with the sender", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(16.dp) ) } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt index d57a71d..0347954 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreen.kt @@ -18,6 +18,7 @@ import org.koin.compose.koinInject fun ReceiveScreen() { val receiveScreenViewModel = koinInject() val receiveScreenState by receiveScreenViewModel.screenState.collectAsStateWithLifecycle() + val registrationStatus by receiveScreenViewModel.registrationStatus.collectAsStateWithLifecycle() Sync360Surface( modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.surfaceContainer @@ -25,7 +26,11 @@ fun ReceiveScreen() { when (val state = receiveScreenState) { is ReceiveState.Idle -> { IdleReceiveStateUi( - fileReceiveCode = state.fileReceiveCode + fileReceiveCode = state.fileReceiveCode, + registrationStatus = registrationStatus, + onStartNetworkServices = receiveScreenViewModel::startNetworkServices, + onStopNetworkServices = receiveScreenViewModel::stopNetworkServices, + onRetryNetworkServices = receiveScreenViewModel::retryNetworkServices ) } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt index deedbac..0b349c4 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/ReceiveScreenViewModel.kt @@ -3,6 +3,7 @@ package com.liftley.sync360.presentation.receive import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.liftley.sync360.data.IncomingServerRequestsController +import com.liftley.sync360.data.NetworkServicesController import com.liftley.sync360.domain.model.ClientServerState import com.liftley.sync360.domain.repository.ClipboardProvider import com.liftley.sync360.domain.repository.DownloadsFolderOpener @@ -15,7 +16,8 @@ import kotlinx.coroutines.launch class ReceiveScreenViewModel( private val incomingServerRequestsController: IncomingServerRequestsController, private val clipboardProvider: ClipboardProvider, - private val downloadsFolderOpener: DownloadsFolderOpener + private val downloadsFolderOpener: DownloadsFolderOpener, + private val networkServicesController: NetworkServicesController ) : ViewModel() { private val _screenState = MutableStateFlow( @@ -24,6 +26,7 @@ class ReceiveScreenViewModel( ) ) val screenState: StateFlow = _screenState.asStateFlow() + val registrationStatus = networkServicesController.registrationServiceStatus init { viewModelScope.launch { @@ -49,6 +52,18 @@ class ReceiveScreenViewModel( downloadsFolderOpener.openDownloads() } + fun startNetworkServices() { + networkServicesController.startNetworkServices() + } + + fun stopNetworkServices() { + networkServicesController.stopNetworkServices() + } + + fun retryNetworkServices() { + networkServicesController.retryNetworkServices() + } + } private fun ClientServerState.toReceiveScreenState( diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt index 169d0f0..8a75db6 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/IdleReceiveStateUi.kt @@ -1,41 +1,43 @@ package com.liftley.sync360.presentation.receive.components import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.presentation.app.components.FileReceiveCodeCard @Composable fun IdleReceiveStateUi( - fileReceiveCode: String + fileReceiveCode: String, + registrationStatus: RegistrationStatus, + onStartNetworkServices: () -> Unit, + onStopNetworkServices: () -> Unit, + onRetryNetworkServices: () -> Unit ) { - Box( + Column( modifier = Modifier .verticalScroll(rememberScrollState()) .fillMaxSize(), - contentAlignment = Alignment.Center + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.Start ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - FileReceiveCodeCard( - modifier = Modifier.padding(16.dp), - fileReceiveCode = fileReceiveCode - ) + YouWillAppearAs() - } + FileReceiveCodeCard( + fileReceiveCode = fileReceiveCode + ) + + NearbyVisibilityCard( + registrationStatus = registrationStatus, + onStartNetworkServices = onStartNetworkServices, + onStopNetworkServices = onStopNetworkServices, + onRetryNetworkServices = onRetryNetworkServices + ) } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/NearbyVisibilityCard.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/NearbyVisibilityCard.kt new file mode 100644 index 0000000..9a328b7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/NearbyVisibilityCard.kt @@ -0,0 +1,74 @@ +package com.liftley.sync360.presentation.receive.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.liftley.sync360.domain.model.RegistrationStatus +import com.liftley.sync360.presentation.app.components.Sync360Surface + +@Composable +fun NearbyVisibilityCard( + registrationStatus: RegistrationStatus, + onStartNetworkServices: () -> Unit, + onStopNetworkServices: () -> Unit, + onRetryNetworkServices: () -> Unit +) { + val statusText = when (registrationStatus) { + RegistrationStatus.Idle -> "Not visible to nearby devices šŸ˜Ž" + RegistrationStatus.Starting -> "Becoming visible...ā˜•" + RegistrationStatus.FailedToStart -> "Couldn’t become visible 🤧" + RegistrationStatus.Running -> "Visible to nearby devices šŸŒž" + RegistrationStatus.Stopping -> "Becoming hidden...ā˜•" + RegistrationStatus.CleanupFailed -> "Couldn’t become hidden 🤧" + } + + val buttonLabel = when (registrationStatus) { + RegistrationStatus.Idle -> "Start" + RegistrationStatus.Starting -> "Starting…" + RegistrationStatus.Running -> "Stop" + RegistrationStatus.Stopping -> "Stopping…" + RegistrationStatus.FailedToStart, + RegistrationStatus.CleanupFailed -> "Try again" + } + + val onButtonClick: (() -> Unit)? = when (registrationStatus) { + RegistrationStatus.Idle -> onStartNetworkServices + RegistrationStatus.Running -> onStopNetworkServices + RegistrationStatus.FailedToStart, + RegistrationStatus.CleanupFailed -> onRetryNetworkServices + + RegistrationStatus.Starting, + RegistrationStatus.Stopping -> null + } + + Text(text = "Nearby visibility", modifier = Modifier.padding(horizontal = 16.dp)) + + Sync360Surface( + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = statusText, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + + OutlinedButton( + onClick = { onButtonClick?.invoke() }, + enabled = onButtonClick != null + ) { + Text(buttonLabel) + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.kt new file mode 100644 index 0000000..6a570e1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.kt @@ -0,0 +1,6 @@ +package com.liftley.sync360.presentation.receive.components + +import androidx.compose.runtime.Composable + +@Composable +expect fun YouWillAppearAs() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt index 6a9c03f..7b8b6d7 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreen.kt @@ -104,12 +104,11 @@ fun SendScreen() { NearbyDevicesSection( screenState = screenState, - onStartNearbySharing = sendScreenViewModel::startNearbySharing, - onStopNearbySharing = sendScreenViewModel::stopNearbySharing, - onRetryDiscovery = sendScreenViewModel::retryDiscovery, + onStartNetworkServices = sendScreenViewModel::startNetworkServices, + onStopNetworkServices = sendScreenViewModel::stopNetworkServices, + onRetryNetworkServices = sendScreenViewModel::retryNetworkServices, onDeviceClick = sendScreenViewModel::sendToDevice ) - } } diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt index 72025fc..fe8d14b 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/SendScreenViewModel.kt @@ -69,27 +69,19 @@ class SendScreenViewModel( } } } - - viewModelScope.launch { - networkServicesController.registrationServiceStatus.collect { status -> - _sendScreenState.update { - it.copy(registrationStatus = status) - } - } - } } - fun startNearbySharing() { + fun startNetworkServices() { networkServicesController.startNetworkServices() } - fun stopNearbySharing() { + fun stopNetworkServices() { networkServicesController.stopNetworkServices() } - fun retryDiscovery() { - networkServicesController.retryNearbySharing() + fun retryNetworkServices() { + networkServicesController.retryNetworkServices() } fun sendToDevice(deviceId: String) { diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt index d322076..12b4c05 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/components/NearbyDevicesSection.kt @@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -37,31 +38,49 @@ import com.liftley.sync360.presentation.send.model.SendScreenState @Composable fun NearbyDevicesSection( screenState: SendScreenState, - onStartNearbySharing: () -> Unit, - onStopNearbySharing: () -> Unit, - onRetryDiscovery: () -> Unit, + onStartNetworkServices: () -> Unit, + onStopNetworkServices: () -> Unit, + onRetryNetworkServices: () -> Unit, onDeviceClick: (String) -> Unit ) { + val discoveryStatus = screenState.discoveryStatus val hasDevices = screenState.nearbyDevices.isNotEmpty() - val status = when (screenState.discoveryStatus) { - DiscoveryStatus.Idle -> "Discovery is off" - DiscoveryStatus.Starting -> "Starting discovery…" - DiscoveryStatus.FailedToStart -> "Couldn’t start discovery" - DiscoveryStatus.Running -> if (hasDevices) "searching for more devices…" else "Searching for nearby devices…" - DiscoveryStatus.Stopping -> "Stopping discovery…" - DiscoveryStatus.CleanupFailed -> "Couldn’t stop discovery" + + val statusText = when (discoveryStatus) { + DiscoveryStatus.Idle -> "Device search is off" + DiscoveryStatus.Starting -> "Starting device search…" + DiscoveryStatus.FailedToStart -> "Couldn’t start device search" + DiscoveryStatus.Running -> if (hasDevices) { + "Searching for more devices…" + } else { + "Searching for nearby devices…" + } + + DiscoveryStatus.Stopping -> "Stopping device search…" + DiscoveryStatus.CleanupFailed -> "Couldn’t stop device search" } - val (buttonLabel, onButtonClick) = when (screenState.discoveryStatus) { - DiscoveryStatus.Idle -> "Start" to onStartNearbySharing - DiscoveryStatus.Starting, - DiscoveryStatus.Running, - DiscoveryStatus.Stopping -> "Stop" to onStopNearbySharing + + val buttonLabel = when (discoveryStatus) { + DiscoveryStatus.Idle -> "Start" + DiscoveryStatus.Starting -> "Starting…" + DiscoveryStatus.Running -> "Stop" + DiscoveryStatus.Stopping -> "Stopping…" + DiscoveryStatus.FailedToStart, + DiscoveryStatus.CleanupFailed -> "Try again" + } + + val onButtonClick: (() -> Unit)? = when (discoveryStatus) { + DiscoveryStatus.Idle -> onStartNetworkServices + DiscoveryStatus.Running -> onStopNetworkServices DiscoveryStatus.FailedToStart, - DiscoveryStatus.CleanupFailed -> "Try again" to onRetryDiscovery + DiscoveryStatus.CleanupFailed -> onRetryNetworkServices + + DiscoveryStatus.Starting, + DiscoveryStatus.Stopping -> null } + Column( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) ) { @@ -76,11 +95,14 @@ fun NearbyDevicesSection( verticalAlignment = Alignment.CenterVertically ) { Text( - "Nearby devices", + text = "Nearby devices", modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleLarge ) - OutlinedButton(onClick = onButtonClick) { + OutlinedButton( + onClick = { onButtonClick?.invoke() }, + enabled = onButtonClick != null + ) { Text(buttonLabel) } } @@ -98,16 +120,19 @@ fun NearbyDevicesSection( device = device, enabled = screenState.isContentReadyToSend, actionLabel = screenState.deviceActionLabel, - onClick = { onDeviceClick(device.id) }) + onClick = { onDeviceClick(device.id) } + ) } Sync360Surface( - Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraLarge.copy( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge.copy( topStart = CornerSize(8.dp), - topEnd = CornerSize(8.dp), + topEnd = CornerSize(8.dp) ) ) { Spacer(Modifier.height(32.dp)) } + Spacer(modifier = Modifier) Sync360Surface { @@ -116,11 +141,11 @@ fun NearbyDevicesSection( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - if (screenState.discoveryStatus == DiscoveryStatus.Running) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + if (discoveryStatus == DiscoveryStatus.Running) { + CircularWavyProgressIndicator(modifier = Modifier.size(24.dp)) } Text( - status, + text = statusText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -139,24 +164,29 @@ fun NearbyDevicesSection( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Sync360Surface(containerColor = MaterialTheme.colorScheme.surfaceContainer) { - Icon( - Wifi, - contentDescription = null, - modifier = Modifier.padding(16.dp).size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) + if (discoveryStatus == DiscoveryStatus.Running) { + LoadingIndicator() + } else { + Sync360Surface(containerColor = MaterialTheme.colorScheme.surfaceContainer) { + Icon( + imageVector = Wifi, + contentDescription = null, + modifier = Modifier.padding(16.dp).size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } } Text( - status, + text = statusText, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center ) Text( - text = when (screenState.discoveryStatus) { - DiscoveryStatus.Idle -> "Click Start to find nearby devices and let them find you." + text = when (discoveryStatus) { + DiscoveryStatus.Idle -> "Click Start to find nearby devices." DiscoveryStatus.FailedToStart, - DiscoveryStatus.CleanupFailed -> "Tap Try again to retry discovery." + DiscoveryStatus.CleanupFailed -> "Tap Try again to recover device search." + else -> "Open Sync360 on the other device and connect both to the same Wi-Fi network or hotspot." }, maxLines = 2, @@ -205,7 +235,8 @@ private fun NearbyDeviceRow( ) } Column( - modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(device.deviceName, style = MaterialTheme.typography.titleMedium) Text( diff --git a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt index a63aa7e..88305a4 100644 --- a/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt +++ b/shared/src/commonMain/kotlin/com/liftley/sync360/presentation/send/model/SendScreenState.kt @@ -1,7 +1,6 @@ package com.liftley.sync360.presentation.send.model import com.liftley.sync360.domain.model.DiscoveryStatus -import com.liftley.sync360.domain.model.RegistrationStatus import com.liftley.sync360.domain.model.SelectedFile import com.liftley.sync360.domain.model.TextDeliveryLimits @@ -13,8 +12,7 @@ data class SendScreenState( val fileReceiveCodePrompt: FileReceiveCodePrompt? = null, val sendState: SendState = SendState.Idle, val nearbyDevices: List = emptyList(), - val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle, - val registrationStatus: RegistrationStatus = RegistrationStatus.Idle + val discoveryStatus: DiscoveryStatus = DiscoveryStatus.Idle ) { val isTextTooLong: Boolean get() = textInput.length > TextDeliveryLimits.MAX_CHARACTER_COUNT diff --git a/shared/src/iosMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.ios.kt b/shared/src/iosMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.ios.kt new file mode 100644 index 0000000..f7f4b0e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.ios.kt @@ -0,0 +1,47 @@ +package com.liftley.sync360.presentation.receive.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.liftley.sync360.presentation.app.components.Sync360Surface +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIDevice + +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun YouWillAppearAs() { + val deviceName = remember { + UIDevice.currentDevice.name + .trim() + .ifBlank { UIDevice.currentDevice.model } + } + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Text("You'll appear as") + + Spacer(modifier = Modifier.height(16.dp)) + + Sync360Surface(modifier = Modifier.fillMaxWidth()) { + Text( + text = deviceName, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(16.dp) + ) + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.jvm.kt b/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.jvm.kt new file mode 100644 index 0000000..e84da24 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/liftley/sync360/presentation/receive/components/YouWillAppearAs.jvm.kt @@ -0,0 +1,59 @@ +package com.liftley.sync360.presentation.receive.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.liftley.sync360.presentation.app.components.Sync360Surface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.InetAddress + +@Composable +actual fun YouWillAppearAs() { + var hostName by remember { mutableStateOf("Loading...") } + + // Safely runs the blocking call on a background thread + LaunchedEffect(Unit) { + val name = withContext(Dispatchers.IO) { + try { + InetAddress.getLocalHost().hostName + } catch (e: Exception) { + "Desktop" + } + } + hostName = name + } + + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Text("You'll appear as") + + Spacer(modifier = Modifier.height(16.dp)) + + Sync360Surface(modifier = Modifier.fillMaxWidth()) { + Text( + hostName, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(16.dp) + ) + } + } +} \ No newline at end of file