manager: add basic module repo support
This commit is contained in:
@@ -1,14 +1,7 @@
|
|||||||
package com.sukisu.ultra.ui.component
|
package com.sukisu.ultra.ui.component
|
||||||
|
|
||||||
import android.graphics.text.LineBreaker
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.text.Layout
|
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ScrollView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -21,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -33,15 +25,10 @@ import androidx.compose.runtime.saveable.Saver
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import io.noties.markwon.Markwon
|
|
||||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
|
||||||
import kotlinx.coroutines.CancellableContinuation
|
import kotlinx.coroutines.CancellableContinuation
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -477,36 +464,3 @@ private fun ConfirmDialog(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MarkdownContent(content: String) {
|
|
||||||
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
|
|
||||||
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
val scrollView = ScrollView(context)
|
|
||||||
val textView = TextView(context).apply {
|
|
||||||
movementMethod = LinkMovementMethod.getInstance()
|
|
||||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
|
||||||
}
|
|
||||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
scrollView.addView(textView)
|
|
||||||
scrollView
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight()
|
|
||||||
.clipToBounds(),
|
|
||||||
update = {
|
|
||||||
val textView = it.getChildAt(0) as TextView
|
|
||||||
Markwon.create(textView.context).setMarkdown(textView, content)
|
|
||||||
textView.setTextColor(contentColor)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.sukisu.ultra.ui.component
|
||||||
|
|
||||||
|
import android.graphics.text.LineBreaker
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import io.noties.markwon.Markwon
|
||||||
|
import io.noties.markwon.utils.NoCopySpannableFactory
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkdownContent(content: String) {
|
||||||
|
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
val scrollView = ScrollView(context)
|
||||||
|
val textView = TextView(context).apply {
|
||||||
|
movementMethod = LinkMovementMethod.getInstance()
|
||||||
|
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
||||||
|
}
|
||||||
|
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scrollView.addView(textView)
|
||||||
|
scrollView
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.clipToBounds(),
|
||||||
|
update = {
|
||||||
|
val textView = it.getChildAt(0) as TextView
|
||||||
|
Markwon.create(textView.context).setMarkdown(textView, content)
|
||||||
|
textView.setTextColor(contentColor)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,7 +45,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material.icons.rounded.Code
|
import androidx.compose.material.icons.rounded.Code
|
||||||
import androidx.compose.material.icons.rounded.Download
|
import androidx.compose.material.icons.rounded.Download
|
||||||
@@ -90,6 +89,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.kyant.capsule.ContinuousRoundedRectangle
|
import com.kyant.capsule.ContinuousRoundedRectangle
|
||||||
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||||
|
import com.ramcosta.composedestinations.generated.destinations.ModuleRepoScreenDestination
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeStyle
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
@@ -137,7 +137,9 @@ import top.yukonga.miuix.kmp.basic.TopAppBar
|
|||||||
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
|
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
|
||||||
import top.yukonga.miuix.kmp.extra.DropdownImpl
|
import top.yukonga.miuix.kmp.extra.DropdownImpl
|
||||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
|
||||||
import top.yukonga.miuix.kmp.icon.icons.useful.ImmersionMore
|
import top.yukonga.miuix.kmp.icon.icons.useful.ImmersionMore
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.New
|
||||||
import top.yukonga.miuix.kmp.icon.icons.useful.Undo
|
import top.yukonga.miuix.kmp.icon.icons.useful.Undo
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
import top.yukonga.miuix.kmp.utils.getWindowSize
|
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||||
@@ -458,6 +460,22 @@ fun ModulePager(
|
|||||||
alignment = PopupPositionProvider.Align.TopRight
|
alignment = PopupPositionProvider.Align.TopRight
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
onClick = {
|
||||||
|
navigator.navigate(ModuleRepoScreenDestination) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.New,
|
||||||
|
tint = colorScheme.onSurface,
|
||||||
|
contentDescription = stringResource(id = R.string.settings)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1128,7 @@ fun ModuleItem(
|
|||||||
imageVector = if (module.remove) {
|
imageVector = if (module.remove) {
|
||||||
MiuixIcons.Useful.Undo
|
MiuixIcons.Useful.Undo
|
||||||
} else {
|
} else {
|
||||||
Icons.Outlined.Delete
|
MiuixIcons.Useful.Delete
|
||||||
},
|
},
|
||||||
tint = actionIconTint,
|
tint = actionIconTint,
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.calculateEndPadding
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Code
|
||||||
|
import androidx.compose.material.icons.rounded.Download
|
||||||
|
import androidx.compose.material.icons.rounded.Link
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
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.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
|
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||||
|
import com.ramcosta.composedestinations.generated.destinations.ModuleRepoDetailScreenDestination
|
||||||
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
|
import dev.chrisbanes.haze.HazeTint
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.ksuApp
|
||||||
|
import com.sukisu.ultra.ui.component.MarkdownContent
|
||||||
|
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||||
|
import com.sukisu.ultra.ui.util.DownloadListener
|
||||||
|
import com.sukisu.ultra.ui.util.download
|
||||||
|
import com.sukisu.ultra.ui.viewmodel.ModuleRepoViewModel
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
|
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
|
||||||
|
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.SmallTitle
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
||||||
|
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||||
|
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ReleaseAssetArg(
|
||||||
|
val name: String,
|
||||||
|
val downloadUrl: String,
|
||||||
|
val size: Long
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ReleaseArg(
|
||||||
|
val tagName: String,
|
||||||
|
val name: String,
|
||||||
|
val publishedAt: String,
|
||||||
|
val assets: List<ReleaseAssetArg>
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class AuthorArg(
|
||||||
|
val name: String,
|
||||||
|
val link: String,
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class RepoModuleArg(
|
||||||
|
val moduleId: String,
|
||||||
|
val moduleName: String,
|
||||||
|
val authors: String,
|
||||||
|
val authorsList: List<AuthorArg>,
|
||||||
|
val homepageUrl: String,
|
||||||
|
val sourceUrl: String,
|
||||||
|
val latestRelease: String,
|
||||||
|
val latestReleaseTime: String,
|
||||||
|
val releases: List<ReleaseArg>
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
@SuppressLint("StringFormatInvalid")
|
||||||
|
@Composable
|
||||||
|
@Destination<RootGraph>
|
||||||
|
fun ModuleRepoScreen(
|
||||||
|
navigator: DestinationsNavigator
|
||||||
|
) {
|
||||||
|
val viewModel = viewModel<ModuleRepoViewModel>()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (viewModel.modules.value.isEmpty()) {
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val hazeStyle = HazeStyle(
|
||||||
|
backgroundColor = colorScheme.surface,
|
||||||
|
tint = HazeTint(colorScheme.surface.copy(0.8f))
|
||||||
|
)
|
||||||
|
|
||||||
|
val onInstallModule: (Uri) -> Unit = { uri ->
|
||||||
|
navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(listOf(uri)))) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val confirmTitle = stringResource(R.string.module)
|
||||||
|
var pendingDownload by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val confirmDialog = rememberConfirmDialog(onConfirm = { pendingDownload?.invoke() })
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
modifier = Modifier.hazeEffect(hazeState) {
|
||||||
|
style = hazeStyle
|
||||||
|
blurRadius = 30.dp
|
||||||
|
noiseFactor = 0f
|
||||||
|
},
|
||||||
|
color = Color.Transparent,
|
||||||
|
title = stringResource(R.string.module_repo),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
onClick = {
|
||||||
|
navigator.popBackStack()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
val isLoading = viewModel.modules.value.isEmpty()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(getWindowSize().height.dp)
|
||||||
|
.hazeSource(state = hazeState),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
InfiniteProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(getWindowSize().height.dp)
|
||||||
|
.scrollEndHaptic()
|
||||||
|
.overScrollVertical()
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
.hazeSource(state = hazeState),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = innerPadding.calculateTopPadding(),
|
||||||
|
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||||
|
end = innerPadding.calculateEndPadding(layoutDirection)
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
items(viewModel.modules.value, key = { it.moduleId }) { module ->
|
||||||
|
val moduleName = remember(module.moduleName) { module.moduleName }
|
||||||
|
val moduleId = remember(module.moduleId) { module.moduleId }
|
||||||
|
val authors = remember(module.authors) { module.authors }
|
||||||
|
val summary = remember(module.summary) { module.summary }
|
||||||
|
val latestReleaseTime = remember(module.latestReleaseTime) { module.latestReleaseTime }
|
||||||
|
val latestTag = remember(module.latestRelease) { module.latestRelease }
|
||||||
|
val latestRel = remember(module.releases, latestTag) {
|
||||||
|
module.releases.find { it.tagName == latestTag } ?: module.releases.firstOrNull()
|
||||||
|
}
|
||||||
|
val latestAsset = remember(latestRel) { latestRel?.assets?.firstOrNull() }
|
||||||
|
|
||||||
|
val moduleVersion = stringResource(id = R.string.module_version)
|
||||||
|
val moduleAuthor = stringResource(id = R.string.module_author)
|
||||||
|
|
||||||
|
val secondaryContainer = colorScheme.secondaryContainer.copy(alpha = 0.8f)
|
||||||
|
val actionIconTint = colorScheme.onSurface.copy(alpha = 0.9f)
|
||||||
|
val installBg = colorScheme.tertiaryContainer.copy(alpha = 0.6f)
|
||||||
|
val installTint = colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
insideMargin = PaddingValues(16.dp),
|
||||||
|
showIndication = true,
|
||||||
|
pressFeedbackType = PressFeedbackType.Sink,
|
||||||
|
onClick = {
|
||||||
|
val args = RepoModuleArg(
|
||||||
|
moduleId = module.moduleId,
|
||||||
|
moduleName = module.moduleName,
|
||||||
|
authors = authors,
|
||||||
|
authorsList = module.authorList.map { AuthorArg(it.name, it.link) },
|
||||||
|
homepageUrl = module.homepageUrl,
|
||||||
|
sourceUrl = module.sourceUrl,
|
||||||
|
latestRelease = module.latestRelease,
|
||||||
|
latestReleaseTime = module.latestReleaseTime,
|
||||||
|
releases = module.releases.map { r ->
|
||||||
|
ReleaseArg(
|
||||||
|
tagName = r.tagName,
|
||||||
|
name = r.name,
|
||||||
|
publishedAt = r.publishedAt,
|
||||||
|
assets = r.assets.map { a ->
|
||||||
|
ReleaseAssetArg(name = a.name, downloadUrl = a.downloadUrl, size = a.size)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
navigator.navigate(ModuleRepoDetailScreenDestination(args)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (moduleName.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = moduleName,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight(550),
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (moduleId.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = moduleId,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight(550),
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "$moduleVersion: $latestTag",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
fontWeight = FontWeight(550),
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$moduleAuthor: $authors",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 1.dp),
|
||||||
|
fontWeight = FontWeight(550),
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
)
|
||||||
|
if (summary.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = summary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (latestReleaseTime.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = latestReleaseTime,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
textAlign = TextAlign.End
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (module.homepageUrl.isNotBlank()) {
|
||||||
|
IconButton(
|
||||||
|
backgroundColor = secondaryContainer,
|
||||||
|
minHeight = 35.dp,
|
||||||
|
minWidth = 35.dp,
|
||||||
|
onClick = { uriHandler.openUri(module.homepageUrl) },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = Icons.Rounded.Link,
|
||||||
|
tint = actionIconTint,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (module.sourceUrl.isNotBlank()) {
|
||||||
|
IconButton(
|
||||||
|
backgroundColor = secondaryContainer,
|
||||||
|
minHeight = 35.dp,
|
||||||
|
minWidth = 35.dp,
|
||||||
|
onClick = { uriHandler.openUri(module.sourceUrl) },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = Icons.Rounded.Code,
|
||||||
|
tint = actionIconTint,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (latestAsset != null) {
|
||||||
|
val fileName = latestAsset.name
|
||||||
|
val downloadingText = stringResource(R.string.module_downloading)
|
||||||
|
IconButton(
|
||||||
|
backgroundColor = installBg,
|
||||||
|
minHeight = 35.dp,
|
||||||
|
minWidth = 35.dp,
|
||||||
|
onClick = {
|
||||||
|
pendingDownload = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
download(
|
||||||
|
context,
|
||||||
|
latestAsset.downloadUrl,
|
||||||
|
fileName,
|
||||||
|
downloadingText.format(module.moduleId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val confirmContent = context.getString(R.string.module_install_prompt_with_name, fileName)
|
||||||
|
confirmDialog.showConfirm(
|
||||||
|
title = confirmTitle,
|
||||||
|
content = confirmContent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = Icons.Rounded.Download,
|
||||||
|
tint = installTint,
|
||||||
|
contentDescription = stringResource(R.string.install)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 3.dp),
|
||||||
|
text = stringResource(R.string.install),
|
||||||
|
color = installTint,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(12.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadListener(context, onInstallModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("StringFormatInvalid", "DefaultLocale")
|
||||||
|
@Composable
|
||||||
|
@Destination<RootGraph>
|
||||||
|
fun ModuleRepoDetailScreen(
|
||||||
|
navigator: DestinationsNavigator,
|
||||||
|
module: RepoModuleArg
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val confirmTitle = stringResource(R.string.module)
|
||||||
|
var pendingDownload by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val confirmDialog = rememberConfirmDialog(onConfirm = { pendingDownload?.invoke() })
|
||||||
|
|
||||||
|
var readmeText by remember(module.moduleId) { mutableStateOf<String?>(null) }
|
||||||
|
var readmeLoaded by remember(module.moduleId) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val hazeStyle = HazeStyle(
|
||||||
|
backgroundColor = colorScheme.surface,
|
||||||
|
tint = HazeTint(colorScheme.surface.copy(0.8f))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
modifier = Modifier.hazeEffect(hazeState) {
|
||||||
|
style = hazeStyle
|
||||||
|
blurRadius = 30.dp
|
||||||
|
noiseFactor = 0f
|
||||||
|
},
|
||||||
|
color = Color.Transparent,
|
||||||
|
title = module.moduleId,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
onClick = {
|
||||||
|
navigator.popBackStack()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
LaunchedEffect(module.moduleId) {
|
||||||
|
if (module.moduleId.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val url = "https://modules.kernelsu.org/module/${module.moduleId}.json"
|
||||||
|
ksuApp.okhttpClient.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return@use
|
||||||
|
val body = resp.body?.string() ?: return@use
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
val readme = obj.optString("readme", "")
|
||||||
|
readmeText = readme.ifBlank { null }
|
||||||
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
readmeLoaded = true
|
||||||
|
}.onFailure {
|
||||||
|
readmeLoaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
readmeLoaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(getWindowSize().height.dp)
|
||||||
|
.scrollEndHaptic()
|
||||||
|
.overScrollVertical()
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
.hazeSource(state = hazeState),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
top = innerPadding.calculateTopPadding(),
|
||||||
|
bottom = innerPadding.calculateBottomPadding(),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
if (readmeLoaded && readmeText != null) {
|
||||||
|
item {
|
||||||
|
SmallTitle(text = "README")
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
insideMargin = PaddingValues(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
MarkdownContent(content = readmeText!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (module.authorsList.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
SmallTitle(
|
||||||
|
text = "AUTHORS",
|
||||||
|
modifier = Modifier.padding(top = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
val secondaryContainer = colorScheme.secondaryContainer.copy(alpha = 0.8f)
|
||||||
|
val actionIconTint = colorScheme.onSurface.copy(alpha = 0.9f)
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
insideMargin = PaddingValues(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
module.authorsList.forEachIndexed { index, author ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = author.name,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val clickable = author.link.isNotBlank()
|
||||||
|
val tint = if (clickable) actionIconTint else actionIconTint.copy(alpha = 0.35f)
|
||||||
|
IconButton(
|
||||||
|
backgroundColor = secondaryContainer,
|
||||||
|
minHeight = 35.dp,
|
||||||
|
minWidth = 35.dp,
|
||||||
|
enabled = clickable,
|
||||||
|
onClick = {
|
||||||
|
if (clickable) {
|
||||||
|
uriHandler.openUri(author.link)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = Icons.Rounded.Link,
|
||||||
|
tint = tint,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index != module.authorsList.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (module.releases.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
SmallTitle(
|
||||||
|
text = "RELEASES",
|
||||||
|
modifier = Modifier.padding(top = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(module.releases, key = { it.tagName }) { rel ->
|
||||||
|
val title = remember(rel.name, rel.tagName) { rel.name.ifBlank { rel.tagName } }
|
||||||
|
val installBg = colorScheme.tertiaryContainer.copy(alpha = 0.6f)
|
||||||
|
val installTint = colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
insideMargin = PaddingValues(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight(550),
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = rel.tagName,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight(550),
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = rel.publishedAt,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = rel.assets.isNotEmpty(),
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
|
||||||
|
rel.assets.forEachIndexed { index, asset ->
|
||||||
|
val fileName = asset.name
|
||||||
|
stringResource(R.string.module_start_downloading)
|
||||||
|
val downloadingText = stringResource(R.string.module_downloading)
|
||||||
|
val sizeText = remember(asset.size) {
|
||||||
|
val s = asset.size
|
||||||
|
when {
|
||||||
|
s >= 1024L * 1024L * 1024L -> String.format("%.1f GB", s / (1024f * 1024f * 1024f))
|
||||||
|
s >= 1024L * 1024L -> String.format("%.1f MB", s / (1024f * 1024f))
|
||||||
|
s >= 1024L -> String.format("%.0f KB", s / 1024f)
|
||||||
|
else -> "$s B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val onClickDownload = remember(fileName, asset.downloadUrl) {
|
||||||
|
{
|
||||||
|
pendingDownload = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
download(
|
||||||
|
context,
|
||||||
|
asset.downloadUrl,
|
||||||
|
fileName,
|
||||||
|
downloadingText.format(module.moduleId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val confirmContent = context.getString(R.string.module_install_prompt_with_name, fileName)
|
||||||
|
confirmDialog.showConfirm(
|
||||||
|
title = confirmTitle,
|
||||||
|
content = confirmContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = fileName,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = sizeText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
backgroundColor = installBg,
|
||||||
|
minHeight = 35.dp,
|
||||||
|
minWidth = 35.dp,
|
||||||
|
onClick = onClickDownload,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = Icons.Rounded.Download,
|
||||||
|
tint = installTint,
|
||||||
|
contentDescription = stringResource(R.string.install)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 3.dp),
|
||||||
|
text = stringResource(R.string.install),
|
||||||
|
color = installTint,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index != rel.assets.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = colorScheme.outline.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(12.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,7 +334,7 @@ private fun TopBar(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = MiuixIcons.Useful.Back,
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = colorScheme.onBackground
|
tint = colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.sukisu.ultra.ui.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.sukisu.ultra.ksuApp
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class ModuleRepoViewModel : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ModuleRepoViewModel"
|
||||||
|
private const val MODULES_URL = "https://modules.kernelsu.org/modules.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Author(
|
||||||
|
val name: String,
|
||||||
|
val link: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ReleaseAsset(
|
||||||
|
val name: String,
|
||||||
|
val downloadUrl: String,
|
||||||
|
val size: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Release(
|
||||||
|
val tagName: String,
|
||||||
|
val name: String,
|
||||||
|
val publishedAt: String,
|
||||||
|
val assets: List<ReleaseAsset>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class RepoModule(
|
||||||
|
val moduleId: String,
|
||||||
|
val moduleName: String,
|
||||||
|
val authors: String,
|
||||||
|
val authorList: List<Author>,
|
||||||
|
val summary: String,
|
||||||
|
val homepageUrl: String,
|
||||||
|
val sourceUrl: String,
|
||||||
|
val latestRelease: String,
|
||||||
|
val latestReleaseTime: String,
|
||||||
|
val releases: List<Release>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var _modules = mutableStateOf<List<RepoModule>>(emptyList())
|
||||||
|
val modules: State<List<RepoModule>> = _modules
|
||||||
|
|
||||||
|
var isRefreshing by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.Main) { isRefreshing = true }
|
||||||
|
val parsed = withContext(Dispatchers.IO) { fetchModulesInternal() }
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_modules.value = parsed
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchModulesInternal(): List<RepoModule> {
|
||||||
|
return runCatching {
|
||||||
|
val request = Request.Builder().url(MODULES_URL).build()
|
||||||
|
ksuApp.okhttpClient.newCall(request).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) return emptyList()
|
||||||
|
val body = resp.body?.string() ?: return emptyList()
|
||||||
|
val json = kotlin.runCatching { JSONArray(body) }.getOrElse {
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
obj.optJSONArray("modules") ?: JSONArray()
|
||||||
|
}
|
||||||
|
(0 until json.length()).mapNotNull { idx ->
|
||||||
|
val item = json.optJSONObject(idx) ?: return@mapNotNull null
|
||||||
|
parseRepoModule(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
Log.e(TAG, "fetch modules failed", it)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRepoModule(item: JSONObject): RepoModule? {
|
||||||
|
val moduleId = item.optString("moduleId", "")
|
||||||
|
if (moduleId.isEmpty()) return null
|
||||||
|
val moduleName = item.optString("moduleName", "")
|
||||||
|
val authorsArray = item.optJSONArray("authors")
|
||||||
|
val authorList = if (authorsArray != null) {
|
||||||
|
(0 until authorsArray.length())
|
||||||
|
.mapNotNull { idx ->
|
||||||
|
val authorObj = authorsArray.optJSONObject(idx) ?: return@mapNotNull null
|
||||||
|
val name = authorObj.optString("name", "").trim()
|
||||||
|
val link = authorObj.optString("link", "").trim()
|
||||||
|
if (name.isEmpty()) null else Author(name = name, link = link)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val authors = if (authorList.isNotEmpty()) authorList.joinToString(", ") { it.name } else item.optString("authors", "")
|
||||||
|
val summary = item.optString("summary", "")
|
||||||
|
val homepageUrl = item.optString("homepageUrl", item.optString("url", ""))
|
||||||
|
val sourceUrl = item.optString("sourceUrl", item.optString("url", ""))
|
||||||
|
val latestRelease = item.optString("latestRelease", "")
|
||||||
|
val latestReleaseTime = item.optString("latestReleaseTime", "")
|
||||||
|
|
||||||
|
val releasesArray = item.optJSONArray("releases") ?: JSONArray()
|
||||||
|
val releases = (0 until releasesArray.length()).mapNotNull { rIdx ->
|
||||||
|
val r = releasesArray.optJSONObject(rIdx) ?: return@mapNotNull null
|
||||||
|
val tag = r.optString("tagName", r.optString("name", ""))
|
||||||
|
val rname = r.optString("name", tag)
|
||||||
|
val publishedAt = r.optString("publishedAt", r.optString("updatedAt", ""))
|
||||||
|
val assetsArray = r.optJSONArray("releaseAssets") ?: r.optJSONArray("assets") ?: JSONArray()
|
||||||
|
val assets = (0 until assetsArray.length()).mapNotNull { aIdx ->
|
||||||
|
val a = assetsArray.optJSONObject(aIdx) ?: return@mapNotNull null
|
||||||
|
val aname = a.optString("name", "")
|
||||||
|
val downloadUrl = a.optString("downloadUrl", a.optString("browser_download_url", ""))
|
||||||
|
if (aname.isEmpty() || downloadUrl.isEmpty()) null else ReleaseAsset(
|
||||||
|
name = aname,
|
||||||
|
downloadUrl = downloadUrl,
|
||||||
|
size = a.optLong("size", 0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Release(tagName = tag, name = rname, publishedAt = publishedAt, assets = assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RepoModule(
|
||||||
|
moduleId = moduleId,
|
||||||
|
moduleName = moduleName,
|
||||||
|
authors = authors,
|
||||||
|
authorList = authorList,
|
||||||
|
summary = summary,
|
||||||
|
homepageUrl = homepageUrl,
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
latestRelease = latestRelease,
|
||||||
|
latestReleaseTime = latestReleaseTime,
|
||||||
|
releases = releases,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<string name="module_failed_to_disable">无法禁用模块:%s</string>
|
<string name="module_failed_to_disable">无法禁用模块:%s</string>
|
||||||
<string name="module_empty">没有安装模块</string>
|
<string name="module_empty">没有安装模块</string>
|
||||||
<string name="module">模块</string>
|
<string name="module">模块</string>
|
||||||
|
<string name="module_repo">模块仓库</string>
|
||||||
<string name="module_sort_action_first">可执行优先</string>
|
<string name="module_sort_action_first">可执行优先</string>
|
||||||
<string name="module_sort_enabled_first">已启用优先</string>
|
<string name="module_sort_enabled_first">已启用优先</string>
|
||||||
<string name="uninstall">卸载</string>
|
<string name="uninstall">卸载</string>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<string name="module_failed_to_disable">Failed to disable module: %s</string>
|
<string name="module_failed_to_disable">Failed to disable module: %s</string>
|
||||||
<string name="module_empty">No module installed</string>
|
<string name="module_empty">No module installed</string>
|
||||||
<string name="module">Module</string>
|
<string name="module">Module</string>
|
||||||
|
<string name="module_repo">Module repository</string>
|
||||||
<string name="module_install_prompt_with_name">The following modules will be installed: %1$s</string>
|
<string name="module_install_prompt_with_name">The following modules will be installed: %1$s</string>
|
||||||
<string name="module_sort_action_first">Action first</string>
|
<string name="module_sort_action_first">Action first</string>
|
||||||
<string name="module_sort_enabled_first">Enabled first</string>
|
<string name="module_sort_enabled_first">Enabled first</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user