From 3a91e834b701fe0d650075842b7fb023afce2867 Mon Sep 17 00:00:00 2001 From: FatttSnake Date: Sat, 11 May 2024 16:28:11 +0800 Subject: [PATCH] Feat(ToolView): Add ToolView to support execute tool --- app/build.gradle.kts | 1 + app/src/main/assets/template/tool-view.html | 18 ++ .../data/network/OxygenNetworkDataSource.kt | 2 +- .../toolbox/data/tool/ToolDataSource.kt | 21 ++- .../fatweb/oxygen/toolbox/icon/OxygenIcons.kt | 16 +- .../toolbox/navigation/AboutNavigation.kt | 8 +- .../toolbox/navigation/OxygenNavHost.kt | 4 + .../toolbox/navigation/ToolViewNavigation.kt | 62 +++++++ .../toolbox/navigation/ToolsNavigation.kt | 2 + .../toolbox/network/model/ToolDataVo.kt | 4 +- .../network/retrofit/RetrofitOxygenNetwork.kt | 2 +- .../toolbox/repository/tool/ToolRepository.kt | 4 +- .../tool/impl/NetworkToolRepository.kt | 8 +- .../oxygen/toolbox/ui/about/AboutScreen.kt | 40 ++--- .../toolbox/ui/about/LibrariesScreen.kt | 159 +++++++++--------- .../toolbox/ui/component/OxygenTopAppBar.kt | 4 +- .../oxygen/toolbox/ui/component/ToolCard.kt | 2 +- .../oxygen/toolbox/ui/tool/ToolViewScreen.kt | 125 ++++++++++++++ .../ui/tool/ToolViewScreenViewModel.kt | 95 +++++++++++ .../oxygen/toolbox/ui/tool/ToolsScreen.kt | 12 +- .../fatweb/oxygen/toolbox/util/Transcode.kt | 33 ++++ app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 2 + 24 files changed, 489 insertions(+), 137 deletions(-) create mode 100644 app/src/main/assets/template/tool-view.html create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolViewNavigation.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreen.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreenViewModel.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Transcode.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9433104..99b93ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -169,4 +169,5 @@ dependencies { implementation(libs.paging.runtime) implementation(libs.paging.compose) implementation(libs.androidsvg.aar) + implementation(libs.compose.webview) } \ No newline at end of file diff --git a/app/src/main/assets/template/tool-view.html b/app/src/main/assets/template/tool-view.html new file mode 100644 index 0000000..1a12873 --- /dev/null +++ b/app/src/main/assets/template/tool-view.html @@ -0,0 +1,18 @@ + + + + + + Preview + + + + +
+
+ Loading... +
+
+ + diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/network/OxygenNetworkDataSource.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/network/OxygenNetworkDataSource.kt index 2a0c8e7..0071850 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/network/OxygenNetworkDataSource.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/network/OxygenNetworkDataSource.kt @@ -13,7 +13,7 @@ interface OxygenNetworkDataSource { currentPage: Int = 1 ): ResponseResult> - suspend fun detail( + fun detail( username: String, toolId: String, ver: String = "latest", diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/ToolDataSource.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/ToolDataSource.kt index 7766eb1..70bd6f5 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/ToolDataSource.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/data/tool/ToolDataSource.kt @@ -1,7 +1,24 @@ package top.fatweb.oxygen.toolbox.data.tool +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import top.fatweb.oxygen.toolbox.network.Dispatcher +import top.fatweb.oxygen.toolbox.network.OxygenDispatchers import javax.inject.Inject -class ToolDataSource @Inject constructor() { - +class ToolDataSource @Inject constructor( + private val context: Context, + @Dispatcher(OxygenDispatchers.IO) private val ioDispatcher: CoroutineDispatcher +) { + val toolViewTemplate = flow { + emit( + context.assets.open("template/tool-view.html") + .bufferedReader() + .use { + it.readText() + } + ) + }.flowOn(ioDispatcher) } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt index 6dd08ad..ffc293f 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap import com.caverock.androidsvg.SVG +import top.fatweb.oxygen.toolbox.util.decodeToByteArray +import top.fatweb.oxygen.toolbox.util.decodeToString import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -42,22 +44,16 @@ object OxygenIcons { val Time = Icons.Default.AccessTime val Tool = Icons.Default.Build + @OptIn(ExperimentalEncodingApi::class) fun fromSvgBase64(base64String: String): ImageBitmap { - val svg = SVG.getFromString(base64DecodeToString(base64String)) + val svg = SVG.getFromString(Base64.decodeToString(base64String)) val drawable = PictureDrawable(svg.renderToPicture()) return drawable.toBitmap().asImageBitmap() } + @OptIn(ExperimentalEncodingApi::class) fun fromPngBase64(base64String: String): ImageBitmap { - val byteArray = base64DecodeToByteArray(base64String) + val byteArray = Base64.decodeToByteArray(base64String) return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size).asImageBitmap() } - - @OptIn(ExperimentalEncodingApi::class) - private fun base64DecodeToString(base64String: String): String = - Base64.decode(base64String).decodeToString() - - @OptIn(ExperimentalEncodingApi::class) - private fun base64DecodeToByteArray(base64String: String): ByteArray = - Base64.decode(base64String) } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/AboutNavigation.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/AboutNavigation.kt index 22a052e..4dc9db0 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/AboutNavigation.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/AboutNavigation.kt @@ -14,8 +14,8 @@ fun NavController.navigateToAbout(navOptions: NavOptions? = null) = navigate(ABOUT_ROUTE, navOptions) fun NavGraphBuilder.aboutScreen( - onBackClick: () -> Unit, - onNavigateToLibraries: () -> Unit + onNavigateToLibraries: () -> Unit, + onBackClick: () -> Unit ) { composable( route = ABOUT_ROUTE, @@ -28,8 +28,8 @@ fun NavGraphBuilder.aboutScreen( } ) { AboutRoute( - onBackClick = onBackClick, - onNavigateToLibraries = onNavigateToLibraries + onNavigateToLibraries = onNavigateToLibraries, + onBackClick = onBackClick ) } } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt index a845ce9..191879a 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/OxygenNavHost.kt @@ -30,9 +30,13 @@ fun OxygenNavHost( onBackClick = navController::popBackStack ) toolsScreen( + onNavigateToToolView = navController::navigateToToolView, onShowSnackbar = onShowSnackbar, handleOnCanScrollChange = handleOnCanScrollChange ) + toolViewScreen( + onBackClick = navController::popBackStack + ) starScreen( ) diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolViewNavigation.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolViewNavigation.kt new file mode 100644 index 0000000..6d5fea5 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolViewNavigation.kt @@ -0,0 +1,62 @@ +package top.fatweb.oxygen.toolbox.navigation + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import top.fatweb.oxygen.toolbox.ui.tool.ToolViewRoute +import java.net.URLDecoder +import java.net.URLEncoder +import kotlin.text.Charsets.UTF_8 + +private val URL_CHARACTER_ENCODING = UTF_8.name() + +internal const val USER_NAME_ARG = "username" +internal const val TOOL_ID_ARG = "toolId" +const val TOOL_VIEW_ROUTE = "tool_view_route" + +internal class ToolViewArgs(val username: String, val toolId: String) { + constructor(savedStateHandle: SavedStateHandle) : + this( + URLDecoder.decode( + checkNotNull(savedStateHandle[USER_NAME_ARG]), + URL_CHARACTER_ENCODING + ), + URLDecoder.decode( + checkNotNull(savedStateHandle[TOOL_ID_ARG]), + URL_CHARACTER_ENCODING + ) + ) +} + +fun NavController.navigateToToolView( + username: String, + toolId: String, + navOptions: NavOptionsBuilder.() -> Unit = {} +) { + val encodedUsername = URLEncoder.encode(username, URL_CHARACTER_ENCODING) + val encodedToolId = URLEncoder.encode(toolId, URL_CHARACTER_ENCODING) + val newRoute = "$TOOL_VIEW_ROUTE/$encodedUsername/$encodedToolId" + navigate(newRoute) { + navOptions() + } +} + +fun NavGraphBuilder.toolViewScreen( + onBackClick: () -> Unit +) { + composable( + route = "${TOOL_VIEW_ROUTE}/{$USER_NAME_ARG}/{$TOOL_ID_ARG}", + arguments = listOf( + navArgument(USER_NAME_ARG) { type = NavType.StringType }, + navArgument(TOOL_ID_ARG) { type = NavType.StringType } + ) + ) { + ToolViewRoute( + onBackClick = onBackClick + ) + } +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt index c3cae51..ce8f511 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt @@ -11,6 +11,7 @@ const val TOOLS_ROUTE = "tools_route" fun NavController.navigateToTools(navOptions: NavOptions) = navigate(TOOLS_ROUTE, navOptions) fun NavGraphBuilder.toolsScreen( + onNavigateToToolView: (username: String, toolId: String) -> Unit, onShowSnackbar: suspend (String, String?) -> Boolean, handleOnCanScrollChange: (Boolean) -> Unit ) { @@ -18,6 +19,7 @@ fun NavGraphBuilder.toolsScreen( route = TOOLS_ROUTE ) { ToolsRoute( + onNavigateToToolView = onNavigateToToolView, onShowSnackbar = onShowSnackbar, handleOnCanScrollChange = handleOnCanScrollChange ) diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/model/ToolDataVo.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/model/ToolDataVo.kt index 1508780..396a7bd 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/model/ToolDataVo.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/model/ToolDataVo.kt @@ -11,8 +11,8 @@ data class ToolDataVo( val data: String, @Serializable(LocalDateTimeSerializer::class) - val createTime: LocalDateTime, + val createTime: LocalDateTime? = null, @Serializable(LocalDateTimeSerializer::class) - val updateTime: LocalDateTime + val updateTime: LocalDateTime? = null ) diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/retrofit/RetrofitOxygenNetwork.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/retrofit/RetrofitOxygenNetwork.kt index cf4a27b..3c4b51e 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/retrofit/RetrofitOxygenNetwork.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/network/retrofit/RetrofitOxygenNetwork.kt @@ -58,7 +58,7 @@ internal class RetrofitOxygenNetwork @Inject constructor( ): ResponseResult> = networkApi.getStore(searchValue = searchValue, currentPage = currentPage) - override suspend fun detail( + override fun detail( username: String, toolId: String, ver: String, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt index 1d32b4a..5063b3d 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt @@ -6,9 +6,11 @@ import top.fatweb.oxygen.toolbox.model.Result import top.fatweb.oxygen.toolbox.model.tool.Tool interface ToolRepository { + val toolViewTemplate: Flow + suspend fun getStore(searchValue: String, currentPage: Int): Flow> - suspend fun detail( + fun detail( username: String, toolId: String, ver: String = "latest", diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/NetworkToolRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/NetworkToolRepository.kt index dbfd6a1..a5cd2d0 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/NetworkToolRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/impl/NetworkToolRepository.kt @@ -6,6 +6,7 @@ import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import top.fatweb.oxygen.toolbox.data.network.OxygenNetworkDataSource +import top.fatweb.oxygen.toolbox.data.tool.ToolDataSource import top.fatweb.oxygen.toolbox.model.Result import top.fatweb.oxygen.toolbox.model.asExternalModel import top.fatweb.oxygen.toolbox.model.tool.Tool @@ -19,8 +20,11 @@ import javax.inject.Inject private const val PAGE_SIZE = 20 internal class NetworkToolRepository @Inject constructor( - private val oxygenNetworkDataSource: OxygenNetworkDataSource + private val oxygenNetworkDataSource: OxygenNetworkDataSource, + private val toolDataSource: ToolDataSource ) : ToolRepository { + override val toolViewTemplate: Flow + get() = toolDataSource.toolViewTemplate override suspend fun getStore(searchValue: String, currentPage: Int): Flow> = Pager( @@ -28,7 +32,7 @@ internal class NetworkToolRepository @Inject constructor( pagingSourceFactory = { ToolStorePagingSource(oxygenNetworkDataSource, searchValue) } ).flow - override suspend fun detail( + override fun detail( username: String, toolId: String, ver: String, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/AboutScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/AboutScreen.kt index dc301bd..62f0ec7 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/AboutScreen.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/AboutScreen.kt @@ -7,8 +7,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -43,7 +42,9 @@ import top.fatweb.oxygen.toolbox.ui.util.ResourcesUtils @Composable internal fun AboutRoute( - modifier: Modifier = Modifier, onBackClick: () -> Unit, onNavigateToLibraries: () -> Unit + modifier: Modifier = Modifier, + onNavigateToLibraries: () -> Unit, + onBackClick: () -> Unit ) { AboutScreen( modifier = modifier.safeDrawingPadding(), @@ -55,7 +56,9 @@ internal fun AboutRoute( @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AboutScreen( - modifier: Modifier = Modifier, onBackClick: () -> Unit, onNavigateToLibraries: () -> Unit + modifier: Modifier = Modifier, + onNavigateToLibraries: () -> Unit, + onBackClick: () -> Unit ) { val scrollState = rememberScrollState() val topAppBarScrollBehavior = @@ -66,7 +69,19 @@ internal fun AboutScreen( .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { + ) { padding -> + Column( + modifier = modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + ) + ) + .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally + ) { OxygenTopAppBar( scrollBehavior = topAppBarScrollBehavior, titleRes = R.string.feature_settings_more_about, @@ -78,21 +93,6 @@ internal fun AboutScreen( ), onNavigationClick = onBackClick ) - } - ) { padding -> - Column( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(padding) - .consumeWindowInsets(padding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal - ) - ) - .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally - ) { Spacer( modifier = Modifier.height(64.dp) ) diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt index 7db70dc..2b1ee37 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/about/LibrariesScreen.kt @@ -14,13 +14,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -29,11 +27,9 @@ import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -112,7 +108,8 @@ internal fun LibrariesScreen( var dialogContent by remember { mutableStateOf("") } var dialogUrl by remember { mutableStateOf("") } - val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(canScroll = { state.canScrollForward }) + val topAppBarScrollBehavior = + TopAppBarDefaults.enterAlwaysScrollBehavior(canScroll = { state.canScrollForward }) val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition") @@ -124,7 +121,18 @@ internal fun LibrariesScreen( .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { + ) { padding -> + Column( + modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal + ) + ) + ) { OxygenTopAppBar( scrollBehavior = topAppBarScrollBehavior, titleRes = R.string.feature_settings_open_source_license, @@ -153,88 +161,73 @@ internal fun LibrariesScreen( onSearch("") } ) - } - ) { padding -> - Box( - modifier - .fillMaxWidth() - .padding(padding) - .consumeWindowInsets(padding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal - ) - ) - ) { - when (librariesScreenUiState) { - LibrariesScreenUiState.Loading -> {Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - val angle by infiniteTransition.animateFloat( - initialValue = 0F, - targetValue = 360F, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = Ease), - ), label = "angle" - ) - Icon( - modifier = Modifier - .size(32.dp) - .graphicsLayer { rotationZ = angle }, - imageVector = OxygenIcons.Loading, - contentDescription = "" - ) - } - } - - LibrariesScreenUiState.Nothing -> { - Text(text = "Nothing") - } - - LibrariesScreenUiState.NotFound -> { - Text(text = "Not Found") - } - - is LibrariesScreenUiState.Success -> { - val handleOnClickLicense = { key: String -> - val license = librariesScreenUiState.dependencies.licenses[key] - if (license != null) { - showDialog = true - dialogTitle = license.name - dialogContent = license.content ?: "" - dialogUrl = license.url ?: "" + Box(modifier = Modifier) { + when (librariesScreenUiState) { + LibrariesScreenUiState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val angle by infiniteTransition.animateFloat( + initialValue = 0F, + targetValue = 360F, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = Ease), + ), label = "angle" + ) + Icon( + modifier = Modifier + .size(32.dp) + .graphicsLayer { rotationZ = angle }, + imageVector = OxygenIcons.Loading, + contentDescription = "" + ) } } - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(300.dp), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalItemSpacing = 24.dp, - state = state - ) { - librariesPanel( - librariesScreenUiState = librariesScreenUiState, - onClickLicense = handleOnClickLicense + LibrariesScreenUiState.Nothing -> { + Text(text = "Nothing") + } + + LibrariesScreenUiState.NotFound -> { + Text(text = "Not Found") + } + + is LibrariesScreenUiState.Success -> { + val handleOnClickLicense = { key: String -> + val license = librariesScreenUiState.dependencies.licenses[key] + if (license != null) { + showDialog = true + dialogTitle = license.name + dialogContent = license.content ?: "" + dialogUrl = license.url ?: "" + } + } + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalItemSpacing = 24.dp, + state = state + ) { + librariesPanel( + librariesScreenUiState = librariesScreenUiState, + onClickLicense = handleOnClickLicense + ) + } + + state.DraggableScrollbar( + modifier = Modifier + .fillMaxHeight() + .windowInsetsPadding(WindowInsets.systemBars) + .padding(horizontal = 2.dp) + .align(Alignment.CenterEnd), + state = scrollbarState, orientation = Orientation.Vertical, + onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable) ) - - item(span = StaggeredGridItemSpan.FullLine) { - Spacer(modifier = Modifier.height(8.dp)) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) - } } - - state.DraggableScrollbar( - modifier = Modifier - .fillMaxHeight() - .windowInsetsPadding(WindowInsets.systemBars) - .padding(horizontal = 2.dp) - .align(Alignment.CenterEnd), - state = scrollbarState, orientation = Orientation.Vertical, - onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable) - ) } } } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt index 45ce610..ebc516d 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/OxygenTopAppBar.kt @@ -46,7 +46,7 @@ import android.R as androidR fun OxygenTopAppBar( modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior? = null, - @StringRes titleRes: Int, + @StringRes titleRes: Int? = null, navigationIcon: ImageVector? = null, navigationIconContentDescription: String? = null, actionIcon: ImageVector? = null, @@ -120,7 +120,7 @@ fun OxygenTopAppBar( if ("\n" !in it) onQueryChange(it) } ) - else Text(stringResource(titleRes)) + else if (titleRes != null) Text(stringResource(titleRes)) }, navigationIcon = { navigationIcon?.let { diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolCard.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolCard.kt index f11693a..37eed9e 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolCard.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolCard.kt @@ -108,7 +108,7 @@ fun ToolInfo( toolDesc: String ) { Column( - modifier = modifier, + modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) ) { diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreen.kt new file mode 100644 index 0000000..3c4e85f --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreen.kt @@ -0,0 +1,125 @@ +package top.fatweb.oxygen.toolbox.ui.tool + +import android.annotation.SuppressLint +import androidx.compose.animation.core.Ease +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kevinnzou.web.WebView +import com.kevinnzou.web.rememberWebViewStateWithHTMLData +import top.fatweb.oxygen.toolbox.R +import top.fatweb.oxygen.toolbox.icon.Loading +import top.fatweb.oxygen.toolbox.icon.OxygenIcons +import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar + +@Composable +internal fun ToolViewRoute( + modifier: Modifier = Modifier, + viewModel: ToolViewScreenViewModel = hiltViewModel(), + onBackClick: () -> Unit +) { + val toolViewUiState by viewModel.toolViewUiState.collectAsStateWithLifecycle() + + ToolViewScreen( + modifier = modifier.safeDrawingPadding(), + onBackClick = onBackClick, + toolViewUiState = toolViewUiState + ) +} + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ToolViewScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + toolViewUiState: ToolViewUiState +) { + + val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition") + + Box( + modifier = modifier.windowInsetsPadding(WindowInsets(0, 0, 0, 0)), + ) { + OxygenTopAppBar( + modifier = Modifier.zIndex(100f), + navigationIcon = OxygenIcons.Back, + navigationIconContentDescription = stringResource(R.string.core_back), + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + onNavigationClick = onBackClick + ) + when (toolViewUiState) { + ToolViewUiState.Loading -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val angle by infiniteTransition.animateFloat( + initialValue = 0F, + targetValue = 360F, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = Ease), + ), label = "angle" + ) + Icon( + modifier = Modifier + .size(32.dp) + .graphicsLayer { rotationZ = angle }, + imageVector = OxygenIcons.Loading, + contentDescription = "" + ) + } + } + + ToolViewUiState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = stringResource(R.string.feature_tools_can_not_open)) + } + } + + is ToolViewUiState.Success -> { + val webViewState = rememberWebViewStateWithHTMLData( + data = toolViewUiState.htmlData, + ) + WebView( + modifier = Modifier.fillMaxSize(), + state = webViewState, + onCreated = { + it.settings.javaScriptEnabled = true + }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreenViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreenViewModel.kt new file mode 100644 index 0000000..c16a5e7 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolViewScreenViewModel.kt @@ -0,0 +1,95 @@ +package top.fatweb.oxygen.toolbox.ui.tool + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import top.fatweb.oxygen.toolbox.model.Result +import top.fatweb.oxygen.toolbox.navigation.ToolViewArgs +import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository +import top.fatweb.oxygen.toolbox.util.decodeToStringWithZip +import javax.inject.Inject +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class ToolViewScreenViewModel @Inject constructor( + toolRepository: ToolRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val toolViewArgs = ToolViewArgs(savedStateHandle) + val username = toolViewArgs.username + val toolId = toolViewArgs.toolId + + val toolViewUiState: StateFlow = toolViewUiState( + username = username, + toolId = toolId, + toolRepository = toolRepository + ) + .stateIn( + scope = viewModelScope, + initialValue = ToolViewUiState.Loading, + started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds) + ) +} + +private fun toolViewUiState( + username: String, + toolId: String, + toolRepository: ToolRepository +): Flow { + val result = toolRepository.detail( + username = username, + toolId = toolId + ) + val toolViewTemplate = toolRepository.toolViewTemplate + + return combine(result, toolViewTemplate, ::Pair).map { (result, toolViewTemplate) -> + when (result) { + is Result.Success -> { + val dist = result.data.dist!! + val base = result.data.base!! + ToolViewUiState.Success( + processHtml( + toolViewTemplate = toolViewTemplate, + distBase64 = dist, + baseBase64 = base + ) + ) + } + + is Result.Loading -> ToolViewUiState.Loading + is Result.Error -> { + Log.e("TAG", "toolViewUiState: can not load tool", result.exception) + + ToolViewUiState.Error + } + + is Result.Fail -> ToolViewUiState.Error + } + } +} + +sealed interface ToolViewUiState { + data class Success(val htmlData: String) : ToolViewUiState + + data object Error : ToolViewUiState + + data object Loading : ToolViewUiState +} + +@OptIn(ExperimentalEncodingApi::class) +fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String { + val dist = Base64.decodeToStringWithZip(distBase64) + val base = Base64.decodeToStringWithZip(baseBase64) + + return toolViewTemplate.replace("{{replace_code}}", "$dist\n$base") +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt index 02eedc3..a911cd5 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt @@ -1,6 +1,5 @@ package top.fatweb.oxygen.toolbox.ui.tool -import android.widget.Toast import androidx.activity.compose.ReportDrawnWhen import androidx.compose.animation.core.Ease import androidx.compose.animation.core.animateFloat @@ -35,7 +34,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState @@ -52,6 +50,7 @@ import top.fatweb.oxygen.toolbox.ui.component.scrollbar.scrollbarState internal fun ToolsRoute( modifier: Modifier = Modifier, viewModel: ToolsScreenViewModel = hiltViewModel(), + onNavigateToToolView: (username: String, toolId: String) -> Unit, onShowSnackbar: suspend (String, String?) -> Boolean, handleOnCanScrollChange: (Boolean) -> Unit ) { @@ -59,6 +58,7 @@ internal fun ToolsRoute( ToolsScreen( modifier = modifier, + onNavigateToToolView = onNavigateToToolView, onShowSnackbar = onShowSnackbar, handleOnCanScrollChange = handleOnCanScrollChange, toolStorePagingItems = toolStorePagingItems @@ -68,11 +68,11 @@ internal fun ToolsRoute( @Composable internal fun ToolsScreen( modifier: Modifier = Modifier, + onNavigateToToolView: (username: String, toolId: String) -> Unit, onShowSnackbar: suspend (String, String?) -> Boolean, handleOnCanScrollChange: (Boolean) -> Unit, toolStorePagingItems: LazyPagingItems ) { - val context = LocalContext.current val isToolLoading = toolStorePagingItems.loadState.refresh is LoadState.Loading || toolStorePagingItems.loadState.append is LoadState.Loading @@ -86,10 +86,6 @@ internal fun ToolsScreen( val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition") - val handleOnClickToolCard = { username: String, toolId: String -> - Toast.makeText(context, "$username:$toolId", Toast.LENGTH_LONG).show() - } - LaunchedEffect(state.canScrollForward) { handleOnCanScrollChange(state.canScrollForward) } @@ -106,7 +102,7 @@ internal fun ToolsScreen( toolsPanel( toolStorePagingItems = toolStorePagingItems, - onClickToolCard = handleOnClickToolCard + onClickToolCard = onNavigateToToolView ) item(span = StaggeredGridItemSpan.FullLine) { diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Transcode.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Transcode.kt new file mode 100644 index 0000000..0b53e4c --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/util/Transcode.kt @@ -0,0 +1,33 @@ +package top.fatweb.oxygen.toolbox.util + +import java.util.zip.Inflater +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +fun Base64.decodeToString(base64String: String): String = + this.decode(base64String).decodeToString() + +@OptIn(ExperimentalEncodingApi::class) +fun Base64.decodeToByteArray(base64String: String): ByteArray = + this.decode(base64String) + +@OptIn(ExperimentalEncodingApi::class) +fun Base64.decodeToStringWithZip(base64String: String): String { + val binary = this.decode(base64String).toString(Charsets.ISO_8859_1) + + // zlib header (x78), level 9 (xDA) + if (binary.startsWith("\u0078\u00DA")) { + val byteArray = binary.toByteArray(Charsets.ISO_8859_1) + val inflater = Inflater().apply { + setInput(byteArray) + } + val uncompressed = ByteArray(byteArray.size * 10) + val resultLength = inflater.inflate(uncompressed) + inflater.end() + + return String(uncompressed, 0, resultLength, Charsets.UTF_8) + } + + return "" +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b9133c2..5f26bca 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -35,4 +35,5 @@ 更多 搜索 简介 + ⚠️ 无法打开工具 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b64397..138afc2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,4 +36,5 @@ More Search Desc + ⚠️ Can not open the tool \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4247cc..531bd50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" okhttp = "4.12.0" androidsvg = "1.4" +webviewCompose = "0.33.6" [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -91,3 +92,4 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } androidsvg-aar = { group = "com.caverock", name = "androidsvg-aar", version.ref = "androidsvg" } +compose-webview = { group = "io.github.kevinnzou", name = "compose-webview", version.ref = "webviewCompose" }