Feat - ToolsScreen - add tool group list
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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(
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>>
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
@@ -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>
|
||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package top.fatweb.oxygen.toolbox.ui.tools
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ToolsScreen() {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user