Feat(Tool): Add tool store

Add tool store, support install tool online.
This commit is contained in:
2024-08-08 17:56:45 +08:00
parent c1879dfdc8
commit c9c0debb2b
35 changed files with 1078 additions and 179 deletions

View File

@@ -0,0 +1,30 @@
package top.fatweb.oxygen.toolbox.data.tool
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
@Database(
entities = [ToolEntity::class],
version = 1,
autoMigrations = [],
exportSchema = true
)
abstract class ToolDatabase : RoomDatabase() {
abstract fun toolDao(): ToolDao
companion object {
@Volatile
private var INSTANCE: ToolDatabase? = null
fun getInstance(context: Context): ToolDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context, ToolDatabase::class.java, "tools.db")
.build()
.also { INSTANCE = it }
}
}
}

View File

@@ -0,0 +1,31 @@
package top.fatweb.oxygen.toolbox.data.tool.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
@Dao
interface ToolDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertTool(tool: ToolEntity)
@Update
suspend fun updateTool(tool: ToolEntity)
@Delete
suspend fun deleteTool(tool: ToolEntity)
@Query("SELECT * FROM tools WHERE id = :id")
fun selectToolById(id: Long): Flow<ToolEntity>
@Query("SELECT * FROM tools ORDER BY updateTime DESC")
fun selectAllTools(): Flow<List<ToolEntity>>
@Query("SELECT * FROM tools WHERE authorUsername = :username and toolId = :toolId LIMIT 1")
fun selectToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?>
}

View File

@@ -10,10 +10,12 @@ import top.fatweb.oxygen.toolbox.monitor.TimeZoneBroadcastMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.repository.lib.DepRepository
import top.fatweb.oxygen.toolbox.repository.lib.impl.LocalDepRepository
import top.fatweb.oxygen.toolbox.repository.tool.impl.NetworkToolRepository
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import top.fatweb.oxygen.toolbox.repository.userdata.impl.LocalUserDataRepository
import top.fatweb.oxygen.toolbox.repository.tool.impl.NetworkStoreRepository
import top.fatweb.oxygen.toolbox.repository.tool.impl.OfflineToolRepository
import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
import top.fatweb.oxygen.toolbox.repository.userdata.impl.LocalUserDataRepository
@Module
@InstallIn(SingletonComponent::class)
@@ -28,8 +30,11 @@ abstract class DataModule {
internal abstract fun bindsUserDataRepository(userDataRepository: LocalUserDataRepository): UserDataRepository
@Binds
internal abstract fun bindsToolRepository(toolRepository: NetworkToolRepository): ToolRepository
internal abstract fun bindsDepRepository(depRepository: LocalDepRepository): DepRepository
@Binds
internal abstract fun bindsDepRepository(depRepository: LocalDepRepository): DepRepository
internal abstract fun bindsStoreRepository(storeRepository: NetworkStoreRepository): StoreRepository
@Binds
internal abstract fun bindsToolRepository(toolRepository: OfflineToolRepository): ToolRepository
}

View File

@@ -0,0 +1,18 @@
package top.fatweb.oxygen.toolbox.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import top.fatweb.oxygen.toolbox.data.tool.ToolDatabase
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
fun provideToolDao(@ApplicationContext context: Context): ToolDao =
ToolDatabase.getInstance(context).toolDao()
}

View File

