From ef3e0bc86c2e0cac762ac23833381f48447e6ee7 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 5 Feb 2024 16:00:06 +0800 Subject: [PATCH] :lipstick: Add device refresh button (#295) --- .../com/clipevery/net/ClientHandlerManager.kt | 6 ++ .../com/clipevery/net/DeviceRefresher.kt | 7 +- .../com/clipevery/ui/devices/DevicesView.kt | 95 ++++++++++++++++++- .../com/clipevery/net/DesktopClipClient.kt | 7 +- .../clipevery/net/DesktopDeviceRefresher.kt | 28 +++++- .../kotlin/com/clipevery/utils/TelnetUtils.kt | 20 ++-- 6 files changed, 144 insertions(+), 19 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/clipevery/net/ClientHandlerManager.kt b/composeApp/src/commonMain/kotlin/com/clipevery/net/ClientHandlerManager.kt index f9910a291..d09e1efd6 100644 --- a/composeApp/src/commonMain/kotlin/com/clipevery/net/ClientHandlerManager.kt +++ b/composeApp/src/commonMain/kotlin/com/clipevery/net/ClientHandlerManager.kt @@ -1,10 +1,16 @@ package com.clipevery.net interface ClientHandlerManager { + fun start() + fun addHandler(id: String) + fun removeHandler(id: String) + fun stop() + suspend fun checkConnects(checkAction: CheckAction) + suspend fun checkConnect(id: String, checkAction: CheckAction): Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/clipevery/net/DeviceRefresher.kt b/composeApp/src/commonMain/kotlin/com/clipevery/net/DeviceRefresher.kt index 4eb687f7e..2e6547074 100644 --- a/composeApp/src/commonMain/kotlin/com/clipevery/net/DeviceRefresher.kt +++ b/composeApp/src/commonMain/kotlin/com/clipevery/net/DeviceRefresher.kt @@ -1,5 +1,10 @@ package com.clipevery.net +import androidx.compose.runtime.State + interface DeviceRefresher { - suspend fun refresh(checkAction: CheckAction) + + val isRefreshing: State + + fun refresh(checkAction: CheckAction) } diff --git a/composeApp/src/commonMain/kotlin/com/clipevery/ui/devices/DevicesView.kt b/composeApp/src/commonMain/kotlin/com/clipevery/ui/devices/DevicesView.kt index f18de555f..7319baf26 100644 --- a/composeApp/src/commonMain/kotlin/com/clipevery/ui/devices/DevicesView.kt +++ b/composeApp/src/commonMain/kotlin/com/clipevery/ui/devices/DevicesView.kt @@ -1,7 +1,23 @@ package com.clipevery.ui.devices +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -9,30 +25,99 @@ 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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp import com.clipevery.LocalKoinApplication import com.clipevery.dao.sync.SyncRuntimeInfo import com.clipevery.dao.sync.SyncRuntimeInfoDao +import com.clipevery.net.CheckAction +import com.clipevery.net.DeviceRefresher import com.clipevery.ui.PageViewContext +import com.clipevery.ui.base.ClipIconButton import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.toList @Composable fun DevicesView(currentPageViewContext: MutableState) { + Box(contentAlignment = Alignment.TopCenter) { + DevicesListView(currentPageViewContext) + DevicesRefreshView() + } +} + +@Composable +fun DevicesListView(currentPageViewContext: MutableState) { val current = LocalKoinApplication.current val syncRuntimeInfoDao = current.koin.get() val syncRuntimeInfosFlow = syncRuntimeInfoDao.getAllSyncRuntimeInfos().asFlow() - var rememberSyncRuntimeInfos by remember { mutableStateOf(emptyList()) } LaunchedEffect(syncRuntimeInfosFlow) { rememberSyncRuntimeInfos = syncRuntimeInfosFlow.toList() } + Column { + for ((index, syncRuntimeInfo) in rememberSyncRuntimeInfos.withIndex()) { + DeviceItemView(syncRuntimeInfo, currentPageViewContext) + if (index != rememberSyncRuntimeInfos.size - 1) { + Divider(modifier = Modifier.fillMaxWidth()) + } + } + } +} - for ((index, syncRuntimeInfo) in rememberSyncRuntimeInfos.withIndex()) { - DeviceItemView(syncRuntimeInfo, currentPageViewContext) - if (index != rememberSyncRuntimeInfos.size - 1) { - Divider(modifier = Modifier.fillMaxWidth()) +@Composable +fun DevicesRefreshView() { + val current = LocalKoinApplication.current + val deviceRefresher = current.koin.get() + + val isRefreshing by deviceRefresher.isRefreshing + + val rotationDegrees = remember { Animatable(0f) } + + LaunchedEffect(isRefreshing) { + while (isRefreshing) { + rotationDegrees.animateTo( + targetValue = rotationDegrees.value + 360, + animationSpec = tween( + durationMillis = 1000, + easing = LinearEasing + ) + ) + } + rotationDegrees.snapTo(0f) + } + + Column(modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom) { + Row(modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + ClipIconButton( + radius = 18.dp, + onClick = { + if (!isRefreshing) { + deviceRefresher.refresh(CheckAction.CheckAll) + } + }, + modifier = Modifier + .padding(30.dp) + .background( + MaterialTheme.colors.primary, + CircleShape + ) + ) { + Icon( + Icons.Outlined.Refresh, + contentDescription = "info", + modifier = Modifier.padding(3.dp) + .size(25.dp) + .graphicsLayer(rotationZ = rotationDegrees.value ), + tint = Color.White + ) + } } } } + diff --git a/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopClipClient.kt b/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopClipClient.kt index 174cdc5e3..3f70bbc33 100644 --- a/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopClipClient.kt +++ b/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopClipClient.kt @@ -3,6 +3,7 @@ package com.clipevery.net import com.clipevery.app.AppInfo import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.timeout import io.ktor.client.request.get import io.ktor.client.request.header @@ -13,7 +14,11 @@ import io.ktor.util.InternalAPI class DesktopClipClient(private val appInfo: AppInfo): ClipClient { - private val client: HttpClient = HttpClient(CIO) + private val client: HttpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 1000 + } + } @OptIn(InternalAPI::class) override suspend fun post( diff --git a/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopDeviceRefresher.kt b/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopDeviceRefresher.kt index 3cadf7cd2..145e7b5d9 100644 --- a/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopDeviceRefresher.kt +++ b/composeApp/src/desktopMain/kotlin/com/clipevery/net/DesktopDeviceRefresher.kt @@ -1,13 +1,33 @@ package com.clipevery.net +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import com.clipevery.utils.ioDispatcher -import kotlinx.coroutines.withContext +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class DesktopDeviceRefresher(private val clientHandlerManager: ClientHandlerManager): DeviceRefresher { - override suspend fun refresh(checkAction: CheckAction) { - withContext(ioDispatcher) { - clientHandlerManager.checkConnects(checkAction) + val logger = KotlinLogging.logger {} + + private var _refreshing = mutableStateOf(false) + + override val isRefreshing: State get() = _refreshing + + override fun refresh(checkAction: CheckAction) { + _refreshing.value = true + CoroutineScope(ioDispatcher).launch { + logger.info { "start launch" } + try { + clientHandlerManager.checkConnects(checkAction) + } catch (e: Exception) { + logger.error(e) { "checkConnects error" } + } + delay(1000) + logger.info { "set refreshing false" } + _refreshing.value = false } } } diff --git a/composeApp/src/desktopMain/kotlin/com/clipevery/utils/TelnetUtils.kt b/composeApp/src/desktopMain/kotlin/com/clipevery/utils/TelnetUtils.kt index 269f171e0..c344db205 100644 --- a/composeApp/src/desktopMain/kotlin/com/clipevery/utils/TelnetUtils.kt +++ b/composeApp/src/desktopMain/kotlin/com/clipevery/utils/TelnetUtils.kt @@ -3,6 +3,8 @@ package com.clipevery.utils import com.clipevery.dao.sync.HostInfo import com.clipevery.net.ClipClient import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.URLProtocol +import io.ktor.http.path import kotlinx.coroutines.async import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext @@ -24,14 +26,12 @@ class TelnetUtils(private val clipClient: ClipClient) { } var result: HostInfo? = null - while (deferredArray.isNotEmpty() && result == null) { - select { - deferredArray.forEach { deferred -> - deferred.onAwait { hostInfo -> - if (hostInfo != null) { - result = hostInfo - deferredArray.forEach { it.cancel() } - } + select { + deferredArray.forEach { deferred -> + deferred.onAwait { hostInfo -> + if (hostInfo != null) { + result = hostInfo + deferredArray.forEach { it.cancel() } } } } @@ -42,9 +42,13 @@ class TelnetUtils(private val clipClient: ClipClient) { private suspend fun telnet(hostInfo: HostInfo, port: Int, timeout: Long): Boolean { return try { val httpResponse = clipClient.get(timeout = timeout) { urlBuilder -> + urlBuilder.protocol = URLProtocol.HTTP urlBuilder.port = port urlBuilder.host = hostInfo.hostAddress + urlBuilder.path("sync", "telnet") } + logger.info { "httpResponse.status = ${httpResponse.status.value} ${hostInfo.hostAddress}:$port" } + httpResponse.status.value == 200 } catch (e: Exception) { logger.debug(e) { "telnet $hostInfo fail" }