Feat - ToolsScreen - add tool group list

This commit is contained in:
2024-03-29 18:09:33 +08:00
parent 4cc1c0f68b
commit f81f26a5cb
39 changed files with 1583 additions and 95 deletions

View File

@@ -31,13 +31,13 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig import top.fatweb.oxygen.toolbox.model.userdata.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig import top.fatweb.oxygen.toolbox.model.userdata.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.repository.UserDataRepository import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
import top.fatweb.oxygen.toolbox.ui.OxygenApp import top.fatweb.oxygen.toolbox.ui.OxygenApp
import top.fatweb.oxygen.toolbox.ui.rememberOxygenAppState import top.fatweb.oxygen.toolbox.ui.rememberOxygenAppState
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme

View File

@@ -7,8 +7,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import top.fatweb.oxygen.toolbox.model.UserData import top.fatweb.oxygen.toolbox.model.userdata.UserData
import top.fatweb.oxygen.toolbox.repository.UserDataRepository import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds

View File

@@ -2,7 +2,7 @@ package top.fatweb.oxygen.toolbox
import android.app.Application import android.app.Application
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import top.fatweb.oxygen.toolbox.repository.UserDataRepository import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp

View File

@@ -0,0 +1,36 @@
package top.fatweb.oxygen.toolbox.datastore.tool
import kotlinx.coroutines.flow.flowOf
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolGroup
import javax.inject.Inject
import kotlin.random.Random
class ToolDataSource @Inject constructor() {
val tool = flowOf(
(0..100).map { index ->
ToolGroup(
id = "local-base-$index",
title = "${generateRandomString()}-$index",
icon = OxygenIcons.Tool,
tools = (0..20).map {
Tool(
id = "local-base-$index-time-screen-$it",
icon = OxygenIcons.Time,
name = "${generateRandomString()}-$index-$it"
)
}
)
}
)
private fun generateRandomString(length: Int = (1..10).random()): String {
val words = ('a'..'z') + ('A'..'Z')
return (1..length)
.map { Random.nextInt(0, words.size) }
.map(words::get)
.joinToString("")
}
}

View File

