diff --git a/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt b/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt index 04f29ef1..5f36fa68 100644 --- a/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt +++ b/module/logging/logging/src/androidInstrumentedTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt @@ -25,7 +25,13 @@ internal class FakeLogViewerComponent( ) override val model: Value get() = _model - var shareInvoked: Boolean = false + var copyInvoked: Boolean = false + private set + var saveInvoked: Boolean = false + private set + var shareAsTextInvoked: Boolean = false + private set + var shareAsFileInvoked: Boolean = false private set override fun onBackPressed() = Unit @@ -63,8 +69,20 @@ internal class FakeLogViewerComponent( recalc() } - override fun onShareClick(context: PlatformContext) { - shareInvoked = true + override fun onCopyClick(context: PlatformContext) { + copyInvoked = true + } + + override fun onSaveToFileClick(context: PlatformContext) { + saveInvoked = true + } + + override fun onShareAsTextClick(context: PlatformContext) { + shareAsTextInvoked = true + } + + override fun onShareAsFileClick(context: PlatformContext) { + shareAsFileInvoked = true } override fun onLabelClick(label: String) { diff --git a/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 5a26dd51..786da573 100644 --- a/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/androidMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -1,6 +1,12 @@ package ru.bartwell.kick.module.logging.feature.table.util +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.FileProvider import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.core.data.get @@ -10,26 +16,79 @@ import java.io.File @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal actual object LaunchUtils { - internal actual fun shareLogs(context: PlatformContext, logs: List) { + private const val LOG_MIME_TYPE = "text/plain" + private const val LOGS_FILE_NAME = "logs.txt" + private const val SHARE_FILE_NAME = "android.log" + private const val SHARE_TEXT_TITLE = "Share logs as text" + private const val SHARE_FILE_TITLE = "Share logs as file" + + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = true + internal actual fun canShareLogsAsText(): Boolean = true + internal actual fun canShareLogsAsFile(): Boolean = true + + internal actual fun copyLogs(context: PlatformContext, logs: List) { val androidContext = context.get() - val fileName = "android.log" - val file = File(androidContext.filesDir, fileName) - file.bufferedWriter().use { writer -> - logs.forEach { item -> - writer.appendLine(item.toLogString()) + val text = logs.joinToString(separator = "\n") { it.toLogString() } + val manager = androidContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(ClipData.newPlainText("logs", text)) + } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) { + val activity = context.get() as? ComponentActivity ?: return + val text = logs.joinToString(separator = "\n") { it.toLogString() } + val key = "save_logs_${System.nanoTime()}" + + lateinit var launcher: ActivityResultLauncher + launcher = activity.activityResultRegistry.register( + key, + ActivityResultContracts.CreateDocument(LOG_MIME_TYPE) + ) { uri -> + uri?.let { + activity.contentResolver.openOutputStream(it)?.bufferedWriter()?.use { writer -> + writer.write(text) + } } + launcher.unregister() + } + launcher.launch(LOGS_FILE_NAME) + } + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) { + val androidContext = context.get() + val text = logs.joinToString(separator = "\n") { it.toLogString() } + Intent(Intent.ACTION_SEND).apply { + type = LOG_MIME_TYPE + putExtra(Intent.EXTRA_TEXT, text) + }.also { intent -> + androidContext.startActivity(Intent.createChooser(intent, SHARE_TEXT_TITLE)) } + } + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) { + val androidContext = context.get() + val file = writeLogsToFile(androidContext.filesDir, SHARE_FILE_NAME, logs) val uri = FileProvider.getUriForFile( androidContext, "${androidContext.packageName}.kickfileprovider", file ) Intent(Intent.ACTION_SEND).apply { - type = "text/plain" + type = LOG_MIME_TYPE putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }.also { intent -> - androidContext.startActivity(Intent.createChooser(intent, "Share logs")) + androidContext.startActivity(Intent.createChooser(intent, SHARE_FILE_TITLE)) + } + } + + private fun writeLogsToFile(directory: File, fileName: String, logs: List): File { + val file = File(directory, fileName) + file.bufferedWriter().use { writer -> + logs.forEach { item -> + writer.appendLine(item.toLogString()) + } } + return file } } diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt index b3eff6d0..5d6fa0f5 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/DefaultLogViewerComponent.kt @@ -67,8 +67,20 @@ internal class DefaultLogViewerComponent( filterAndUpdateLog() } - override fun onShareClick(context: PlatformContext) { - LaunchUtils.shareLogs(context = context, logs = model.value.log) + override fun onCopyClick(context: PlatformContext) { + LaunchUtils.copyLogs(context = context, logs = model.value.log) + } + + override fun onSaveToFileClick(context: PlatformContext) { + LaunchUtils.saveLogsToFile(context = context, logs = model.value.log) + } + + override fun onShareAsTextClick(context: PlatformContext) { + LaunchUtils.shareLogsAsText(context = context, logs = model.value.log) + } + + override fun onShareAsFileClick(context: PlatformContext) { + LaunchUtils.shareLogsAsFile(context = context, logs = model.value.log) } override fun onLabelClick(label: String) { diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt index 42f5ea01..c910b036 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerComponent.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.value.Value import ru.bartwell.kick.core.component.Component import ru.bartwell.kick.core.data.PlatformContext +@Suppress("TooManyFunctions") public interface LogViewerComponent : Component { public val model: Value @@ -14,6 +15,9 @@ public interface LogViewerComponent : Component { public fun onFilterDialogDismiss() public fun onFilterApply() public fun onFilterTextChange(text: String) - public fun onShareClick(context: PlatformContext) + public fun onCopyClick(context: PlatformContext) + public fun onSaveToFileClick(context: PlatformContext) + public fun onShareAsTextClick(context: PlatformContext) + public fun onShareAsFileClick(context: PlatformContext) public fun onLabelClick(label: String) } diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt index 1cda333d..e7ca3f73 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerContent.kt @@ -9,13 +9,14 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ClearAll -import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileDownloadOff import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterListOff -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -28,19 +29,21 @@ import androidx.compose.material3.TopAppBar 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState -import ru.bartwell.kick.core.data.Platform import ru.bartwell.kick.core.data.platformContext import ru.bartwell.kick.core.presentation.BackOrCloseButton import ru.bartwell.kick.core.presentation.ErrorBox -import ru.bartwell.kick.core.util.PlatformUtils import ru.bartwell.kick.module.logging.core.data.LogLevel import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString +import ru.bartwell.kick.module.logging.feature.table.util.LaunchUtils @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,6 +54,7 @@ internal fun LogViewerContent( val state by component.model.subscribeAsState() val context = platformContext() val listState = rememberLazyListState() + var isMenuExpanded by remember { mutableStateOf(false) } LaunchedEffect(state.isAutoScrollEnabled, state.log.lastOrNull()?.id) { if (state.isAutoScrollEnabled && state.log.isNotEmpty()) { @@ -65,38 +69,19 @@ internal fun LogViewerContent( BackOrCloseButton(onBack = component::onBackPressed) }, actions = { - IconButton(onClick = component::onFilterClick, modifier = Modifier.testTag("filter_toggle")) { - val (icon, description) = if (state.isFilterActive) { - Icons.Default.FilterListOff to "Disable filter" - } else { - Icons.Default.FilterList to "Filter logs" - } - Icon(imageVector = icon, contentDescription = description) - } - IconButton( - onClick = component::onAutoScrollToggleClick, - modifier = Modifier.testTag("auto_scroll_toggle") - ) { - val (icon, description) = if (state.isAutoScrollEnabled) { - Icons.Filled.FileDownload to "Disable auto-scroll" - } else { - Icons.Filled.FileDownloadOff to "Enable auto-scroll" - } - Icon(imageVector = icon, contentDescription = description) - } - IconButton(onClick = component::onClearAllClick, modifier = Modifier.testTag("clear_all")) { - Icon(imageVector = Icons.Default.ClearAll, contentDescription = "Clear all") - } - IconButton(onClick = { component.onShareClick(context) }, modifier = Modifier.testTag("share_copy")) { - val shouldCopy = PlatformUtils.getPlatform() == Platform.IOS || - PlatformUtils.getPlatform() == Platform.WEB - val (icon, contentDescription) = if (shouldCopy) { - Icons.Default.ContentCopy to "Copy logs" - } else { - Icons.Default.Share to "Share logs" - } - Icon(imageVector = icon, contentDescription = contentDescription) - } + TopBarActions( + state = state, + isMenuExpanded = isMenuExpanded, + onFilterClick = component::onFilterClick, + onAutoScrollToggleClick = component::onAutoScrollToggleClick, + onClearAllClick = component::onClearAllClick, + onMenuOpen = { isMenuExpanded = true }, + onMenuDismiss = { isMenuExpanded = false }, + onCopyClick = { component.onCopyClick(context) }, + onSaveToFileClick = { component.onSaveToFileClick(context) }, + onShareAsTextClick = { component.onShareAsTextClick(context) }, + onShareAsFileClick = { component.onShareAsFileClick(context) }, + ) } ) if (state.isFilterDialogVisible) { @@ -118,6 +103,115 @@ internal fun LogViewerContent( } } +@Composable +private fun TopBarActions( + state: LogViewerState, + isMenuExpanded: Boolean, + onFilterClick: () -> Unit, + onAutoScrollToggleClick: () -> Unit, + onClearAllClick: () -> Unit, + onMenuOpen: () -> Unit, + onMenuDismiss: () -> Unit, + onCopyClick: () -> Unit, + onSaveToFileClick: () -> Unit, + onShareAsTextClick: () -> Unit, + onShareAsFileClick: () -> Unit, +) { + IconButton(onClick = onFilterClick, modifier = Modifier.testTag("filter_toggle")) { + val (icon, description) = if (state.isFilterActive) { + Icons.Default.FilterListOff to "Disable filter" + } else { + Icons.Default.FilterList to "Filter logs" + } + Icon(imageVector = icon, contentDescription = description) + } + IconButton( + onClick = onAutoScrollToggleClick, + modifier = Modifier.testTag("auto_scroll_toggle") + ) { + val (icon, description) = if (state.isAutoScrollEnabled) { + Icons.Filled.FileDownload to "Disable auto-scroll" + } else { + Icons.Filled.FileDownloadOff to "Enable auto-scroll" + } + Icon(imageVector = icon, contentDescription = description) + } + IconButton(onClick = onClearAllClick, modifier = Modifier.testTag("clear_all")) { + Icon(imageVector = Icons.Default.ClearAll, contentDescription = "Clear all") + } + IconButton(onClick = onMenuOpen, modifier = Modifier.testTag("overflow_menu")) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = "Menu") + } + OverflowMenu( + isExpanded = isMenuExpanded, + onDismiss = onMenuDismiss, + onCopyClick = onCopyClick, + onSaveToFileClick = onSaveToFileClick, + onShareAsTextClick = onShareAsTextClick, + onShareAsFileClick = onShareAsFileClick, + ) +} + +@Composable +private fun OverflowMenu( + isExpanded: Boolean, + onDismiss: () -> Unit, + onCopyClick: () -> Unit, + onSaveToFileClick: () -> Unit, + onShareAsTextClick: () -> Unit, + onShareAsFileClick: () -> Unit, +) { + DropdownMenu(expanded = isExpanded, onDismissRequest = onDismiss) { + MenuItem( + isVisible = LaunchUtils.canCopyLogs(), + title = "Copy", + tag = "copy_logs", + onDismiss = onDismiss, + onClick = onCopyClick, + ) + MenuItem( + isVisible = LaunchUtils.canSaveLogsToFile(), + title = "Save to file", + tag = "save_to_file", + onDismiss = onDismiss, + onClick = onSaveToFileClick, + ) + MenuItem( + isVisible = LaunchUtils.canShareLogsAsText(), + title = "Share as text", + tag = "share_as_text", + onDismiss = onDismiss, + onClick = onShareAsTextClick, + ) + MenuItem( + isVisible = LaunchUtils.canShareLogsAsFile(), + title = "Share as file", + tag = "share_as_file", + onDismiss = onDismiss, + onClick = onShareAsFileClick, + ) + } +} + +@Composable +private fun MenuItem( + isVisible: Boolean, + title: String, + tag: String, + onDismiss: () -> Unit, + onClick: () -> Unit, +) { + if (!isVisible) return + DropdownMenuItem( + text = { Text(title) }, + onClick = { + onDismiss() + onClick() + }, + modifier = Modifier.testTag(tag), + ) +} + @Composable private fun LabelsBar(component: LogViewerComponent, state: LogViewerState) { LazyRow(modifier = Modifier.testTag("label_chips")) { diff --git a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 666ec3c7..8116bffa 100644 --- a/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/commonMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -5,5 +5,13 @@ import ru.bartwell.kick.module.logging.core.persist.LogEntity @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") internal expect object LaunchUtils { - internal fun shareLogs(context: PlatformContext, logs: List) + internal fun canCopyLogs(): Boolean + internal fun canSaveLogsToFile(): Boolean + internal fun canShareLogsAsText(): Boolean + internal fun canShareLogsAsFile(): Boolean + + internal fun copyLogs(context: PlatformContext, logs: List) + internal fun saveLogsToFile(context: PlatformContext, logs: List) + internal fun shareLogsAsText(context: PlatformContext, logs: List) + internal fun shareLogsAsFile(context: PlatformContext, logs: List) } diff --git a/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt b/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt index 04f29ef1..5f36fa68 100644 --- a/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt +++ b/module/logging/logging/src/commonTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/FakeLogViewerComponent.kt @@ -25,7 +25,13 @@ internal class FakeLogViewerComponent( ) override val model: Value get() = _model - var shareInvoked: Boolean = false + var copyInvoked: Boolean = false + private set + var saveInvoked: Boolean = false + private set + var shareAsTextInvoked: Boolean = false + private set + var shareAsFileInvoked: Boolean = false private set override fun onBackPressed() = Unit @@ -63,8 +69,20 @@ internal class FakeLogViewerComponent( recalc() } - override fun onShareClick(context: PlatformContext) { - shareInvoked = true + override fun onCopyClick(context: PlatformContext) { + copyInvoked = true + } + + override fun onSaveToFileClick(context: PlatformContext) { + saveInvoked = true + } + + override fun onShareAsTextClick(context: PlatformContext) { + shareAsTextInvoked = true + } + + override fun onShareAsFileClick(context: PlatformContext) { + shareAsFileInvoked = true } override fun onLabelClick(label: String) { diff --git a/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt b/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt index 6ebd59b5..bd3d6db4 100644 --- a/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt +++ b/module/logging/logging/src/iosMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.ios.kt @@ -1,15 +1,62 @@ package ru.bartwell.kick.module.logging.feature.table.util import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication import platform.UIKit.UIPasteboard +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "OptionalUnit") internal actual object LaunchUtils { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = false + internal actual fun canShareLogsAsText(): Boolean = true + internal actual fun canShareLogsAsFile(): Boolean = false + + internal actual fun copyLogs(context: PlatformContext, logs: List) { + UIPasteboard.generalPasteboard.string = logs.joinToString(separator = "\n") { it.toLogString() } + } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) { + val text = logs.joinToString(separator = "\n") { it.toLogString() } + presentShareSheet(items = listOf(text)) + } + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) = Unit + + private fun presentShareSheet(items: List) { + dispatch_async(dispatch_get_main_queue()) { + val presenter = topViewController() ?: return@dispatch_async + val controller = UIActivityViewController( + activityItems = items, + applicationActivities = null, + ) + presenter.presentViewController(controller, animated = true, completion = null) + } + } + @OptIn(ExperimentalForeignApi::class) - internal actual fun shareLogs(context: PlatformContext, logs: List) { - UIPasteboard.generalPasteboard.string = logs.joinToString { logEntity -> logEntity.toLogString() } + private fun topViewController(): UIViewController? { + val window = topWindow() ?: return null + var current = window.rootViewController ?: return null + while (current.presentedViewController != null) { + current = current.presentedViewController!! + } + return current + } + + private fun topWindow(): UIWindow? { + val app = UIApplication.sharedApplication + app.keyWindow?.let { return it } + val windows = app.windows as? List<*> + return windows?.filterIsInstance()?.firstOrNull() } } diff --git a/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt b/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt index a0565546..2ad62f26 100644 --- a/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt +++ b/module/logging/logging/src/iosTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerIosUiTest.kt @@ -48,8 +48,9 @@ class LogViewerIosUiTest { onAllNodesWithTag("log_item").assertCountEquals(0) // Copy on iOS - onNodeWithContentDescription("Copy logs").performClick() - assertTrue(fake.shareInvoked) + onNodeWithContentDescription("Menu").performClick() + onNodeWithText("Copy").performClick() + assertTrue(fake.copyInvoked) } @Test diff --git a/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 9be5701a..330cc6b7 100644 --- a/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/jvmMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -3,26 +3,45 @@ package ru.bartwell.kick.module.logging.feature.table.util import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection import java.io.File import javax.swing.JFileChooser import javax.swing.SwingUtilities -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "OptionalUnit") internal actual object LaunchUtils { - internal actual fun shareLogs(context: PlatformContext, logs: List) { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = true + internal actual fun canShareLogsAsText(): Boolean = false + internal actual fun canShareLogsAsFile(): Boolean = false + + internal actual fun copyLogs(context: PlatformContext, logs: List) { + val text = logs.joinToString(separator = "\n") { it.toLogString() } + Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) + } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) { SwingUtilities.invokeLater { val chooser = JFileChooser().apply { dialogTitle = "Save logs" selectedFile = File("desktop.log") } if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) { - val file = chooser.selectedFile - file.bufferedWriter().use { writer -> - logs.forEach { item -> - writer.write(item.toLogString()) - writer.newLine() - } - } + writeLogsToFile(chooser.selectedFile, logs) + } + } + } + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) = Unit + + private fun writeLogsToFile(file: File, logs: List) { + file.bufferedWriter().use { writer -> + logs.forEach { item -> + writer.write(item.toLogString()) + writer.newLine() } } } diff --git a/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt b/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt index a9f966b6..f8927c48 100644 --- a/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt +++ b/module/logging/logging/src/jvmTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerUiTest.kt @@ -109,7 +109,7 @@ class LogViewerUiTest { } @Test - fun share_or_copy_action_invoked() { + fun copy_action_invoked() { val logs = listOf( LogEntity(id = 1, time = 1_000L, level = LogLevel.INFO, message = "m1"), ) @@ -117,9 +117,27 @@ class LogViewerUiTest { composeTestRule.setContent { LogViewerContent(component = fake) } - // On JVM platform the description is "Share logs" - composeTestRule.onNodeWithContentDescription("Share logs").performClick() - assertTrue(fake.shareInvoked) + composeTestRule.onNodeWithContentDescription("Menu").performClick() + composeTestRule.onNodeWithText("Copy").performClick() + assertTrue(fake.copyInvoked) + } + + @Test + fun save_to_file_action_invoked() { + val logs = listOf( + LogEntity(id = 1, time = 1_000L, level = LogLevel.INFO, message = "m1"), + ) + val fake = FakeLogViewerComponent(logs) + + composeTestRule.setContent { LogViewerContent(component = fake) } + + composeTestRule.onNodeWithContentDescription("Menu").performClick() + composeTestRule.onNodeWithText("Save to file").performClick() + assertTrue(fake.saveInvoked) + + composeTestRule.onNodeWithContentDescription("Menu").performClick() + composeTestRule.onNodeWithText("Share as text").assertDoesNotExist() + composeTestRule.onNodeWithText("Share as file").assertDoesNotExist() } @Test diff --git a/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt b/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt index 49dfa806..29085a72 100644 --- a/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt +++ b/module/logging/logging/src/wasmJsMain/kotlin/ru/bartwell/kick/module/logging/feature/table/util/LaunchUtils.kt @@ -5,9 +5,14 @@ import ru.bartwell.kick.core.data.PlatformContext import ru.bartwell.kick.module.logging.core.persist.LogEntity import ru.bartwell.kick.module.logging.feature.table.extension.toLogString -@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING", "OptionalUnit") internal actual object LaunchUtils { - internal actual fun shareLogs(context: PlatformContext, logs: List) { + internal actual fun canCopyLogs(): Boolean = true + internal actual fun canSaveLogsToFile(): Boolean = false + internal actual fun canShareLogsAsText(): Boolean = false + internal actual fun canShareLogsAsFile(): Boolean = false + + internal actual fun copyLogs(context: PlatformContext, logs: List) { val text = logs.joinToString(separator = "\n") { it.toLogString() } try { val clipboard = window.navigator.clipboard @@ -16,4 +21,10 @@ internal actual object LaunchUtils { kotlin.io.println(text) } } + + internal actual fun saveLogsToFile(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsText(context: PlatformContext, logs: List) = Unit + + internal actual fun shareLogsAsFile(context: PlatformContext, logs: List) = Unit } diff --git a/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt b/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt index f018cc3f..aae6a744 100644 --- a/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt +++ b/module/logging/logging/src/wasmJsTest/kotlin/ru/bartwell/kick/module/logging/feature/table/presentation/LogViewerWasmUiTest.kt @@ -25,7 +25,7 @@ private const val LABEL_B = "B" private const val CD_FILTER = "Filter logs" private const val CD_DISABLE_FILTER = "Disable filter" private const val CD_CLEAR_ALL = "Clear all" -private const val CD_COPY_LOGS = "Copy logs" +private const val CD_COPY_LOGS = "Copy" private const val TEXT_FILTER = "Filter" private const val COUNT_ZERO = 0 private const val COUNT_TWO = 2 @@ -59,9 +59,9 @@ class LogViewerWasmUiTest { onNodeWithContentDescription(CD_CLEAR_ALL).performClick() onAllNodesWithTag(TAG_LOG_ITEM).assertCountEquals(COUNT_ZERO) - // On Web platform compose.uiTest uses same contentDescription - onNodeWithContentDescription(CD_COPY_LOGS).performClick() - assertTrue(fake.shareInvoked) + onNodeWithContentDescription("Menu").performClick() + onNodeWithText(CD_COPY_LOGS).performClick() + assertTrue(fake.copyInvoked) } @Test