From f81f26a5cb5ca85cc99120ea7471488bb396c0a9 Mon Sep 17 00:00:00 2001 From: FatttSnake Date: Fri, 29 Mar 2024 18:09:33 +0800 Subject: [PATCH] Feat - ToolsScreen - add tool group list --- .../top/fatweb/oxygen/toolbox/MainActivity.kt | 10 +- .../oxygen/toolbox/MainActivityViewModel.kt | 4 +- .../oxygen/toolbox/OxygenApplication.kt | 2 +- .../toolbox/datastore/tool/ToolDataSource.kt | 36 ++ .../{ => userdata}/IntToStringIdsMigration.kt | 4 +- .../OxygenPreferencesDataSource.kt | 18 +- .../UserPreferencesSerializer.kt | 3 +- .../fatweb/oxygen/toolbox/di/DataModule.kt | 15 +- .../oxygen/toolbox/di/DataStoreModule.kt | 4 +- .../fatweb/oxygen/toolbox/icon/OxygenIcons.kt | 10 + .../fatweb/oxygen/toolbox/model/tool/Tool.kt | 12 + .../oxygen/toolbox/model/tool/ToolGroup.kt | 14 + .../model/{ => userdata}/DarkThemeConfig.kt | 2 +- .../model/{ => userdata}/LanguageConfig.kt | 2 +- .../model/{ => userdata}/LaunchPageConfig.kt | 2 +- .../model/{ => userdata}/ThemeBrandConfig.kt | 2 +- .../toolbox/model/{ => userdata}/UserData.kt | 2 +- .../toolbox/navigation/ToolsNavigation.kt | 5 +- .../toolbox/navigation/TopLevelDestination.kt | 5 +- .../tool/OfflineFirstToolRepository.kt | 13 + .../toolbox/repository/tool/ToolRepository.kt | 8 + .../OfflineFirstUserDataRepository.kt | 14 +- .../{ => userdata}/UserDataRepository.kt | 12 +- .../top/fatweb/oxygen/toolbox/ui/OxygenApp.kt | 10 +- .../oxygen/toolbox/ui/OxygenAppState.kt | 2 +- .../oxygen/toolbox/ui/component/Navigation.kt | 8 +- .../toolbox/ui/component/ToolGroupCard.kt | 206 +++++++++ .../ui/component/scrollbar/AppScrollbars.kt | 237 +++++++++++ .../component/scrollbar/LazyScrollbarUtils.kt | 73 ++++ .../ui/component/scrollbar/Scrollbar.kt | 402 ++++++++++++++++++ .../ui/component/scrollbar/ScrollbarExt.kt | 221 ++++++++++ .../ui/component/scrollbar/ThumbExt.kt | 73 ++++ .../toolbox/ui/settings/SettingsDialog.kt | 15 +- .../toolbox/ui/settings/SettingsViewModel.kt | 33 +- .../oxygen/toolbox/ui/tool/ToolsPanel.kt | 22 + .../oxygen/toolbox/ui/tool/ToolsScreen.kt | 133 ++++++ .../toolbox/ui/tool/ToolsScreenViewModel.kt | 34 ++ .../oxygen/toolbox/ui/tools/ToolsScreen.kt | 8 - .../oxygen/toolbox/ui/util/LocaleUtils.kt | 2 +- 39 files changed, 1583 insertions(+), 95 deletions(-) create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/tool/ToolDataSource.kt rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/{ => userdata}/IntToStringIdsMigration.kt (69%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/{ => userdata}/OxygenPreferencesDataSource.kt (86%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/{ => userdata}/UserPreferencesSerializer.kt (87%) create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/Tool.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/ToolGroup.kt rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/{ => userdata}/DarkThemeConfig.kt (59%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/{ => userdata}/LanguageConfig.kt (69%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/{ => userdata}/LaunchPageConfig.kt (51%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/{ => userdata}/ThemeBrandConfig.kt (53%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/{ => userdata}/UserData.kt (82%) create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/OfflineFirstToolRepository.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/{ => userdata}/OfflineFirstUserDataRepository.kt (71%) rename app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/{ => userdata}/UserDataRepository.kt (54%) create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolGroupCard.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/AppScrollbars.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/LazyScrollbarUtils.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/Scrollbar.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ScrollbarExt.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ThumbExt.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsPanel.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt create mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreenViewModel.kt delete mode 100644 app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivity.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivity.kt index 4220e0d..2435f0a 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivity.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivity.kt @@ -31,13 +31,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import top.fatweb.oxygen.toolbox.model.DarkThemeConfig -import top.fatweb.oxygen.toolbox.model.LanguageConfig -import top.fatweb.oxygen.toolbox.model.LaunchPageConfig -import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig +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.monitor.NetworkMonitor 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.rememberOxygenAppState import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivityViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivityViewModel.kt index 07c49b9..eec6640 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivityViewModel.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/MainActivityViewModel.kt @@ -7,8 +7,8 @@ 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.UserData -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 kotlin.time.Duration.Companion.seconds diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/OxygenApplication.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/OxygenApplication.kt index 67e1148..c5a3acd 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/OxygenApplication.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/OxygenApplication.kt @@ -2,7 +2,7 @@ package top.fatweb.oxygen.toolbox import android.app.Application import dagger.hilt.android.HiltAndroidApp -import top.fatweb.oxygen.toolbox.repository.UserDataRepository +import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository import javax.inject.Inject @HiltAndroidApp diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/tool/ToolDataSource.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/tool/ToolDataSource.kt new file mode 100644 index 0000000..f447333 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/tool/ToolDataSource.kt @@ -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("") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/IntToStringIdsMigration.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/IntToStringIdsMigration.kt similarity index 69% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/IntToStringIdsMigration.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/IntToStringIdsMigration.kt index 4cc379f..fcc424e 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/IntToStringIdsMigration.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/IntToStringIdsMigration.kt @@ -1,6 +1,8 @@ -package top.fatweb.oxygen.toolbox.datastore +package top.fatweb.oxygen.toolbox.datastore.userdata import androidx.datastore.core.DataMigration +import top.fatweb.oxygen.toolbox.datastore.UserPreferences +import top.fatweb.oxygen.toolbox.datastore.copy internal object IntToStringIdsMigration : DataMigration { override suspend fun cleanUp() = Unit diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/OxygenPreferencesDataSource.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/OxygenPreferencesDataSource.kt similarity index 86% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/OxygenPreferencesDataSource.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/OxygenPreferencesDataSource.kt index e172df5..53dc743 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/OxygenPreferencesDataSource.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/OxygenPreferencesDataSource.kt @@ -1,12 +1,18 @@ -package top.fatweb.oxygen.toolbox.datastore +package top.fatweb.oxygen.toolbox.datastore.userdata import androidx.datastore.core.DataStore import kotlinx.coroutines.flow.map -import top.fatweb.oxygen.toolbox.model.DarkThemeConfig -import top.fatweb.oxygen.toolbox.model.LanguageConfig -import top.fatweb.oxygen.toolbox.model.LaunchPageConfig -import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig -import top.fatweb.oxygen.toolbox.model.UserData +import top.fatweb.oxygen.toolbox.datastore.DarkThemeConfigProto +import top.fatweb.oxygen.toolbox.datastore.LanguageConfigProto +import top.fatweb.oxygen.toolbox.datastore.LaunchPageConfigProto +import top.fatweb.oxygen.toolbox.datastore.ThemeBrandConfigProto +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 class OxygenPreferencesDataSource @Inject constructor( diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/UserPreferencesSerializer.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/UserPreferencesSerializer.kt similarity index 87% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/UserPreferencesSerializer.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/UserPreferencesSerializer.kt index e387bc5..95b3ec0 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/UserPreferencesSerializer.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/datastore/userdata/UserPreferencesSerializer.kt @@ -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.Serializer import com.google.protobuf.InvalidProtocolBufferException +import top.fatweb.oxygen.toolbox.datastore.UserPreferences import java.io.InputStream import java.io.OutputStream import javax.inject.Inject diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataModule.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataModule.kt index 64e0591..492ac46 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataModule.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataModule.kt @@ -8,18 +8,23 @@ import top.fatweb.oxygen.toolbox.monitor.ConnectivityManagerNetworkMonitor import top.fatweb.oxygen.toolbox.monitor.NetworkMonitor import top.fatweb.oxygen.toolbox.monitor.TimeZoneBroadcastMonitor import top.fatweb.oxygen.toolbox.monitor.TimeZoneMonitor -import top.fatweb.oxygen.toolbox.repository.OfflineFirstUserDataRepository -import top.fatweb.oxygen.toolbox.repository.UserDataRepository +import top.fatweb.oxygen.toolbox.repository.tool.OfflineFirstToolRepository +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 @InstallIn(SingletonComponent::class) abstract class DataModule { - @Binds - internal abstract fun bindsUserDataRepository(userDataRepository: OfflineFirstUserDataRepository): UserDataRepository - @Binds internal abstract fun bindsNetworkMonitor(networkMonitor: ConnectivityManagerNetworkMonitor): NetworkMonitor @Binds internal abstract fun bindsTimeZoneMonitor(timeZoneMonitor: TimeZoneBroadcastMonitor): TimeZoneMonitor + + @Binds + internal abstract fun bindsUserDataRepository(userDataRepository: OfflineFirstUserDataRepository): UserDataRepository + + @Binds + internal abstract fun bindsToolRepository(toolRepository: OfflineFirstToolRepository): ToolRepository } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataStoreModule.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataStoreModule.kt index ec1a090..1c739f4 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataStoreModule.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/di/DataStoreModule.kt @@ -11,9 +11,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import top.fatweb.oxygen.toolbox.datastore.IntToStringIdsMigration 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.OxygenDispatchers import javax.inject.Singleton diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt index 710e20d..9a6e48d 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/icon/OxygenIcons.kt @@ -1,11 +1,15 @@ package top.fatweb.oxygen.toolbox.icon 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.outlined.Home import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.rounded.ArrowBackIosNew import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Star @@ -17,4 +21,10 @@ object OxygenIcons { val Search = Icons.Rounded.Search val MoreVert = Icons.Default.MoreVert 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 } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/Tool.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/Tool.kt new file mode 100644 index 0000000..d081007 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/Tool.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/ToolGroup.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/ToolGroup.kt new file mode 100644 index 0000000..7025cad --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/tool/ToolGroup.kt @@ -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 = emptyList() +) \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/DarkThemeConfig.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/DarkThemeConfig.kt similarity index 59% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/DarkThemeConfig.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/DarkThemeConfig.kt index 199f7f2..666ab4d 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/DarkThemeConfig.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/DarkThemeConfig.kt @@ -1,4 +1,4 @@ -package top.fatweb.oxygen.toolbox.model +package top.fatweb.oxygen.toolbox.model.userdata enum class DarkThemeConfig { FOLLOW_SYSTEM, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/LanguageConfig.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/LanguageConfig.kt similarity index 69% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/LanguageConfig.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/LanguageConfig.kt index 9cfb529..d3dcc53 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/LanguageConfig.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/LanguageConfig.kt @@ -1,4 +1,4 @@ -package top.fatweb.oxygen.toolbox.model +package top.fatweb.oxygen.toolbox.model.userdata enum class LanguageConfig(val code: String? = null) { FOLLOW_SYSTEM, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/LaunchPageConfig.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/LaunchPageConfig.kt similarity index 51% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/LaunchPageConfig.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/LaunchPageConfig.kt index 09218cc..9ce742c 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/LaunchPageConfig.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/LaunchPageConfig.kt @@ -1,4 +1,4 @@ -package top.fatweb.oxygen.toolbox.model +package top.fatweb.oxygen.toolbox.model.userdata enum class LaunchPageConfig { TOOLS, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/ThemeBrandConfig.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/ThemeBrandConfig.kt similarity index 53% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/ThemeBrandConfig.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/ThemeBrandConfig.kt index fa2bc05..1f5b1b9 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/ThemeBrandConfig.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/ThemeBrandConfig.kt @@ -1,4 +1,4 @@ -package top.fatweb.oxygen.toolbox.model +package top.fatweb.oxygen.toolbox.model.userdata enum class ThemeBrandConfig { DEFAULT, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/UserData.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/UserData.kt similarity index 82% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/UserData.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/UserData.kt index f790a7e..f169ddd 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/UserData.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/userdata/UserData.kt @@ -1,4 +1,4 @@ -package top.fatweb.oxygen.toolbox.model +package top.fatweb.oxygen.toolbox.model.userdata data class UserData( val languageConfig: LanguageConfig, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt index d3fac9b..85e664a 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/ToolsNavigation.kt @@ -4,6 +4,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import top.fatweb.oxygen.toolbox.ui.tool.ToolsRoute const val TOOLS_ROUTE = "tools_route" @@ -12,5 +13,7 @@ fun NavController.navigateToTools(navOptions: NavOptions) = navigate(TOOLS_ROUTE fun NavGraphBuilder.toolsScreen() { composable( route = TOOLS_ROUTE - ) { } + ) { + ToolsRoute() + } } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/TopLevelDestination.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/TopLevelDestination.kt index 9a3a5b5..3fbdf90 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/navigation/TopLevelDestination.kt @@ -1,5 +1,6 @@ package top.fatweb.oxygen.toolbox.navigation +import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector import top.fatweb.oxygen.toolbox.R import top.fatweb.oxygen.toolbox.icon.OxygenIcons @@ -7,8 +8,8 @@ import top.fatweb.oxygen.toolbox.icon.OxygenIcons enum class TopLevelDestination( val selectedIcon: ImageVector, val unselectedIcon: ImageVector, - val iconTextId: Int, - val titleTextId: Int + @StringRes val iconTextId: Int, + @StringRes val titleTextId: Int ) { TOOLS( selectedIcon = OxygenIcons.Home, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/OfflineFirstToolRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/OfflineFirstToolRepository.kt new file mode 100644 index 0000000..f8e89e3 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/OfflineFirstToolRepository.kt @@ -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> = + toolDataSource.tool +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt new file mode 100644 index 0000000..1f95461 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/tool/ToolRepository.kt @@ -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> +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/OfflineFirstUserDataRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/userdata/OfflineFirstUserDataRepository.kt similarity index 71% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/OfflineFirstUserDataRepository.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/userdata/OfflineFirstUserDataRepository.kt index 3577623..1aeb19b 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/OfflineFirstUserDataRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/userdata/OfflineFirstUserDataRepository.kt @@ -1,12 +1,12 @@ -package top.fatweb.oxygen.toolbox.repository +package top.fatweb.oxygen.toolbox.repository.userdata import kotlinx.coroutines.flow.Flow -import top.fatweb.oxygen.toolbox.datastore.OxygenPreferencesDataSource -import top.fatweb.oxygen.toolbox.model.DarkThemeConfig -import top.fatweb.oxygen.toolbox.model.LanguageConfig -import top.fatweb.oxygen.toolbox.model.LaunchPageConfig -import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig -import top.fatweb.oxygen.toolbox.model.UserData +import top.fatweb.oxygen.toolbox.datastore.userdata.OxygenPreferencesDataSource +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 internal class OfflineFirstUserDataRepository @Inject constructor( diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/UserDataRepository.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/userdata/UserDataRepository.kt similarity index 54% rename from app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/UserDataRepository.kt rename to app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/userdata/UserDataRepository.kt index 1a418ca..22ac7aa 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/UserDataRepository.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/repository/userdata/UserDataRepository.kt @@ -1,11 +1,11 @@ -package top.fatweb.oxygen.toolbox.repository +package top.fatweb.oxygen.toolbox.repository.userdata import kotlinx.coroutines.flow.Flow -import top.fatweb.oxygen.toolbox.model.DarkThemeConfig -import top.fatweb.oxygen.toolbox.model.LanguageConfig -import top.fatweb.oxygen.toolbox.model.LaunchPageConfig -import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig -import top.fatweb.oxygen.toolbox.model.UserData +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 interface UserDataRepository { val userData: Flow diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenApp.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenApp.kt index dfdf3f1..64c3c3c 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenApp.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenApp.kt @@ -36,7 +36,7 @@ import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import top.fatweb.oxygen.toolbox.R 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.STAR_ROUTE import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE @@ -181,13 +181,13 @@ private fun OxygenBottomBar( icon = { Icon( imageVector = destination.unselectedIcon, - contentDescription = null + contentDescription = stringResource(destination.iconTextId) ) }, selectedIcon = { Icon( imageVector = destination.selectedIcon, - contentDescription = null + contentDescription = stringResource(destination.iconTextId) ) }, onClick = { onNavigateToDestination(destination) } @@ -215,13 +215,13 @@ private fun OxygenNavRail( icon = { Icon( imageVector = destination.unselectedIcon, - contentDescription = null + contentDescription = stringResource(destination.iconTextId) ) }, selectedIcon = { Icon( imageVector = destination.selectedIcon, - contentDescription = null + contentDescription = stringResource(destination.iconTextId) ) }, onClick = { onNavigateToDestination(destination) } diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenAppState.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenAppState.kt index afae84d..a664a04 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenAppState.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/OxygenAppState.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn 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.TimeZoneMonitor import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/Navigation.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/Navigation.kt index ccc6e1a..6a6437b 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/Navigation.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/Navigation.kt @@ -130,13 +130,13 @@ fun OxygenNavigationBarPreview() { icon = { Icon( imageVector = item.unselectedIcon, - contentDescription = stringResource(item.titleTextId) + contentDescription = stringResource(item.iconTextId) ) }, selectedIcon = { Icon( imageVector = item.selectedIcon, contentDescription = stringResource( - item.titleTextId + item.iconTextId ) ) }, @@ -161,13 +161,13 @@ fun OxygenNavigationRailPreview() { icon = { Icon( imageVector = item.unselectedIcon, - contentDescription = stringResource(item.titleTextId) + contentDescription = stringResource(item.iconTextId) ) }, selectedIcon = { Icon( imageVector = item.selectedIcon, contentDescription = stringResource( - item.titleTextId + item.iconTextId ) ) }, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolGroupCard.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolGroupCard.kt new file mode 100644 index 0000000..03ae22e --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/ToolGroupCard.kt @@ -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 +) { + 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() + }) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/AppScrollbars.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/AppScrollbars.kt new file mode 100644 index 0000000..23121e7 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/AppScrollbars.kt @@ -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() { + 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 { + 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, +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/LazyScrollbarUtils.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/LazyScrollbarUtils.kt new file mode 100644 index 0000000..daf48b5 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/LazyScrollbarUtils.kt @@ -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.interpolateFirstItemIndex( + visibleItems: List, + 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 +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/Scrollbar.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/Scrollbar.kt new file mode 100644 index 0000000..3c8d01e --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/Scrollbar.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ScrollbarExt.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ScrollbarExt.kt new file mode 100644 index 0000000..be760ca --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ScrollbarExt.kt @@ -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 List.floatSumOf(selector: (T) -> Float): Float = + fold(initial = 0f) { accumulator, listItem -> accumulator + selector(listItem) } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ThumbExt.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ThumbExt.kt new file mode 100644 index 0000000..54f474f --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/component/scrollbar/ThumbExt.kt @@ -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 } + } +} diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsDialog.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsDialog.kt index ed884dc..92c9f54 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsDialog.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsDialog.kt @@ -28,14 +28,14 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import top.fatweb.oxygen.toolbox.R -import top.fatweb.oxygen.toolbox.model.DarkThemeConfig -import top.fatweb.oxygen.toolbox.model.LanguageConfig -import top.fatweb.oxygen.toolbox.model.LaunchPageConfig -import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig +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 top.fatweb.oxygen.toolbox.ui.component.ThemePreviews import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme import top.fatweb.oxygen.toolbox.ui.theme.supportsDynamicTheming @@ -77,7 +77,6 @@ fun SettingsDialog( modifier = modifier .widthIn(max = configuration.screenWidthDp.dp - 80.dp) .heightIn(max = configuration.screenHeightDp.dp - 40.dp), - properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = onDismiss, title = { Text( @@ -128,7 +127,7 @@ fun SettingsDialog( @Composable private fun ColumnScope.SettingsPanel( - settings: UserEditableSettings, + settings: UserData, supportDynamicColor: Boolean, onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit, onChangeLaunchPageConfig: (launchPageConfig: LaunchPageConfig) -> Unit, @@ -283,7 +282,7 @@ private fun SettingDialogPreview() { SettingsDialog( onDismiss = {}, settingsUiState = SettingsUiState.Success( - UserEditableSettings( + UserData( languageConfig = LanguageConfig.FOLLOW_SYSTEM, launchPageConfig = LaunchPageConfig.TOOLS, themeBrandConfig = ThemeBrandConfig.DEFAULT, diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsViewModel.kt index 88219d0..4074aa6 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/settings/SettingsViewModel.kt @@ -8,11 +8,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import top.fatweb.oxygen.toolbox.model.DarkThemeConfig -import top.fatweb.oxygen.toolbox.model.LanguageConfig -import top.fatweb.oxygen.toolbox.model.LaunchPageConfig -import top.fatweb.oxygen.toolbox.model.ThemeBrandConfig -import top.fatweb.oxygen.toolbox.repository.UserDataRepository +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 top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -22,16 +23,8 @@ class SettingsViewModel @Inject constructor( ) : ViewModel() { val settingsUiState: StateFlow = userDataRepository.userData - .map { userData -> - SettingsUiState.Success( - settings = UserEditableSettings( - languageConfig = userData.languageConfig, - launchPageConfig = userData.launchPageConfig, - themeBrandConfig = userData.themeBrandConfig, - darkThemeConfig = userData.darkThemeConfig, - useDynamicColor = userData.useDynamicColor - ) - ) + .map { + SettingsUiState.Success(it) } .stateIn( 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 { data object Loading : SettingsUiState - data class Success(val settings: UserEditableSettings) : SettingsUiState + data class Success(val settings: UserData) : SettingsUiState } \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsPanel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsPanel.kt new file mode 100644 index 0000000..0fe9596 --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsPanel.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt new file mode 100644 index 0000000..bb8005f --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreen.kt @@ -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()) } + + 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() + }) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreenViewModel.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreenViewModel.kt new file mode 100644 index 0000000..75a8f1a --- /dev/null +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tool/ToolsScreenViewModel.kt @@ -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 = + 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) : ToolsScreenUiState +} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt deleted file mode 100644 index 7716e18..0000000 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/tools/ToolsScreen.kt +++ /dev/null @@ -1,8 +0,0 @@ -package top.fatweb.oxygen.toolbox.ui.tools - -import androidx.compose.runtime.Composable - -@Composable -internal fun ToolsScreen() { - -} \ No newline at end of file diff --git a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/util/LocaleUtils.kt b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/util/LocaleUtils.kt index 50b5982..7f51b79 100644 --- a/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/util/LocaleUtils.kt +++ b/app/src/main/kotlin/top/fatweb/oxygen/toolbox/ui/util/LocaleUtils.kt @@ -5,7 +5,7 @@ import android.content.Context import android.os.Build import android.os.LocaleList import androidx.annotation.RequiresApi -import top.fatweb.oxygen.toolbox.model.LanguageConfig +import top.fatweb.oxygen.toolbox.model.userdata.LanguageConfig import java.util.Locale object LocaleUtils {