@@ -1,6 +1,8 @@
package top.fatweb.oxygen.toolbox.datastore package top.fatweb.oxygen.toolbox.datastore.userdata
import androidx.datastore.core.DataMigration import androidx.datastore.core.DataMigration
import top.fatweb.oxygen.toolbox.datastore.UserPreferences
import top.fatweb.oxygen.toolbox.datastore.copy
internal object IntToStringIdsMigration : DataMigration<UserPreferences> { internal object IntToStringIdsMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit override suspend fun cleanUp() = Unit

View File

@@ -1,12 +1,18 @@
package top.fatweb.oxygen.toolbox.datastore package top.fatweb.oxygen.toolbox.datastore.userdata
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig import top.fatweb.oxygen.toolbox.datastore.DarkThemeConfigProto
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.datastore.LanguageConfigProto
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.datastore.LaunchPageConfigProto
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig import top.fatweb.oxygen.toolbox.datastore.ThemeBrandConfigProto
import top.fatweb.oxygen.toolbox.model.UserData import top.fatweb.oxygen.toolbox.datastore.UserPreferences
import top.fatweb.oxygen.toolbox.datastore.copy
import top.fatweb.oxygen.toolbox.model.userdata.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.userdata.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.userdata.UserData
import javax.inject.Inject import javax.inject.Inject
class OxygenPreferencesDataSource @Inject constructor( class OxygenPreferencesDataSource @Inject constructor(

View File

@@ -1,8 +1,9 @@
package top.fatweb.oxygen.toolbox.datastore package top.fatweb.oxygen.toolbox.datastore.userdata
import androidx.datastore.core.CorruptionException import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException import com.google.protobuf.InvalidProtocolBufferException
import top.fatweb.oxygen.toolbox.datastore.UserPreferences
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import javax.inject.Inject import javax.inject.Inject

View File

@@ -8,18 +8,23 @@ import top.fatweb.oxygen.toolbox.monitor.ConnectivityManagerNetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneBroadcastMonitor import top.fatweb.oxygen.toolbox.monitor.TimeZoneBroadcastMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.repository.OfflineFirstUserDataRepository import top.fatweb.oxygen.toolbox.repository.tool.OfflineFirstToolRepository
import top.fatweb.oxygen.toolbox.repository.UserDataRepository import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import top.fatweb.oxygen.toolbox.repository.userdata.OfflineFirstUserDataRepository
import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class DataModule { abstract class DataModule {
@Binds
internal abstract fun bindsUserDataRepository(userDataRepository: OfflineFirstUserDataRepository): UserDataRepository
@Binds @Binds
internal abstract fun bindsNetworkMonitor(networkMonitor: ConnectivityManagerNetworkMonitor): NetworkMonitor internal abstract fun bindsNetworkMonitor(networkMonitor: ConnectivityManagerNetworkMonitor): NetworkMonitor
@Binds @Binds
internal abstract fun bindsTimeZoneMonitor(timeZoneMonitor: TimeZoneBroadcastMonitor): TimeZoneMonitor internal abstract fun bindsTimeZoneMonitor(timeZoneMonitor: TimeZoneBroadcastMonitor): TimeZoneMonitor
@Binds
internal abstract fun bindsUserDataRepository(userDataRepository: OfflineFirstUserDataRepository): UserDataRepository
@Binds
internal abstract fun bindsToolRepository(toolRepository: OfflineFirstToolRepository): ToolRepository
} }

View File

@@ -11,9 +11,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import top.fatweb.oxygen.toolbox.datastore.IntToStringIdsMigration
import top.fatweb.oxygen.toolbox.datastore.UserPreferences import top.fatweb.oxygen.toolbox.datastore.UserPreferences
import top.fatweb.oxygen.toolbox.datastore.UserPreferencesSerializer import top.fatweb.oxygen.toolbox.datastore.userdata.IntToStringIdsMigration
import top.fatweb.oxygen.toolbox.datastore.userdata.UserPreferencesSerializer
import top.fatweb.oxygen.toolbox.network.Dispatcher import top.fatweb.oxygen.toolbox.network.Dispatcher
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
import javax.inject.Singleton import javax.inject.Singleton

View File

@@ -1,11 +1,15 @@
package top.fatweb.oxygen.toolbox.icon package top.fatweb.oxygen.toolbox.icon
import androidx.compose.material.icons.Icons 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.Inbox
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.rounded.ArrowBackIosNew import androidx.compose.material.icons.rounded.ArrowBackIosNew
import androidx.compose.material.icons.rounded.Home 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.Search
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
@@ -17,4 +21,10 @@ object OxygenIcons {
val Search = Icons.Rounded.Search val Search = Icons.Rounded.Search
val MoreVert = Icons.Default.MoreVert val MoreVert = Icons.Default.MoreVert
val Back = Icons.Rounded.ArrowBackIosNew val Back = Icons.Rounded.ArrowBackIosNew
val ArrowDown = Icons.Rounded.KeyboardArrowDown
val Box = Icons.Default.Inbox
val Tool = Icons.Default.Build
val Time = Icons.Default.AccessTime
} }

View File

@@ -0,0 +1,12 @@
package top.fatweb.oxygen.toolbox.model.tool
import androidx.compose.ui.graphics.vector.ImageVector
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
data class Tool(
val id: String,
val icon: ImageVector = OxygenIcons.Tool,
val name: String
)

View File

@@ -0,0 +1,14 @@
package top.fatweb.oxygen.toolbox.model.tool
import androidx.compose.ui.graphics.vector.ImageVector
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
data class ToolGroup(
val id: String,
val icon: ImageVector = OxygenIcons.Box,
val title: String,
val tools: List<Tool> = emptyList()
)

View File

@@ -1,4 +1,4 @@
package top.fatweb.oxygen.toolbox.model package top.fatweb.oxygen.toolbox.model.userdata
enum class DarkThemeConfig { enum class DarkThemeConfig {
FOLLOW_SYSTEM, FOLLOW_SYSTEM,

View File

@@ -1,4 +1,4 @@
package top.fatweb.oxygen.toolbox.model package top.fatweb.oxygen.toolbox.model.userdata
enum class LanguageConfig(val code: String? = null) { enum class LanguageConfig(val code: String? = null) {
FOLLOW_SYSTEM, FOLLOW_SYSTEM,

View File

@@ -1,4 +1,4 @@
package top.fatweb.oxygen.toolbox.model package top.fatweb.oxygen.toolbox.model.userdata
enum class LaunchPageConfig { enum class LaunchPageConfig {
TOOLS, TOOLS,

View File

@@ -1,4 +1,4 @@
package top.fatweb.oxygen.toolbox.model package top.fatweb.oxygen.toolbox.model.userdata
enum class ThemeBrandConfig { enum class ThemeBrandConfig {
DEFAULT, DEFAULT,

View File

@@ -1,4 +1,4 @@
package top.fatweb.oxygen.toolbox.model package top.fatweb.oxygen.toolbox.model.userdata
data class UserData( data class UserData(
val languageConfig: LanguageConfig, val languageConfig: LanguageConfig,

View File

@@ -4,6 +4,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import top.fatweb.oxygen.toolbox.ui.tool.ToolsRoute
const val TOOLS_ROUTE = "tools_route" const val TOOLS_ROUTE = "tools_route"
@@ -12,5 +13,7 @@ fun NavController.navigateToTools(navOptions: NavOptions) = navigate(TOOLS_ROUTE
fun NavGraphBuilder.toolsScreen() { fun NavGraphBuilder.toolsScreen() {
composable( composable(
route = TOOLS_ROUTE route = TOOLS_ROUTE
) { } ) {
ToolsRoute()
}
} }

View File

@@ -1,5 +1,6 @@
package top.fatweb.oxygen.toolbox.navigation package top.fatweb.oxygen.toolbox.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import top.fatweb.oxygen.toolbox.R import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons import top.fatweb.oxygen.toolbox.icon.OxygenIcons
@@ -7,8 +8,8 @@ import top.fatweb.oxygen.toolbox.icon.OxygenIcons
enum class TopLevelDestination( enum class TopLevelDestination(
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: ImageVector, val unselectedIcon: ImageVector,
val iconTextId: Int, @StringRes val iconTextId: Int,
val titleTextId: Int @StringRes val titleTextId: Int
) { ) {
TOOLS( TOOLS(
selectedIcon = OxygenIcons.Home, selectedIcon = OxygenIcons.Home,

View File

@@ -0,0 +1,13 @@
package top.fatweb.oxygen.toolbox.repository.tool
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.datastore.tool.ToolDataSource
import top.fatweb.oxygen.toolbox.model.tool.ToolGroup
import javax.inject.Inject
internal class OfflineFirstToolRepository @Inject constructor(
toolDataSource: ToolDataSource
) : ToolRepository {
override val toolGroups: Flow<List<ToolGroup>> =
toolDataSource.tool
}

View File

@@ -0,0 +1,8 @@
package top.fatweb.oxygen.toolbox.repository.tool
import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.tool.ToolGroup
interface ToolRepository {
val toolGroups: Flow<List<ToolGroup>>
}

View File

@@ -1,12 +1,12 @@
package top.fatweb.oxygen.toolbox.repository package top.fatweb.oxygen.toolbox.repository.userdata
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.datastore.OxygenPreferencesDataSource import top.fatweb.oxygen.toolbox.datastore.userdata.OxygenPreferencesDataSource
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig import top.fatweb.oxygen.toolbox.model.userdata.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig import top.fatweb.oxygen.toolbox.model.userdata.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.UserData import top.fatweb.oxygen.toolbox.model.userdata.UserData
import javax.inject.Inject import javax.inject.Inject
internal class OfflineFirstUserDataRepository @Inject constructor( internal class OfflineFirstUserDataRepository @Inject constructor(

View File

@@ -1,11 +1,11 @@
package top.fatweb.oxygen.toolbox.repository package top.fatweb.oxygen.toolbox.repository.userdata
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig import top.fatweb.oxygen.toolbox.model.userdata.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig import top.fatweb.oxygen.toolbox.model.userdata.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.UserData import top.fatweb.oxygen.toolbox.model.userdata.UserData
interface UserDataRepository { interface UserDataRepository {
val userData: Flow<UserData> val userData: Flow<UserData>

View File

@@ -36,7 +36,7 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import top.fatweb.oxygen.toolbox.R import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.navigation.OxygenNavHost import top.fatweb.oxygen.toolbox.navigation.OxygenNavHost
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
@@ -181,13 +181,13 @@ private fun OxygenBottomBar(
icon = { icon = {
Icon( Icon(
imageVector = destination.unselectedIcon, imageVector = destination.unselectedIcon,
contentDescription = null contentDescription = stringResource(destination.iconTextId)
) )
}, },
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = destination.selectedIcon, imageVector = destination.selectedIcon,
contentDescription = null contentDescription = stringResource(destination.iconTextId)
) )
}, },
onClick = { onNavigateToDestination(destination) } onClick = { onNavigateToDestination(destination) }
@@ -215,13 +215,13 @@ private fun OxygenNavRail(
icon = { icon = {
Icon( Icon(
imageVector = destination.unselectedIcon, imageVector = destination.unselectedIcon,
contentDescription = null contentDescription = stringResource(destination.iconTextId)
) )
}, },
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = destination.selectedIcon, imageVector = destination.selectedIcon,
contentDescription = null contentDescription = stringResource(destination.iconTextId)
) )
}, },
onClick = { onNavigateToDestination(destination) } onClick = { onNavigateToDestination(destination) }

View File

@@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor
import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE

View File

@@ -130,13 +130,13 @@ fun OxygenNavigationBarPreview() {
icon = { icon = {
Icon( Icon(
imageVector = item.unselectedIcon, imageVector = item.unselectedIcon,
contentDescription = stringResource(item.titleTextId) contentDescription = stringResource(item.iconTextId)
) )
}, },
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = item.selectedIcon, contentDescription = stringResource( imageVector = item.selectedIcon, contentDescription = stringResource(
item.titleTextId item.iconTextId
) )
) )
}, },
@@ -161,13 +161,13 @@ fun OxygenNavigationRailPreview() {
icon = { icon = {
Icon( Icon(
imageVector = item.unselectedIcon, imageVector = item.unselectedIcon,
contentDescription = stringResource(item.titleTextId) contentDescription = stringResource(item.iconTextId)
) )
}, },
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = item.selectedIcon, contentDescription = stringResource( imageVector = item.selectedIcon, contentDescription = stringResource(
item.titleTextId item.iconTextId
) )
) )
}, },

View File

@@ -0,0 +1,206 @@
package top.fatweb.oxygen.toolbox.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import top.fatweb.oxygen.toolbox.datastore.tool.ToolDataSource
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.model.tool.Tool
import top.fatweb.oxygen.toolbox.model.tool.ToolGroup
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
@Composable
fun ToolGroupCard(
modifier: Modifier = Modifier,
toolGroup: ToolGroup,
isExpanded: Boolean = true,
onExpandSwitch: ((newStatus: Boolean) -> Unit)? = null
) {
val (_, icon, title, tools) = toolGroup
Card(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column {
ToolGroupTitle(modifier = Modifier.padding(16.dp),
icon = icon,
title = title,
isExpanded = isExpanded,
onClick = { onExpandSwitch?.let { it(!isExpanded) } })
AnimatedVisibility(visible = isExpanded) {
ToolGroupContent(modifier = Modifier.padding(16.dp), toolList = tools)
}
}
}
}
@Composable
fun ToolGroupTitle(
modifier: Modifier = Modifier,
icon: ImageVector,
title: String,
isExpanded: Boolean,
onClick: (() -> Unit)? = null
) {
Surface(onClick = { onClick?.let { it() } }) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(modifier = Modifier.size(18.dp), imageVector = icon, contentDescription = title)
Spacer(modifier = Modifier.width(10.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
SwitchableIcon(icon = OxygenIcons.ArrowDown, switched = !isExpanded)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ToolGroupContent(
modifier: Modifier = Modifier,
toolList: List<Tool>
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
toolList.mapIndexed { index, it ->
ToolGroupItem(icon = it.icon, title = it.name)
}
}
}
@Composable
fun ToolGroupItem(
modifier: Modifier = Modifier,
icon: ImageVector,
title: String,
onClick: (() -> Unit)? = null
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(100),
onClick = { onClick?.let { it() } }
) {
Box(
modifier = Modifier.padding(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(modifier = Modifier.size(16.dp), imageVector = icon, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text(text = title, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Composable
fun SwitchableIcon(
modifier: Modifier = Modifier,
icon: ImageVector,
switched: Boolean,
defaultRotate: Float = 0f,
switchedRotate: Float = 180f,
) {
val rotate by animateFloatAsState(
if (switched) switchedRotate else defaultRotate,
label = "Rotate"
)
Icon(
modifier = modifier
.rotate(rotate), imageVector = icon, contentDescription = null
)
}
@ThemePreviews
@Composable
private fun ToolGroupCardPreview() {
val groups = runBlocking { ToolDataSource().tool.first() }
OxygenTheme {
LazyColumn {
itemsIndexed(groups) { index, item ->
if (index != 0) {
Spacer(modifier = Modifier.height(10.dp))
}
var isExpanded by remember {
mutableStateOf(true)
}
ToolGroupCard(
toolGroup = item,
isExpanded = isExpanded,
onExpandSwitch = { isExpanded = it })
}
}
}
}
@ThemePreviews
@Composable
fun SwitchableIconPreview() {
var switched by remember { mutableStateOf(false) }
OxygenTheme {
Surface(
onClick = { switched = !switched }
) {
SwitchableIcon(icon = OxygenIcons.ArrowDown, switched = switched)
}
}
}
@ThemePreviews
@Composable
fun ToolGroupItemPreview() {
OxygenTheme {
ToolGroupItem(icon = OxygenIcons.Time, title = "Time Screen")
}
}
@ThemePreviews
@Composable
fun ToolGroupContentPreview() {
OxygenTheme {
ToolGroupContent(toolList = runBlocking {
ToolDataSource().tool.first().map { it.tools }.flatten()
})
}
}

View File

@@ -0,0 +1,237 @@
package top.fatweb.oxygen.toolbox.ui.component.scrollbar
import android.annotation.SuppressLint
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* The time period for showing the scrollbar thumb after interacting with it, before it fades away
*/
private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L
/**
* A [Scrollbar] that allows for fast scrolling of content by dragging its thumb.
* Its thumb disappears when the scrolling container is dormant.
* @param modifier a [Modifier] for the [Scrollbar]
* @param state the driving state for the [Scrollbar]
* @param orientation the orientation of the scrollbar
* @param onThumbMoved the fast scroll implementation
*/
@Composable
fun ScrollableState.DraggableScrollbar(
modifier: Modifier = Modifier,
state: ScrollbarState,
orientation: Orientation,
onThumbMoved: (Float) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Scrollbar(
modifier = modifier,
orientation = orientation,
interactionSource = interactionSource,
state = state,
thumb = {
DraggableScrollbarThumb(
interactionSource = interactionSource,
orientation = orientation
)
},
onThumbMoved = onThumbMoved
)
}
/**
* A scrollbar thumb that is intended to also be a touch target for fast scrolling.
*/
@Composable
private fun ScrollableState.DraggableScrollbarThumb(
interactionSource: InteractionSource,
orientation: Orientation
) {
Box(
modifier = Modifier
.run {
when (orientation) {
Orientation.Vertical -> width(12.dp).fillMaxHeight()
Orientation.Horizontal -> height(12.dp).fillMaxWidth()
}
}
.scrollThumb(this, interactionSource)
)
}
/**
* A simple [Scrollbar].
* Its thumb disappears when the scrolling container is dormant.
* @param modifier a [Modifier] for the [Scrollbar]
* @param state the driving state for the [Scrollbar]
* @param orientation the orientation of the scrollbar
*/
@Composable
fun ScrollableState.DecorativeScrollbar(
modifier: Modifier,
state: ScrollbarState,
orientation: Orientation
) {
val interactionSource = remember { MutableInteractionSource() }
Scrollbar(
modifier = modifier,
orientation = orientation,
interactionSource = interactionSource,
state = state,
thumb = {
DecorativeScrollbarThumb(
interactionSource = interactionSource,
orientation = orientation
)
}
)
}
/**
* A decorative scrollbar thumb used solely for communicating a user's position in a list.
*/
@Composable
private fun ScrollableState.DecorativeScrollbarThumb(
interactionSource: InteractionSource,
orientation: Orientation
) {
Box(
modifier = Modifier
.run {
when (orientation) {
Orientation.Vertical -> width(2.dp).fillMaxHeight()
Orientation.Horizontal -> height(2.dp).fillMaxWidth()
}
}
.scrollThumb(this, interactionSource)
)
}
@Composable
private fun Modifier.scrollThumb(
scrollbarState: ScrollableState,
interactionSource: InteractionSource
): Modifier {
val colorState =
scrollbarThumbColor(scrollableState = scrollbarState, interactionSource = interactionSource)
return this then ScrollThumbElement { colorState.value }
}
@SuppressLint("ModifierNodeInspectableProperties")
private data class ScrollThumbElement(val colorProducer: ColorProducer) :
ModifierNodeElement<ScrollThumbNode>() {
override fun create(): ScrollThumbNode = ScrollThumbNode(colorProducer)
override fun update(node: ScrollThumbNode) {
node.colorProducer = colorProducer
node.invalidateDraw()
}
}
private class ScrollThumbNode(var colorProducer: ColorProducer) : DrawModifierNode,
Modifier.Node() {
private val shape = RoundedCornerShape(16.dp)
// Naive cache outline calculation if size is th same
private var lastSize: Size? = null
private var lastLayoutDirection: LayoutDirection? = null
private var lastoutline: Outline? = null
override fun ContentDrawScope.draw() {
val color = colorProducer()
val outline =
if (size == lastSize && layoutDirection == lastLayoutDirection) {
lastoutline!!
} else {
shape.createOutline(size, layoutDirection, this)
}
if (color != Color.Unspecified) drawOutline(outline, color)
lastoutline = outline
lastSize = size
lastLayoutDirection = layoutDirection
}
}
/**
* The color of the scrollbar thumb as a function of its interaction state.
* @param interactionSource source of interactions in the scrolling container
*/
@Composable
private fun scrollbarThumbColor(
scrollableState: ScrollableState,
interactionSource: InteractionSource
): State<Color> {
var state by remember { mutableStateOf(ThumbState.Dormant) }
val pressed by interactionSource.collectIsPressedAsState()
val hovered by interactionSource.collectIsHoveredAsState()
val dragged by interactionSource.collectIsDraggedAsState()
val active =
(scrollableState.canScrollForward || scrollableState.canScrollBackward && (pressed || hovered || dragged || scrollableState.isScrollInProgress))
val color = animateColorAsState(
targetValue = when (state) {
ThumbState.Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
ThumbState.Inactive -> MaterialTheme.colorScheme.onSurface.copy(0.2f)
ThumbState.Dormant -> Color.Transparent
},
animationSpec = SpringSpec(
stiffness = Spring.StiffnessLow
),
label = "Scrollbar thumb color"
)
LaunchedEffect(active) {
when (active) {
true -> state = ThumbState.Active
false -> if (state == ThumbState.Active) {
state = ThumbState.Inactive
delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS)
state = ThumbState.Dormant
}
}
}
return color
}
private enum class ThumbState {
Active,
Inactive,
Dormant,
}

View File

@@ -0,0 +1,73 @@
package top.fatweb.oxygen.toolbox.ui.component.scrollbar
import androidx.compose.foundation.gestures.ScrollableState
import kotlin.math.abs
/**
* Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar
* progression.
* @param visibleItems a list of items currently visible in the layout.
* @param itemSize a lookup function for the size of an item in the layout.
* @param offset a lookup function for the offset of an item relative to the start of the view port.
* @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction
* of the scroll.
* @param itemIndex a lookup function for index of an item in the layout relative to
* the total amount of items available.
*
* @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition
* is the index of the consecutive item along the major axis.
* */
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(
visibleItems: List<LazyStateItem>,
crossinline itemSize: LazyState.(LazyStateItem) -> Int,
crossinline offset: LazyState.(LazyStateItem) -> Int,
crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?,
crossinline itemIndex: (LazyStateItem) -> Int
): Float {
if (visibleItems.isEmpty()) return 0f
val firstItem = visibleItems.first()
val firstItemIndex = itemIndex(firstItem)
if (firstItemIndex < 0) return Float.NaN
val firstItemSize = itemSize(firstItem)
if (firstItemSize == 0) return Float.NaN
val itemOffset = offset(firstItem).toFloat()
val offsetPercentage = abs(itemOffset) / firstItemSize
val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage
val nextItemIndex = itemIndex(nextItem)
return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage)
}
/**
* Returns the percentage of an item that is currently visible in the view port.
* @param itemSize the size of the item
* @param itemStartOffset the start offset of the item relative to the view port start
* @param viewportStartOffset the start offset of the view port
* @param viewportEndOffset the end offset of the view port
*/
internal fun itemVisibilityPercentage(
itemSize: Int,
itemStartOffset: Int,
viewportStartOffset: Int,
viewportEndOffset: Int
): Float {
if (itemSize == 0) return 0f
val itemEndOffset = itemStartOffset + itemSize
val startOffset = when {
itemStartOffset > viewportStartOffset -> 0
else -> abs(abs(viewportStartOffset) - abs(itemStartOffset))
}
val endOffset = when {
itemEndOffset < viewportEndOffset -> 0
else -> abs(abs(itemEndOffset) - abs(viewportEndOffset))
}
val size = itemSize.toFloat()
return (size - startOffset - endOffset) / size
}

View File

@@ -0,0 +1,402 @@
package top.fatweb.oxygen.toolbox.ui.component.scrollbar
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.packFloats
import androidx.compose.ui.util.unpackFloat1
import androidx.compose.ui.util.unpackFloat2
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll
* instead of dragging the scrollbar thumb.
*/
private const val SCROLLBAR_PRESS_DELAY_MS = 10L
/**
* The percentage displacement of the scrollbar when scrolled by long presses on the scrollbar
* track.
*/
private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f
class ScrollbarState {
private var packedValue by mutableLongStateOf(0L)
internal fun onScroll(stateValue: ScrollbarStateValue) {
packedValue = stateValue.packedValue
}
/**
* Returns the thumb size of the scrollbar as a percentage of the total track size
*/
val thumbSizePercent
get() = unpackFloat1(packedValue)
/**
* Returns the distance the thumb has traveled as a percentage of total track size
*/
val thumbMovedPercent
get() = unpackFloat2(packedValue)
/**
* Returns the max distance the thumb can travel as a percentage of total track size
*/
val thumbTrackSizePercent
get() = 1f - thumbSizePercent
}
/**
* Returns the size of the scrollbar track in pixels
*/
private val ScrollbarTrack.size
get() = unpackFloat2(packedValue) - unpackFloat1(packedValue)
/**
* Returns the position of the scrollbar thumb on the track as a percentage
*/
private fun ScrollbarTrack.thumbPosition(
dimension: Float
): Float = max(
a = min(
a = dimension / size,
b = 1f
),
b = 0f
)
/**
* Class definition for the core properties of a scroll bar
*/
@Immutable
@JvmInline
value class ScrollbarStateValue internal constructor(
internal val packedValue: Long
)
/**
* Class definition for the core properties of a scroll bar track
*/
@Immutable
@JvmInline
private value class ScrollbarTrack(
val packedValue: Long
) {
constructor(
max: Float,
min: Float
) : this(packFloats(max, min))
}
/**
* Creates a [ScrollbarStateValue] with the listed properties
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size.
* Refers to either the thumb width (for horizontal scrollbars)
* or height (for vertical scrollbars).
* @param thumbMovedPercent the distance the thumb has traveled as a percentage of total
* track size.
*/
fun scrollbarStateValue(
thumbSizePercent: Float,
thumbMovedPercent: Float,
) = ScrollbarStateValue(
packFloats(
val1 = thumbSizePercent,
val2 = thumbMovedPercent,
),
)
/**
* Returns the value of [offset] along the axis specified by [this]
*/
internal fun Orientation.valueOf(offset: Offset) = when (this) {
Orientation.Horizontal -> offset.x
Orientation.Vertical -> offset.y
}
/**
* Returns the value of [intSize] along the axis specified by [this]
*/
internal fun Orientation.valueOf(intSize: IntSize) = when (this) {
Orientation.Horizontal -> intSize.width
Orientation.Vertical -> intSize.height
}
/**
* Returns the value of [intOffset] along the axis specified by [this]
*/
internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {
Orientation.Horizontal -> intOffset.x
Orientation.Vertical -> intOffset.y
}
/**
* A Composable for drawing a scrollbar
* @param orientation the scroll direction of the scrollbar
* @param state the state describing the position of the scrollbar
* @param interactionSource allows for observing the state of the scroll bar
* @param thumb a composable for drawing the scrollbar thumb
* @param minThumbSize the minimum size of the scrollbar thumb
* @param onThumbMoved an function for reacting to scroll bar displacements caused by direct
* interactions on the scrollbar thumb by the user, for example implementing a fast scroll
*/
@Composable
fun Scrollbar(
modifier: Modifier = Modifier,
orientation: Orientation,
state: ScrollbarState,
interactionSource: MutableInteractionSource? = null,
thumb: @Composable () -> Unit,
minThumbSize: Dp = 40.dp,
onThumbMoved: ((Float) -> Unit)? = null,
) {
// Using Offset.Unspecified and Float.NaN instead of null
// to prevent unnecessary boxing of primitives
var pressedOffset by remember { mutableStateOf(Offset.Unspecified) }
var draggedOffset by remember { mutableStateOf(Offset.Unspecified) }
// Used to immediately show drag feedback in the UI while the scrolling implementation
// catches up
var interactionThumbTravelPercent by remember { mutableFloatStateOf(Float.NaN) }
var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) }
Box(
modifier = modifier
.run {
val withHover = interactionSource?.let(::hoverable) ?: this
when (orientation) {
Orientation.Vertical -> withHover.fillMaxHeight()
Orientation.Horizontal -> withHover.fillMaxWidth()
}
}
.onGloballyPositioned { coordinates ->
val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot())
track = ScrollbarTrack(
max = scrollbarStartCoordinate,
min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size)
)
}
// Process scrollbar presses
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
try {
// Wait for a long press before scrolling
withTimeout(viewConfiguration.longPressTimeoutMillis) {
tryAwaitRelease()
}
} catch (e: TimeoutCancellationException) {
// Start the press triggered scroll
val initialPress = PressInteraction.Press(offset)
interactionSource?.tryEmit(initialPress)
pressedOffset = offset
interactionSource?.tryEmit(
when {
tryAwaitRelease() -> PressInteraction.Release(initialPress)
else -> PressInteraction.Cancel(initialPress)
}
)
// End the press
pressedOffset = Offset.Unspecified
}
}
)
}
// Process scrollbar drags
.pointerInput(Unit) {
var dragInteraction: DragInteraction.Start? = null
val onDragStart: (Offset) -> Unit = { offset ->
val start = DragInteraction.Start()
dragInteraction = start
interactionSource?.tryEmit(start)
draggedOffset = offset
}
val onDragEnd: () -> Unit = {
dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Stop(it)) }
draggedOffset = Offset.Unspecified
}
val onDragCancel: () -> Unit = {
dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Cancel(it)) }
draggedOffset = Offset.Unspecified
}
val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit =
onDrag@{ _, delta ->
if (draggedOffset == Offset.Unspecified) return@onDrag
draggedOffset = when (orientation) {
Orientation.Vertical -> draggedOffset.copy(
y = draggedOffset.y + delta
)
Orientation.Horizontal -> draggedOffset.copy(
x = draggedOffset.x + delta
)
}
}
when (orientation) {
Orientation.Horizontal -> detectHorizontalDragGestures(
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
onHorizontalDrag = onDrag
)
Orientation.Vertical -> detectVerticalDragGestures(
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
onVerticalDrag = onDrag
)
}
}
) {
// scrollbar thumb container
Layout(content = { thumb() }) { measurableList, constraints ->
val measurable = measurableList.first()
val thumbSizePx = max(
a = state.thumbSizePercent * track.size,
b = minThumbSize.toPx()
)
val trackSizePx = when (state.thumbTrackSizePercent) {
0f -> track.size
else -> (track.size - thumbSizePx) / state.thumbTrackSizePercent
}
val thumbTravelPercent = max(
a = min(
a = when {
interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
else -> interactionThumbTravelPercent
},
b = state.thumbTrackSizePercent
),
b = 0f
)
val thumbMovedPx = trackSizePx * thumbTravelPercent
val y = when (orientation) {
Orientation.Horizontal -> 0
Orientation.Vertical -> thumbMovedPx.roundToInt()
}
val x = when (orientation) {
Orientation.Horizontal -> thumbMovedPx.roundToInt()
Orientation.Vertical -> 0
}
val updatedConstraints = when (orientation) {
Orientation.Horizontal -> {
constraints.copy(
minWidth = thumbSizePx.roundToInt(),
maxWidth = thumbSizePx.roundToInt()
)
}
Orientation.Vertical -> {
constraints.copy(
minHeight = thumbSizePx.roundToInt(),
maxHeight = thumbSizePx.roundToInt()
)
}
}
val placeable = measurable.measure(updatedConstraints)
layout(placeable.width, placeable.height) {
placeable.place(x, y)
}
}
}
if (onThumbMoved == null) {
return
}
// Process presses
LaunchedEffect(Unit) {
snapshotFlow { pressedOffset }.collect { pressedOffset ->
// Press ended, reset interactionThumbTravelPercent
if (pressedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@collect
}
var currentThumbMovedPercent = state.thumbMovedPercent
val destinationThumbMovedPercent = track.thumbPosition(
dimension = orientation.valueOf(pressedOffset)
)
val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent
val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f
while (currentThumbMovedPercent != destinationThumbMovedPercent) {
currentThumbMovedPercent = when {
isPositive -> min(
a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent
)
else -> max(
a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent
)
}
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { draggedOffset }.collect { draggedOffset ->
if (draggedOffset == Offset.Unspecified) {
interactionThumbTravelPercent = Float.NaN
return@collect
}
val currentTravel = track.thumbPosition(
dimension = orientation.valueOf(draggedOffset)
)
onThumbMoved(currentTravel)
interactionThumbTravelPercent = currentTravel
}
}
}

View File

@@ -0,0 +1,221 @@
package top.fatweb.oxygen.toolbox.ui.component.scrollbar
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlin.math.min
/**
* Calculates a [ScrollbarState] driven by the changes in a [LazyListState].
*
* @param itemsAvailable the total amount of items available to scroll in the lazy list.
* @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable].
*/
@Composable
fun LazyListState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
itemIndex = itemIndex
),
b = itemsAvailable.toFloat()
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.index,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
}
)
}
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
/**
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
*
* @param itemsAvailable the total amount of items available to scroll in the grid.
* @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable].
*/
@Composable
fun LazyGridState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
when (layoutInfo.orientation) {
Orientation.Vertical -> visibleItemsInfo.find {
it != first && it.row != first.row
}
Orientation.Horizontal -> visibleItemsInfo.find {
it != first && it.column != first.column
}
}
},
itemIndex = itemIndex
),
b = itemsAvailable.toFloat()
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable.toFloat(),
b = 1f
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
}
)
}
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
/**
* Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState]
*
* @param itemsAvailable the total amount of items available to scroll in the staggered grid.
* @param itemIndex a lookup function for index of an item in the staggered grid relative
* to [itemsAvailable].
*/
@Composable
fun LazyStaggeredGridState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
visibleItemsInfo.find { it != first && it.lane == first.lane }
},
itemIndex = itemIndex
),
b = itemsAvailable.toFloat()
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = thumbTravelPercent
)
}
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float =
fold(initial = 0f) { accumulator, listItem -> accumulator + selector(listItem) }

