Compare commits
81 Commits
master
...
3b4b6b4e8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
3b4b6b4e8e
|
|||
|
253d186fdf
|
|||
|
cead8fe91e
|
|||
|
c602ce0726
|
|||
|
4dfb500370
|
|||
|
93b22ea14b
|
|||
|
c9363ee34b
|
|||
|
e9232631de
|
|||
|
402965503f
|
|||
|
7653028241
|
|||
|
f0ef28bd19
|
|||
|
e0485ecc32
|
|||
|
893131fe02
|
|||
|
0e5b6d7f98
|
|||
|
310214fc20
|
|||
|
18c03c194b
|
|||
|
b115d3d598
|
|||
|
da4c58b631
|
|||
|
086b588445
|
|||
|
3f325126f1
|
|||
|
1b350eb22a
|
|||
|
167df010a9
|
|||
|
47647217f1
|
|||
|
5839f1d394
|
|||
|
21264d8ff7
|
|||
|
0e592c1600
|
|||
|
2f0c4b6a97
|
|||
|
4ba02420ed
|
|||
|
3a6fb2a6f0
|
|||
|
0c6baba099
|
|||
|
35a421472d
|
|||
|
6732eb6a22
|
|||
|
f5cfbd7296
|
|||
|
627a32c7a3
|
|||
|
5246715d78
|
|||
|
60ffc569a5
|
|||
|
a3800bfad6
|
|||
|
454108d871
|
|||
|
cb6fe19033
|
|||
|
3d69514a39
|
|||
|
96159e5b80
|
|||
|
9b3b391fef
|
|||
|
3286ed2934
|
|||
|
c9c0debb2b
|
|||
|
c1879dfdc8
|
|||
|
640686296e
|
|||
|
a3b1241fca
|
|||
|
596ad2ccbe
|
|||
|
1607897fc9
|
|||
|
9d6094173d
|
|||
|
5efbf660c6
|
|||
|
8fedafd261
|
|||
|
1d4f317bb5
|
|||
|
a85e561863
|
|||
|
d84427b039
|
|||
|
3338522d40
|
|||
|
3a91e834b7
|
|||
|
3d8bc944e3
|
|||
|
c596767c37
|
|||
|
b2cbea5383
|
|||
|
2e0efd1cb9
|
|||
|
61d229b100
|
|||
|
32d19ae291
|
|||
|
c8f072c930
|
|||
|
4d047247f1
|
|||
|
b11ae055c3
|
|||
|
b2e7ecc92c
|
|||
|
23893a4ac1
|
|||
|
54a7625c1b
|
|||
|
8ed9d6942a
|
|||
|
92cd20f36f
|
|||
|
8b200d14c6
|
|||
|
58117cc6f6
|
|||
|
e777f832e6
|
|||
|
0e24b46525
|
|||
|
cba2e83074
|
|||
|
cda1f455b9
|
|||
|
96cc7c221f
|
|||
|
f81f26a5cb
|
|||
|
4cc1c0f68b
|
|||
|
1bd81cdf6c
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,3 +43,5 @@ _sandbox
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
.kotlin/
|
||||
|
||||
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/build
|
||||
/src/main/res/raw/dependencies.json
|
||||
/release/
|
||||
|
||||
@@ -4,10 +4,15 @@ import com.mikepenz.aboutlibraries.plugin.AboutLibrariesTask
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.protobuf)
|
||||
alias(libs.plugins.kotlinxSerialization)
|
||||
alias(libs.plugins.secrets)
|
||||
alias(libs.plugins.room)
|
||||
alias(libs.plugins.parcelize)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -25,9 +30,6 @@ android {
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// Required when setting minSdkVersion to 20 or lower
|
||||
multiDexEnabled = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -60,9 +62,10 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.10"
|
||||
kotlinCompilerExtensionVersion = "1.5.12"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -101,11 +104,7 @@ aboutLibraries {
|
||||
registerAndroidTasks = false
|
||||
configPath = "libConfig"
|
||||
outputFileName = "dependencies.json"
|
||||
exclusionPatterns = listOf(
|
||||
Regex("androidx.*"),
|
||||
Regex("org.jetbrains.*"),
|
||||
Regex("com.google.guava:listenablefuture")
|
||||
).map { it.toPattern() }
|
||||
exclusionPatterns = listOf<Regex>().map { it.toPattern() }
|
||||
}
|
||||
|
||||
task("exportLibrariesToJson", AboutLibrariesTask::class) {
|
||||
@@ -116,12 +115,26 @@ task("exportLibrariesToJson", AboutLibrariesTask::class) {
|
||||
afterEvaluate {
|
||||
tasks.findByName("preBuild")?.dependsOn(tasks.findByName("exportLibrariesToJson"))
|
||||
tasks.findByName("kspDebugKotlin")?.dependsOn(tasks.findByName("generateDebugProto"))
|
||||
tasks.findByName("kspReleaseKotlin")?.dependsOn(tasks.findByName("generateReleaseProto"))
|
||||
}
|
||||
|
||||
secrets {
|
||||
defaultPropertiesFileName = "secrets.defaults.properties"
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.generateKotlin", "true")
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.paging.common)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
@@ -160,4 +173,17 @@ dependencies {
|
||||
implementation(libs.protobuf.kotlin.lite)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.core)
|
||||
implementation(libs.retrofit.kotlin.serialization)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.paging.runtime)
|
||||
implementation(libs.paging.compose)
|
||||
implementation(libs.androidsvg.aar)
|
||||
implementation(libs.compose.webview)
|
||||
ksp(libs.room.compiler)
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.compose.shimmer)
|
||||
}
|
||||
13
app/proguard-rules.pro
vendored
13
app/proguard-rules.pro
vendored
@@ -12,6 +12,16 @@
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Keep DataStore fields
|
||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite* {
|
||||
<fields>;
|
||||
}
|
||||
|
||||
# Keep SerialName annotation
|
||||
-keepclassmembers class * {
|
||||
@kotlinx.serialization.SerialName <fields>;
|
||||
}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
@@ -19,3 +29,6 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-dontwarn kotlinx.serialization.KSerializer
|
||||
-dontwarn kotlinx.serialization.Serializable
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "34c5a37d790e5542a93e0dc27bb3f4f1",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "tools",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `toolId` TEXT NOT NULL, `icon` TEXT NOT NULL, `platform` TEXT NOT NULL, `description` TEXT, `base` TEXT, `authorUsername` TEXT NOT NULL, `authorNickname` TEXT NOT NULL, `authorAvatar` TEXT NOT NULL, `ver` TEXT NOT NULL, `keywords` TEXT NOT NULL, `categories` TEXT NOT NULL, `source` TEXT, `dist` TEXT, `entryPoint` TEXT NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `isStar` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `upgrade` TEXT DEFAULT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolId",
|
||||
"columnName": "toolId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "platform",
|
||||
"columnName": "platform",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "base",
|
||||
"columnName": "base",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorUsername",
|
||||
"columnName": "authorUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorNickname",
|
||||
"columnName": "authorNickname",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorAvatar",
|
||||
"columnName": "authorAvatar",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ver",
|
||||
"columnName": "ver",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keywords",
|
||||
"columnName": "keywords",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "categories",
|
||||
"columnName": "categories",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "dist",
|
||||
"columnName": "dist",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "entryPoint",
|
||||
"columnName": "entryPoint",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createTime",
|
||||
"columnName": "createTime",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updateTime",
|
||||
"columnName": "updateTime",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStar",
|
||||
"columnName": "isStar",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isInstalled",
|
||||
"columnName": "isInstalled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upgrade",
|
||||
"columnName": "upgrade",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false,
|
||||
"defaultValue": "NULL"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '34c5a37d790e5542a93e0dc27bb3f4f1')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".OxygenApplication"
|
||||
@@ -27,6 +28,13 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="oxygen" android:host="opentool"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
18
app/src/main/assets/template/tool-view.html
Normal file
18
app/src/main/assets/template/tool-view.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Preview</title>
|
||||
<!-- es-module-shims -->
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" id="appSrc">{{replace_code}}</script>
|
||||
<div id="root">
|
||||
<div
|
||||
style="position:absolute;top: 0;left:0;width:100%;height:100%;display: flex;justify-content: center;align-items: center;">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,6 @@ package top.fatweb.oxygen.toolbox
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -32,13 +31,15 @@ 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.navigation.PREVIEW_ARG
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToToolView
|
||||
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
|
||||
@@ -46,8 +47,6 @@ import top.fatweb.oxygen.toolbox.ui.util.LocalTimeZone
|
||||
import top.fatweb.oxygen.toolbox.ui.util.LocaleUtils
|
||||
import javax.inject.Inject
|
||||
|
||||
const val TAG = "MainActivity"
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -93,12 +92,10 @@ class MainActivity : ComponentActivity() {
|
||||
LaunchedEffect(darkTheme) {
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
lightScrim = android.graphics.Color.TRANSPARENT,
|
||||
darkScrim = android.graphics.Color.TRANSPARENT
|
||||
) { darkTheme },
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
lightScrim, darkScrim
|
||||
) { darkTheme }
|
||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,9 +108,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalTimeZone provides currentTimeZone
|
||||
) {
|
||||
CompositionLocalProvider(LocalTimeZone provides currentTimeZone) {
|
||||
OxygenTheme(
|
||||
darkTheme = darkTheme,
|
||||
androidTheme = shouldUseAndroidTheme(uiState),
|
||||
@@ -122,10 +117,21 @@ class MainActivity : ComponentActivity() {
|
||||
OxygenApp(appState)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "onCreate: C")
|
||||
}
|
||||
|
||||
Log.d(TAG, "onCreate: D")
|
||||
LaunchedEffect(intent.data) {
|
||||
intent.data?.run {
|
||||
val pathSegments = pathSegments
|
||||
val preview = getBooleanQueryParameter(PREVIEW_ARG, false)
|
||||
if (pathSegments.size == 2) {
|
||||
appState.navController.navigateToToolView(
|
||||
username = pathSegments[0],
|
||||
toolId = pathSegments[1],
|
||||
preview = preview
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@@ -137,67 +143,57 @@ class MainActivity : ComponentActivity() {
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
val userDataRepository =
|
||||
EntryPointAccessors.fromApplication<UserDataRepositoryEntryPoint>(newBase).userDataRepository
|
||||
super.attachBaseContext(LocaleUtils.attachBaseContext(newBase, runBlocking {
|
||||
super.attachBaseContext(
|
||||
LocaleUtils.attachBaseContext(
|
||||
context = newBase,
|
||||
languageConfig = runBlocking {
|
||||
userDataRepository.userData.first().languageConfig
|
||||
}))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun shouldUseDarkTheme(
|
||||
uiState: MainActivityUiState
|
||||
): Boolean = when (uiState) {
|
||||
private fun shouldUseDarkTheme(uiState: MainActivityUiState): Boolean =
|
||||
when (uiState) {
|
||||
MainActivityUiState.Loading -> isSystemInDarkTheme()
|
||||
is MainActivityUiState.Success -> when (uiState.userData.darkThemeConfig) {
|
||||
DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
||||
DarkThemeConfig.LIGHT -> false
|
||||
DarkThemeConfig.DARK -> true
|
||||
is MainActivityUiState.Success ->
|
||||
when (uiState.userData.darkThemeConfig) {
|
||||
DarkThemeConfig.FollowSystem -> isSystemInDarkTheme()
|
||||
DarkThemeConfig.Light -> false
|
||||
DarkThemeConfig.Dark -> true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun shouldUseAndroidTheme(
|
||||
uiState: MainActivityUiState
|
||||
): Boolean = when (uiState) {
|
||||
private fun shouldUseAndroidTheme(uiState: MainActivityUiState): Boolean =
|
||||
when (uiState) {
|
||||
MainActivityUiState.Loading -> false
|
||||
is MainActivityUiState.Success -> when (uiState.userData.themeBrandConfig) {
|
||||
ThemeBrandConfig.DEFAULT -> false
|
||||
ThemeBrandConfig.ANDROID -> true
|
||||
is MainActivityUiState.Success ->
|
||||
when (uiState.userData.themeBrandConfig) {
|
||||
ThemeBrandConfig.Default -> false
|
||||
ThemeBrandConfig.Android -> true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun shouldUseDynamicColor(
|
||||
uiState: MainActivityUiState
|
||||
): Boolean = when (uiState) {
|
||||
private fun shouldUseDynamicColor(uiState: MainActivityUiState): Boolean =
|
||||
when (uiState) {
|
||||
MainActivityUiState.Loading -> true
|
||||
is MainActivityUiState.Success -> uiState.userData.useDynamicColor
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun whatLocale(
|
||||
uiState: MainActivityUiState
|
||||
): LanguageConfig = when (uiState) {
|
||||
MainActivityUiState.Loading -> LanguageConfig.FOLLOW_SYSTEM
|
||||
private fun whatLocale(uiState: MainActivityUiState): LanguageConfig =
|
||||
when (uiState) {
|
||||
MainActivityUiState.Loading -> LanguageConfig.FollowSystem
|
||||
is MainActivityUiState.Success -> uiState.userData.languageConfig
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun whatLaunchPage(
|
||||
uiState: MainActivityUiState
|
||||
): LaunchPageConfig = when (uiState) {
|
||||
MainActivityUiState.Loading -> LaunchPageConfig.TOOLS
|
||||
private fun whatLaunchPage(uiState: MainActivityUiState): LaunchPageConfig =
|
||||
when (uiState) {
|
||||
MainActivityUiState.Loading -> LaunchPageConfig.Tools
|
||||
is MainActivityUiState.Success -> uiState.userData.launchPageConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* The default light scrim, as defined by androidx and the platform:
|
||||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598
|
||||
*/
|
||||
private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
|
||||
|
||||
/**
|
||||
* The default dark scrim, as defined by androidx and the platform:
|
||||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598
|
||||
*/
|
||||
private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ class MainActivityViewModel @Inject constructor(
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
initialValue = MainActivityUiState.Loading,
|
||||
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,19 @@ package top.fatweb.oxygen.toolbox
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import top.fatweb.oxygen.toolbox.repository.UserDataRepository
|
||||
import timber.log.Timber
|
||||
import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
|
||||
import top.fatweb.oxygen.toolbox.util.OxygenLogTree
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class OxygenApplication : Application() {
|
||||
@Inject
|
||||
lateinit var userDataRepository: UserDataRepository
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Timber.plant(if (BuildConfig.DEBUG) Timber.DebugTree() else OxygenLogTree(this))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package top.fatweb.oxygen.toolbox.data.lib
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.fatweb.oxygen.toolbox.R
|
||||
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
|
||||
import top.fatweb.oxygen.toolbox.network.Dispatcher
|
||||
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
|
||||
import javax.inject.Inject
|
||||
|
||||
class DepDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@Dispatcher(OxygenDispatchers.IO) private val ioDispatcher: CoroutineDispatcher
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
val dependencies = flow {
|
||||
val inputStream = context.resources.openRawResource(R.raw.dependencies)
|
||||
val jsonString = inputStream.bufferedReader().use { it.readText() }
|
||||
val dependencies = json.decodeFromString<Dependencies>(jsonString)
|
||||
emit(dependencies)
|
||||
}.flowOn(ioDispatcher)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package top.fatweb.oxygen.toolbox.data.network
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.fatweb.oxygen.toolbox.model.Result
|
||||
import top.fatweb.oxygen.toolbox.network.model.PageVo
|
||||
import top.fatweb.oxygen.toolbox.network.model.ResponseResult
|
||||
import top.fatweb.oxygen.toolbox.network.model.ToolBaseVo
|
||||
import top.fatweb.oxygen.toolbox.network.model.ToolVo
|
||||
|
||||
interface OxygenNetworkDataSource {
|
||||
suspend fun getStore(
|
||||
searchValue: String = "",
|
||||
currentPage: Int = 1
|
||||
): ResponseResult<PageVo<ToolVo>>
|
||||
|
||||
fun detail(
|
||||
username: String,
|
||||
toolId: String,
|
||||
ver: String = "latest",
|
||||
platform: ToolBaseVo.Platform = ToolBaseVo.Platform.Android
|
||||
): Flow<Result<ToolVo>>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package top.fatweb.oxygen.toolbox.data.tool
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import top.fatweb.oxygen.toolbox.network.Dispatcher
|
||||
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
|
||||
import javax.inject.Inject
|
||||
|
||||
class ToolDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@Dispatcher(OxygenDispatchers.IO) private val ioDispatcher: CoroutineDispatcher
|
||||
) {
|
||||
val toolViewTemplate = flow {
|
||||
emit(
|
||||
context.assets.open("template/tool-view.html")
|
||||
.bufferedReader()
|
||||
.use {
|
||||
it.readText()
|
||||
}
|
||||
)
|
||||
}.flowOn(ioDispatcher)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package top.fatweb.oxygen.toolbox.data.tool
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
|
||||
@Database(
|
||||
entities = [ToolEntity::class],
|
||||
version = 1,
|
||||
autoMigrations = [],
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class ToolDatabase : RoomDatabase() {
|
||||
abstract fun toolDao(): ToolDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: ToolDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): ToolDatabase =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
Room.databaseBuilder(
|
||||
context = context,
|
||||
klass = ToolDatabase::class.java,
|
||||
name = "tools.db"
|
||||
)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package top.fatweb.oxygen.toolbox.data.tool.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
|
||||
@Dao
|
||||
interface ToolDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertTool(tool: ToolEntity)
|
||||
|
||||
@Update
|
||||
suspend fun updateTool(tool: ToolEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteTool(tool: ToolEntity)
|
||||
|
||||
@Query("SELECT * FROM tools " +
|
||||
"WHERE id = :id")
|
||||
fun selectToolById(id: Long): Flow<ToolEntity?>
|
||||
|
||||
@Query("SELECT * FROM tools " +
|
||||
"WHERE :searchValue = '' " +
|
||||
"OR name LIKE '%' || :searchValue || '%' COLLATE NOCASE " +
|
||||
"OR keywords LIKE '%\"%' || :searchValue || '%\"%' COLLATE NOCASE " +
|
||||
"ORDER BY updateTime DESC")
|
||||
fun selectAllTools(searchValue: String): Flow<List<ToolEntity>>
|
||||
|
||||
@Query("SELECT * FROM tools " +
|
||||
"WHERE isStar = 1 " +
|
||||
"AND (:searchValue = '' " +
|
||||
"OR name LIKE '%' || :searchValue || '%' COLLATE NOCASE " +
|
||||
"OR keywords LIKE '%\"%' || :searchValue || '%\"%' COLLATE NOCASE" +
|
||||
") " +
|
||||
"ORDER BY updateTime DESC")
|
||||
fun selectStarTools(searchValue: String): Flow<List<ToolEntity>>
|
||||
|
||||
@Query("SELECT * FROM tools " +
|
||||
"WHERE authorUsername = :username " +
|
||||
"and toolId = :toolId LIMIT 1")
|
||||
fun selectToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?>
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package top.fatweb.oxygen.toolbox.datastore
|
||||
package top.fatweb.oxygen.toolbox.data.userdata
|
||||
|
||||
import androidx.datastore.core.DataMigration
|
||||
import top.fatweb.oxygen.toolbox.data.UserPreferences
|
||||
import top.fatweb.oxygen.toolbox.data.copy
|
||||
|
||||
internal object IntToStringIdsMigration : DataMigration<UserPreferences> {
|
||||
override suspend fun cleanUp() = Unit
|
||||
@@ -1,12 +1,18 @@
|
||||
package top.fatweb.oxygen.toolbox.datastore
|
||||
package top.fatweb.oxygen.toolbox.data.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.data.DarkThemeConfigProto
|
||||
import top.fatweb.oxygen.toolbox.data.LanguageConfigProto
|
||||
import top.fatweb.oxygen.toolbox.data.LaunchPageConfigProto
|
||||
import top.fatweb.oxygen.toolbox.data.ThemeBrandConfigProto
|
||||
import top.fatweb.oxygen.toolbox.data.UserPreferences
|
||||
import top.fatweb.oxygen.toolbox.data.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(
|
||||
@@ -20,23 +26,23 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
LanguageConfigProto.UNRECOGNIZED,
|
||||
LanguageConfigProto.LANGUAGE_CONFIG_UNSPECIFIED,
|
||||
LanguageConfigProto.LANGUAGE_CONFIG_FOLLOW_SYSTEM
|
||||
-> LanguageConfig.FOLLOW_SYSTEM
|
||||
-> LanguageConfig.FollowSystem
|
||||
|
||||
LanguageConfigProto.LANGUAGE_CONFIG_CHINESE
|
||||
-> LanguageConfig.CHINESE
|
||||
-> LanguageConfig.Chinese
|
||||
|
||||
LanguageConfigProto.LANGUAGE_CONFIG_ENGLISH
|
||||
-> LanguageConfig.ENGLISH
|
||||
-> LanguageConfig.English
|
||||
},
|
||||
launchPageConfig = when (it.launchPageConfig) {
|
||||
null,
|
||||
LaunchPageConfigProto.UNRECOGNIZED,
|
||||
LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_UNSPECIFIED,
|
||||
LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_TOOLS
|
||||
-> LaunchPageConfig.TOOLS
|
||||
-> LaunchPageConfig.Tools
|
||||
|
||||
LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_STAR
|
||||
-> LaunchPageConfig.STAR
|
||||
-> LaunchPageConfig.Star
|
||||
},
|
||||
themeBrandConfig = when (it.themeBrandConfig) {
|
||||
null,
|
||||
@@ -44,10 +50,10 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
ThemeBrandConfigProto.THEME_BRAND_CONFIG_UNSPECIFIED,
|
||||
ThemeBrandConfigProto.THEME_BRAND_CONFIG_DEFAULT
|
||||
->
|
||||
ThemeBrandConfig.DEFAULT
|
||||
ThemeBrandConfig.Default
|
||||
|
||||
ThemeBrandConfigProto.THEME_BRAND_CONFIG_ANDROID
|
||||
-> ThemeBrandConfig.ANDROID
|
||||
-> ThemeBrandConfig.Android
|
||||
},
|
||||
darkThemeConfig = when (it.darkThemeConfig) {
|
||||
null,
|
||||
@@ -55,13 +61,13 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED,
|
||||
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
|
||||
->
|
||||
DarkThemeConfig.FOLLOW_SYSTEM
|
||||
DarkThemeConfig.FollowSystem
|
||||
|
||||
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
|
||||
-> DarkThemeConfig.LIGHT
|
||||
-> DarkThemeConfig.Light
|
||||
|
||||
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
|
||||
-> DarkThemeConfig.DARK
|
||||
-> DarkThemeConfig.Dark
|
||||
},
|
||||
useDynamicColor = it.useDynamicColor
|
||||
)
|
||||
@@ -71,9 +77,9 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
userPreferences.updateData {
|
||||
it.copy {
|
||||
this.languageConfig = when (languageConfig) {
|
||||
LanguageConfig.FOLLOW_SYSTEM -> LanguageConfigProto.LANGUAGE_CONFIG_FOLLOW_SYSTEM
|
||||
LanguageConfig.CHINESE -> LanguageConfigProto.LANGUAGE_CONFIG_CHINESE
|
||||
LanguageConfig.ENGLISH -> LanguageConfigProto.LANGUAGE_CONFIG_ENGLISH
|
||||
LanguageConfig.FollowSystem -> LanguageConfigProto.LANGUAGE_CONFIG_FOLLOW_SYSTEM
|
||||
LanguageConfig.Chinese -> LanguageConfigProto.LANGUAGE_CONFIG_CHINESE
|
||||
LanguageConfig.English -> LanguageConfigProto.LANGUAGE_CONFIG_ENGLISH
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,8 +89,8 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
userPreferences.updateData {
|
||||
it.copy {
|
||||
this.launchPageConfig = when (launchPageConfig) {
|
||||
LaunchPageConfig.TOOLS -> LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_TOOLS
|
||||
LaunchPageConfig.STAR -> LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_STAR
|
||||
LaunchPageConfig.Tools -> LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_TOOLS
|
||||
LaunchPageConfig.Star -> LaunchPageConfigProto.LAUNCH_PAGE_CONFIG_STAR
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,8 +100,8 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
userPreferences.updateData {
|
||||
it.copy {
|
||||
this.themeBrandConfig = when (themeBrandConfig) {
|
||||
ThemeBrandConfig.DEFAULT -> ThemeBrandConfigProto.THEME_BRAND_CONFIG_DEFAULT
|
||||
ThemeBrandConfig.ANDROID -> ThemeBrandConfigProto.THEME_BRAND_CONFIG_ANDROID
|
||||
ThemeBrandConfig.Default -> ThemeBrandConfigProto.THEME_BRAND_CONFIG_DEFAULT
|
||||
ThemeBrandConfig.Android -> ThemeBrandConfigProto.THEME_BRAND_CONFIG_ANDROID
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,9 +111,9 @@ class OxygenPreferencesDataSource @Inject constructor(
|
||||
userPreferences.updateData {
|
||||
it.copy {
|
||||
this.darkThemeConfig = when (darkThemeConfig) {
|
||||
DarkThemeConfig.FOLLOW_SYSTEM -> DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
|
||||
DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
|
||||
DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
|
||||
DarkThemeConfig.FollowSystem -> DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
|
||||
DarkThemeConfig.Light -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
|
||||
DarkThemeConfig.Dark -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package top.fatweb.oxygen.toolbox.datastore
|
||||
package top.fatweb.oxygen.toolbox.data.userdata
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import top.fatweb.oxygen.toolbox.data.UserPreferences
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
@@ -14,7 +15,10 @@ class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferenc
|
||||
try {
|
||||
UserPreferences.parseFrom(input)
|
||||
} catch (exception: InvalidProtocolBufferException) {
|
||||
throw CorruptionException("Cannot read proto.", exception)
|
||||
throw CorruptionException(
|
||||
message = "Cannot read proto.",
|
||||
cause = exception
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
|
||||
@@ -23,6 +23,8 @@ internal object CoroutineScopesModule {
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun providesCoroutineScope(
|
||||
@Dispatcher(OxygenDispatchers.Default) dispatcher: CoroutineDispatcher
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
@Dispatcher(OxygenDispatchers.Default)
|
||||
dispatcher: CoroutineDispatcher
|
||||
): CoroutineScope =
|
||||
CoroutineScope(SupervisorJob() + dispatcher)
|
||||
}
|
||||
@@ -8,18 +8,33 @@ 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.lib.DepRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.lib.impl.LocalDepRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.tool.impl.NetworkStoreRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.tool.impl.OfflineToolRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
|
||||
import top.fatweb.oxygen.toolbox.repository.userdata.impl.LocalUserDataRepository
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
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: LocalUserDataRepository): UserDataRepository
|
||||
|
||||
@Binds
|
||||
internal abstract fun bindsDepRepository(depRepository: LocalDepRepository): DepRepository
|
||||
|
||||
@Binds
|
||||
internal abstract fun bindsStoreRepository(storeRepository: NetworkStoreRepository): StoreRepository
|
||||
|
||||
@Binds
|
||||
internal abstract fun bindsToolRepository(toolRepository: OfflineToolRepository): ToolRepository
|
||||
}
|
||||
@@ -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.data.UserPreferences
|
||||
import top.fatweb.oxygen.toolbox.data.userdata.IntToStringIdsMigration
|
||||
import top.fatweb.oxygen.toolbox.data.userdata.UserPreferencesSerializer
|
||||
import top.fatweb.oxygen.toolbox.network.Dispatcher
|
||||
import top.fatweb.oxygen.toolbox.network.OxygenDispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package top.fatweb.oxygen.toolbox.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import top.fatweb.oxygen.toolbox.data.tool.ToolDatabase
|
||||
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
@Provides
|
||||
fun provideToolDao(@ApplicationContext context: Context): ToolDao =
|
||||
ToolDatabase.getInstance(context).toolDao()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package top.fatweb.oxygen.toolbox.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import top.fatweb.oxygen.toolbox.BuildConfig
|
||||
import top.fatweb.oxygen.toolbox.data.network.OxygenNetworkDataSource
|
||||
import top.fatweb.oxygen.toolbox.network.retrofit.RetrofitOxygenNetwork
|
||||
import top.fatweb.oxygen.toolbox.util.HttpLogger
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesNetworkJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpCallFactory(): Call.Factory =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor(HttpLogger())
|
||||
.apply {
|
||||
level =
|
||||
if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesOxygenNetworkDataSource(
|
||||
networkJson: Json,
|
||||
okhttpCallFactory: dagger.Lazy<Call.Factory>
|
||||
): OxygenNetworkDataSource =
|
||||
RetrofitOxygenNetwork(networkJson, okhttpCallFactory)
|
||||
}
|
||||
@@ -1,20 +1,73 @@
|
||||
package top.fatweb.oxygen.toolbox.icon
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.PictureDrawable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Reorder
|
||||
import androidx.compose.material.icons.filled.Upgrade
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.StarBorder
|
||||
import androidx.compose.material.icons.outlined.Store
|
||||
import androidx.compose.material.icons.rounded.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.rounded.CheckCircle
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.Store
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.caverock.androidsvg.SVG
|
||||
import top.fatweb.oxygen.toolbox.util.decodeToByteArray
|
||||
import top.fatweb.oxygen.toolbox.util.decodeToString
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
object OxygenIcons {
|
||||
val ArrowDown = Icons.Rounded.KeyboardArrowDown
|
||||
val Back = Icons.Rounded.ArrowBackIosNew
|
||||
val Box = Icons.Default.Inbox
|
||||
val Close = Icons.Default.Close
|
||||
val Code = Icons.Default.Code
|
||||
val Delete = Icons.Default.Delete
|
||||
val Download = Icons.Default.Download
|
||||
val Error = Icons.Default.Cancel
|
||||
val Home = Icons.Rounded.Home
|
||||
val HomeBorder = Icons.Outlined.Home
|
||||
val Info = Icons.Outlined.Info
|
||||
val MoreVert = Icons.Default.MoreVert
|
||||
val Reorder = Icons.Default.Reorder
|
||||
val Search = Icons.Rounded.Search
|
||||
val Star = Icons.Rounded.Star
|
||||
val StarBorder = Icons.Outlined.StarBorder
|
||||
val Search = Icons.Rounded.Search
|
||||
val MoreVert = Icons.Default.MoreVert
|
||||
val Back = Icons.Rounded.ArrowBackIosNew
|
||||
val Store = Icons.Rounded.Store
|
||||
val StoreBorder = Icons.Outlined.Store
|
||||
val Success = Icons.Rounded.CheckCircle
|
||||
val Time = Icons.Default.AccessTime
|
||||
val Tool = Icons.Default.Build
|
||||
val Upgrade = Icons.Default.Upgrade
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun fromSvgBase64(base64String: String): ImageBitmap {
|
||||
val svg = SVG.getFromString(Base64.decodeToString(base64String))
|
||||
val drawable = PictureDrawable(svg.renderToPicture())
|
||||
return drawable.toBitmap().asImageBitmap()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun fromPngBase64(base64String: String): ImageBitmap {
|
||||
val byteArray = Base64.decodeToByteArray(base64String)
|
||||
return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size).asImageBitmap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity.Platform
|
||||
|
||||
class Converters {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@TypeConverter
|
||||
fun fromPlatform(platform: Platform): String = platform.name
|
||||
|
||||
@TypeConverter
|
||||
fun toPlatform(name: String): Platform = Platform.valueOf(name)
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(stringList: List<String>): String = json.encodeToString(stringList)
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(stringList: String): List<String> = json.decodeFromString(stringList)
|
||||
|
||||
@TypeConverter
|
||||
fun fromLocalDateTime(localDateTime: LocalDateTime): String = localDateTime.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun toLocalDateTime(string: String): LocalDateTime = LocalDateTime.parse(string)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
enum class DarkThemeConfig {
|
||||
FOLLOW_SYSTEM,
|
||||
LIGHT,
|
||||
DARK,
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
enum class LanguageConfig(val code: String? = null) {
|
||||
FOLLOW_SYSTEM,
|
||||
CHINESE("cn"),
|
||||
ENGLISH("en")
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
enum class LaunchPageConfig {
|
||||
TOOLS,
|
||||
STAR
|
||||
}
|
||||
13
app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/Page.kt
Normal file
13
app/src/main/kotlin/top/fatweb/oxygen/toolbox/model/Page.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
data class Page<T>(
|
||||
val total: Long,
|
||||
|
||||
val pages: Long,
|
||||
|
||||
val size: Long,
|
||||
|
||||
val current: Long,
|
||||
|
||||
val records: List<T>
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import top.fatweb.oxygen.toolbox.network.model.ResponseResult
|
||||
|
||||
sealed interface Result<out T> {
|
||||
data class Success<T>(val data: T) : Result<T>
|
||||
data class Fail(val message: String): Result<Nothing>
|
||||
data class Error(val exception: Throwable) : Result<Nothing>
|
||||
data object Loading : Result<Nothing>
|
||||
}
|
||||
|
||||
fun <T> Flow<ResponseResult<T>>.asResult(): Flow<Result<T>> = map<ResponseResult<T>, Result<T>> {
|
||||
if (it.success) {
|
||||
Result.Success(it.data!!)
|
||||
} else {
|
||||
Result.Fail(it.msg)
|
||||
}
|
||||
}
|
||||
.onStart { emit(Result.Loading) }
|
||||
.catch { emit(Result.Error(it)) }
|
||||
|
||||
fun <T, R> Result<T>.asExternalModel(block: (T) -> R): Result<R> =
|
||||
when (this) {
|
||||
is Result.Success -> {
|
||||
Result.Success(block(data))
|
||||
}
|
||||
|
||||
is Result.Fail -> {
|
||||
Result.Fail(message)
|
||||
}
|
||||
|
||||
is Result.Error -> {
|
||||
Result.Error(exception)
|
||||
}
|
||||
|
||||
Result.Loading -> Result.Loading
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
|
||||
enum class ThemeBrandConfig {
|
||||
DEFAULT,
|
||||
ANDROID
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Dependencies(
|
||||
val metadata: Metadata,
|
||||
|
||||
val libraries: List<Library>,
|
||||
|
||||
val licenses: Map<String, License>
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Developer(
|
||||
val name: String? = null,
|
||||
|
||||
val organisationUrl: String? = null
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Funding(
|
||||
val platform: String,
|
||||
|
||||
val url: String
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Library(
|
||||
val uniqueId: String,
|
||||
|
||||
val artifactVersion: String? = null,
|
||||
|
||||
val name: String? = null,
|
||||
|
||||
val description: String? = null,
|
||||
|
||||
val website: String? = null,
|
||||
|
||||
val developers: List<Developer>,
|
||||
|
||||
val organization: Organization? = null,
|
||||
|
||||
val scm: Scm? = null,
|
||||
|
||||
val licenses: List<String>,
|
||||
|
||||
val funding: List<Funding>,
|
||||
|
||||
val tag: String? = null
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class License(
|
||||
val name: String,
|
||||
|
||||
val url: String? = null,
|
||||
|
||||
val year: String? = null,
|
||||
|
||||
val content: String? = null,
|
||||
|
||||
val internalHash: String? = null,
|
||||
|
||||
val hash: String,
|
||||
|
||||
val spdxId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Metadata(
|
||||
val generated: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Organization(
|
||||
val name: String,
|
||||
|
||||
val url: String? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package top.fatweb.oxygen.toolbox.model.lib
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Scm(
|
||||
val connection: String? = null,
|
||||
|
||||
val developerConnection: String? = null,
|
||||
|
||||
val url: String? = null
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
package top.fatweb.oxygen.toolbox.model.tool
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import top.fatweb.oxygen.toolbox.model.Converters
|
||||
|
||||
@Entity(tableName = "tools")
|
||||
@TypeConverters(Converters::class)
|
||||
data class ToolEntity(
|
||||
@PrimaryKey
|
||||
val id: Long,
|
||||
|
||||
val name: String,
|
||||
|
||||
val toolId: String,
|
||||
|
||||
val icon: String,
|
||||
|
||||
val platform: Platform,
|
||||
|
||||
val description: String? = null,
|
||||
|
||||
val base: String? = null,
|
||||
|
||||
val authorUsername: String,
|
||||
|
||||
val authorNickname: String,
|
||||
|
||||
val authorAvatar: String,
|
||||
|
||||
val ver: String,
|
||||
|
||||
val keywords: List<String>,
|
||||
|
||||
val categories: List<String>,
|
||||
|
||||
val source: String? = null,
|
||||
|
||||
val dist: String? = null,
|
||||
|
||||
val entryPoint: String,
|
||||
|
||||
val createTime: LocalDateTime,
|
||||
|
||||
val updateTime: LocalDateTime,
|
||||
|
||||
val isStar: Boolean = false,
|
||||
|
||||
var isInstalled: Boolean = false,
|
||||
|
||||
@ColumnInfo(defaultValue = "NULL")
|
||||
var upgrade: String? = null
|
||||
) {
|
||||
constructor(toolId: String, authorUsername: String, ver: String, upgrade: String? = null) :
|
||||
this(
|
||||
id = -1,
|
||||
name = "Unknown",
|
||||
toolId = toolId,
|
||||
icon = "",
|
||||
platform = Platform.Android,
|
||||
authorUsername = authorUsername,
|
||||
authorNickname = "Unknown",
|
||||
authorAvatar = "",
|
||||
ver = ver,
|
||||
keywords = emptyList(),
|
||||
categories = emptyList(),
|
||||
entryPoint = "",
|
||||
createTime = LocalDateTime(
|
||||
year = 1970,
|
||||
monthNumber = 1,
|
||||
dayOfMonth = 1,
|
||||
hour = 0,
|
||||
minute = 0
|
||||
),
|
||||
updateTime = LocalDateTime(
|
||||
year = 1970,
|
||||
monthNumber = 1,
|
||||
dayOfMonth = 1,
|
||||
hour = 0,
|
||||
minute = 0
|
||||
),
|
||||
upgrade = upgrade
|
||||
)
|
||||
|
||||
enum class Platform {
|
||||
Web,
|
||||
|
||||
Desktop,
|
||||
|
||||
Android
|
||||
}
|
||||
}
|
||||
@@ -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<ToolEntity> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.fatweb.oxygen.toolbox.model.userdata
|
||||
|
||||
enum class DarkThemeConfig {
|
||||
FollowSystem,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.fatweb.oxygen.toolbox.model.userdata
|
||||
|
||||
enum class LanguageConfig(val code: String? = null) {
|
||||
FollowSystem,
|
||||
Chinese("cn"),
|
||||
English("en")
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package top.fatweb.oxygen.toolbox.model.userdata
|
||||
|
||||
enum class LaunchPageConfig {
|
||||
Tools,
|
||||
Star
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package top.fatweb.oxygen.toolbox.model.userdata
|
||||
|
||||
enum class ThemeBrandConfig {
|
||||
Default,
|
||||
Android
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package top.fatweb.oxygen.toolbox.model
|
||||
package top.fatweb.oxygen.toolbox.model.userdata
|
||||
|
||||
data class UserData(
|
||||
val languageConfig: LanguageConfig,
|
||||
@@ -58,7 +58,8 @@ internal class ConnectivityManagerNetworkMonitor @Inject constructor(
|
||||
.conflate()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun ConnectivityManager.isCurrentlyConnected() = when {
|
||||
private fun ConnectivityManager.isCurrentlyConnected() =
|
||||
when {
|
||||
VERSION.SDK_INT >= VERSION_CODES.M ->
|
||||
activeNetwork
|
||||
?.let(::getNetworkCapabilities)
|
||||
|
||||
@@ -43,8 +43,8 @@ class TimeZoneBroadcastMonitor @Inject constructor(
|
||||
val zonIdFromIntent = if (Build.VERSION.SDK_INT < VERSION_CODES.R) {
|
||||
null
|
||||
} else {
|
||||
intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId ->
|
||||
val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS)
|
||||
intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.run {
|
||||
val zoneId = ZoneId.of(this, ZoneId.SHORT_IDS)
|
||||
zoneId.toKotlinTimeZone()
|
||||
}
|
||||
}
|
||||
@@ -64,5 +64,9 @@ class TimeZoneBroadcastMonitor @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.conflate()
|
||||
.flowOn(ioDispatcher)
|
||||
.shareIn(appScope, SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), 1)
|
||||
.shareIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds),
|
||||
replay = 1
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import top.fatweb.oxygen.toolbox.ui.about.AboutRoute
|
||||
|
||||
const val ABOUT_ROUTE = "about_route"
|
||||
|
||||
fun NavController.navigateToAbout(navOptions: NavOptions? = null) =
|
||||
navigate(route = ABOUT_ROUTE, navOptions = navOptions)
|
||||
|
||||
fun NavGraphBuilder.aboutScreen(
|
||||
onNavigateToLibraries: () -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = ABOUT_ROUTE,
|
||||
enterTransition = {
|
||||
slideInHorizontally { it }
|
||||
},
|
||||
popEnterTransition = null,
|
||||
popExitTransition = {
|
||||
slideOutHorizontally { it }
|
||||
}
|
||||
) {
|
||||
AboutRoute(
|
||||
onNavigateToLibraries = onNavigateToLibraries,
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import top.fatweb.oxygen.toolbox.ui.about.LibrariesRoute
|
||||
|
||||
const val LIBRARIES_ROUTE = "libraries_route"
|
||||
|
||||
fun NavController.navigateToLibraries(navOptions: NavOptions? = null) =
|
||||
navigate(route = LIBRARIES_ROUTE, navOptions = navOptions)
|
||||
|
||||
fun NavGraphBuilder.librariesScreen(
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = LIBRARIES_ROUTE,
|
||||
enterTransition = {
|
||||
slideInHorizontally { it }
|
||||
},
|
||||
popEnterTransition = null,
|
||||
popExitTransition = {
|
||||
slideOutHorizontally { it }
|
||||
}
|
||||
) {
|
||||
LibrariesRoute(onBackClick = onBackClick)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,11 @@ import top.fatweb.oxygen.toolbox.ui.OxygenAppState
|
||||
fun OxygenNavHost(
|
||||
modifier: Modifier = Modifier,
|
||||
appState: OxygenAppState,
|
||||
onShowSnackbar: suspend (String, String?) -> Boolean,
|
||||
startDestination: String
|
||||
startDestination: String,
|
||||
isVertical: Boolean,
|
||||
searchValue: String,
|
||||
searchCount: Int,
|
||||
onShowSnackbar: suspend (message: String, action: String?) -> Boolean
|
||||
) {
|
||||
val navController = appState.navController
|
||||
NavHost(
|
||||
@@ -18,14 +21,33 @@ fun OxygenNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
searchScreen(
|
||||
aboutScreen(
|
||||
onBackClick = navController::popBackStack,
|
||||
onNavigateToLibraries = navController::navigateToLibraries
|
||||
)
|
||||
librariesScreen(
|
||||
onBackClick = navController::popBackStack
|
||||
)
|
||||
toolStoreScreen(
|
||||
isVertical = isVertical,
|
||||
searchValue = searchValue,
|
||||
searchCount = searchCount,
|
||||
onNavigateToToolView = navController::navigateToToolView
|
||||
)
|
||||
toolsScreen(
|
||||
|
||||
isVertical = isVertical,
|
||||
searchValue = searchValue,
|
||||
onShowSnackbar = onShowSnackbar,
|
||||
onNavigateToToolView = navController::navigateToToolView,
|
||||
onNavigateToToolStore = { appState.navigateToTopLevelDestination(TopLevelDestination.ToolStore) }
|
||||
)
|
||||
starScreen(
|
||||
|
||||
isVertical = isVertical,
|
||||
searchValue = searchValue,
|
||||
onNavigateToToolView = navController::navigateToToolView
|
||||
)
|
||||
toolViewScreen(
|
||||
onBackClick = navController::popBackStack
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import top.fatweb.oxygen.toolbox.ui.search.SearchRoute
|
||||
|
||||
const val SEARCH_ROUTE = "search_route"
|
||||
|
||||
fun NavController.navigateToSearch(navOptions: NavOptions? = null) =
|
||||
navigate(SEARCH_ROUTE, navOptions)
|
||||
|
||||
fun NavGraphBuilder.searchScreen(
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = SEARCH_ROUTE
|
||||
) {
|
||||
SearchRoute(onBackClick = onBackClick)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,49 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import top.fatweb.oxygen.toolbox.ui.star.StarRoute
|
||||
|
||||
const val STAR_ROUTE = "star_route"
|
||||
|
||||
fun NavController.navigateToStar(navOptions: NavOptions) = navigate(STAR_ROUTE, navOptions)
|
||||
fun NavController.navigateToStar(navOptions: NavOptions) =
|
||||
navigate(route = STAR_ROUTE, navOptions = navOptions)
|
||||
|
||||
fun NavGraphBuilder.starScreen() {
|
||||
composable(
|
||||
route = STAR_ROUTE
|
||||
fun NavGraphBuilder.starScreen(
|
||||
isVertical: Boolean,
|
||||
searchValue: String,
|
||||
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = STAR_ROUTE,
|
||||
enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
TOOL_STORE_ROUTE, TOOLS_ROUTE ->
|
||||
if (isVertical) slideInHorizontally { it }
|
||||
else slideInVertically { it }
|
||||
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
exitTransition = {
|
||||
when (targetState.destination.route) {
|
||||
TOOL_STORE_ROUTE, TOOLS_ROUTE ->
|
||||
if (isVertical) slideOutHorizontally { it }
|
||||
else slideOutVertically { it }
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
) {
|
||||
StarRoute(
|
||||
searchValue = searchValue,
|
||||
onNavigateToToolView = onNavigateToToolView
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import top.fatweb.oxygen.toolbox.ui.store.ToolStoreRoute
|
||||
|
||||
const val TOOL_STORE_ROUTE = "tool_store_route"
|
||||
|
||||
fun NavController.navigateToToolStore(navOptions: NavOptions? = null) =
|
||||
navigate(route = TOOL_STORE_ROUTE, navOptions = navOptions)
|
||||
|
||||
fun NavGraphBuilder.toolStoreScreen(
|
||||
isVertical: Boolean,
|
||||
searchValue: String,
|
||||
searchCount: Int,
|
||||
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = TOOL_STORE_ROUTE,
|
||||
enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
TOOLS_ROUTE, STAR_ROUTE ->
|
||||
if (isVertical) slideInHorizontally { -it }
|
||||
else slideInVertically { -it }
|
||||
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
exitTransition = {
|
||||
when (targetState.destination.route) {
|
||||
TOOLS_ROUTE, STAR_ROUTE ->
|
||||
if (isVertical) slideOutHorizontally { -it }
|
||||
else slideOutVertically { -it }
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
) {
|
||||
ToolStoreRoute(
|
||||
searchValue = searchValue,
|
||||
searchCount = searchCount,
|
||||
onNavigateToToolView = onNavigateToToolView
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptionsBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import top.fatweb.oxygen.toolbox.ui.view.ToolViewRoute
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.text.Charsets.UTF_8
|
||||
|
||||
private val URL_CHARACTER_ENCODING = UTF_8.name()
|
||||
|
||||
internal const val USER_NAME_ARG = "username"
|
||||
internal const val TOOL_ID_ARG = "toolId"
|
||||
internal const val PREVIEW_ARG = "preview"
|
||||
const val TOOL_VIEW_ROUTE = "tool_view_route"
|
||||
|
||||
internal class ToolViewArgs(
|
||||
val username: String,
|
||||
val toolId: String,
|
||||
val preview: Boolean
|
||||
) {
|
||||
constructor(savedStateHandle: SavedStateHandle) :
|
||||
this(
|
||||
URLDecoder.decode(
|
||||
checkNotNull(savedStateHandle[USER_NAME_ARG]),
|
||||
URL_CHARACTER_ENCODING
|
||||
),
|
||||
URLDecoder.decode(
|
||||
checkNotNull(savedStateHandle[TOOL_ID_ARG]),
|
||||
URL_CHARACTER_ENCODING
|
||||
),
|
||||
checkNotNull(savedStateHandle[PREVIEW_ARG])
|
||||
)
|
||||
}
|
||||
|
||||
fun NavController.navigateToToolView(
|
||||
username: String,
|
||||
toolId: String,
|
||||
preview: Boolean,
|
||||
navOptions: NavOptionsBuilder.() -> Unit = {}
|
||||
) {
|
||||
val encodedUsername = URLEncoder.encode(username, URL_CHARACTER_ENCODING)
|
||||
val encodedToolId = URLEncoder.encode(toolId, URL_CHARACTER_ENCODING)
|
||||
val newRoute = "$TOOL_VIEW_ROUTE/$encodedUsername/$encodedToolId?$PREVIEW_ARG=$preview"
|
||||
navigate(newRoute) {
|
||||
navOptions()
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.toolViewScreen(
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = "${TOOL_VIEW_ROUTE}/{$USER_NAME_ARG}/{$TOOL_ID_ARG}?$PREVIEW_ARG={$PREVIEW_ARG}",
|
||||
arguments = listOf(
|
||||
navArgument(USER_NAME_ARG) { type = NavType.StringType },
|
||||
navArgument(TOOL_ID_ARG) { type = NavType.StringType },
|
||||
navArgument(PREVIEW_ARG) { type = NavType.BoolType; defaultValue = false }
|
||||
)
|
||||
) {
|
||||
ToolViewRoute(
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,61 @@
|
||||
package top.fatweb.oxygen.toolbox.navigation
|
||||
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import top.fatweb.oxygen.toolbox.ui.tools.ToolsRoute
|
||||
|
||||
const val TOOLS_ROUTE = "tools_route"
|
||||
|
||||
fun NavController.navigateToTools(navOptions: NavOptions) = navigate(TOOLS_ROUTE, navOptions)
|
||||
fun NavController.navigateToTools(navOptions: NavOptions) =
|
||||
navigate(route = TOOLS_ROUTE, navOptions = navOptions)
|
||||
|
||||
fun NavGraphBuilder.toolsScreen() {
|
||||
fun NavGraphBuilder.toolsScreen(
|
||||
isVertical: Boolean,
|
||||
searchValue: String,
|
||||
onNavigateToToolView: (username: String, toolId: String, preview: Boolean) -> Unit,
|
||||
onNavigateToToolStore: () -> Unit,
|
||||
onShowSnackbar: suspend (message: String, action: String?) -> Boolean
|
||||
) {
|
||||
composable(
|
||||
route = TOOLS_ROUTE
|
||||
) { }
|
||||
route = TOOLS_ROUTE,
|
||||
enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
TOOL_STORE_ROUTE ->
|
||||
if (isVertical) slideInHorizontally { it }
|
||||
else slideInVertically { it }
|
||||
|
||||
STAR_ROUTE ->
|
||||
if (isVertical) slideInHorizontally { -it }
|
||||
else slideInVertically { -it }
|
||||
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
exitTransition = {
|
||||
when (targetState.destination.route) {
|
||||
TOOL_STORE_ROUTE ->
|
||||
if (isVertical) slideOutHorizontally { it }
|
||||
else slideOutVertically { it }
|
||||
|
||||
STAR_ROUTE ->
|
||||
if (isVertical) slideOutHorizontally { -it }
|
||||
else slideOutVertically { -it }
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
) {
|
||||
ToolsRoute(
|
||||
searchValue = searchValue,
|
||||
onNavigateToToolView = onNavigateToToolView,
|
||||
onNavigateToToolStore = onNavigateToToolStore,
|
||||
onShowSnackbar = onShowSnackbar
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
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
|
||||
|
||||
enum class TopLevelDestination(
|
||||
val route: String,
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector,
|
||||
val iconTextId: Int,
|
||||
val titleTextId: Int
|
||||
@StringRes val iconTextId: Int,
|
||||
@StringRes val titleTextId: Int
|
||||
) {
|
||||
TOOLS(
|
||||
ToolStore(
|
||||
route = "tool_store_route",
|
||||
selectedIcon = OxygenIcons.Store,
|
||||
unselectedIcon = OxygenIcons.StoreBorder,
|
||||
iconTextId = R.string.feature_store_title,
|
||||
titleTextId = R.string.feature_store_title
|
||||
),
|
||||
|
||||
Tools(
|
||||
route = "tools_route",
|
||||
selectedIcon = OxygenIcons.Home,
|
||||
unselectedIcon = OxygenIcons.HomeBorder,
|
||||
iconTextId = R.string.feature_tools_title,
|
||||
titleTextId = R.string.feature_tools_title
|
||||
),
|
||||
|
||||
STAR(
|
||||
Star(
|
||||
route = "star_route",
|
||||
selectedIcon = OxygenIcons.Star,
|
||||
unselectedIcon = OxygenIcons.StarBorder,
|
||||
iconTextId = R.string.feature_star_title,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.fatweb.oxygen.toolbox.model.Page
|
||||
|
||||
@Serializable
|
||||
data class PageVo<T>(
|
||||
val total: Long,
|
||||
|
||||
val pages: Long,
|
||||
|
||||
val size: Long,
|
||||
|
||||
val current: Long,
|
||||
|
||||
val records: List<T>
|
||||
)
|
||||
|
||||
fun <T, R> PageVo<T>.asExternalModel(block: (T) -> R): Page<R> =
|
||||
Page(
|
||||
total = total,
|
||||
pages = pages,
|
||||
size = size,
|
||||
current = current,
|
||||
records = records.map(block)
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ResponseResult<T>(
|
||||
val code: Long,
|
||||
|
||||
val success: Boolean,
|
||||
|
||||
val msg: String,
|
||||
|
||||
val data: T? = null
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
import top.fatweb.oxygen.toolbox.network.serializer.LocalDateTimeSerializer
|
||||
|
||||
@Serializable
|
||||
data class ToolBaseVo(
|
||||
val id: Long,
|
||||
|
||||
val name: String,
|
||||
|
||||
val source: ToolDataVo? = null,
|
||||
|
||||
val dist: ToolDataVo,
|
||||
|
||||
val platform: Platform? = null,
|
||||
|
||||
val compiled: Boolean? = null,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val createTime: LocalDateTime? = null,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val updateTime: LocalDateTime? = null
|
||||
) {
|
||||
@Serializable
|
||||
enum class Platform {
|
||||
@SerialName("WEB")
|
||||
Web,
|
||||
|
||||
@SerialName("DESKTOP")
|
||||
Desktop,
|
||||
|
||||
@SerialName("ANDROID")
|
||||
Android;
|
||||
|
||||
override fun toString(): String =
|
||||
javaClass.getField(name).getAnnotation(SerialName::class.java)!!.value
|
||||
}
|
||||
}
|
||||
|
||||
fun ToolBaseVo.Platform.asExternalModel() = ToolEntity.Platform.valueOf(this.name)
|
||||
@@ -0,0 +1,20 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.fatweb.oxygen.toolbox.network.serializer.LocalDateTimeSerializer
|
||||
|
||||
@Serializable
|
||||
data class ToolCategoryVo(
|
||||
val id: Long,
|
||||
|
||||
val name: String,
|
||||
|
||||
val enable: Boolean,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val createTime: LocalDateTime,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val updateTime: LocalDateTime
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.fatweb.oxygen.toolbox.network.serializer.LocalDateTimeSerializer
|
||||
|
||||
@Serializable
|
||||
data class ToolDataVo(
|
||||
val id: Long,
|
||||
|
||||
val data: String,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val createTime: LocalDateTime? = null,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val updateTime: LocalDateTime? = null
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
import top.fatweb.oxygen.toolbox.network.serializer.LocalDateTimeSerializer
|
||||
|
||||
@Serializable
|
||||
data class ToolVo(
|
||||
val id: Long,
|
||||
|
||||
val name: String,
|
||||
|
||||
val toolId: String,
|
||||
|
||||
val icon: String,
|
||||
|
||||
val platform: ToolBaseVo.Platform,
|
||||
|
||||
val description: String? = null,
|
||||
|
||||
val base: ToolBaseVo? = null,
|
||||
|
||||
val author: UserWithInfoVo,
|
||||
|
||||
val ver: String,
|
||||
|
||||
val keywords: List<String>,
|
||||
|
||||
val categories: List<ToolCategoryVo>,
|
||||
|
||||
val source: ToolDataVo? = null,
|
||||
|
||||
val dist: ToolDataVo? = null,
|
||||
|
||||
val entryPoint: String,
|
||||
|
||||
val publish: Long,
|
||||
|
||||
val review: ReviewType,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val createTime: LocalDateTime,
|
||||
|
||||
@Serializable(LocalDateTimeSerializer::class)
|
||||
val updateTime: LocalDateTime
|
||||
) {
|
||||
@Serializable
|
||||
enum class ReviewType {
|
||||
@SerialName("NONE")
|
||||
None,
|
||||
|
||||
@SerialName("PROCESSING")
|
||||
Processing,
|
||||
|
||||
@SerialName("PASS")
|
||||
Pass,
|
||||
|
||||
@SerialName("REJECT")
|
||||
Reject
|
||||
}
|
||||
}
|
||||
|
||||
fun ToolVo.asExternalModel() = ToolEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
toolId = toolId,
|
||||
icon = icon,
|
||||
platform = platform.asExternalModel(),
|
||||
description = description,
|
||||
base = base?.dist?.data,
|
||||
authorUsername = author.username,
|
||||
authorNickname = author.userInfo.nickname,
|
||||
authorAvatar = author.userInfo.avatar,
|
||||
ver = ver,
|
||||
keywords = keywords,
|
||||
categories = categories.map { it.name },
|
||||
source = source?.data,
|
||||
dist = dist?.data,
|
||||
entryPoint = entryPoint,
|
||||
createTime = createTime,
|
||||
updateTime = updateTime
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package top.fatweb.oxygen.toolbox.network.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserWithInfoVo(
|
||||
val id: Long,
|
||||
|
||||
val username: String,
|
||||
|
||||
val userInfo: UserInfoVo
|
||||
) {
|
||||
@Serializable
|
||||
data class UserInfoVo(
|
||||
val id: Long,
|
||||
|
||||
val nickname: String,
|
||||
|
||||
val avatar: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.fatweb.oxygen.toolbox.network.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import kotlinx.coroutines.flow.first
|
||||
import top.fatweb.oxygen.toolbox.data.network.OxygenNetworkDataSource
|
||||
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
import top.fatweb.oxygen.toolbox.network.model.ToolVo
|
||||
import top.fatweb.oxygen.toolbox.network.model.asExternalModel
|
||||
|
||||
internal class ToolStorePagingSource(
|
||||
private val oxygenNetworkDataSource: OxygenNetworkDataSource,
|
||||
private val toolDao: ToolDao,
|
||||
private val searchValue: String
|
||||
) : PagingSource<Int, ToolEntity>() {
|
||||
override fun getRefreshKey(state: PagingState<Int, ToolEntity>): Int? =
|
||||
state.anchorPosition?.let {
|
||||
val anchorPage = state.closestPageToPosition(it)
|
||||
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ToolEntity> {
|
||||
return try {
|
||||
val currentPage = params.key ?: 1
|
||||
val (_, success, msg, data) = oxygenNetworkDataSource.getStore(
|
||||
searchValue = searchValue,
|
||||
currentPage = currentPage
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
return LoadResult.Error(RuntimeException(msg))
|
||||
}
|
||||
val (_, pages, _, _, records) = data!!
|
||||
|
||||
LoadResult.Page(
|
||||
data = records.map(ToolVo::asExternalModel).map { toolEntity ->
|
||||
toolDao.selectToolByUsernameAndToolId(
|
||||
username = toolEntity.authorUsername,
|
||||
toolId = toolEntity.toolId
|
||||
).first()?.let {
|
||||
if (it.id == toolEntity.id) {
|
||||
it
|
||||
} else {
|
||||
it.copy(upgrade = toolEntity.ver).also { copy ->
|
||||
toolDao.updateTool(copy)
|
||||
}
|
||||
}
|
||||
} ?: toolEntity
|
||||
},
|
||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
||||
nextKey = if (currentPage < pages) currentPage + 1 else null
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package top.fatweb.oxygen.toolbox.network.retrofit
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Call
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import top.fatweb.oxygen.toolbox.BuildConfig
|
||||
import top.fatweb.oxygen.toolbox.data.network.OxygenNetworkDataSource
|
||||
import top.fatweb.oxygen.toolbox.model.Result
|
||||
import top.fatweb.oxygen.toolbox.model.asResult
|
||||
import top.fatweb.oxygen.toolbox.network.model.PageVo
|
||||
import top.fatweb.oxygen.toolbox.network.model.ResponseResult
|
||||
import top.fatweb.oxygen.toolbox.network.model.ToolBaseVo
|
||||
import top.fatweb.oxygen.toolbox.network.model.ToolVo
|
||||
import javax.inject.Inject
|
||||
|
||||
private interface RetrofitOxygenNetworkApi {
|
||||
@GET(value = "/tool/store")
|
||||
suspend fun getStore(
|
||||
@Query("currentPage") currentPage: Int,
|
||||
@Query("searchValue") searchValue: String,
|
||||
@Query("platform") platform: ToolBaseVo.Platform? = ToolBaseVo.Platform.Android
|
||||
): ResponseResult<PageVo<ToolVo>>
|
||||
|
||||
@GET(value = "/tool/detail/{username}/{toolId}/{ver}")
|
||||
suspend fun detail(
|
||||
@Path("username") username: String,
|
||||
@Path("toolId") toolId: String,
|
||||
@Path("ver") ver: String,
|
||||
@Query("platform") platform: ToolBaseVo.Platform? = ToolBaseVo.Platform.Android
|
||||
): ResponseResult<ToolVo>
|
||||
}
|
||||
|
||||
private const val API_BASE_URL = BuildConfig.API_URL
|
||||
|
||||
internal class RetrofitOxygenNetwork @Inject constructor(
|
||||
networkJson: Json,
|
||||
okhttpCallFactory: dagger.Lazy<Call.Factory>
|
||||
) : OxygenNetworkDataSource {
|
||||
private val networkApi = Retrofit.Builder()
|
||||
.baseUrl(API_BASE_URL)
|
||||
.callFactory { okhttpCallFactory.get().newCall(it) }
|
||||
.addConverterFactory(
|
||||
networkJson.asConverterFactory("application/json".toMediaType())
|
||||
)
|
||||
.build()
|
||||
.create(RetrofitOxygenNetworkApi::class.java)
|
||||
|
||||
override suspend fun getStore(
|
||||
searchValue: String,
|
||||
currentPage: Int
|
||||
): ResponseResult<PageVo<ToolVo>> =
|
||||
networkApi.getStore(searchValue = searchValue, currentPage = currentPage)
|
||||
|
||||
override fun detail(
|
||||
username: String,
|
||||
toolId: String,
|
||||
ver: String,
|
||||
platform: ToolBaseVo.Platform
|
||||
): Flow<Result<ToolVo>> =
|
||||
flow {
|
||||
emit(
|
||||
networkApi.detail(
|
||||
username = username,
|
||||
toolId = toolId,
|
||||
ver = ver,
|
||||
platform = platform
|
||||
)
|
||||
)
|
||||
}.asResult()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package top.fatweb.oxygen.toolbox.network.serializer
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor(serialName = "LocalDateTime", kind = PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): LocalDateTime =
|
||||
LocalDateTime.parse(input = decoder.decodeString().removeSuffix("Z"))
|
||||
|
||||
override fun serialize(encoder: Encoder, value: LocalDateTime) {
|
||||
encoder.encodeString(value.toString().padEnd(length = 24, padChar = 'Z'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package top.fatweb.oxygen.toolbox.repository.lib
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
|
||||
|
||||
interface DepRepository {
|
||||
fun searchName(name: String): Flow<Dependencies>
|
||||
|
||||
fun getSearchNameCount(): Flow<Int>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.fatweb.oxygen.toolbox.repository.lib.impl
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import top.fatweb.oxygen.toolbox.data.lib.DepDataSource
|
||||
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
|
||||
import top.fatweb.oxygen.toolbox.repository.lib.DepRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalDepRepository @Inject constructor(
|
||||
private val depDataSource: DepDataSource
|
||||
) : DepRepository {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun searchName(name: String): Flow<Dependencies> =
|
||||
depDataSource.dependencies.flatMapLatest { dependencies ->
|
||||
flowOf(dependencies.copy(
|
||||
libraries = dependencies.libraries.filter {
|
||||
it.name?.lowercase()?.contains(Regex("^.*${name.lowercase()}.*$")) ?: false
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getSearchNameCount(): Flow<Int> =
|
||||
depDataSource.dependencies.flatMapLatest { flowOf(it.libraries.size) }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package top.fatweb.oxygen.toolbox.repository.tool
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.fatweb.oxygen.toolbox.model.Result
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
|
||||
interface StoreRepository {
|
||||
suspend fun getStore(
|
||||
searchValue: String
|
||||
): Flow<PagingData<ToolEntity>>
|
||||
|
||||
fun detail(
|
||||
username: String,
|
||||
toolId: String,
|
||||
ver: String = "latest"
|
||||
): Flow<Result<ToolEntity>>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package top.fatweb.oxygen.toolbox.repository.tool
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
|
||||
interface ToolRepository {
|
||||
val toolViewTemplate: Flow<String>
|
||||
|
||||
fun getAllToolsStream(searchValue: String): Flow<List<ToolEntity>>
|
||||
|
||||
fun getStarToolsStream(searchValue: String): Flow<List<ToolEntity>>
|
||||
|
||||
fun getToolById(id: Long): Flow<ToolEntity?>
|
||||
|
||||
fun getToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?>
|
||||
|
||||
suspend fun saveTool(toolEntity: ToolEntity)
|
||||
|
||||
suspend fun updateTool(toolEntity: ToolEntity)
|
||||
|
||||
suspend fun removeTool(toolEntity: ToolEntity)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.fatweb.oxygen.toolbox.repository.tool.impl
|
||||
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import top.fatweb.oxygen.toolbox.data.network.OxygenNetworkDataSource
|
||||
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
|
||||
import top.fatweb.oxygen.toolbox.model.Result
|
||||
import top.fatweb.oxygen.toolbox.model.asExternalModel
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
import top.fatweb.oxygen.toolbox.network.model.ToolVo
|
||||
import top.fatweb.oxygen.toolbox.network.model.asExternalModel
|
||||
import top.fatweb.oxygen.toolbox.network.paging.ToolStorePagingSource
|
||||
import top.fatweb.oxygen.toolbox.repository.tool.StoreRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
|
||||
internal class NetworkStoreRepository @Inject constructor(
|
||||
private val oxygenNetworkDataSource: OxygenNetworkDataSource,
|
||||
private val toolDao: ToolDao
|
||||
) : StoreRepository {
|
||||
override suspend fun getStore(
|
||||
searchValue: String
|
||||
): Flow<PagingData<ToolEntity>> =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
pagingSourceFactory = {
|
||||
ToolStorePagingSource(
|
||||
oxygenNetworkDataSource = oxygenNetworkDataSource,
|
||||
toolDao = toolDao,
|
||||
searchValue = searchValue
|
||||
)
|
||||
}
|
||||
).flow
|
||||
|
||||
override fun detail(
|
||||
username: String,
|
||||
toolId: String,
|
||||
ver: String
|
||||
): Flow<Result<ToolEntity>> =
|
||||
oxygenNetworkDataSource.detail(
|
||||
username = username,
|
||||
toolId = toolId,
|
||||
ver = ver
|
||||
).map {
|
||||
it.asExternalModel(ToolVo::asExternalModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package top.fatweb.oxygen.toolbox.repository.tool.impl
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.fatweb.oxygen.toolbox.data.tool.ToolDataSource
|
||||
import top.fatweb.oxygen.toolbox.data.tool.dao.ToolDao
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
import top.fatweb.oxygen.toolbox.repository.tool.ToolRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class OfflineToolRepository @Inject constructor(
|
||||
private val toolDataSource: ToolDataSource,
|
||||
private val toolDao: ToolDao
|
||||
) : ToolRepository {
|
||||
override val toolViewTemplate: Flow<String>
|
||||
get() = toolDataSource.toolViewTemplate
|
||||
|
||||
override fun getAllToolsStream(searchValue: String): Flow<List<ToolEntity>> =
|
||||
toolDao.selectAllTools(searchValue)
|
||||
|
||||
override fun getStarToolsStream(searchValue: String): Flow<List<ToolEntity>> =
|
||||
toolDao.selectStarTools(searchValue)
|
||||
|
||||
override fun getToolById(id: Long): Flow<ToolEntity?> =
|
||||
toolDao.selectToolById(id)
|
||||
|
||||
override fun getToolByUsernameAndToolId(username: String, toolId: String): Flow<ToolEntity?> =
|
||||
toolDao.selectToolByUsernameAndToolId(username, toolId)
|
||||
|
||||
override suspend fun saveTool(toolEntity: ToolEntity) =
|
||||
toolDao.insertTool(toolEntity.copy(isInstalled = true))
|
||||
|
||||
override suspend fun updateTool(toolEntity: ToolEntity) =
|
||||
toolDao.updateTool(toolEntity)
|
||||
|
||||
override suspend fun removeTool(toolEntity: ToolEntity) =
|
||||
toolDao.deleteTool(toolEntity)
|
||||
}
|
||||
@@ -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<UserData>
|
||||
@@ -1,15 +1,16 @@
|
||||
package top.fatweb.oxygen.toolbox.repository
|
||||
package top.fatweb.oxygen.toolbox.repository.userdata.impl
|
||||
|
||||
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.data.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 top.fatweb.oxygen.toolbox.repository.userdata.UserDataRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class OfflineFirstUserDataRepository @Inject constructor(
|
||||
internal class LocalUserDataRepository @Inject constructor(
|
||||
private val oxygenPreferencesDataSource: OxygenPreferencesDataSource
|
||||
) : UserDataRepository {
|
||||
override val userData: Flow<UserData> =
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.fatweb.oxygen.toolbox.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -24,19 +26,22 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.ABOUT_ROUTE
|
||||
import top.fatweb.oxygen.toolbox.navigation.OxygenNavHost
|
||||
import top.fatweb.oxygen.toolbox.navigation.STAR_ROUTE
|
||||
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
|
||||
@@ -48,6 +53,7 @@ import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationBarItem
|
||||
import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationRail
|
||||
import top.fatweb.oxygen.toolbox.ui.component.OxygenNavigationRailItem
|
||||
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
|
||||
import top.fatweb.oxygen.toolbox.ui.component.SearchButtonPosition
|
||||
import top.fatweb.oxygen.toolbox.ui.settings.SettingsDialog
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.GradientColors
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.LocalGradientColors
|
||||
@@ -56,7 +62,7 @@ import top.fatweb.oxygen.toolbox.ui.theme.LocalGradientColors
|
||||
@Composable
|
||||
fun OxygenApp(appState: OxygenAppState) {
|
||||
val shouldShowGradientBackground =
|
||||
appState.currentTopLevelDestination == TopLevelDestination.TOOLS
|
||||
appState.currentDestination?.route == ABOUT_ROUTE
|
||||
var showSettingsDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -71,7 +77,29 @@ fun OxygenApp(appState: OxygenAppState) {
|
||||
|
||||
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
|
||||
|
||||
val noConnectMessage = stringResource(R.string.no_connect)
|
||||
val noConnectMessage = stringResource(R.string.core_no_connect)
|
||||
|
||||
var canScroll by remember { mutableStateOf(true) }
|
||||
val topAppBarScrollBehavior =
|
||||
if (canScroll) TopAppBarDefaults.enterAlwaysScrollBehavior() else TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var activeSearch by remember { mutableStateOf(false) }
|
||||
var searchValue by remember { mutableStateOf("") }
|
||||
var searchCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(activeSearch) {
|
||||
canScroll = !activeSearch
|
||||
}
|
||||
|
||||
LaunchedEffect(destination) {
|
||||
activeSearch = false
|
||||
searchValue = ""
|
||||
if (searchCount == 0) {
|
||||
searchCount++
|
||||
} else {
|
||||
searchCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isOffline) {
|
||||
if (isOffline) {
|
||||
@@ -84,24 +112,34 @@ fun OxygenApp(appState: OxygenAppState) {
|
||||
|
||||
if (showSettingsDialog) {
|
||||
SettingsDialog(
|
||||
onDismiss = { showSettingsDialog = false }
|
||||
onDismiss = { showSettingsDialog = false },
|
||||
onNavigateToLibraries = appState::navigateToLibraries,
|
||||
onNavigateToAbout = appState::navigateToAbout
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.nestedScroll(connection = topAppBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
contentWindowInsets = WindowInsets(left = 0, top = 0, right = 0, bottom = 0),
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
bottomBar = {
|
||||
if (appState.shouldShowBottomBar && destination != null) {
|
||||
AnimatedVisibility(
|
||||
visible = appState.shouldShowBottomBar && destination != null
|
||||
) {
|
||||
BottomAppBar(
|
||||
windowInsets = WindowInsets(0)
|
||||
) {
|
||||
OxygenBottomBar(
|
||||
destinations = appState.topLevelDestinations,
|
||||
onNavigateToDestination = appState::navigateToTopLevelDestination,
|
||||
currentDestination = appState.currentDestination
|
||||
currentDestination = appState.currentDestination,
|
||||
onNavigateToDestination = appState::navigateToTopLevelDestination
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Row(
|
||||
Modifier
|
||||
@@ -114,45 +152,70 @@ fun OxygenApp(appState: OxygenAppState) {
|
||||
)
|
||||
)
|
||||
) {
|
||||
if (appState.shouldShowNavRail && destination != null) {
|
||||
AnimatedVisibility(
|
||||
visible = appState.shouldShowNavRail && destination != null
|
||||
) {
|
||||
OxygenNavRail(
|
||||
modifier = Modifier.safeDrawingPadding(),
|
||||
destinations = appState.topLevelDestinations,
|
||||
onNavigateToDestination = appState::navigateToTopLevelDestination,
|
||||
currentDestination = appState.currentDestination
|
||||
currentDestination = appState.currentDestination,
|
||||
onNavigateToDestination = appState::navigateToTopLevelDestination
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize()
|
||||
) {
|
||||
if (destination != null) {
|
||||
AnimatedVisibility(
|
||||
visible = destination != null
|
||||
) {
|
||||
OxygenTopAppBar(
|
||||
titleRes = destination.titleTextId,
|
||||
scrollBehavior = topAppBarScrollBehavior,
|
||||
title = {
|
||||
destination?.let { Text(text = stringResource(destination.titleTextId)) }
|
||||
},
|
||||
navigationIcon = OxygenIcons.Search,
|
||||
navigationIconContentDescription = stringResource(R.string.feature_settings_top_app_bar_navigation_icon_description),
|
||||
actionIcon = OxygenIcons.MoreVert,
|
||||
actionIconContentDescription = stringResource(R.string.feature_settings_top_app_bar_action_icon_description),
|
||||
activeSearch = activeSearch,
|
||||
searchButtonPosition = SearchButtonPosition.Navigation,
|
||||
query = searchValue,
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
),
|
||||
onNavigationClick = { appState.navigateToSearch() },
|
||||
onActionClick = { showSettingsDialog = true }
|
||||
onNavigationClick = { activeSearch = true },
|
||||
onActionClick = { showSettingsDialog = true },
|
||||
onQueryChange = {
|
||||
searchValue = it
|
||||
},
|
||||
onSearch = {
|
||||
searchCount++
|
||||
},
|
||||
onCancelSearch = {
|
||||
searchValue = ""
|
||||
activeSearch = false
|
||||
searchCount = 0
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OxygenNavHost(
|
||||
appState = appState,
|
||||
startDestination = when (appState.launchPageConfig) {
|
||||
LaunchPageConfig.Tools -> TOOLS_ROUTE
|
||||
LaunchPageConfig.Star -> STAR_ROUTE
|
||||
},
|
||||
isVertical = appState.shouldShowBottomBar,
|
||||
searchValue = searchValue,
|
||||
searchCount = searchCount,
|
||||
onShowSnackbar = { message, action ->
|
||||
snackbarHostState.showSnackbar(
|
||||
message = message,
|
||||
actionLabel = action,
|
||||
duration = SnackbarDuration.Short
|
||||
) == SnackbarResult.ActionPerformed
|
||||
},
|
||||
startDestination = when (appState.launchPageConfig) {
|
||||
LaunchPageConfig.TOOLS -> TOOLS_ROUTE
|
||||
LaunchPageConfig.STAR -> STAR_ROUTE
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -166,8 +229,8 @@ fun OxygenApp(appState: OxygenAppState) {
|
||||
private fun OxygenBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
destinations: List<TopLevelDestination>,
|
||||
onNavigateToDestination: (TopLevelDestination) -> Unit,
|
||||
currentDestination: NavDestination?
|
||||
currentDestination: NavDestination?,
|
||||
onNavigateToDestination: (TopLevelDestination) -> Unit
|
||||
) {
|
||||
OxygenNavigationBar(
|
||||
modifier = modifier
|
||||
@@ -181,13 +244,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) }
|
||||
@@ -200,8 +263,8 @@ private fun OxygenBottomBar(
|
||||
private fun OxygenNavRail(
|
||||
modifier: Modifier = Modifier,
|
||||
destinations: List<TopLevelDestination>,
|
||||
onNavigateToDestination: (TopLevelDestination) -> Unit,
|
||||
currentDestination: NavDestination?
|
||||
currentDestination: NavDestination?,
|
||||
onNavigateToDestination: (TopLevelDestination) -> Unit
|
||||
) {
|
||||
OxygenNavigationRail(
|
||||
modifier = modifier
|
||||
@@ -215,13 +278,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) }
|
||||
@@ -232,5 +295,5 @@ private fun OxygenNavRail(
|
||||
|
||||
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
|
||||
this?.hierarchy?.any {
|
||||
it.route?.contains(destination.name, true) ?: false
|
||||
it.route?.equals(destination.route) ?: false
|
||||
} ?: false
|
||||
|
||||
@@ -17,14 +17,17 @@ 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
|
||||
import top.fatweb.oxygen.toolbox.navigation.TOOLS_ROUTE
|
||||
import top.fatweb.oxygen.toolbox.navigation.TOOL_STORE_ROUTE
|
||||
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToSearch
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToAbout
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToLibraries
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToStar
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToToolStore
|
||||
import top.fatweb.oxygen.toolbox.navigation.navigateToTools
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -56,21 +59,24 @@ fun rememberOxygenAppState(
|
||||
|
||||
@Stable
|
||||
class OxygenAppState(
|
||||
val windowSizeClass: WindowSizeClass,
|
||||
private val windowSizeClass: WindowSizeClass,
|
||||
networkMonitor: NetworkMonitor,
|
||||
timeZoneMonitor: TimeZoneMonitor,
|
||||
coroutineScope: CoroutineScope,
|
||||
val navController: NavHostController,
|
||||
val launchPageConfig: LaunchPageConfig
|
||||
) {
|
||||
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
|
||||
|
||||
val currentDestination: NavDestination?
|
||||
@Composable get() = navController
|
||||
.currentBackStackEntryAsState().value?.destination
|
||||
|
||||
val currentTopLevelDestination: TopLevelDestination?
|
||||
@Composable get() = when (currentDestination?.route) {
|
||||
TOOLS_ROUTE -> TopLevelDestination.TOOLS
|
||||
STAR_ROUTE -> TopLevelDestination.STAR
|
||||
TOOL_STORE_ROUTE -> TopLevelDestination.ToolStore
|
||||
TOOLS_ROUTE -> TopLevelDestination.Tools
|
||||
STAR_ROUTE -> TopLevelDestination.Star
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -85,16 +91,14 @@ class OxygenAppState(
|
||||
.stateIn(
|
||||
scope = coroutineScope,
|
||||
initialValue = false,
|
||||
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds)
|
||||
)
|
||||
|
||||
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
|
||||
|
||||
val currentTimeZone = timeZoneMonitor.currentTimeZone
|
||||
.stateIn(
|
||||
scope = coroutineScope,
|
||||
initialValue = TimeZone.currentSystemDefault(),
|
||||
started = SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds)
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds)
|
||||
)
|
||||
|
||||
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
|
||||
@@ -108,10 +112,13 @@ class OxygenAppState(
|
||||
}
|
||||
|
||||
when (topLevelDestination) {
|
||||
TopLevelDestination.TOOLS -> navController.navigateToTools(topLevelNavOptions)
|
||||
TopLevelDestination.STAR -> navController.navigateToStar(topLevelNavOptions)
|
||||
TopLevelDestination.ToolStore -> navController.navigateToToolStore(topLevelNavOptions)
|
||||
TopLevelDestination.Tools -> navController.navigateToTools(topLevelNavOptions)
|
||||
TopLevelDestination.Star -> navController.navigateToStar(topLevelNavOptions)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToSearch() = navController.navigateToSearch()
|
||||
fun navigateToLibraries() = navController.navigateToLibraries()
|
||||
|
||||
fun navigateToAbout() = navController.navigateToAbout()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.about
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.fatweb.oxygen.toolbox.R
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
import top.fatweb.oxygen.toolbox.ui.util.ResourcesUtils
|
||||
|
||||
@Composable
|
||||
internal fun AboutRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClick: () -> Unit,
|
||||
onNavigateToLibraries: () -> Unit
|
||||
) {
|
||||
AboutScreen(
|
||||
modifier = modifier.safeDrawingPadding(),
|
||||
onBackClick = onBackClick,
|
||||
onNavigateToLibraries = onNavigateToLibraries
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AboutScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClick: () -> Unit,
|
||||
onNavigateToLibraries: () -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val topAppBarScrollBehavior =
|
||||
TopAppBarDefaults.pinnedScrollBehavior(canScroll = { scrollState.maxValue > 0 })
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.nestedScroll(connection = topAppBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets(left = 0, top = 0, right = 0, bottom = 0),
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal
|
||||
)
|
||||
)
|
||||
.verticalScroll(state = scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OxygenTopAppBar(
|
||||
scrollBehavior = topAppBarScrollBehavior,
|
||||
title = {
|
||||
Text(text = stringResource(R.string.feature_settings_more_about))
|
||||
},
|
||||
navigationIcon = OxygenIcons.Back,
|
||||
navigationIconContentDescription = stringResource(R.string.core_back),
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
),
|
||||
onNavigationClick = onBackClick
|
||||
)
|
||||
Spacer(Modifier.height(64.dp))
|
||||
AboutAppInfo()
|
||||
Spacer(Modifier.weight(1f))
|
||||
AboutFooter(
|
||||
onNavigateToLibraries = onNavigateToLibraries
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutAppInfo(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_oxygen),
|
||||
contentDescription = stringResource(R.string.app_full_name)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = stringResource(R.string.app_name)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
text = stringResource(R.string.app_description)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
text = "${ResourcesUtils.getAppVersionName(LocalContext.current)}(${
|
||||
ResourcesUtils.getAppVersionCode(
|
||||
LocalContext.current
|
||||
)
|
||||
})"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutFooter(
|
||||
modifier: Modifier = Modifier, onNavigateToLibraries: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(32.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onNavigateToLibraries
|
||||
) {
|
||||
Text(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
text = stringResource(R.string.feature_settings_open_source_license)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun AboutAppInfoPreview() {
|
||||
OxygenTheme {
|
||||
AboutAppInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun AboutScreenPreview() {
|
||||
OxygenTheme {
|
||||
AboutScreen(onBackClick = {}, onNavigateToLibraries = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.about
|
||||
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
import top.fatweb.oxygen.toolbox.ui.component.LibraryCard
|
||||
|
||||
fun LazyStaggeredGridScope.librariesPanel(
|
||||
librariesScreenUiState: LibrariesScreenUiState,
|
||||
onClickLicense: (key: String) -> Unit
|
||||
) {
|
||||
when (librariesScreenUiState) {
|
||||
LibrariesScreenUiState.Loading, LibrariesScreenUiState.Nothing, LibrariesScreenUiState.NotFound -> Unit
|
||||
|
||||
is LibrariesScreenUiState.Success -> {
|
||||
items(
|
||||
items = librariesScreenUiState.dependencies.libraries,
|
||||
key = { it.uniqueId }
|
||||
) {
|
||||
LibraryCard(
|
||||
library = it,
|
||||
licenses = librariesScreenUiState.dependencies.licenses.filter { entry ->
|
||||
it.licenses.contains(entry.key)
|
||||
},
|
||||
onClickLicense = onClickLicense
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.rememberLazyStaggeredGridState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import top.fatweb.oxygen.toolbox.R
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
import top.fatweb.oxygen.toolbox.ui.component.Indicator
|
||||
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
|
||||
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
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
|
||||
@Composable
|
||||
internal fun LibrariesRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LibrariesScreenViewModel = hiltViewModel(),
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
val librariesScreenUiState by viewModel.librariesScreenUiState.collectAsStateWithLifecycle()
|
||||
|
||||
LibrariesScreen(
|
||||
modifier = modifier,
|
||||
librariesScreenUiState = librariesScreenUiState,
|
||||
onBackClick = onBackClick,
|
||||
onSearch = { viewModel.onSearchValueChange(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun LibrariesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
librariesScreenUiState: LibrariesScreenUiState,
|
||||
onBackClick: () -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val isLibrariesLoading = librariesScreenUiState is LibrariesScreenUiState.Loading
|
||||
|
||||
ReportDrawnWhen { !isLibrariesLoading }
|
||||
|
||||
val itemsAvailable = howManyItems(librariesScreenUiState)
|
||||
|
||||
val state = rememberLazyStaggeredGridState()
|
||||
val scrollbarState = state.scrollbarState(itemsAvailable = itemsAvailable)
|
||||
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var dialogTitle by remember { mutableStateOf("") }
|
||||
var dialogContent by remember { mutableStateOf("") }
|
||||
var dialogUrl by remember { mutableStateOf("") }
|
||||
|
||||
var canScroll by remember { mutableStateOf(true) }
|
||||
val topAppBarScrollBehavior =
|
||||
if (canScroll) TopAppBarDefaults.enterAlwaysScrollBehavior() else TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var activeSearch by remember { mutableStateOf(false) }
|
||||
var searchValue by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(activeSearch) {
|
||||
canScroll = !activeSearch
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.nestedScroll(connection = topAppBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets(left = 0, top = 0, right = 0, bottom = 0),
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal
|
||||
)
|
||||
)
|
||||
) {
|
||||
OxygenTopAppBar(
|
||||
scrollBehavior = topAppBarScrollBehavior,
|
||||
title = {
|
||||
Text(text = stringResource(R.string.feature_settings_open_source_license))
|
||||
},
|
||||
navigationIcon = OxygenIcons.Back,
|
||||
navigationIconContentDescription = stringResource(R.string.core_back),
|
||||
actionIcon = OxygenIcons.Search,
|
||||
actionIconContentDescription = stringResource(R.string.core_search),
|
||||
activeSearch = activeSearch,
|
||||
query = searchValue,
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
),
|
||||
onNavigationClick = onBackClick,
|
||||
onActionClick = {
|
||||
activeSearch = true
|
||||
},
|
||||
onQueryChange = {
|
||||
searchValue = it
|
||||
onSearch(it)
|
||||
},
|
||||
onSearch = onSearch,
|
||||
onCancelSearch = {
|
||||
searchValue = ""
|
||||
activeSearch = false
|
||||
onSearch("")
|
||||
}
|
||||
)
|
||||
Box(modifier = Modifier) {
|
||||
when (librariesScreenUiState) {
|
||||
LibrariesScreenUiState.Loading -> {
|
||||
Indicator()
|
||||
}
|
||||
|
||||
LibrariesScreenUiState.Nothing -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(state = rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = stringResource(R.string.core_nothing))
|
||||
}
|
||||
}
|
||||
|
||||
LibrariesScreenUiState.NotFound -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(state = rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = stringResource(R.string.core_nothing_found))
|
||||
}
|
||||
}
|
||||
|
||||
is LibrariesScreenUiState.Success -> {
|
||||
val handleOnClickLicense = { key: String ->
|
||||
val license = librariesScreenUiState.dependencies.licenses[key]
|
||||
if (license != null) {
|
||||
showDialog = true
|
||||
dialogTitle = license.name
|
||||
dialogContent = license.content ?: ""
|
||||
dialogUrl = license.url ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalStaggeredGrid(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
columns = StaggeredGridCells.Adaptive(300.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalItemSpacing = 24.dp,
|
||||
state = state
|
||||
) {
|
||||
librariesPanel(
|
||||
librariesScreenUiState = librariesScreenUiState,
|
||||
onClickLicense = handleOnClickLicense
|
||||
)
|
||||
}
|
||||
|
||||
state.DraggableScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(horizontal = 2.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
state = scrollbarState,
|
||||
orientation = Orientation.Vertical,
|
||||
onThumbMoved = state.rememberDraggableScroller(itemsAvailable = itemsAvailable)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
modifier = Modifier
|
||||
.widthIn(max = configuration.screenWidthDp.dp - 80.dp)
|
||||
.heightIn(max = configuration.screenHeightDp.dp - 40.dp),
|
||||
onDismissRequest = { showDialog = false },
|
||||
title = {
|
||||
Text(
|
||||
text = dialogTitle,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(state = rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier,
|
||||
text = dialogContent
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(dialogUrl)))
|
||||
}) {
|
||||
Text(text = stringResource(R.string.core_website))
|
||||
}
|
||||
TextButton(onClick = { showDialog = false }) {
|
||||
Text(text = stringResource(R.string.core_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun howManyItems(librariesScreenUiState: LibrariesScreenUiState) =
|
||||
when (librariesScreenUiState) {
|
||||
LibrariesScreenUiState.Loading, LibrariesScreenUiState.Nothing, LibrariesScreenUiState.NotFound -> 0
|
||||
|
||||
is LibrariesScreenUiState.Success -> librariesScreenUiState.dependencies.libraries.size
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun LibrariesScreenLoadingPreview() {
|
||||
OxygenTheme {
|
||||
LibrariesScreen(
|
||||
librariesScreenUiState = LibrariesScreenUiState.Loading,
|
||||
onBackClick = {},
|
||||
onSearch = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.about
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import top.fatweb.oxygen.toolbox.model.lib.Dependencies
|
||||
import top.fatweb.oxygen.toolbox.repository.lib.DepRepository
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltViewModel
|
||||
class LibrariesScreenViewModel @Inject constructor(
|
||||
private val depRepository: DepRepository,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
private val searchValue = savedStateHandle.getStateFlow(SEARCH_VALUE, "")
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val librariesScreenUiState: StateFlow<LibrariesScreenUiState> =
|
||||
depRepository.getSearchNameCount()
|
||||
.flatMapLatest { totalCount ->
|
||||
if (totalCount < SEARCH_MIN_COUNT) {
|
||||
flowOf(LibrariesScreenUiState.Nothing)
|
||||
} else {
|
||||
searchValue.flatMapLatest { value ->
|
||||
depRepository.searchName(value).map {
|
||||
if (it.libraries.isEmpty()) {
|
||||
LibrariesScreenUiState.NotFound
|
||||
} else {
|
||||
LibrariesScreenUiState.Success(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
initialValue = LibrariesScreenUiState.Loading,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5.seconds.inWholeMilliseconds)
|
||||
)
|
||||
|
||||
fun onSearchValueChange(value: String) {
|
||||
savedStateHandle[SEARCH_VALUE] = value
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface LibrariesScreenUiState {
|
||||
data object Loading : LibrariesScreenUiState
|
||||
|
||||
data object Nothing : LibrariesScreenUiState
|
||||
|
||||
data object NotFound : LibrariesScreenUiState
|
||||
|
||||
data class Success(val dependencies: Dependencies) : LibrariesScreenUiState
|
||||
}
|
||||
|
||||
private const val SEARCH_MIN_COUNT = 1
|
||||
private const val SEARCH_VALUE = "searchValue"
|
||||
@@ -1,6 +1,5 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.component
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -15,12 +14,12 @@ import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.GradientColors
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.LocalBackgroundTheme
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.LocalGradientColors
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
import kotlin.math.tan
|
||||
|
||||
@@ -92,11 +91,7 @@ fun OxygenGradientBackground(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme")
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme")
|
||||
annotation class ThemePreviews
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun BackgroundDefault() {
|
||||
OxygenTheme(dynamicColor = false) {
|
||||
@@ -104,7 +99,7 @@ fun BackgroundDefault() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun BackgroundDynamic() {
|
||||
OxygenTheme(dynamicColor = true) {
|
||||
@@ -112,7 +107,7 @@ fun BackgroundDynamic() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun BackgroundAndroid() {
|
||||
OxygenTheme(androidTheme = true) {
|
||||
@@ -120,7 +115,7 @@ fun BackgroundAndroid() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun GradientBackgroundDefault() {
|
||||
OxygenTheme(dynamicColor = false) {
|
||||
@@ -128,7 +123,7 @@ fun GradientBackgroundDefault() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun GradientBackgroundDynamic() {
|
||||
OxygenTheme(dynamicColor = true) {
|
||||
@@ -136,7 +131,7 @@ fun GradientBackgroundDynamic() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun GradientBackgroundAndroid() {
|
||||
OxygenTheme(androidTheme = true) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.component
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import top.fatweb.oxygen.toolbox.ui.util.ResourcesUtils
|
||||
|
||||
@Composable
|
||||
fun ClickableText(
|
||||
@StringRes text: Int,
|
||||
@StringRes replaceText: Int,
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
val clickablePart = ResourcesUtils.getString(
|
||||
context = context,
|
||||
resId = replaceText
|
||||
)
|
||||
val mainText = ResourcesUtils.getString(
|
||||
context = context,
|
||||
resId = text,
|
||||
clickablePart
|
||||
)
|
||||
append(mainText.substringBefore(clickablePart))
|
||||
pushStringAnnotation(tag = "Click", annotation = clickablePart)
|
||||
withStyle(style = SpanStyle(color = primaryColor)) {
|
||||
append(clickablePart)
|
||||
}
|
||||
pop()
|
||||
append(mainText.substringAfter(clickablePart))
|
||||
}
|
||||
|
||||
ClickableText(text = annotatedString, onClick = onClick)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
|
||||
@Composable
|
||||
fun DialogTitle(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier.padding(16.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogSectionTitle(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier.padding(top = 16.dp, bottom = 8.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogSectionGroup(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.selectableGroup()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogChooserRow(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.selectable(
|
||||
selected = selected,
|
||||
role = Role.RadioButton,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogClickerRow(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector? = null,
|
||||
text: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(imageVector = icon ?: OxygenIcons.Reorder, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Indicator(
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CompositionLocalProvider(value = LocalContentColor provides contentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(SpinnerContainerSize)
|
||||
.shadow(
|
||||
elevation = Elevation,
|
||||
shape = CircleShape,
|
||||
clip = true
|
||||
)
|
||||
.background(color = containerColor, shape = CircleShape)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(SpinnerSize),
|
||||
strokeWidth = StrokeWidth,
|
||||
color = LocalContentColor.current
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SpinnerContainerSize = 40.dp
|
||||
private val Elevation = 3.dp
|
||||
private val StrokeWidth = 2.5.dp
|
||||
private val SpinnerSize = 16.dp
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.fatweb.oxygen.toolbox.navigation.TopLevelDestination
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
|
||||
@Composable
|
||||
@@ -26,16 +27,15 @@ fun RowScope.OxygenNavigationBarItem(
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
icon: @Composable () -> Unit,
|
||||
selectedIcon: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
alwaysShowLabel: Boolean = false
|
||||
alwaysShowLabel: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
NavigationBarItem(
|
||||
modifier = modifier,
|
||||
selected = selected,
|
||||
label = label,
|
||||
icon = if (selected) selectedIcon else icon,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
alwaysShowLabel = alwaysShowLabel,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
@@ -44,7 +44,8 @@ fun RowScope.OxygenNavigationBarItem(
|
||||
selectedTextColor = OxygenNavigationDefaults.navigationSelectedItemColor(),
|
||||
unselectedTextColor = OxygenNavigationDefaults.navigationContentColor(),
|
||||
indicatorColor = OxygenNavigationDefaults.navigationIndicatorColor()
|
||||
)
|
||||
),
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,16 +69,15 @@ fun OxygenNavigationRailItem(
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
icon: @Composable () -> Unit,
|
||||
selectedIcon: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
alwaysShowLabel: Boolean = true
|
||||
alwaysShowLabel: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
NavigationRailItem(
|
||||
modifier = modifier,
|
||||
selected = selected,
|
||||
label = label,
|
||||
icon = if (selected) selectedIcon else icon,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
alwaysShowLabel = alwaysShowLabel,
|
||||
colors = NavigationRailItemDefaults.colors(
|
||||
@@ -86,7 +86,8 @@ fun OxygenNavigationRailItem(
|
||||
selectedTextColor = OxygenNavigationDefaults.navigationSelectedItemColor(),
|
||||
unselectedTextColor = OxygenNavigationDefaults.navigationContentColor(),
|
||||
indicatorColor = OxygenNavigationDefaults.navigationIndicatorColor()
|
||||
)
|
||||
),
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,9 +117,9 @@ object OxygenNavigationDefaults {
|
||||
fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun OxygenNavigationBarPreview() {
|
||||
private fun OxygenNavigationBarPreview() {
|
||||
val items = TopLevelDestination.entries
|
||||
|
||||
OxygenTheme {
|
||||
@@ -130,13 +131,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
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -147,9 +148,9 @@ fun OxygenNavigationBarPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun OxygenNavigationRailPreview() {
|
||||
private fun OxygenNavigationRailPreview() {
|
||||
val items = TopLevelDestination.entries
|
||||
|
||||
OxygenTheme {
|
||||
@@ -161,13 +162,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
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.component
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.fatweb.oxygen.toolbox.R
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
import android.R as androidR
|
||||
|
||||
@@ -22,19 +44,94 @@ import android.R as androidR
|
||||
@Composable
|
||||
fun OxygenTopAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes titleRes: Int,
|
||||
navigationIcon: ImageVector,
|
||||
navigationIconContentDescription: String,
|
||||
actionIcon: ImageVector,
|
||||
actionIconContentDescription: String,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
title: @Composable () -> Unit = {},
|
||||
navigationIcon: ImageVector? = null,
|
||||
navigationIconContentDescription: String? = null,
|
||||
actionIcon: ImageVector? = null,
|
||||
actionIconContentDescription: String? = null,
|
||||
activeSearch: Boolean = false,
|
||||
searchButtonPosition: SearchButtonPosition = SearchButtonPosition.Action,
|
||||
query: String = "",
|
||||
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onActionClick: () -> Unit = {}
|
||||
onActionClick: () -> Unit = {},
|
||||
onQueryChange: (String) -> Unit = {},
|
||||
onSearch: (String) -> Unit = {},
|
||||
onCancelSearch: () -> Unit = {}
|
||||
) {
|
||||
val topInset by animateIntAsState(
|
||||
if (scrollBehavior != null && -scrollBehavior.state.heightOffset >= with(LocalDensity.current) { 64.0.dp.toPx() }) 0
|
||||
else TopAppBarDefaults.windowInsets.getTop(LocalDensity.current), label = ""
|
||||
)
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val onSearchExplicitlyTriggered = {
|
||||
keyboardController?.hide()
|
||||
onSearch(query)
|
||||
}
|
||||
|
||||
LaunchedEffect(activeSearch) {
|
||||
if (activeSearch) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
title = { Text(stringResource(titleRes)) },
|
||||
scrollBehavior = scrollBehavior,
|
||||
title = {
|
||||
if (activeSearch) TextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Transparent)
|
||||
.focusRequester(focusRequester)
|
||||
.onKeyEvent {
|
||||
if (it.key == Key.Enter) {
|
||||
onSearchExplicitlyTriggered()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
value = query,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = {
|
||||
onSearchExplicitlyTriggered()
|
||||
}
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = OxygenIcons.Search,
|
||||
contentDescription = stringResource(R.string.core_search)
|
||||
)
|
||||
},
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
onValueChange = {
|
||||
if ("\n" !in it) onQueryChange(it)
|
||||
}
|
||||
)
|
||||
else title()
|
||||
},
|
||||
navigationIcon = {
|
||||
if (activeSearch && searchButtonPosition == SearchButtonPosition.Navigation) IconButton(
|
||||
onClick = onCancelSearch
|
||||
) {
|
||||
Icon(
|
||||
imageVector = OxygenIcons.Close,
|
||||
contentDescription = stringResource(R.string.core_close)
|
||||
)
|
||||
}
|
||||
else navigationIcon?.let {
|
||||
IconButton(onClick = onNavigationClick) {
|
||||
Icon(
|
||||
imageVector = navigationIcon,
|
||||
@@ -42,8 +139,18 @@ fun OxygenTopAppBar(
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (activeSearch && searchButtonPosition == SearchButtonPosition.Action) IconButton(
|
||||
onClick = onCancelSearch
|
||||
) {
|
||||
Icon(
|
||||
imageVector = OxygenIcons.Close,
|
||||
contentDescription = stringResource(R.string.core_close)
|
||||
)
|
||||
}
|
||||
else actionIcon?.let {
|
||||
IconButton(onClick = onActionClick) {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
@@ -51,18 +158,24 @@ fun OxygenTopAppBar(
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = colors
|
||||
colors = colors,
|
||||
windowInsets = WindowInsets(top = topInset)
|
||||
)
|
||||
}
|
||||
|
||||
enum class SearchButtonPosition {
|
||||
Navigation, Action
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun OxygenTopAppBarPreview() {
|
||||
OxygenTheme {
|
||||
OxygenTopAppBar(
|
||||
titleRes = androidR.string.untitled,
|
||||
title = { Text(text = stringResource(androidR.string.untitled)) },
|
||||
navigationIcon = OxygenIcons.Search,
|
||||
navigationIconContentDescription = "Navigation icon",
|
||||
actionIcon = OxygenIcons.MoreVert,
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.component
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.valentinilk.shimmer.LocalShimmerTheme
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import com.valentinilk.shimmer.shimmerSpec
|
||||
import top.fatweb.oxygen.toolbox.R
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
import top.fatweb.oxygen.toolbox.model.tool.ToolEntity
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ToolCard(
|
||||
modifier: Modifier = Modifier,
|
||||
tool: ToolEntity,
|
||||
specifyVer: String? = null,
|
||||
actionIcon: ImageVector? = null,
|
||||
actionIconContentDescription: String = "",
|
||||
onAction: () -> Unit = {},
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {}
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
ToolHeader(
|
||||
ver = specifyVer ?: tool.ver,
|
||||
actionIcon = actionIcon,
|
||||
actionIconContentDescription = actionIconContentDescription,
|
||||
onAction = onAction
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ToolIcon(icon = tool.icon)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ToolInfo(
|
||||
toolName = tool.name,
|
||||
toolId = tool.toolId,
|
||||
toolDesc = tool.description
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
AuthorInfo(
|
||||
avatar = tool.authorAvatar,
|
||||
nickname = tool.authorNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
ver: String,
|
||||
actionIcon: ImageVector?,
|
||||
actionIconContentDescription: String,
|
||||
onAction: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
) {
|
||||
ToolVer(ver = ver)
|
||||
Spacer(Modifier.weight(1f))
|
||||
actionIcon?.let {
|
||||
ToolAction(
|
||||
actionIcon = actionIcon,
|
||||
actionIconContentDescription = actionIconContentDescription,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolVer(
|
||||
modifier: Modifier = Modifier,
|
||||
ver: String
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxHeight(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.background(color = MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
Arrangement.Center,
|
||||
Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = ver
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolAction(
|
||||
modifier: Modifier = Modifier,
|
||||
actionIcon: ImageVector,
|
||||
actionIconContentDescription: String,
|
||||
onAction: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
onClick = onAction
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.background(color = MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier,
|
||||
imageVector = actionIcon,
|
||||
contentDescription = actionIconContentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: String
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(80.dp),
|
||||
bitmap = OxygenIcons.fromSvgBase64(icon),
|
||||
contentDescription = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
toolName: String,
|
||||
toolId: String,
|
||||
toolDesc: String?
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
textAlign = TextAlign.Center,
|
||||
text = toolName
|
||||
)
|
||||
Text(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
text = "ID: $toolId"
|
||||
)
|
||||
toolDesc?.let {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
text = "${stringResource(R.string.feature_tools_description)}: $it"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
avatar: String,
|
||||
nickname: String
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
bitmap = OxygenIcons.fromPngBase64(avatar), contentDescription = "Avatar"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToolCardSkeleton(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val shimmer = rememberShimmer(
|
||||
shimmerBounds = ShimmerBounds.Window,
|
||||
theme = LocalShimmerTheme.current.copy(
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = shimmerSpec(
|
||||
durationMillis = 1_500,
|
||||
easing = LinearEasing,
|
||||
delayMillis = 200
|
||||
),
|
||||
repeatMode = RepeatMode.Restart
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(28.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(64.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(width = 40.dp, height = 22.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(width = 60.dp, height = 20.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(width = 120.dp, height = 12.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(width = 120.dp, height = 12.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(width = 80.dp, height = 12.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(width = 120.dp, height = 12.dp)
|
||||
.shimmer(shimmer),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val DEFAULT_TOOL_CARD_SKELETON_COUNT = 20
|
||||
@@ -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.data.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.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
|
||||
@Composable
|
||||
fun ToolGroupCard(
|
||||
modifier: Modifier = Modifier,
|
||||
toolGroup: ToolGroup
|
||||
) {
|
||||
val (_, icon, title, tools) = toolGroup
|
||||
|
||||
var isExpanded by remember { mutableStateOf(true) }
|
||||
|
||||
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 = { isExpanded = !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 ?: {}) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(modifier = Modifier.size(18.dp), imageVector = icon, contentDescription = title)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(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.map {
|
||||
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 ?: { }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(modifier = Modifier.size(16.dp), imageVector = icon, contentDescription = null)
|
||||
Spacer(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
|
||||
)
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun ToolGroupCardPreview() {
|
||||
val groups = runBlocking { ToolDataSource().tool.first() }
|
||||
|
||||
OxygenTheme {
|
||||
LazyColumn {
|
||||
itemsIndexed(groups) { index, item ->
|
||||
if (index != 0) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
ToolGroupCard(
|
||||
toolGroup = item
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun SwitchableIconPreview() {
|
||||
var switched by remember { mutableStateOf(false) }
|
||||
|
||||
OxygenTheme {
|
||||
Surface(
|
||||
onClick = { switched = !switched }
|
||||
) {
|
||||
SwitchableIcon(icon = OxygenIcons.ArrowDown, switched = switched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
fun ToolGroupItemPreview() {
|
||||
OxygenTheme {
|
||||
ToolGroupItem(icon = OxygenIcons.Time, title = "Time Screen")
|
||||
}
|
||||
}
|
||||
|
||||
@OxygenPreviews
|
||||
@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(2.dp).fillMaxHeight()
|
||||
Orientation.Horizontal -> height(2.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?.run { interactionSource?.tryEmit(DragInteraction.Stop(this)) }
|
||||
draggedOffset = Offset.Unspecified
|
||||
}
|
||||
val onDragCancel: () -> Unit = {
|
||||
dragInteraction?.run { interactionSource?.tryEmit(DragInteraction.Cancel(this)) }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.search
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
|
||||
@Composable
|
||||
internal fun SearchRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClick: () -> Unit,
|
||||
// searchViewmodel: SearchViewModel = hiltViewModel()
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
) {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(imageVector = OxygenIcons.Back, contentDescription = null)
|
||||
}
|
||||
Text("Search")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
) : ViewModel()
|
||||
@@ -1,55 +1,56 @@
|
||||
package top.fatweb.oxygen.toolbox.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.ui.component.ThemePreviews
|
||||
import top.fatweb.oxygen.toolbox.icon.OxygenIcons
|
||||
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.DialogChooserRow
|
||||
import top.fatweb.oxygen.toolbox.ui.component.DialogClickerRow
|
||||
import top.fatweb.oxygen.toolbox.ui.component.DialogSectionGroup
|
||||
import top.fatweb.oxygen.toolbox.ui.component.DialogSectionTitle
|
||||
import top.fatweb.oxygen.toolbox.ui.component.Indicator
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenPreviews
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.OxygenTheme
|
||||
import top.fatweb.oxygen.toolbox.ui.theme.supportsDynamicTheming
|
||||
|
||||
@Composable
|
||||
fun SettingsDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
onNavigateToLibraries: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
|
||||
SettingsDialog(
|
||||
modifier = modifier,
|
||||
settingsUiState = settingsUiState,
|
||||
onNavigateToLibraries = onNavigateToLibraries,
|
||||
onNavigateToAbout = onNavigateToAbout,
|
||||
onDismiss = onDismiss,
|
||||
onChangeLanguageConfig = viewModel::updateLanguageConfig,
|
||||
onChangeLaunchPageConfig = viewModel::updateLaunchPageConfig,
|
||||
@@ -63,6 +64,8 @@ fun SettingsDialog(
|
||||
fun SettingsDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
settingsUiState: SettingsUiState,
|
||||
onNavigateToLibraries: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
supportDynamicColor: Boolean = supportsDynamicTheming(),
|
||||
onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit,
|
||||
@@ -77,7 +80,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(
|
||||
@@ -92,16 +94,16 @@ fun SettingsDialog(
|
||||
) {
|
||||
when (settingsUiState) {
|
||||
SettingsUiState.Loading -> {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
text = stringResource(R.string.feature_settings_loading)
|
||||
)
|
||||
Indicator()
|
||||
}
|
||||
|
||||
is SettingsUiState.Success -> {
|
||||
SettingsPanel(
|
||||
settings = settingsUiState.settings,
|
||||
supportDynamicColor = supportDynamicColor,
|
||||
onNavigateToLibraries = onNavigateToLibraries,
|
||||
onNavigateToAbout = onNavigateToAbout,
|
||||
onDismiss = onDismiss,
|
||||
onChangeLanguageConfig = onChangeLanguageConfig,
|
||||
onChangeLaunchPageConfig = onChangeLaunchPageConfig,
|
||||
onchangeThemeBrandConfig = onchangeThemeBrandConfig,
|
||||
@@ -114,157 +116,136 @@ fun SettingsDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onDismiss() }) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.clickable { onDismiss() },
|
||||
text = stringResource(R.string.feature_settings_dismiss_dialog_button_text),
|
||||
text = stringResource(R.string.core_ok),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.SettingsPanel(
|
||||
settings: UserEditableSettings,
|
||||
settings: UserData,
|
||||
supportDynamicColor: Boolean,
|
||||
onNavigateToLibraries: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeLanguageConfig: (languageConfig: LanguageConfig) -> Unit,
|
||||
onChangeLaunchPageConfig: (launchPageConfig: LaunchPageConfig) -> Unit,
|
||||
onchangeThemeBrandConfig: (themeBrandConfig: ThemeBrandConfig) -> Unit,
|
||||
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
|
||||
onchangeUseDynamicColor: (useDynamicColor: Boolean) -> Unit
|
||||
) {
|
||||
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_language))
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup()
|
||||
) {
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogSectionTitle(text = stringResource(R.string.feature_settings_language))
|
||||
DialogSectionGroup {
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_language_system_default),
|
||||
selected = settings.languageConfig == LanguageConfig.FOLLOW_SYSTEM,
|
||||
onClick = { onChangeLanguageConfig(LanguageConfig.FOLLOW_SYSTEM) }
|
||||
selected = settings.languageConfig == LanguageConfig.FollowSystem,
|
||||
onClick = { onChangeLanguageConfig(LanguageConfig.FollowSystem) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_language_chinese),
|
||||
selected = settings.languageConfig == LanguageConfig.CHINESE,
|
||||
onClick = { onChangeLanguageConfig(LanguageConfig.CHINESE) }
|
||||
selected = settings.languageConfig == LanguageConfig.Chinese,
|
||||
onClick = { onChangeLanguageConfig(LanguageConfig.Chinese) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_language_english),
|
||||
selected = settings.languageConfig == LanguageConfig.ENGLISH,
|
||||
onClick = { onChangeLanguageConfig(LanguageConfig.ENGLISH) }
|
||||
selected = settings.languageConfig == LanguageConfig.English,
|
||||
onClick = { onChangeLanguageConfig(LanguageConfig.English) }
|
||||
)
|
||||
}
|
||||
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_launch_page))
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup()
|
||||
) {
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogSectionTitle(text = stringResource(R.string.feature_settings_launch_page))
|
||||
DialogSectionGroup {
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_launch_page_tools),
|
||||
selected = settings.launchPageConfig == LaunchPageConfig.TOOLS,
|
||||
onClick = { onChangeLaunchPageConfig(LaunchPageConfig.TOOLS) }
|
||||
selected = settings.launchPageConfig == LaunchPageConfig.Tools,
|
||||
onClick = { onChangeLaunchPageConfig(LaunchPageConfig.Tools) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_launch_page_star),
|
||||
selected = settings.launchPageConfig == LaunchPageConfig.STAR,
|
||||
onClick = { onChangeLaunchPageConfig(LaunchPageConfig.STAR) }
|
||||
selected = settings.launchPageConfig == LaunchPageConfig.Star,
|
||||
onClick = { onChangeLaunchPageConfig(LaunchPageConfig.Star) }
|
||||
)
|
||||
}
|
||||
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_theme_brand))
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup()
|
||||
) {
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogSectionTitle(text = stringResource(R.string.feature_settings_theme_brand))
|
||||
DialogSectionGroup {
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_theme_brand_default),
|
||||
selected = settings.themeBrandConfig == ThemeBrandConfig.DEFAULT,
|
||||
onClick = { onchangeThemeBrandConfig(ThemeBrandConfig.DEFAULT) }
|
||||
selected = settings.themeBrandConfig == ThemeBrandConfig.Default,
|
||||
onClick = { onchangeThemeBrandConfig(ThemeBrandConfig.Default) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_theme_brand_android),
|
||||
selected = settings.themeBrandConfig == ThemeBrandConfig.ANDROID,
|
||||
onClick = { onchangeThemeBrandConfig(ThemeBrandConfig.ANDROID) }
|
||||
selected = settings.themeBrandConfig == ThemeBrandConfig.Android,
|
||||
onClick = { onchangeThemeBrandConfig(ThemeBrandConfig.Android) }
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = settings.themeBrandConfig == ThemeBrandConfig.DEFAULT && supportDynamicColor) {
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup()
|
||||
) {
|
||||
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_dynamic_color))
|
||||
SettingsDialogThemeChooserRow(
|
||||
AnimatedVisibility(visible = settings.themeBrandConfig == ThemeBrandConfig.Default && supportDynamicColor) {
|
||||
DialogSectionGroup {
|
||||
DialogSectionTitle(text = stringResource(R.string.feature_settings_dynamic_color))
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_dynamic_color_enable),
|
||||
selected = settings.useDynamicColor,
|
||||
onClick = { onchangeUseDynamicColor(true) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_dynamic_color_disable),
|
||||
selected = !settings.useDynamicColor,
|
||||
onClick = { onchangeUseDynamicColor(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsDialogSectionTitle(text = stringResource(R.string.feature_settings_dark_mode))
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup()
|
||||
) {
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogSectionTitle(text = stringResource(R.string.feature_settings_dark_mode))
|
||||
DialogSectionGroup {
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_dark_mode_system_default),
|
||||
selected = settings.darkThemeConfig == DarkThemeConfig.FOLLOW_SYSTEM,
|
||||
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.FOLLOW_SYSTEM) }
|
||||
selected = settings.darkThemeConfig == DarkThemeConfig.FollowSystem,
|
||||
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.FollowSystem) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_dark_mode_light),
|
||||
selected = settings.darkThemeConfig == DarkThemeConfig.LIGHT,
|
||||
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.LIGHT) }
|
||||
selected = settings.darkThemeConfig == DarkThemeConfig.Light,
|
||||
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.Light) }
|
||||
)
|
||||
SettingsDialogThemeChooserRow(
|
||||
DialogChooserRow(
|
||||
text = stringResource(R.string.feature_settings_dark_mode_dark),
|
||||
selected = settings.darkThemeConfig == DarkThemeConfig.DARK,
|
||||
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.DARK) }
|
||||
selected = settings.darkThemeConfig == DarkThemeConfig.Dark,
|
||||
onClick = { onChangeDarkThemeConfig(DarkThemeConfig.Dark) }
|
||||
)
|
||||
}
|
||||
DialogSectionTitle(text = stringResource(R.string.feature_settings_more))
|
||||
DialogSectionGroup {
|
||||
DialogClickerRow(
|
||||
icon = OxygenIcons.Code,
|
||||
text = stringResource(R.string.feature_settings_open_source_license),
|
||||
onClick = {
|
||||
onNavigateToLibraries()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
DialogClickerRow(
|
||||
icon = OxygenIcons.Info,
|
||||
text = stringResource(R.string.feature_settings_more_about),
|
||||
onClick = {
|
||||
onNavigateToAbout()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsDialogSectionTitle(text: String) {
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsDialogThemeChooserRow(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.selectable(
|
||||
selected = selected,
|
||||
role = Role.RadioButton,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun SettingsDialogLoadingPreview() {
|
||||
OxygenTheme {
|
||||
SettingsDialog(
|
||||
onNavigateToLibraries = {},
|
||||
onNavigateToAbout = {},
|
||||
onDismiss = { },
|
||||
settingsUiState = SettingsUiState.Loading,
|
||||
onChangeLanguageConfig = {},
|
||||
@@ -276,18 +257,20 @@ private fun SettingsDialogLoadingPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@OxygenPreviews
|
||||
@Composable
|
||||
private fun SettingDialogPreview() {
|
||||
OxygenTheme {
|
||||
SettingsDialog(
|
||||
onNavigateToLibraries = {},
|
||||
onNavigateToAbout = {},
|
||||
onDismiss = {},
|
||||
settingsUiState = SettingsUiState.Success(
|
||||
UserEditableSettings(
|
||||
languageConfig = LanguageConfig.FOLLOW_SYSTEM,
|
||||
launchPageConfig = LaunchPageConfig.TOOLS,
|
||||
themeBrandConfig = ThemeBrandConfig.DEFAULT,
|
||||
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
|
||||
UserData(
|
||||
languageConfig = LanguageConfig.FollowSystem,
|
||||
launchPageConfig = LaunchPageConfig.Tools,
|
||||
themeBrandConfig = ThemeBrandConfig.Default,
|
||||
darkThemeConfig = DarkThemeConfig.FollowSystem,
|
||||
useDynamicColor = true
|
||||
)
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user