@@ -5,19 +5,25 @@ import android.graphics.drawable.PictureDrawable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Reorder
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.outlined.Store
import androidx.compose.material.icons.rounded.ArrowBackIosNew
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Store
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
@@ -33,6 +39,8 @@ object OxygenIcons {
val Box = Icons.Default.Inbox
val Close = Icons.Default.Close
val Code = Icons.Default.Code
val Download = Icons.Default.Download
val Error = Icons.Default.Cancel
val Home = Icons.Rounded.Home
val HomeBorder = Icons.Outlined.Home
val Info = Icons.Outlined.Info
@@ -41,6 +49,9 @@ object OxygenIcons {
val Search = Icons.Rounded.Search
val Star = Icons.Rounded.Star
val StarBorder = Icons.Outlined.StarBorder
val Store = Icons.Rounded.Store
val StoreBorder = Icons.Outlined.Store
val Success = Icons.Rounded.CheckCircle
val Time = Icons.Default.AccessTime
val Tool = Icons.Default.Build

View File

@@ -0,0 +1,29 @@
package top.fatweb.oxygen.toolbox.model
import androidx.room.TypeConverter
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity.Platform
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun fromPlatform(platform: Platform): String = platform.name
@TypeConverter
fun toPlatform(name: String): Platform = Platform.valueOf(name)
@TypeConverter
fun fromStringList(stringList: List<String>): String = json.encodeToString(stringList)
@TypeConverter
fun toStringList(stringList: String): List<String> = json.decodeFromString(stringList)
@TypeConverter
fun fromLocalDateTime(localDateTime: LocalDateTime): String = localDateTime.toString()
@TypeConverter
fun toLocalDateTime(string: String): LocalDateTime = LocalDateTime.parse(string)
}

View File

@@ -1,8 +1,16 @@
package top.fatweb.oxygen.toolbox.model.tool
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import kotlinx.datetime.LocalDateTime
import top.fatweb.oxygen.toolbox.model.Converters
data class Tool(
@Entity(tableName = "tools")
@TypeConverters(Converters::class)
data class ToolEntity(
@PrimaryKey
val id: Long,
val name: String,
@@ -17,7 +25,11 @@ data class Tool(
val base: String? = null,
val author: Author,
val authorUsername: String,
val authorNickname: String,
val authorAvatar: String,
val ver: String,
@@ -33,7 +45,13 @@ data class Tool(
val createTime: LocalDateTime,
val updateTime: LocalDateTime
val updateTime: LocalDateTime,
@ColumnInfo(defaultValue = "false")
val isStar: Boolean = false,
@ColumnInfo(defaultValue = "NULL")
val upgrade: String? = null
) {
enum class Platform {
WEB,
@@ -42,12 +60,4 @@ data class Tool(
ANDROID
}
data class Author(
val username: String,
val nickname: String,
val avatar: String
)
}

View File

@@ -10,5 +10,5 @@ data class ToolGroup(
val title: String,
val tools: List<Tool> = emptyList()
val tools: List<ToolEntity> = emptyList()
)

View File

@@ -28,9 +28,12 @@ fun OxygenNavHost(
librariesScreen(
onBackClick = navController::popBackStack
)
toolStoreScreen(
onNavigateToToolView = navController::navigateToToolView
)
toolsScreen(
onNavigateToToolView = navController::navigateToToolView,
onShowSnackbar = onShowSnackbar
onNavigateToToolStore = { appState.navigateToTopLevelDestination(TopLevelDestination.TOOL_STORE) }
)
toolViewScreen(
onBackClick = navController::popBackStack

View File

@@ -0,0 +1,23 @@
package top.fatweb.oxygen.toolbox.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import top.fatweb.oxygen.toolbox.ui.tool.ToolStoreRoute
const val TOOL_STORE_ROUTE = "tool_store_route"
fun NavController.navigateToToolStore(navOptions: NavOptions? = null) = navigate(TOOL_STORE_ROUTE, navOptions)
fun NavGraphBuilder.toolStoreScreen(
onNavigateToToolView: (username: String, toolId: String) -> Unit
) {
composable(
route = TOOL_STORE_ROUTE
) {
ToolStoreRoute(
onNavigateToToolView = onNavigateToToolView
)
}
}

View File

@@ -12,14 +12,14 @@ fun NavController.navigateToTools(navOptions: NavOptions) = navigate(TOOLS_ROUTE
fun NavGraphBuilder.toolsScreen(
onNavigateToToolView: (username: String, toolId: String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean
onNavigateToToolStore: () -> Unit
) {
composable(
route = TOOLS_ROUTE
) {
ToolsRoute(
onNavigateToToolView = onNavigateToToolView,
onShowSnackbar = onShowSnackbar
onNavigateToToolStore = onNavigateToToolStore
)
}
}

View File

@@ -11,6 +11,13 @@ enum class TopLevelDestination(
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int
) {
TOOL_STORE(
selectedIcon = OxygenIcons.Store,
unselectedIcon = OxygenIcons.StoreBorder,
iconTextId = R.string.feature_store_title,
titleTextId = R.string.feature_store_title
),
TOOLS(
selectedIcon = OxygenIcons.Home,
unselectedIcon = OxygenIcons.HomeBorder,

View File

@@ -3,7 +3,7 @@ package top.fatweb.oxygen.toolbox.network.model
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.network.serializer.LocalDateTimeSerializer
@Serializable
@@ -39,4 +39,4 @@ data class ToolBaseVo(
}
}
fun ToolBaseVo.Platform.asExternalModel() = Tool.Platform.valueOf(this.name)
fun ToolBaseVo.Platform.asExternalModel() = ToolEntity.Platform.valueOf(this.name)

View File

@@ -3,7 +3,7 @@ package top.fatweb.oxygen.toolbox.network.model
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.network.serializer.LocalDateTimeSerializer
@Serializable
@@ -62,7 +62,7 @@ data class ToolVo(
}
}
fun ToolVo.asExternalModel() = Tool(
fun ToolVo.asExternalModel() = ToolEntity(
id = id,
name = name,
toolId = toolId,
@@ -70,7 +70,9 @@ fun ToolVo.asExternalModel() = Tool(
platform = platform.asExternalModel(),
description = description,
base = base?.dist?.data,
author = author.asExternalModel(),
authorUsername = author.username,
authorNickname = author.userInfo.nickname,
authorAvatar = author.userInfo.avatar,
ver = ver,
keywords = keywords,
categories = categories.map { it.name },

View File

@@ -1,7 +1,6 @@
package top.fatweb.oxygen.toolbox.network.model
import kotlinx.serialization.Serializable
import top.fatweb.oxygen.toolbox.model.tool.Tool
@Serializable
data class UserWithInfoVo(
@@ -19,10 +18,4 @@ data class UserWithInfoVo(
val avatar: String
)
}
fun UserWithInfoVo.asExternalModel() = Tool.Author(
username = username,
nickname = userInfo.nickname,
avatar = userInfo.avatar
)
}

View File

@@ -3,17 +3,17 @@ package top.fatweb.oxygen.toolbox.network.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import top.fatweb.oxygen.toolbox.data.network.OxygenNetworkDataSource
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.network.model.ToolVo
import top.fatweb.oxygen.toolbox.network.model.asExternalModel
internal class ToolStorePagingSource(
private val oxygenNetworkDataSource: OxygenNetworkDataSource,
private val searchValue: String
) : PagingSource<Int, Tool>() {
override fun getRefreshKey(state: PagingState<Int, Tool>): Int? = null
) : PagingSource<Int, ToolEntity>() {
override fun getRefreshKey(state: PagingState<Int, ToolEntity>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Tool> {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ToolEntity> {
return try {
val currentPage = params.key ?: 1
val (_, success, msg, data) = oxygenNetworkDataSource.getStore(searchValue, currentPage)

View File

@@ -0,0 +1,19 @@
package top.fatweb.oxygen.toolbox.repository.tool
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.Result
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
interface StoreRepository {
val toolViewTemplate: Flow<String>
suspend fun getStore(searchValue: String, currentPage: Int): Flow<PagingData<ToolEntity>>
fun detail(
username: String,
toolId: String,
ver: String = "latest",
platform: ToolEntity.Platform = ToolEntity.Platform.ANDROID
): Flow<Result<ToolEntity>>
}

View File

@@ -1,19 +1,18 @@
package top.fatweb.oxygen.toolbox.repository.tool
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.Result
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
interface ToolRepository {
val toolViewTemplate: Flow<String>
fun getAllToolsStream(): Flow<List<ToolEntity>>
suspend fun getStore(searchValue: String, currentPage: Int): Flow<PagingData<Tool>>
fun getToolById(id: Long): Flow<ToolEntity?>
fun detail(
username: String,
toolId: String,
ver: String = "latest",
platform: Tool.Platform = Tool.Platform.ANDROID
): Flow<Result<Tool>>
fun getToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?>
suspend fun saveTool(toolEntity: ToolEntity)
suspend fun updateTool(toolEntity: ToolEntity)
suspend fun removeTool(toolEntity: ToolEntity)
}

View File

@@ -9,24 +9,24 @@ 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
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.network.model.ToolBaseVo
import top.fatweb.oxygen.toolbox.network.model.ToolVo
import top.fatweb.oxygen.toolbox.network.model.asExternalModel
import top.fatweb.oxygen.toolbox.network.paging.ToolStorePagingSource
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import javax.inject.Inject
private const val PAGE_SIZE = 20
internal class NetworkToolRepository @Inject constructor(
internal class NetworkStoreRepository @Inject constructor(
private val oxygenNetworkDataSource: OxygenNetworkDataSource,
private val toolDataSource: ToolDataSource
) : ToolRepository {
) : StoreRepository {
override val toolViewTemplate: Flow<String>
get() = toolDataSource.toolViewTemplate
override suspend fun getStore(searchValue: String, currentPage: Int): Flow<PagingData<Tool>> =
override suspend fun getStore(searchValue: String, currentPage: Int): Flow<PagingData<ToolEntity>> =
Pager(
config = PagingConfig(PAGE_SIZE),
pagingSourceFactory = { ToolStorePagingSource(oxygenNetworkDataSource, searchValue) }
@@ -36,8 +36,8 @@ internal class NetworkToolRepository @Inject constructor(
username: String,
toolId: String,
ver: String,
platform: Tool.Platform
): Flow<Result<Tool>> =
platform: ToolEntity.Platform
): Flow<Result<ToolEntity>> =
oxygenNetworkDataSource.detail(
username,
toolId,

View File

@@ -0,0 +1,29 @@
package top.fatweb.oxygen.toolbox.repository.tool.impl
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
class OfflineToolRepository @Inject constructor(
private val toolDao: ToolDao
) : ToolRepository {
override fun getAllToolsStream(): Flow<List<ToolEntity>> =
toolDao.selectAllTools()
override fun getToolById(id: Long): Flow<ToolEntity?> =
toolDao.selectToolById(id)
override fun getToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?> =
toolDao.selectToolByUsernameAndToolId(username, toolId)
override suspend fun saveTool(toolEntity: ToolEntity) =
toolDao.insertTool(toolEntity)
override suspend fun updateTool(toolEntity: ToolEntity) =
toolDao.updateTool(toolEntity)
override suspend fun removeTool(toolEntity: ToolEntity) =
toolDao.deleteTool(toolEntity)
}

View File

@@ -22,11 +22,13 @@ import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOL_STORE_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
import top.fatweb.oxygen.toolbox.navigation.navigateToAbout
import top.fatweb.oxygen.toolbox.navigation.navigateToLibraries
import top.fatweb.oxygen.toolbox.navigation.navigateToSearch
import top.fatweb.oxygen.toolbox.navigation.navigateToStar
import top.fatweb.oxygen.toolbox.navigation.navigateToToolStore
import top.fatweb.oxygen.toolbox.navigation.navigateToTools
import kotlin.time.Duration.Companion.seconds
@@ -73,6 +75,7 @@ class OxygenAppState(
val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) {
TOOL_STORE_ROUTE -> TopLevelDestination.TOOL_STORE
TOOLS_ROUTE -> TopLevelDestination.TOOLS
STAR_ROUTE -> TopLevelDestination.STAR
else -> null
@@ -110,6 +113,7 @@ class OxygenAppState(
}
when (topLevelDestination) {
TopLevelDestination.TOOL_STORE -> navController.navigateToToolStore(topLevelNavOptions)
TopLevelDestination.TOOLS -> navController.navigateToTools(topLevelNavOptions)
TopLevelDestination.STAR -> navController.navigateToStar(topLevelNavOptions)
}

View File

@@ -1,12 +1,16 @@
package top.fatweb.oxygen.toolbox.ui.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -14,47 +18,88 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ToolCard(
modifier: Modifier = Modifier,
tool: Tool,
onClickToolCard: () -> Unit
tool: ToolEntity,
actionIcon: ImageVector? = null,
actionIconContentDescription: String = "",
onAction: () -> Unit = {},
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
Card(
modifier = modifier,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
onClick = onClickToolCard
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
ToolVer(ver = tool.ver)
ToolHeader(
ver = tool.ver,
actionIcon = actionIcon,
actionIconContentDescription = actionIconContentDescription,
onAction = onAction
)
Spacer(modifier = Modifier.height(16.dp))
ToolIcon(icon = tool.icon)
Spacer(modifier = Modifier.height(16.dp))
ToolInfo(
toolName = tool.name,
toolId = tool.toolId,
toolDesc = tool.description ?: ""
toolDesc = tool.description
)
Spacer(modifier = Modifier.height(16.dp))
AuthorInfo(
avatar = tool.author.avatar,
nickname = tool.author.nickname
avatar = tool.authorAvatar,
nickname = tool.authorNickname
)
}
}
}
@Composable
fun ToolHeader(
modifier: Modifier = Modifier,
ver: String,
actionIcon: ImageVector?,
actionIconContentDescription: String,
onAction: () -> Unit
) {
Row(
modifier = modifier
.height(28.dp)
) {
ToolVer(ver = ver)
Spacer(modifier = Modifier.weight(1f))
actionIcon?.let {
ToolAction(
actionIcon = actionIcon,
actionIconContentDescription = actionIconContentDescription,
onAction = onAction
)
}
}
@@ -66,13 +111,18 @@ fun ToolVer(
ver: String
) {
Card(
modifier = modifier,
modifier = modifier
.fillMaxHeight(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Column(
modifier = Modifier
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 8.dp, vertical = 4.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
Arrangement.Center,
Alignment.CenterHorizontally
) {
Text(
style = MaterialTheme.typography.bodyMedium,
@@ -82,6 +132,38 @@ fun ToolVer(
}
}
@Composable
fun ToolAction(
modifier: Modifier = Modifier,
actionIcon: ImageVector,
actionIconContentDescription: String,
onAction: () -> Unit
) {
Card(
modifier = modifier
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.clickable(
onClick = onAction
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 6.dp, vertical = 6.dp)
) {
Icon(
modifier = Modifier,
imageVector = actionIcon,
contentDescription = actionIconContentDescription
)
}
}
}
@Composable
fun ToolIcon(
modifier: Modifier = Modifier,
@@ -105,7 +187,7 @@ fun ToolInfo(
modifier: Modifier = Modifier,
toolName: String,
toolId: String,
toolDesc: String
toolDesc: String?
) {
Column(
modifier = modifier.fillMaxWidth(),
@@ -121,12 +203,14 @@ fun ToolInfo(
style = MaterialTheme.typography.bodyMedium,
text = "ID: $toolId"
)
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
text = "${stringResource(R.string.feature_tools_description)}: $toolDesc"
)
toolDesc?.let {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
text = "${stringResource(R.string.feature_tools_description)}: $it"
)
}
}
}

View File

@@ -0,0 +1,289 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.activity.compose.ReportDrawnWhen
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.gestures.Orientation
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
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.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
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.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.ui.component.ToolCard
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.DraggableScrollbar
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.rememberDraggableScroller
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.scrollbarState
@Composable
internal fun ToolStoreRoute(
modifier: Modifier = Modifier,
viewModel: ToolStoreViewModel = hiltViewModel(),
onNavigateToToolView: (username: String, toolId: String) -> Unit,
) {
val toolStorePagingItems = viewModel.storeData.collectAsLazyPagingItems()
val installInfo by viewModel.installInfo.collectAsState()
ToolStoreScreen(
modifier = modifier,
onNavigateToToolView = onNavigateToToolView,
toolStorePagingItems = toolStorePagingItems,
onChangeInstallStatus = viewModel::changeInstallStatus,
onInstallTool = viewModel::installTool,
installInfo = installInfo
)
}
@Composable
internal fun ToolStoreScreen(
modifier: Modifier = Modifier,
onNavigateToToolView: (username: String, toolId: String) -> Unit,
toolStorePagingItems: LazyPagingItems<ToolEntity>,
onChangeInstallStatus: (installStatus: ToolStoreUiState.Status, username: String?, toolId: String?) -> Unit,
onInstallTool: () -> Unit,
installInfo: ToolStoreUiState.InstallInfo
) {
val isToolLoading =
toolStorePagingItems.loadState.refresh is LoadState.Loading
|| toolStorePagingItems.loadState.append is LoadState.Loading
ReportDrawnWhen { !isToolLoading }
val itemsAvailable = toolStorePagingItems.itemCount
val state = rememberLazyStaggeredGridState()
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")
Box(
modifier.fillMaxSize()
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
toolsPanel(
toolStorePagingItems = toolStorePagingItems,
onAction = { username, toolId ->
onChangeInstallStatus(
ToolStoreUiState.Status.Pending,
username,
toolId
)
},
onClick = onNavigateToToolView
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
if (toolStorePagingItems.loadState.refresh is LoadState.Loading || toolStorePagingItems.loadState.append is LoadState.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 = ""
)
}
}
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)
)
}
if (installInfo.status != ToolStoreUiState.Status.None) {
Box(
modifier = Modifier.fillMaxSize()
) {
AlertDialog(
onDismissRequest = {
if (installInfo.status == ToolStoreUiState.Status.Pending) {
onChangeInstallStatus(ToolStoreUiState.Status.None, null, null)
}
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (installInfo.status) {
ToolStoreUiState.Status.Success -> OxygenIcons.Success
ToolStoreUiState.Status.Fail -> OxygenIcons.Error
else -> OxygenIcons.Info
},
contentDescription = stringResource(R.string.core_install)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(
when (installInfo.status) {
ToolStoreUiState.Status.Success -> R.string.feature_store_install_success
ToolStoreUiState.Status.Fail -> R.string.feature_store_install_fail
else -> R.string.feature_store_install_tool
}
)
)
}
},
text = {
Column(
modifier = Modifier
.width(360.dp)
.padding(vertical = 16.dp)
) {
when (installInfo.status) {
ToolStoreUiState.Status.Pending ->
Text(
text = stringResource(
R.string.feature_store_ask_install,
installInfo.username,
installInfo.toolId
)
)
ToolStoreUiState.Status.Installing ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.core_installing))
}
ToolStoreUiState.Status.Success ->
Text(text = stringResource(R.string.feature_store_install_success_info))
ToolStoreUiState.Status.Fail ->
Text(text = stringResource(R.string.feature_store_install_fail_info))
ToolStoreUiState.Status.None -> Unit
}
}
},
dismissButton = {
if (installInfo.status == ToolStoreUiState.Status.Pending) {
TextButton(onClick = {
onChangeInstallStatus(ToolStoreUiState.Status.None, null, null)
}) {
Text(text = stringResource(R.string.core_cancel))
}
}
},
confirmButton = {
when (installInfo.status) {
ToolStoreUiState.Status.Pending ->
TextButton(onClick = onInstallTool) {
Text(text = stringResource(R.string.core_install))
}
ToolStoreUiState.Status.Success,
ToolStoreUiState.Status.Fail ->
TextButton(onClick = {
onChangeInstallStatus(ToolStoreUiState.Status.None, null, null)
}) {
Text(
text = stringResource(
if (installInfo.status == ToolStoreUiState.Status.Success) R.string.core_ok
else R.string.core_close
)
)
}
ToolStoreUiState.Status.None,
ToolStoreUiState.Status.Installing -> Unit
}
}
)
}
}
}
private fun LazyStaggeredGridScope.toolsPanel(
toolStorePagingItems: LazyPagingItems<ToolEntity>,
onAction: (username: String, toolId: String) -> Unit,
onClick: (username: String, toolId: String) -> Unit
) {
items(
items = toolStorePagingItems.itemSnapshotList,
key = { it!!.id },
) {
ToolCard(
tool = it!!,
actionIcon = OxygenIcons.Download,
actionIconContentDescription = stringResource(R.string.core_install),
onAction = { onAction(it.authorUsername, it.toolId) },
onClick = { onClick(it.authorUsername, it.toolId) },
onLongClick = { onClick(it.authorUsername, it.toolId) },
)
}
}

View File

@@ -0,0 +1,88 @@
package top.fatweb.oxygen.toolbox.ui.tool
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import top.fatweb.oxygen.toolbox.model.Result
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
@HiltViewModel
class ToolStoreViewModel @Inject constructor(
private val storeRepository: StoreRepository,
private val toolRepository: ToolRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
private val currentPage = savedStateHandle.getStateFlow(CURRENT_PAGE, 1)
val installInfo = savedStateHandle.getStateFlow(
INSTALL_INFO, ToolStoreUiState.InstallInfo()
)
@OptIn(ExperimentalCoroutinesApi::class)
val storeData: Flow<PagingData<ToolEntity>> = combine(
searchValue, currentPage, ::Pair
).flatMapLatest { (searchValue, currentPage) ->
storeRepository.getStore(searchValue, currentPage).cachedIn(viewModelScope)
}
fun changeInstallStatus(
installStatus: ToolStoreUiState.Status, username: String?, toolId: String?
) {
savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(installStatus, username ?: "Unknown", toolId ?: "Unknown")
}
fun installTool() {
viewModelScope.launch {
val (_, username, toolId) = installInfo.value
storeRepository.detail(username, toolId).collect {
when (it) {
Result.Loading -> savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(ToolStoreUiState.Status.Installing)
is Result.Error, is Result.Fail -> savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(ToolStoreUiState.Status.Fail)
is Result.Success -> {
toolRepository.saveTool(it.data)
savedStateHandle[INSTALL_INFO] =
ToolStoreUiState.InstallInfo(ToolStoreUiState.Status.Success)
}
}
}
}
}
}
@Parcelize
data class ToolStoreUiState(
val installInfo: InstallInfo
) : Parcelable {
@Parcelize
data class InstallInfo(
var status: Status = Status.None,
var username: String = "Unknown",
var toolId: String = "Unknown"
) : Parcelable
enum class Status {
None, Pending, Installing, Success, Fail
}
}
private const val SEARCH_VALUE = "searchValue"
private const val CURRENT_PAGE = "currentPage"
private const val INSTALL_INFO = "installInfo"

View File

@@ -13,7 +13,7 @@ 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.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.util.decodeToStringWithZip
import javax.inject.Inject
import kotlin.io.encoding.Base64
@@ -22,7 +22,7 @@ import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class ToolViewScreenViewModel @Inject constructor(
toolRepository: ToolRepository,
storeRepository: StoreRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val toolViewArgs = ToolViewArgs(savedStateHandle)
@@ -32,7 +32,7 @@ class ToolViewScreenViewModel @Inject constructor(
val toolViewUiState: StateFlow<ToolViewUiState> = toolViewUiState(
username = username,
toolId = toolId,
toolRepository = toolRepository
storeRepository = storeRepository
)
.stateIn(
scope = viewModelScope,
@@ -44,13 +44,13 @@ class ToolViewScreenViewModel @Inject constructor(
private fun toolViewUiState(
username: String,
toolId: String,
toolRepository: ToolRepository
storeRepository: StoreRepository
): Flow<ToolViewUiState> {
val result = toolRepository.detail(
val result = storeRepository.detail(
username = username,
toolId = toolId
)
val toolViewTemplate = toolRepository.toolViewTemplate
val toolViewTemplate = storeRepository.toolViewTemplate
return combine(result, toolViewTemplate, ::Pair).map { (result, toolViewTemplate) ->
when (result) {
@@ -87,7 +87,7 @@ sealed interface ToolViewUiState {
}
@OptIn(ExperimentalEncodingApi::class)
fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String {
private fun processHtml(toolViewTemplate: String, distBase64: String, baseBase64: String): String {
val dist = Base64.decodeToStringWithZip(distBase64)
val base = Base64.decodeToStringWithZip(baseBase64)

View File

@@ -1,22 +0,0 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.paging.compose.LazyPagingItems
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.ui.component.ToolCard
fun LazyStaggeredGridScope.toolsPanel(
toolStorePagingItems: LazyPagingItems<Tool>,
onClickToolCard: (username: String, toolId: String) -> Unit
) {
items(
items = toolStorePagingItems.itemSnapshotList,
key = { it!!.id },
) {
ToolCard(
tool = it!!,
onClickToolCard = {onClickToolCard(it.author.username, it.toolId)}
)
}
}

View File

@@ -23,25 +23,29 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
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.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.ui.component.ToolCard
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.DraggableScrollbar
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.rememberDraggableScroller
import top.fatweb.oxygen.toolbox.ui.component.scrollbar.scrollbarState
@@ -51,14 +55,15 @@ internal fun ToolsRoute(
modifier: Modifier = Modifier,
viewModel: ToolsScreenViewModel = hiltViewModel(),
onNavigateToToolView: (username: String, toolId: String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean
onNavigateToToolStore: () -> Unit
) {
val toolStorePagingItems = viewModel.storeData.collectAsLazyPagingItems()
val toolsScreenUiStateState by viewModel.toolsScreenUiState.collectAsStateWithLifecycle()
ToolsScreen(
modifier = modifier,
onNavigateToToolView = onNavigateToToolView,
toolStorePagingItems = toolStorePagingItems
onNavigateToToolStore = onNavigateToToolStore,
toolsScreenUiState = toolsScreenUiStateState
)
}
@@ -66,15 +71,12 @@ internal fun ToolsRoute(
internal fun ToolsScreen(
modifier: Modifier = Modifier,
onNavigateToToolView: (username: String, toolId: String) -> Unit,
toolStorePagingItems: LazyPagingItems<Tool>
onNavigateToToolStore: () -> Unit,
toolsScreenUiState: ToolsScreenUiState
) {
val isToolLoading =
toolStorePagingItems.loadState.refresh is LoadState.Loading
|| toolStorePagingItems.loadState.append is LoadState.Loading
ReportDrawnWhen { toolsScreenUiState !is ToolsScreenUiState.Loading }
ReportDrawnWhen { !isToolLoading }
val itemsAvailable = toolStorePagingItems.itemCount
val itemsAvailable = howManyTools(toolsScreenUiState)
val state = rememberLazyStaggeredGridState()
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
@@ -84,45 +86,61 @@ internal fun ToolsScreen(
Box(
modifier.fillMaxSize()
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
toolsPanel(
toolStorePagingItems = toolStorePagingItems,
onClickToolCard = onNavigateToToolView
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
when (toolsScreenUiState) {
ToolsScreenUiState.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 = ""
)
}
}
}
ToolsScreenUiState.Nothing -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = stringResource(R.string.feature_tools_no_tools_installed))
TextButton(onClick = onNavigateToToolStore) {
Text(text = stringResource(R.string.feature_tools_go_to_store))
}
}
}
is ToolsScreenUiState.Success -> {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
if (toolStorePagingItems.loadState.refresh is LoadState.Loading || toolStorePagingItems.loadState.append is LoadState.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 = ""
)
toolsPanel(
toolItems = toolsScreenUiState.tools,
onClickToolCard = onNavigateToToolView
)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
}
@@ -136,4 +154,27 @@ internal fun ToolsScreen(
onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable)
)
}
}
}
private fun LazyStaggeredGridScope.toolsPanel(
toolItems: List<ToolEntity>,
onClickToolCard: (username: String, toolId: String) -> Unit
) {
items(
items = toolItems,
key = { it.id },
) {
ToolCard(
tool = it,
onClick = {onClickToolCard(it.authorUsername, it.toolId)},
onLongClick = {onClickToolCard(it.authorUsername, it.toolId)}
)
}
}
@Composable
private fun howManyTools(toolsScreenUiState: ToolsScreenUiState) =
when (toolsScreenUiState) {
ToolsScreenUiState.Loading, ToolsScreenUiState.Nothing -> 0
is ToolsScreenUiState.Success -> toolsScreenUiState.tools.size
}

View File

@@ -3,40 +3,46 @@ package top.fatweb.oxygen.toolbox.ui.tool
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import top.fatweb.oxygen.toolbox.model.Page
import top.fatweb.oxygen.toolbox.model.tool.Tool
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class ToolsScreenViewModel @Inject constructor(
private val toolRepository: ToolRepository,
private val storeRepository: StoreRepository,
toolRepository: ToolRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
private val currentPage = savedStateHandle.getStateFlow(CURRENT_PAGE, 1)
@OptIn(ExperimentalCoroutinesApi::class)
val storeData: Flow<PagingData<Tool>> = combine(
searchValue,
currentPage,
::Pair
).flatMapLatest { (searchValue, currentPage) ->
toolRepository.getStore(searchValue, currentPage).cachedIn(viewModelScope)
}
val toolsScreenUiState: StateFlow<ToolsScreenUiState> =
toolRepository.getAllToolsStream()
.map {
if (it.isEmpty()) {
ToolsScreenUiState.Nothing
} else {
ToolsScreenUiState.Success(it)
}
}
.stateIn(
scope = viewModelScope,
initialValue = ToolsScreenUiState.Loading,
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
)
}
sealed interface ToolsScreenUiState {
data object Loading : ToolsScreenUiState
data class Success(val tools: Page<Tool>) : ToolsScreenUiState
data object Nothing : ToolsScreenUiState
data class Success(val tools: List<ToolEntity>) : ToolsScreenUiState
}
private const val SEARCH_VALUE = "searchValue"
private const val CURRENT_PAGE = "currentPage"

View File

@@ -3,6 +3,7 @@
<string name="app_name">氧工具</string>
<string name="app_full_name">氧工具</string>
<string name="app_description">All in One</string>
<string name="core_ok">完成</string>
<string name="core_back">返回</string>
<string name="core_close">关闭</string>
@@ -11,8 +12,26 @@
<string name="core_search">搜索</string>
<string name="core_loading">加载中…</string>
<string name="core_no_connect">⚠️ 无法连接至互联网</string>
<string name="core_install">安装</string>
<string name="core_installing">安装中……</string>
<string name="core_cancel">取消</string>
<string name="feature_store_title">商店</string>
<string name="feature_store_install_tool">安装工具</string>
<string name="feature_store_ask_install">确定安装由用户 %1$s 提供的工具 %2$s 吗?</string>
<string name="feature_store_install_success">安装成功</string>
<string name="feature_store_install_success_info">恭喜!工具安装成功。</string>
<string name="feature_store_install_fail">安装失败</string>
<string name="feature_store_install_fail_info">安装失败!请稍后重试……</string>
<string name="feature_tools_title">工具</string>
<string name="feature_tools_description">简介</string>
<string name="feature_tools_can_not_open">⚠️ 无法打开工具</string>
<string name="feature_tools_no_tools_installed">暂无工具已安装</string>
<string name="feature_tools_go_to_store">前往商店…</string>
<string name="feature_star_title">收藏</string>
<string name="feature_settings_title">设置</string>
<string name="feature_settings_language">语言</string>
<string name="feature_settings_language_system_default">系统默认</string>
@@ -34,6 +53,4 @@
<string name="feature_settings_more_about">关于</string>
<string name="feature_settings_top_app_bar_action_icon_description">更多</string>
<string name="feature_settings_top_app_bar_navigation_icon_description">搜索</string>
<string name="feature_tools_description">简介</string>
<string name="feature_tools_can_not_open">⚠️ 无法打开工具</string>
</resources>

View File

@@ -2,6 +2,7 @@
<string name="app_name">Oxygen</string>
<string name="app_full_name">Oxygen Toolbox</string>
<string name="app_description">All in One</string>
<string name="core_ok">OK</string>
<string name="core_back">Back</string>
<string name="core_close">Close</string>
@@ -10,8 +11,26 @@
<string name="core_search">Search</string>
<string name="core_loading">Loading…</string>
<string name="core_no_connect">⚠️ Unable to connect to the internet</string>
<string name="core_install">Install</string>
<string name="core_installing">Installing…</string>
<string name="core_cancel">Cancel</string>
<string name="feature_store_title">Store</string>
<string name="feature_store_install_tool">Install Tool</string>
<string name="feature_store_ask_install">Are you sure to install tool %1$s provided by user %2$s?</string>
<string name="feature_store_install_success">Install Success</string>
<string name="feature_store_install_success_info">Congratulations, the tool installation is successful.</string>
<string name="feature_store_install_fail">Install Failed</string>
<string name="feature_store_install_fail_info">Installation failed, please try again later…</string>
<string name="feature_tools_title">Tools</string>
<string name="feature_tools_description">Desc</string>
<string name="feature_tools_can_not_open">⚠️ Can not open the tool</string>
<string name="feature_tools_no_tools_installed">No tools installed yet</string>
<string name="feature_tools_go_to_store">Go to store…</string>
<string name="feature_star_title">Star</string>
<string name="feature_settings_title">Settings</string>
<string name="feature_settings_language">Language</string>
<string name="feature_settings_language_system_default">System Default</string>
@@ -35,6 +54,4 @@
<string name="feature_settings_more_about">About</string>
<string name="feature_settings_top_app_bar_action_icon_description">More</string>
<string name="feature_settings_top_app_bar_navigation_icon_description">Search</string>
<string name="feature_tools_description">Desc</string>
<string name="feature_tools_can_not_open">⚠️ Can not open the tool</string>
</resources>