View File

@@ -0,0 +1,73 @@
package top.fatweb.oxygen.toolbox.ui.component.scrollbar
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlin.math.roundToInt
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]
* @param itemsAvailable the amount of items in the list.
*/
@Composable
fun LazyListState.rememberDraggableScroller(
itemsAvailable: Int
): (Float) -> Unit = rememberDraggableScroller(
itemsAvailable = itemsAvailable, scroll = ::scrollToItem
)
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState]
* @param itemsAvailable the amount of items in the grid.
*/
@Composable
fun LazyGridState.rememberDraggableScroller(
itemsAvailable: Int
): (Float) -> Unit = rememberDraggableScroller(
itemsAvailable = itemsAvailable,
scroll = ::scrollToItem
)
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a
* [LazyStaggeredGridState]
* @param itemsAvailable the amount of items in the staggered grid.
*/
@Composable
fun LazyStaggeredGridState.rememberDraggableScroller(
itemsAvailable: Int
): (Float) -> Unit = rememberDraggableScroller(
itemsAvailable = itemsAvailable,
scroll = ::scrollToItem
)
/**
* Generic function to react to [Scrollbar] thumb displacements in a lazy layout.
* @param itemsAvailable the total amount of items available to scroll in the layout.
* @param scroll a function to be invoked when an index has been identified to scroll to.
*/
@Composable
private inline fun rememberDraggableScroller(
itemsAvailable: Int,
crossinline scroll: suspend (index: Int) -> Unit
): (Float) -> Unit {
var percentage by remember { mutableFloatStateOf(Float.NaN) }
val itemCount by rememberUpdatedState(itemsAvailable)
LaunchedEffect(percentage) {
if (percentage.isNaN()) return@LaunchedEffect
val indexToFind = (itemCount * percentage).roundToInt()
scroll(indexToFind)
}
return remember {
{ newPercentage -> percentage = newPercentage }
}
}

