Feat(ToolView): Add ToolView to support execute tool

This commit is contained in:
2024-05-11 16:28:11 +08:00
parent 3d8bc944e3
commit 3a91e834b7
24 changed files with 489 additions and 137 deletions

View File

@@ -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)
)

View File

@@ -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)
)
}
}
}

View File

@@ -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 {

View File

@@ -108,7 +108,7 @@ fun ToolInfo(
toolDesc: String
) {
Column(
modifier = modifier,
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {

View File

@@ -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
})
}
}
}
}

View File

@@ -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> = 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<ToolViewUiState> {
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")
}

View File

@@ -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<Tool>
) {
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) {