View File

@@ -28,14 +28,14 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import top.fatweb.oxygen.toolbox.R import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig import top.fatweb.oxygen.toolbox.model.userdata.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig import top.fatweb.oxygen.toolbox.model.userdata.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.model.userdata.UserData
import top.fatweb.oxygen.toolbox.ui.component.ThemePreviews import top.fatweb.oxygen.toolbox.ui.component.ThemePreviews
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
import top.fatweb.oxygen.toolbox.ui.theme.supportsDynamicTheming import top.fatweb.oxygen.toolbox.ui.theme.supportsDynamicTheming
@@ -77,7 +77,6 @@ fun SettingsDialog(
modifier = modifier modifier = modifier
.widthIn(max = configuration.screenWidthDp.dp - 80.dp) .widthIn(max = configuration.screenWidthDp.dp - 80.dp)
.heightIn(max = configuration.screenHeightDp.dp - 40.dp), .heightIn(max = configuration.screenHeightDp.dp - 40.dp),
properties = DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
Text( Text(
@@ -128,7 +127,7 @@ fun SettingsDialog(
@Composable @Composable
private fun ColumnScope.SettingsPanel( private fun ColumnScope.SettingsPanel(
settings: UserEditableSettings, settings: UserData,
supportDynamicColor: Boolean, supportDynamicColor: Boolean,
onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit, onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit,
onChangeLaunchPageConfig: (launchPageConfig: LaunchPageConfig) -> Unit, onChangeLaunchPageConfig: (launchPageConfig: LaunchPageConfig) -> Unit,
@@ -283,7 +282,7 @@ private fun SettingDialogPreview() {
SettingsDialog( SettingsDialog(
onDismiss = {}, onDismiss = {},
settingsUiState = SettingsUiState.Success( settingsUiState = SettingsUiState.Success(
UserEditableSettings( UserData(
languageConfig = LanguageConfig.FOLLOW_SYSTEM, languageConfig = LanguageConfig.FOLLOW_SYSTEM,
launchPageConfig = LaunchPageConfig.TOOLS, launchPageConfig = LaunchPageConfig.TOOLS,
themeBrandConfig = ThemeBrandConfig.DEFAULT, themeBrandConfig = ThemeBrandConfig.DEFAULT,

View File

@@ -8,11 +8,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.fatweb.oxygen.toolbox.model.DarkThemeConfig import top.fatweb.oxygen.toolbox.model.userdata.DarkThemeConfig
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import top.fatweb.oxygen.toolbox.model.LaunchPageConfig import top.fatweb.oxygen.toolbox.model.userdata.LaunchPageConfig
import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig import top.fatweb.oxygen.toolbox.model.userdata.ThemeBrandConfig
import top.fatweb.oxygen.toolbox.repository.UserDataRepository import top.fatweb.oxygen.toolbox.model.userdata.UserData
import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -22,16 +23,8 @@ class SettingsViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
val settingsUiState: StateFlow<SettingsUiState> = val settingsUiState: StateFlow<SettingsUiState> =
userDataRepository.userData userDataRepository.userData
.map { userData -> .map {
SettingsUiState.Success( SettingsUiState.Success(it)
settings = UserEditableSettings(
languageConfig = userData.languageConfig,
launchPageConfig = userData.launchPageConfig,
themeBrandConfig = userData.themeBrandConfig,
darkThemeConfig = userData.darkThemeConfig,
useDynamicColor = userData.useDynamicColor
)
)
} }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -70,15 +63,7 @@ class SettingsViewModel @Inject constructor(
} }
} }
data class UserEditableSettings(
val languageConfig: LanguageConfig,
val launchPageConfig: LaunchPageConfig,
val themeBrandConfig: ThemeBrandConfig,
val darkThemeConfig: DarkThemeConfig,
val useDynamicColor: Boolean
)
sealed interface SettingsUiState { sealed interface SettingsUiState {
data object Loading : SettingsUiState data object Loading : SettingsUiState
data class Success(val settings: UserEditableSettings) : SettingsUiState data class Success(val settings: UserData) : SettingsUiState
} }

View File

@@ -0,0 +1,22 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.items
import top.fatweb.oxygen.toolbox.ui.component.ToolGroupCard
fun LazyStaggeredGridScope.toolsPanel(
toolsScreenUiState: ToolsScreenUiState
) {
when (toolsScreenUiState) {
ToolsScreenUiState.Loading -> Unit
is ToolsScreenUiState.Success -> {
items(
items = toolsScreenUiState.toolGroups,
key = { it.id },
) {
ToolGroupCard(toolGroup = it)
}
}
}
}

View File

@@ -0,0 +1,133 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.datastore.tool.ToolDataSource
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
fun ToolsRoute(
modifier: Modifier = Modifier,
viewModel: ToolsScreenViewModel = hiltViewModel()
) {
val toolsScreenUiState by viewModel.toolsScreenUiState.collectAsStateWithLifecycle()
ToolsScreen(
modifier = modifier,
toolsScreenUiState = toolsScreenUiState
)
}
@Composable
fun ToolsScreen(
modifier: Modifier = Modifier,
toolsScreenUiState: ToolsScreenUiState
) {
val isToolLoading = toolsScreenUiState is ToolsScreenUiState.Loading
ReportDrawnWhen { !isToolLoading }
val itemsAvailable = howManyItems(toolsScreenUiState)
val state = rememberLazyStaggeredGridState()
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
var isExpanded by remember { mutableStateOf(mapOf<String, Boolean>()) }
Box(
modifier.fillMaxSize()
) {
when (toolsScreenUiState) {
ToolsScreenUiState.Loading -> {
Text(text = stringResource(R.string.feature_settings_loading))
}
is ToolsScreenUiState.Success -> {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 24.dp,
state = state
) {
toolsPanel(toolsScreenUiState = toolsScreenUiState)
item(span = StaggeredGridItemSpan.FullLine) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
state.DraggableScrollbar(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 2.dp)
.align(Alignment.CenterEnd),
state = scrollbarState, orientation = Orientation.Vertical,
onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable)
)
}
}
}
}
fun howManyItems(toolScreenUiState: ToolsScreenUiState) =
when (toolScreenUiState) {
ToolsScreenUiState.Loading -> 0
is ToolsScreenUiState.Success -> toolScreenUiState.toolGroups.size
}
@Preview("Loading")
@Composable
fun ToolsScreenLoadingPreview() {
ToolsScreen(toolsScreenUiState = ToolsScreenUiState.Loading)
}
@Preview("ToolsPage")
@Composable
fun ToolsScreenPreview() {
ToolsScreen(
toolsScreenUiState = ToolsScreenUiState.Success(
runBlocking {
ToolDataSource().tool.first()
})
)
}

View File

@@ -0,0 +1,34 @@
package top.fatweb.oxygen.toolbox.ui.tool
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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.ToolGroup
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class ToolsScreenViewModel @Inject constructor(
toolRepository: ToolRepository
) : ViewModel() {
val toolsScreenUiState: StateFlow<ToolsScreenUiState> =
toolRepository.toolGroups
.map {
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 toolGroups: List<ToolGroup>) : ToolsScreenUiState
}

View File

@@ -1,8 +0,0 @@
package top.fatweb.oxygen.toolbox.ui.tools
import androidx.compose.runtime.Composable
@Composable
internal fun ToolsScreen() {
}

View File

@@ -5,7 +5,7 @@ import android.content.Context
import android.os.Build import android.os.Build
import android.os.LocaleList import android.os.LocaleList
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import top.fatweb.oxygen.toolbox.model.LanguageConfig import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig
import java.util.Locale import java.util.Locale
object LocaleUtils { object LocaleUtils {