fix: 处理引用依赖问题
This commit is contained in:
56
spa/Android.bp
Normal file
56
spa/Android.bp
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Copyright (C) 2022 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package {
|
||||
default_applicable_licenses: ["frameworks_base_license"],
|
||||
}
|
||||
|
||||
android_library {
|
||||
name: "SpaLib",
|
||||
|
||||
srcs: ["src/**/*.kt"],
|
||||
use_resource_processor: true,
|
||||
static_libs: [
|
||||
"SettingsLibColor",
|
||||
"androidx.slice_slice-builders",
|
||||
"androidx.slice_slice-core",
|
||||
"androidx.slice_slice-view",
|
||||
"androidx.compose.animation_animation",
|
||||
"androidx.compose.material3_material3",
|
||||
"androidx.compose.material_material-icons-extended",
|
||||
"androidx.compose.runtime_runtime",
|
||||
"androidx.compose.runtime_runtime-livedata",
|
||||
"androidx.compose.ui_ui-tooling-preview",
|
||||
"androidx.lifecycle_lifecycle-livedata-ktx",
|
||||
"androidx.lifecycle_lifecycle-runtime-compose",
|
||||
"androidx.navigation_navigation-compose",
|
||||
"com.google.android.material_material",
|
||||
"lottie_compose",
|
||||
"MPAndroidChart",
|
||||
],
|
||||
kotlincflags: [
|
||||
"-Xjvm-default=all",
|
||||
],
|
||||
sdk_version: "current",
|
||||
min_sdk_version: "31",
|
||||
}
|
||||
|
||||
// Expose the srcs to tests, so the tests can access the internal classes.
|
||||
filegroup {
|
||||
name: "SpaLib_srcs",
|
||||
visibility: ["//frameworks/base/packages/SettingsLib/Spa/tests"],
|
||||
srcs: ["src/**/*.kt"],
|
||||
}
|
||||
20
spa/AndroidManifest.xml
Normal file
20
spa/AndroidManifest.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Copyright (C) 2022 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.android.settingslib.spa">
|
||||
<uses-sdk android:minSdkVersion="21"/>
|
||||
</manifest>
|
||||
106
spa/build.gradle
Normal file
106
spa/build.gradle
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'jacoco'
|
||||
}
|
||||
|
||||
ext.jetpackComposeVersion = '1.3.0-alpha01' // 请根据实际版本号进行调整
|
||||
|
||||
android {
|
||||
namespace = "com.android.settingslib.spa"
|
||||
compileSdkVersion = 34
|
||||
defaultConfig {
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
kotlin.srcDirs = ['src']
|
||||
res.srcDirs = ['res']
|
||||
manifest.srcFile 'AndroidManifest.xml'
|
||||
}
|
||||
androidTest {
|
||||
kotlin.srcDirs = ['../tests/src']
|
||||
res.srcDirs = ['../tests/res']
|
||||
manifest.srcFile '../tests/AndroidManifest.xml'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
enableAndroidTestCoverage = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = '1.5.0' // 假设1.4.0支持Kotlin 1.9.0
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api project(':SettingsLib:Color')
|
||||
api 'androidx.appcompat:appcompat:1.7.0-alpha03'
|
||||
api 'androidx.slice:slice-builders:1.1.0-alpha02'
|
||||
api 'androidx.slice:slice-core:1.1.0-alpha02'
|
||||
api 'androidx.slice:slice-view:1.1.0-alpha02'
|
||||
api "androidx.compose.material3:material3:1.3.0"
|
||||
api "androidx.compose.material:material-icons-extended:1.5.0"
|
||||
api "androidx.compose.runtime:runtime-livedata:1.5.0"
|
||||
api "androidx.compose.ui:ui-tooling-preview:1.5.0"
|
||||
api 'androidx.lifecycle:lifecycle-livedata-ktx'
|
||||
api 'androidx.lifecycle:lifecycle-runtime-compose'
|
||||
api 'androidx.navigation:navigation-compose:2.8.0-alpha02'
|
||||
api 'com.github.PhilJay:MPAndroidChart:v3.1.0'
|
||||
api 'com.google.android.material:material:1.7.0-alpha03'
|
||||
debugApi "androidx.compose.ui:ui-tooling:1.5.0"
|
||||
implementation 'com.airbnb.android:lottie-compose:5.2.0'
|
||||
|
||||
// androidTestImplementation project(':testutils')
|
||||
// androidTestImplementation libs.dexmaker.mockito
|
||||
}
|
||||
|
||||
tasks.register('coverageReport', JacocoReport) {
|
||||
group = 'Reporting'
|
||||
description = 'Generate Jacoco coverage reports after running tests.'
|
||||
dependsOn 'connectedDebugAndroidTest'
|
||||
sourceDirectories.setFrom files('src')
|
||||
classDirectories.setFrom fileTree(dir: "${layout.buildDirectory.dir('tmp/kotlin-classes/debug')}", excludes: [
|
||||
'com/android/settingslib/spa/debug/**',
|
||||
'com/android/settingslib/spa/widget/scaffold/CustomizedAppBar*',
|
||||
'com/android/settingslib/spa/widget/scaffold/TopAppBarColors*',
|
||||
'com/android/settingslib/spa/framework/compose/DrawablePainter*',
|
||||
'com/android/settingslib/spa/framework/util/Collections*',
|
||||
'com/android/settingslib/spa/framework/util/Flows*',
|
||||
'com/android/settingslib/spa/framework/compose/TimeMeasurer*',
|
||||
'com/android/settingslib/spa/slice/presenter/Demo*',
|
||||
'com/android/settingslib/spa/slice/provider/Demo*',
|
||||
])
|
||||
executionData.setFrom fileTree(dir: "${layout.buildDirectory.dir('outputs/code_coverage/debugAndroidTest/connected')}")
|
||||
}
|
||||
111
spa/build.gradle.kts
Normal file
111
spa/build.gradle.kts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
jacoco
|
||||
}
|
||||
|
||||
val jetpackComposeVersion: String? by extra
|
||||
|
||||
android {
|
||||
namespace = "com.android.settingslib.spa"
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
sourceSets.getByName("main") {
|
||||
kotlin.setSrcDirs(listOf("src"))
|
||||
res.setSrcDirs(listOf("res"))
|
||||
manifest.srcFile("AndroidManifest.xml")
|
||||
}
|
||||
sourceSets.getByName("androidTest") {
|
||||
kotlin.setSrcDirs(listOf("../tests/src"))
|
||||
res.setSrcDirs(listOf("../tests/res"))
|
||||
manifest.srcFile("../tests/AndroidManifest.xml")
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
enableAndroidTestCoverage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":SettingsLib:Color"))
|
||||
api("androidx.appcompat:appcompat:1.7.0-alpha03")
|
||||
api("androidx.slice:slice-builders:1.1.0-alpha02")
|
||||
api("androidx.slice:slice-core:1.1.0-alpha02")
|
||||
api("androidx.slice:slice-view:1.1.0-alpha02")
|
||||
api("androidx.compose.material3:material3:1.3.0-alpha01")
|
||||
api("androidx.compose.material:material-icons-extended:$jetpackComposeVersion")
|
||||
api("androidx.compose.runtime:runtime-livedata:$jetpackComposeVersion")
|
||||
api("androidx.compose.ui:ui-tooling-preview:$jetpackComposeVersion")
|
||||
api("androidx.lifecycle:lifecycle-livedata-ktx")
|
||||
api("androidx.lifecycle:lifecycle-runtime-compose")
|
||||
api("androidx.navigation:navigation-compose:2.8.0-alpha02")
|
||||
api("com.github.PhilJay:MPAndroidChart:v3.1.0")
|
||||
api("com.google.android.material:material:1.7.0-alpha03")
|
||||
debugApi("androidx.compose.ui:ui-tooling:$jetpackComposeVersion")
|
||||
implementation("com.airbnb.android:lottie-compose:5.2.0")
|
||||
|
||||
// androidTestImplementation(project(":testutils"))
|
||||
// androidTestImplementation(libs.dexmaker.mockito)
|
||||
}
|
||||
|
||||
tasks.register<JacocoReport>("coverageReport") {
|
||||
group = "Reporting"
|
||||
description = "Generate Jacoco coverage reports after running tests."
|
||||
dependsOn("connectedDebugAndroidTest")
|
||||
sourceDirectories.setFrom(files("src"))
|
||||
classDirectories.setFrom(
|
||||
fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) {
|
||||
setExcludes(
|
||||
listOf(
|
||||
"com/android/settingslib/spa/debug/**",
|
||||
|
||||
// Excludes files forked from AndroidX.
|
||||
"com/android/settingslib/spa/widget/scaffold/CustomizedAppBar*",
|
||||
"com/android/settingslib/spa/widget/scaffold/TopAppBarColors*",
|
||||
|
||||
// Excludes files forked from Accompanist.
|
||||
"com/android/settingslib/spa/framework/compose/DrawablePainter*",
|
||||
|
||||
// Excludes inline functions, which is not covered in Jacoco reports.
|
||||
"com/android/settingslib/spa/framework/util/Collections*",
|
||||
"com/android/settingslib/spa/framework/util/Flows*",
|
||||
|
||||
// Excludes debug functions
|
||||
"com/android/settingslib/spa/framework/compose/TimeMeasurer*",
|
||||
|
||||
// Excludes slice demo presenter & provider
|
||||
"com/android/settingslib/spa/slice/presenter/Demo*",
|
||||
"com/android/settingslib/spa/slice/provider/Demo*",
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
executionData.setFrom(
|
||||
fileTree(layout.buildDirectory.dir("outputs/code_coverage/debugAndroidTest/connected"))
|
||||
)
|
||||
}
|
||||
31
spa/res/values/themes.xml
Normal file
31
spa/res/values/themes.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2022 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<resources>
|
||||
|
||||
<style name="Theme.SpaLib" parent="@android:style/Theme.DeviceDefault.Settings">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.SpaLib.Dialog" parent="Theme.Material3.DayNight.Dialog"/>
|
||||
<style name="Theme.SpaLib.BottomSheetDialog" parent="Theme.SpaLib">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
46
spa/src/com/android/settingslib/spa/SpaBaseDialogActivity.kt
Normal file
46
spa/src/com/android/settingslib/spa/SpaBaseDialogActivity.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.android.settingslib.spa.framework.common.LogCategory
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
|
||||
abstract class SpaBaseDialogActivity : ComponentActivity() {
|
||||
private val spaEnvironment get() = SpaEnvironmentFactory.instance
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
spaEnvironment.logger.message(TAG, "onCreate", category = LogCategory.FRAMEWORK)
|
||||
setContent {
|
||||
SettingsTheme {
|
||||
Content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
abstract fun Content()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SpaBaseDialogActivity"
|
||||
}
|
||||
}
|
||||
254
spa/src/com/android/settingslib/spa/debug/DebugActivity.kt
Normal file
254
spa/src/com/android/settingslib/spa/debug/DebugActivity.kt
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.debug
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.android.settingslib.spa.R
|
||||
import com.android.settingslib.spa.framework.common.LogCategory
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntry
|
||||
import com.android.settingslib.spa.framework.common.SettingsPage
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.compose.localNavController
|
||||
import com.android.settingslib.spa.framework.compose.navigator
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.util.SESSION_BROWSE
|
||||
import com.android.settingslib.spa.framework.util.SESSION_SEARCH
|
||||
import com.android.settingslib.spa.framework.util.createIntent
|
||||
import com.android.settingslib.spa.slice.fromEntry
|
||||
import com.android.settingslib.spa.slice.presenter.SliceDemo
|
||||
import com.android.settingslib.spa.widget.preference.Preference
|
||||
import com.android.settingslib.spa.widget.preference.PreferenceModel
|
||||
import com.android.settingslib.spa.widget.scaffold.HomeScaffold
|
||||
import com.android.settingslib.spa.widget.scaffold.RegularScaffold
|
||||
|
||||
private const val TAG = "DebugActivity"
|
||||
private const val ROUTE_ROOT = "root"
|
||||
private const val ROUTE_All_PAGES = "pages"
|
||||
private const val ROUTE_All_ENTRIES = "entries"
|
||||
private const val ROUTE_All_SLICES = "slices"
|
||||
private const val ROUTE_PAGE = "page"
|
||||
private const val ROUTE_ENTRY = "entry"
|
||||
private const val PARAM_NAME_PAGE_ID = "pid"
|
||||
private const val PARAM_NAME_ENTRY_ID = "eid"
|
||||
|
||||
/**
|
||||
* The Debug Activity to display all Spa Pages & Entries.
|
||||
* One can open the debug activity by:
|
||||
* $ adb shell am start -n <Package>/com.android.settingslib.spa.debug.DebugActivity
|
||||
* For gallery, Package = com.android.settingslib.spa.gallery
|
||||
*/
|
||||
class DebugActivity : ComponentActivity() {
|
||||
private val spaEnvironment get() = SpaEnvironmentFactory.instance
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(R.style.Theme_SpaLib)
|
||||
super.onCreate(savedInstanceState)
|
||||
spaEnvironment.logger.message(TAG, "onCreate", category = LogCategory.FRAMEWORK)
|
||||
|
||||
setContent {
|
||||
SettingsTheme {
|
||||
MainContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainContent() {
|
||||
val navController = rememberNavController()
|
||||
CompositionLocalProvider(navController.localNavController()) {
|
||||
NavHost(navController, ROUTE_ROOT) {
|
||||
composable(route = ROUTE_ROOT) { RootPage() }
|
||||
composable(route = ROUTE_All_PAGES) { AllPages() }
|
||||
composable(route = ROUTE_All_ENTRIES) { AllEntries() }
|
||||
composable(route = ROUTE_All_SLICES) { AllSlices() }
|
||||
composable(
|
||||
route = "$ROUTE_PAGE/{$PARAM_NAME_PAGE_ID}",
|
||||
arguments = listOf(
|
||||
navArgument(PARAM_NAME_PAGE_ID) { type = NavType.StringType },
|
||||
)
|
||||
) { navBackStackEntry -> OnePage(navBackStackEntry.arguments) }
|
||||
composable(
|
||||
route = "$ROUTE_ENTRY/{$PARAM_NAME_ENTRY_ID}",
|
||||
arguments = listOf(
|
||||
navArgument(PARAM_NAME_ENTRY_ID) { type = NavType.StringType },
|
||||
)
|
||||
) { navBackStackEntry -> OneEntry(navBackStackEntry.arguments) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RootPage() {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val allPageWithEntry = remember { entryRepository.getAllPageWithEntry() }
|
||||
val allEntry = remember { entryRepository.getAllEntries() }
|
||||
val allSliceEntry =
|
||||
remember { entryRepository.getAllEntries().filter { it.hasSliceSupport } }
|
||||
HomeScaffold(title = "Settings Debug") {
|
||||
Preference(object : PreferenceModel {
|
||||
override val title = "List All Pages (${allPageWithEntry.size})"
|
||||
override val onClick = navigator(route = ROUTE_All_PAGES)
|
||||
})
|
||||
Preference(object : PreferenceModel {
|
||||
override val title = "List All Entries (${allEntry.size})"
|
||||
override val onClick = navigator(route = ROUTE_All_ENTRIES)
|
||||
})
|
||||
Preference(object : PreferenceModel {
|
||||
override val title = "List All Slices (${allSliceEntry.size})"
|
||||
override val onClick = navigator(route = ROUTE_All_SLICES)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AllPages() {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val allPageWithEntry = remember { entryRepository.getAllPageWithEntry() }
|
||||
RegularScaffold(title = "All Pages (${allPageWithEntry.size})") {
|
||||
for (pageWithEntry in allPageWithEntry) {
|
||||
val page = pageWithEntry.page
|
||||
Preference(object : PreferenceModel {
|
||||
override val title = "${page.debugBrief()} (${pageWithEntry.entries.size})"
|
||||
override val summary = { page.debugArguments() }
|
||||
override val onClick = navigator(route = ROUTE_PAGE + "/${page.id}")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AllEntries() {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val allEntry = remember { entryRepository.getAllEntries() }
|
||||
RegularScaffold(title = "All Entries (${allEntry.size})") {
|
||||
EntryList(allEntry)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AllSlices() {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val authority = spaEnvironment.sliceProviderAuthorities
|
||||
val allSliceEntry =
|
||||
remember { entryRepository.getAllEntries().filter { it.hasSliceSupport } }
|
||||
RegularScaffold(title = "All Slices (${allSliceEntry.size})") {
|
||||
for (entry in allSliceEntry) {
|
||||
SliceDemo(sliceUri = Uri.Builder().fromEntry(entry, authority).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnePage(arguments: Bundle?) {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val id = arguments!!.getString(PARAM_NAME_PAGE_ID, "")
|
||||
val pageWithEntry = entryRepository.getPageWithEntry(id)!!
|
||||
val page = pageWithEntry.page
|
||||
RegularScaffold(title = "Page - ${page.debugBrief()}") {
|
||||
Text(text = "id = ${page.id}")
|
||||
Text(text = page.debugArguments())
|
||||
Text(text = "enabled = ${page.isEnabled()}")
|
||||
Text(text = "Entry size: ${pageWithEntry.entries.size}")
|
||||
Preference(model = object : PreferenceModel {
|
||||
override val title = "open page"
|
||||
override val enabled = {
|
||||
spaEnvironment.browseActivityClass != null && page.isBrowsable()
|
||||
}
|
||||
override val onClick = openPage(page)
|
||||
})
|
||||
EntryList(pageWithEntry.entries)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OneEntry(arguments: Bundle?) {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val id = arguments!!.getString(PARAM_NAME_ENTRY_ID, "")
|
||||
val entry = entryRepository.getEntry(id)!!
|
||||
val entryContent = remember { entry.debugContent(entryRepository) }
|
||||
RegularScaffold(title = "Entry - ${entry.debugBrief()}") {
|
||||
Preference(model = object : PreferenceModel {
|
||||
override val title = "open entry"
|
||||
override val enabled = {
|
||||
spaEnvironment.browseActivityClass != null &&
|
||||
entry.containerPage().isBrowsable()
|
||||
}
|
||||
override val onClick = openEntry(entry)
|
||||
})
|
||||
Text(text = entryContent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryList(entries: Collection<SettingsEntry>) {
|
||||
for (entry in entries) {
|
||||
Preference(object : PreferenceModel {
|
||||
override val title = entry.debugBrief()
|
||||
override val summary = {
|
||||
"${entry.fromPage?.displayName} -> ${entry.toPage?.displayName}"
|
||||
}
|
||||
override val onClick = navigator(route = ROUTE_ENTRY + "/${entry.id}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun openPage(page: SettingsPage): (() -> Unit)? {
|
||||
val context = LocalContext.current
|
||||
val intent =
|
||||
page.createIntent(SESSION_BROWSE) ?: return null
|
||||
val route = page.buildRoute()
|
||||
return {
|
||||
spaEnvironment.logger.message(
|
||||
TAG, "OpenPage: $route", category = LogCategory.FRAMEWORK
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun openEntry(entry: SettingsEntry): (() -> Unit)? {
|
||||
val context = LocalContext.current
|
||||
val intent = entry.createIntent(SESSION_SEARCH)
|
||||
?: return null
|
||||
val route = entry.containerPage().buildRoute()
|
||||
return {
|
||||
spaEnvironment.logger.message(
|
||||
TAG, "OpenEntry: $route", category = LogCategory.FRAMEWORK
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank activity without any page.
|
||||
*/
|
||||
class BlankActivity : ComponentActivity()
|
||||
80
spa/src/com/android/settingslib/spa/debug/DebugFormat.kt
Normal file
80
spa/src/com/android/settingslib/spa/debug/DebugFormat.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.debug
|
||||
|
||||
import com.android.settingslib.spa.framework.common.EntrySearchData
|
||||
import com.android.settingslib.spa.framework.common.EntryStatusData
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntry
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntryRepository
|
||||
import com.android.settingslib.spa.framework.common.SettingsPage
|
||||
import com.android.settingslib.spa.framework.util.normalize
|
||||
|
||||
private fun EntrySearchData.debugContent(): String {
|
||||
val content = listOf(
|
||||
"search_title = $title",
|
||||
"search_keyword = $keyword",
|
||||
)
|
||||
return content.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun EntryStatusData.debugContent(): String {
|
||||
val content = listOf(
|
||||
"is_disabled = $isDisabled",
|
||||
"is_switch_off = $isSwitchOff",
|
||||
)
|
||||
return content.joinToString("\n")
|
||||
}
|
||||
|
||||
fun SettingsPage.debugArguments(): String {
|
||||
val normArguments = parameter.normalize(arguments, eraseRuntimeValues = true)
|
||||
if (normArguments == null || normArguments.isEmpty) return "[No arguments]"
|
||||
return normArguments.toString().removeRange(0, 6)
|
||||
}
|
||||
|
||||
fun SettingsPage.debugBrief(): String {
|
||||
return displayName
|
||||
}
|
||||
|
||||
fun SettingsEntry.debugBrief(): String {
|
||||
return "${owner.displayName}:$label"
|
||||
}
|
||||
|
||||
fun SettingsEntry.debugContent(entryRepository: SettingsEntryRepository): String {
|
||||
val searchData = getSearchData()
|
||||
val statusData = getStatusData()
|
||||
val entryPathWithLabel = entryRepository.getEntryPathWithLabel(id)
|
||||
val entryPathWithTitle = entryRepository.getEntryPathWithTitle(id,
|
||||
searchData?.title ?: label)
|
||||
val content = listOf(
|
||||
"------ STATIC ------",
|
||||
"id = $id",
|
||||
"owner = ${owner.debugBrief()} ${owner.debugArguments()}",
|
||||
"linkFrom = ${fromPage?.debugBrief()} ${fromPage?.debugArguments()}",
|
||||
"linkTo = ${toPage?.debugBrief()} ${toPage?.debugArguments()}",
|
||||
"hierarchy_path = $entryPathWithLabel",
|
||||
"------ ATTRIBUTION ------",
|
||||
"allowSearch = $isAllowSearch",
|
||||
"isSearchDynamic = $isSearchDataDynamic",
|
||||
"isSearchMutable = $hasMutableStatus",
|
||||
"hasSlice = $hasSliceSupport",
|
||||
"------ SEARCH ------",
|
||||
"search_path = $entryPathWithTitle",
|
||||
searchData?.debugContent() ?: "no search data",
|
||||
statusData?.debugContent() ?: "no status data",
|
||||
)
|
||||
return content.joinToString("\n")
|
||||
}
|
||||
34
spa/src/com/android/settingslib/spa/debug/DebugLogger.kt
Normal file
34
spa/src/com/android/settingslib/spa/debug/DebugLogger.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.debug
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import com.android.settingslib.spa.framework.common.LogCategory
|
||||
import com.android.settingslib.spa.framework.common.LogEvent
|
||||
import com.android.settingslib.spa.framework.common.SpaLogger
|
||||
|
||||
class DebugLogger : SpaLogger {
|
||||
override fun message(tag: String, msg: String, category: LogCategory) {
|
||||
Log.d("SpaMsg-$category", "[$tag] $msg")
|
||||
}
|
||||
|
||||
override fun event(id: String, event: LogEvent, category: LogCategory, extraData: Bundle) {
|
||||
val extraMsg = extraData.toString().removeRange(0, 6)
|
||||
Log.d("SpaEvent-$category", "[$id] $event $extraMsg")
|
||||
}
|
||||
}
|
||||
205
spa/src/com/android/settingslib/spa/debug/DebugProvider.kt
Normal file
205
spa/src/com/android/settingslib/spa/debug/DebugProvider.kt
Normal file
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.debug
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.URI_INTENT_SCHEME
|
||||
import android.content.UriMatcher
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.util.KEY_DESTINATION
|
||||
import com.android.settingslib.spa.framework.util.KEY_HIGHLIGHT_ENTRY
|
||||
import com.android.settingslib.spa.framework.util.KEY_SESSION_SOURCE_NAME
|
||||
import com.android.settingslib.spa.framework.util.SESSION_BROWSE
|
||||
import com.android.settingslib.spa.framework.util.SESSION_SEARCH
|
||||
import com.android.settingslib.spa.framework.util.createIntent
|
||||
|
||||
private const val TAG = "DebugProvider"
|
||||
|
||||
/**
|
||||
* The content provider to return debug data.
|
||||
* One can query the provider result by:
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/<QueryPath>
|
||||
* For gallery, AuthorityPath = com.android.spa.gallery.debug
|
||||
* Some examples:
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/page_debug
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/entry_debug
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/page_info
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/entry_info
|
||||
*/
|
||||
class DebugProvider : ContentProvider() {
|
||||
private val spaEnvironment get() = SpaEnvironmentFactory.instance
|
||||
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
|
||||
TODO("Implement this to handle requests to delete one or more rows")
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? {
|
||||
TODO(
|
||||
"Implement this to handle requests for the MIME type of the data" +
|
||||
"at the given URI"
|
||||
)
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||
TODO("Implement this to handle requests to insert a new row.")
|
||||
}
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): Int {
|
||||
TODO("Implement this to handle requests to update one or more rows.")
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
Log.d(TAG, "onCreate")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun attachInfo(context: Context?, info: ProviderInfo?) {
|
||||
if (info != null) {
|
||||
QueryEnum.PAGE_DEBUG_QUERY.addUri(uriMatcher, info.authority)
|
||||
QueryEnum.ENTRY_DEBUG_QUERY.addUri(uriMatcher, info.authority)
|
||||
QueryEnum.PAGE_INFO_QUERY.addUri(uriMatcher, info.authority)
|
||||
QueryEnum.ENTRY_INFO_QUERY.addUri(uriMatcher, info.authority)
|
||||
}
|
||||
super.attachInfo(context, info)
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? {
|
||||
return try {
|
||||
when (uriMatcher.match(uri)) {
|
||||
QueryEnum.PAGE_DEBUG_QUERY.queryMatchCode -> queryPageDebug()
|
||||
QueryEnum.ENTRY_DEBUG_QUERY.queryMatchCode -> queryEntryDebug()
|
||||
QueryEnum.PAGE_INFO_QUERY.queryMatchCode -> queryPageInfo()
|
||||
QueryEnum.ENTRY_INFO_QUERY.queryMatchCode -> queryEntryInfo()
|
||||
else -> throw UnsupportedOperationException("Unknown Uri $uri")
|
||||
}
|
||||
} catch (e: UnsupportedOperationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Provider querying exception:", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryPageDebug(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.PAGE_DEBUG_QUERY.getColumns())
|
||||
for (pageWithEntry in entryRepository.getAllPageWithEntry()) {
|
||||
val page = pageWithEntry.page
|
||||
if (!page.isBrowsable()) continue
|
||||
val command = createBrowseAdbCommand(
|
||||
destination = page.buildRoute(),
|
||||
sessionName = SESSION_BROWSE
|
||||
)
|
||||
if (command != null) {
|
||||
cursor.newRow().add(ColumnEnum.PAGE_START_ADB.id, command)
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
private fun queryEntryDebug(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.ENTRY_DEBUG_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
val page = entry.containerPage()
|
||||
if (!page.isBrowsable()) continue
|
||||
val command = createBrowseAdbCommand(
|
||||
destination = page.buildRoute(),
|
||||
entryId = entry.id,
|
||||
sessionName = SESSION_SEARCH
|
||||
)
|
||||
if (command != null) {
|
||||
cursor.newRow().add(ColumnEnum.ENTRY_START_ADB.id, command)
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
private fun queryPageInfo(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.PAGE_INFO_QUERY.getColumns())
|
||||
for (pageWithEntry in entryRepository.getAllPageWithEntry()) {
|
||||
val page = pageWithEntry.page
|
||||
val intent = page.createIntent(SESSION_BROWSE) ?: Intent()
|
||||
cursor.newRow()
|
||||
.add(ColumnEnum.PAGE_ID.id, page.id)
|
||||
.add(ColumnEnum.PAGE_NAME.id, page.displayName)
|
||||
.add(ColumnEnum.PAGE_ROUTE.id, page.buildRoute())
|
||||
.add(ColumnEnum.PAGE_INTENT_URI.id, intent.toUri(URI_INTENT_SCHEME))
|
||||
.add(ColumnEnum.PAGE_ENTRY_COUNT.id, pageWithEntry.entries.size)
|
||||
.add(ColumnEnum.PAGE_BROWSABLE.id, if (page.isBrowsable()) 1 else 0)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
private fun queryEntryInfo(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.ENTRY_INFO_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
val intent = entry.createIntent(SESSION_SEARCH) ?: Intent()
|
||||
cursor.newRow()
|
||||
.add(ColumnEnum.ENTRY_ID.id, entry.id)
|
||||
.add(ColumnEnum.ENTRY_LABEL.id, entry.label)
|
||||
.add(ColumnEnum.ENTRY_ROUTE.id, entry.containerPage().buildRoute())
|
||||
.add(ColumnEnum.ENTRY_INTENT_URI.id, intent.toUri(URI_INTENT_SCHEME))
|
||||
.add(
|
||||
ColumnEnum.ENTRY_HIERARCHY_PATH.id,
|
||||
entryRepository.getEntryPathWithLabel(entry.id)
|
||||
)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBrowseAdbCommand(
|
||||
destination: String? = null,
|
||||
entryId: String? = null,
|
||||
sessionName: String? = null,
|
||||
): String? {
|
||||
val context = SpaEnvironmentFactory.instance.appContext
|
||||
val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
|
||||
val packageName = context.packageName
|
||||
val activityName = browseActivityClass.name.replace(packageName, "")
|
||||
val destinationParam =
|
||||
if (destination != null) " -e $KEY_DESTINATION $destination" else ""
|
||||
val highlightParam =
|
||||
if (entryId != null) " -e $KEY_HIGHLIGHT_ENTRY $entryId" else ""
|
||||
val sessionParam =
|
||||
if (sessionName != null) " -e $KEY_SESSION_SOURCE_NAME $sessionName" else ""
|
||||
return "adb shell am start -n $packageName/$activityName" +
|
||||
"$destinationParam$highlightParam$sessionParam"
|
||||
}
|
||||
97
spa/src/com/android/settingslib/spa/debug/ProviderColumn.kt
Normal file
97
spa/src/com/android/settingslib/spa/debug/ProviderColumn.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.debug
|
||||
|
||||
import android.content.UriMatcher
|
||||
|
||||
/**
|
||||
* Enum to define all column names in provider.
|
||||
*/
|
||||
enum class ColumnEnum(val id: String) {
|
||||
// Columns related to page
|
||||
PAGE_ID("pageId"),
|
||||
PAGE_NAME("pageName"),
|
||||
PAGE_ROUTE("pageRoute"),
|
||||
PAGE_INTENT_URI("pageIntent"),
|
||||
PAGE_ENTRY_COUNT("entryCount"),
|
||||
PAGE_BROWSABLE("pageBrowsable"),
|
||||
PAGE_START_ADB("pageStartAdb"),
|
||||
|
||||
// Columns related to entry
|
||||
ENTRY_ID("entryId"),
|
||||
ENTRY_LABEL("entryLabel"),
|
||||
ENTRY_ROUTE("entryRoute"),
|
||||
ENTRY_INTENT_URI("entryIntent"),
|
||||
ENTRY_HIERARCHY_PATH("entryPath"),
|
||||
ENTRY_START_ADB("entryStartAdb"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum to define all queries supported in the provider.
|
||||
*/
|
||||
enum class QueryEnum(
|
||||
val queryPath: String,
|
||||
val queryMatchCode: Int,
|
||||
val columnNames: List<ColumnEnum>
|
||||
) {
|
||||
// For debug
|
||||
PAGE_DEBUG_QUERY(
|
||||
"page_debug", 1,
|
||||
listOf(ColumnEnum.PAGE_START_ADB)
|
||||
),
|
||||
ENTRY_DEBUG_QUERY(
|
||||
"entry_debug", 2,
|
||||
listOf(ColumnEnum.ENTRY_START_ADB)
|
||||
),
|
||||
|
||||
// page related queries.
|
||||
PAGE_INFO_QUERY(
|
||||
"page_info", 100,
|
||||
listOf(
|
||||
ColumnEnum.PAGE_ID,
|
||||
ColumnEnum.PAGE_NAME,
|
||||
ColumnEnum.PAGE_ROUTE,
|
||||
ColumnEnum.PAGE_INTENT_URI,
|
||||
ColumnEnum.PAGE_ENTRY_COUNT,
|
||||
ColumnEnum.PAGE_BROWSABLE,
|
||||
)
|
||||
),
|
||||
|
||||
// entry related queries
|
||||
ENTRY_INFO_QUERY(
|
||||
"entry_info", 200,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.ENTRY_ROUTE,
|
||||
ColumnEnum.ENTRY_INTENT_URI,
|
||||
ColumnEnum.ENTRY_HIERARCHY_PATH,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
internal fun QueryEnum.getColumns(): Array<String> {
|
||||
return columnNames.map { it.id }.toTypedArray()
|
||||
}
|
||||
|
||||
internal fun QueryEnum.getIndex(name: ColumnEnum): Int {
|
||||
return columnNames.indexOf(name)
|
||||
}
|
||||
|
||||
internal fun QueryEnum.addUri(uriMatcher: UriMatcher, authority: String) {
|
||||
uriMatcher.addURI(authority, queryPath, queryMatchCode)
|
||||
}
|
||||
24
spa/src/com/android/settingslib/spa/debug/UiModePreviews.kt
Normal file
24
spa/src/com/android/settingslib/spa/debug/UiModePreviews.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.debug
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
annotation class UiModePreviews
|
||||
184
spa/src/com/android/settingslib/spa/framework/BrowseActivity.kt
Normal file
184
spa/src/com/android/settingslib/spa/framework/BrowseActivity.kt
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.android.settingslib.spa.R
|
||||
import com.android.settingslib.spa.framework.common.LogCategory
|
||||
import com.android.settingslib.spa.framework.common.NullPageProvider
|
||||
import com.android.settingslib.spa.framework.common.SettingsPage
|
||||
import com.android.settingslib.spa.framework.common.SettingsPageProvider
|
||||
import com.android.settingslib.spa.framework.common.SettingsPageProvider.NavType
|
||||
import com.android.settingslib.spa.framework.common.SettingsPageProviderRepository
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.common.createSettingsPage
|
||||
import com.android.settingslib.spa.framework.compose.LocalNavController
|
||||
import com.android.settingslib.spa.framework.compose.NavControllerWrapperImpl
|
||||
import com.android.settingslib.spa.framework.compose.animatedComposable
|
||||
import com.android.settingslib.spa.framework.compose.localNavController
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.util.PageLogger
|
||||
import com.android.settingslib.spa.framework.util.getDestination
|
||||
import com.android.settingslib.spa.framework.util.getEntryId
|
||||
import com.android.settingslib.spa.framework.util.getSessionName
|
||||
import com.android.settingslib.spa.framework.util.navRoute
|
||||
|
||||
private const val TAG = "BrowseActivity"
|
||||
|
||||
/**
|
||||
* The Activity to render ALL SPA pages, and handles jumps between SPA pages.
|
||||
*
|
||||
* One can open any SPA page by:
|
||||
* ```
|
||||
* $ adb shell am start -n <BrowseActivityComponent> -e spaActivityDestination <SpaPageRoute>
|
||||
* ```
|
||||
* - For Gallery, BrowseActivityComponent = com.android.settingslib.spa.gallery/.GalleryMainActivity
|
||||
* - For Settings, BrowseActivityComponent = com.android.settings/.spa.SpaActivity
|
||||
*
|
||||
* Some examples:
|
||||
* ```
|
||||
* $ adb shell am start -n <BrowseActivityComponent> -e spaActivityDestination HOME
|
||||
* $ adb shell am start -n <BrowseActivityComponent> -e spaActivityDestination ARGUMENT/bar/5
|
||||
* ```
|
||||
*/
|
||||
open class BrowseActivity : ComponentActivity() {
|
||||
private val spaEnvironment get() = SpaEnvironmentFactory.instance
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(R.style.Theme_SpaLib)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
spaEnvironment.logger.message(TAG, "onCreate", category = LogCategory.FRAMEWORK)
|
||||
|
||||
setContent {
|
||||
SettingsTheme {
|
||||
val sppRepository by spaEnvironment.pageProviderRepository
|
||||
BrowseContent(
|
||||
sppRepository = sppRepository,
|
||||
isPageEnabled = ::isPageEnabled,
|
||||
initialIntent = intent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun isPageEnabled(page: SettingsPage) = page.isEnabled()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@Composable
|
||||
internal fun BrowseContent(
|
||||
sppRepository: SettingsPageProviderRepository,
|
||||
isPageEnabled: (SettingsPage) -> Boolean,
|
||||
initialIntent: Intent?,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
CompositionLocalProvider(navController.localNavController()) {
|
||||
val controller = LocalNavController.current as NavControllerWrapperImpl
|
||||
controller.NavContent(sppRepository.getAllProviders()) { page ->
|
||||
if (remember { isPageEnabled(page) }) {
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Launching page ${page.sppName}")
|
||||
}
|
||||
page.PageLogger()
|
||||
page.UiLayout()
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
controller.navigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
controller.InitialDestination(initialIntent, sppRepository.getDefaultStartPage())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavControllerWrapperImpl.NavContent(
|
||||
allProvider: Collection<SettingsPageProvider>,
|
||||
content: @Composable (SettingsPage) -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = NullPageProvider.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
composable(NullPageProvider.name) {}
|
||||
for (spp in allProvider) {
|
||||
destination(spp) { navBackStackEntry ->
|
||||
val page = remember { spp.createSettingsPage(navBackStackEntry.arguments) }
|
||||
content(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.destination(
|
||||
spp: SettingsPageProvider,
|
||||
content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
val route = spp.name + spp.parameter.navRoute()
|
||||
when (spp.navType) {
|
||||
NavType.Page -> animatedComposable(route, spp.parameter) { content(it) }
|
||||
NavType.Dialog -> dialog(route, spp.parameter) { content(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavControllerWrapperImpl.InitialDestination(
|
||||
initialIntent: Intent?,
|
||||
defaultDestination: String
|
||||
) {
|
||||
val destinationNavigated = rememberSaveable { mutableStateOf(false) }
|
||||
if (destinationNavigated.value) return
|
||||
destinationNavigated.value = true
|
||||
|
||||
val initialDestination = initialIntent?.getDestination() ?: defaultDestination
|
||||
if (initialDestination.isEmpty()) return
|
||||
val initialEntryId = initialIntent?.getEntryId()
|
||||
val sessionSourceName = initialIntent?.getSessionName()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
highlightId = initialEntryId
|
||||
sessionName = sessionSourceName
|
||||
navController.navigate(initialDestination) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Defines interface of a entry macro, which contains entry functions to support different
|
||||
* scenarios, such as browsing (UiLayout), search, etc.
|
||||
*/
|
||||
interface EntryMacro {
|
||||
@Composable
|
||||
fun UiLayout() {}
|
||||
fun getSearchData(): EntrySearchData? = null
|
||||
fun getStatusData(): EntryStatusData? = null
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
/**
|
||||
* Defines Search data of one Settings entry.
|
||||
*/
|
||||
data class EntrySearchData(
|
||||
val title: String = "",
|
||||
val keyword: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.slice.Slice
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class EntrySliceData : LiveData<Slice?>() {
|
||||
private val asyncRunnerScope = CoroutineScope(Dispatchers.IO)
|
||||
private var asyncRunnerJob: Job? = null
|
||||
private var asyncActionJob: Job? = null
|
||||
private var isActive = false
|
||||
|
||||
open suspend fun asyncRunner() {}
|
||||
|
||||
open suspend fun asyncAction() {}
|
||||
|
||||
override fun onActive() {
|
||||
asyncRunnerJob?.cancel()
|
||||
asyncRunnerJob = asyncRunnerScope.launch { asyncRunner() }
|
||||
isActive = true
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
asyncRunnerJob?.cancel()
|
||||
asyncRunnerJob = null
|
||||
asyncActionJob?.cancel()
|
||||
asyncActionJob = null
|
||||
isActive = false
|
||||
}
|
||||
|
||||
fun isActive(): Boolean {
|
||||
return isActive
|
||||
}
|
||||
|
||||
fun doAction() {
|
||||
asyncActionJob?.cancel()
|
||||
asyncActionJob = asyncRunnerScope.launch { asyncAction() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
/**
|
||||
* Defines the status data of one Settings entry, which could be changed frequently.
|
||||
*/
|
||||
data class EntryStatusData(
|
||||
val isDisabled: Boolean = false,
|
||||
val isSwitchOff: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
open class PageModel : ViewModel() {
|
||||
var initialized = false
|
||||
|
||||
fun initOnce(arguments: Bundle? = null) {
|
||||
// Initialize only once
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
initialize(arguments)
|
||||
}
|
||||
|
||||
open fun initialize(arguments: Bundle?) {}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidedValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.android.settingslib.spa.framework.compose.LocalNavController
|
||||
|
||||
interface EntryData {
|
||||
val pageId: String?
|
||||
get() = null
|
||||
val entryId: String?
|
||||
get() = null
|
||||
val isHighlighted: Boolean
|
||||
get() = false
|
||||
val arguments: Bundle?
|
||||
get() = null
|
||||
}
|
||||
|
||||
val LocalEntryDataProvider =
|
||||
compositionLocalOf<EntryData> { object : EntryData {} }
|
||||
|
||||
typealias UiLayerRenderer = @Composable (arguments: Bundle?) -> Unit
|
||||
typealias StatusDataGetter = (arguments: Bundle?) -> EntryStatusData?
|
||||
typealias SearchDataGetter = (arguments: Bundle?) -> EntrySearchData?
|
||||
typealias SliceDataGetter = (sliceUri: Uri, arguments: Bundle?) -> EntrySliceData?
|
||||
|
||||
/**
|
||||
* Defines data of a Settings entry.
|
||||
*/
|
||||
data class SettingsEntry(
|
||||
// The unique id of this entry, which is computed by name + owner + fromPage + toPage.
|
||||
val id: String,
|
||||
|
||||
// The name of the entry, which is used to compute the unique id, and need to be stable.
|
||||
private val name: String,
|
||||
|
||||
// The label of the entry, for better readability.
|
||||
// For migration mapping, this should match the android:key field in the old architecture
|
||||
// if applicable.
|
||||
val label: String,
|
||||
|
||||
// The owner page of this entry.
|
||||
val owner: SettingsPage,
|
||||
|
||||
// Defines linking of Settings entries
|
||||
val fromPage: SettingsPage? = null,
|
||||
val toPage: SettingsPage? = null,
|
||||
|
||||
/**
|
||||
* ========================================
|
||||
* Defines entry attributes here.
|
||||
* ========================================
|
||||
*/
|
||||
val isAllowSearch: Boolean = false,
|
||||
|
||||
// Indicate whether the search indexing data of entry is dynamic.
|
||||
val isSearchDataDynamic: Boolean = false,
|
||||
|
||||
// Indicate whether the status of entry is mutable.
|
||||
// If so, for instance, we'll reindex its status for search.
|
||||
val hasMutableStatus: Boolean = false,
|
||||
|
||||
// Indicate whether the entry has SliceProvider support.
|
||||
val hasSliceSupport: Boolean = false,
|
||||
|
||||
/**
|
||||
* ========================================
|
||||
* Defines entry APIs to get data here.
|
||||
* ========================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* API to get the status data of the entry, such as isDisabled / isSwitchOff.
|
||||
* Returns null if this entry do NOT have any status.
|
||||
*/
|
||||
private val statusDataImpl: StatusDataGetter = { null },
|
||||
|
||||
/**
|
||||
* API to get Search indexing data for this entry, such as title / keyword.
|
||||
* Returns null if this entry do NOT support search.
|
||||
*/
|
||||
private val searchDataImpl: SearchDataGetter = { null },
|
||||
|
||||
/**
|
||||
* API to get Slice data of this entry. The Slice data is implemented as a LiveData,
|
||||
* and is associated with the Slice's lifecycle (pin / unpin) by the framework.
|
||||
*/
|
||||
private val sliceDataImpl: SliceDataGetter = { _: Uri, _: Bundle? -> null },
|
||||
|
||||
/**
|
||||
* API to Render UI of this entry directly. For now, we use it in the internal injection, to
|
||||
* support the case that the injection page owner wants to maintain both data and UI of the
|
||||
* injected entry. In the long term, we may deprecate the @Composable Page() API in SPP, and
|
||||
* use each entries' UI rendering function in the page instead.
|
||||
*/
|
||||
private val uiLayoutImpl: UiLayerRenderer = {},
|
||||
) {
|
||||
fun containerPage(): SettingsPage {
|
||||
// The Container page of the entry, which is the from-page or
|
||||
// the owner-page if from-page is unset.
|
||||
return fromPage ?: owner
|
||||
}
|
||||
|
||||
private fun fullArgument(runtimeArguments: Bundle? = null): Bundle {
|
||||
return Bundle().apply {
|
||||
if (owner.arguments != null) putAll(owner.arguments)
|
||||
// Put runtime args later, which can override page args.
|
||||
if (runtimeArguments != null) putAll(runtimeArguments)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusData(runtimeArguments: Bundle? = null): EntryStatusData? {
|
||||
return statusDataImpl(fullArgument(runtimeArguments))
|
||||
}
|
||||
|
||||
fun getSearchData(runtimeArguments: Bundle? = null): EntrySearchData? {
|
||||
return searchDataImpl(fullArgument(runtimeArguments))
|
||||
}
|
||||
|
||||
fun getSliceData(sliceUri: Uri, runtimeArguments: Bundle? = null): EntrySliceData? {
|
||||
return sliceDataImpl(sliceUri, fullArgument(runtimeArguments))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UiLayout(runtimeArguments: Bundle? = null) {
|
||||
val arguments = remember { fullArgument(runtimeArguments) }
|
||||
CompositionLocalProvider(provideLocalEntryData(arguments)) {
|
||||
uiLayoutImpl(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun provideLocalEntryData(arguments: Bundle): ProvidedValue<EntryData> {
|
||||
val controller = LocalNavController.current
|
||||
return LocalEntryDataProvider provides remember {
|
||||
object : EntryData {
|
||||
override val pageId = containerPage().id
|
||||
override val entryId = id
|
||||
override val isHighlighted = controller.highlightEntryId == id
|
||||
override val arguments = arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.remember
|
||||
import com.android.settingslib.spa.framework.util.genEntryId
|
||||
|
||||
private const val INJECT_ENTRY_LABEL = "INJECT"
|
||||
private const val ROOT_ENTRY_LABEL = "ROOT"
|
||||
|
||||
/**
|
||||
* The helper to build a Settings Entry instance.
|
||||
*/
|
||||
class SettingsEntryBuilder(private val name: String, private val owner: SettingsPage) {
|
||||
private var label = name
|
||||
private var fromPage: SettingsPage? = null
|
||||
private var toPage: SettingsPage? = null
|
||||
|
||||
// Attributes
|
||||
private var isAllowSearch: Boolean = false
|
||||
private var isSearchDataDynamic: Boolean = false
|
||||
private var hasMutableStatus: Boolean = false
|
||||
private var hasSliceSupport: Boolean = false
|
||||
|
||||
// Functions
|
||||
private var uiLayoutFn: UiLayerRenderer = { }
|
||||
private var statusDataFn: StatusDataGetter = { null }
|
||||
private var searchDataFn: SearchDataGetter = { null }
|
||||
private var sliceDataFn: SliceDataGetter = { _: Uri, _: Bundle? -> null }
|
||||
|
||||
fun build(): SettingsEntry {
|
||||
val page = fromPage ?: owner
|
||||
val isEnabled = page.isEnabled()
|
||||
return SettingsEntry(
|
||||
id = genEntryId(name, owner, fromPage, toPage),
|
||||
name = name,
|
||||
owner = owner,
|
||||
label = label,
|
||||
|
||||
// linking data
|
||||
fromPage = fromPage,
|
||||
toPage = toPage,
|
||||
|
||||
// attributes
|
||||
// TODO: set isEnabled & (isAllowSearch, hasSliceSupport) separately
|
||||
isAllowSearch = isEnabled && isAllowSearch,
|
||||
isSearchDataDynamic = isSearchDataDynamic,
|
||||
hasMutableStatus = hasMutableStatus,
|
||||
hasSliceSupport = isEnabled && hasSliceSupport,
|
||||
|
||||
// functions
|
||||
statusDataImpl = statusDataFn,
|
||||
searchDataImpl = searchDataFn,
|
||||
sliceDataImpl = sliceDataFn,
|
||||
uiLayoutImpl = uiLayoutFn,
|
||||
)
|
||||
}
|
||||
|
||||
fun setLabel(label: String): SettingsEntryBuilder {
|
||||
this.label = label
|
||||
return this
|
||||
}
|
||||
|
||||
fun setLink(
|
||||
fromPage: SettingsPage? = null,
|
||||
toPage: SettingsPage? = null
|
||||
): SettingsEntryBuilder {
|
||||
if (fromPage != null) this.fromPage = fromPage
|
||||
if (toPage != null) this.toPage = toPage
|
||||
return this
|
||||
}
|
||||
|
||||
fun setIsSearchDataDynamic(isDynamic: Boolean): SettingsEntryBuilder {
|
||||
this.isSearchDataDynamic = isDynamic
|
||||
return this
|
||||
}
|
||||
|
||||
fun setHasMutableStatus(hasMutableStatus: Boolean): SettingsEntryBuilder {
|
||||
this.hasMutableStatus = hasMutableStatus
|
||||
return this
|
||||
}
|
||||
|
||||
fun setMacro(fn: (arguments: Bundle?) -> EntryMacro): SettingsEntryBuilder {
|
||||
setStatusDataFn { fn(it).getStatusData() }
|
||||
setSearchDataFn { fn(it).getSearchData() }
|
||||
setUiLayoutFn {
|
||||
val macro = remember { fn(it) }
|
||||
macro.UiLayout()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun setStatusDataFn(fn: StatusDataGetter): SettingsEntryBuilder {
|
||||
this.statusDataFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
fun setSearchDataFn(fn: SearchDataGetter): SettingsEntryBuilder {
|
||||
this.searchDataFn = fn
|
||||
this.isAllowSearch = true
|
||||
return this
|
||||
}
|
||||
|
||||
fun clearSearchDataFn(): SettingsEntryBuilder {
|
||||
this.searchDataFn = { null }
|
||||
this.isAllowSearch = false
|
||||
return this
|
||||
}
|
||||
|
||||
fun setSliceDataFn(fn: SliceDataGetter): SettingsEntryBuilder {
|
||||
this.sliceDataFn = fn
|
||||
this.hasSliceSupport = true
|
||||
return this
|
||||
}
|
||||
|
||||
fun setUiLayoutFn(fn: UiLayerRenderer): SettingsEntryBuilder {
|
||||
this.uiLayoutFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(entryName: String, owner: SettingsPage): SettingsEntryBuilder {
|
||||
return SettingsEntryBuilder(entryName, owner)
|
||||
}
|
||||
|
||||
fun createLinkFrom(entryName: String, owner: SettingsPage): SettingsEntryBuilder {
|
||||
return create(entryName, owner).setLink(fromPage = owner)
|
||||
}
|
||||
|
||||
fun createLinkTo(entryName: String, owner: SettingsPage): SettingsEntryBuilder {
|
||||
return create(entryName, owner).setLink(toPage = owner)
|
||||
}
|
||||
|
||||
fun create(
|
||||
owner: SettingsPage,
|
||||
entryName: String,
|
||||
label: String = entryName,
|
||||
): SettingsEntryBuilder = SettingsEntryBuilder(entryName, owner).setLabel(label)
|
||||
|
||||
fun createInject(
|
||||
owner: SettingsPage,
|
||||
label: String = "${INJECT_ENTRY_LABEL}_${owner.displayName}",
|
||||
): SettingsEntryBuilder = createLinkTo(INJECT_ENTRY_LABEL, owner).setLabel(label)
|
||||
|
||||
fun createRoot(
|
||||
owner: SettingsPage,
|
||||
label: String = "${ROOT_ENTRY_LABEL}_${owner.displayName}",
|
||||
): SettingsEntryBuilder = createLinkTo(ROOT_ENTRY_LABEL, owner).setLabel(label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.util.Log
|
||||
import java.util.LinkedList
|
||||
|
||||
private const val TAG = "EntryRepository"
|
||||
private const val MAX_ENTRY_SIZE = 5000
|
||||
private const val MAX_ENTRY_DEPTH = 10
|
||||
|
||||
data class SettingsPageWithEntry(
|
||||
val page: SettingsPage,
|
||||
val entries: List<SettingsEntry>,
|
||||
// The inject entry, which to-page is current page.
|
||||
val injectEntry: SettingsEntry,
|
||||
)
|
||||
|
||||
/**
|
||||
* The repository to maintain all Settings entries
|
||||
*/
|
||||
class SettingsEntryRepository(sppRepository: SettingsPageProviderRepository) {
|
||||
// Map of entry unique Id to entry
|
||||
private val entryMap: Map<String, SettingsEntry>
|
||||
|
||||
// Map of Settings page to its contained entries.
|
||||
private val pageWithEntryMap: Map<String, SettingsPageWithEntry>
|
||||
|
||||
init {
|
||||
Log.d(TAG, "Initialize")
|
||||
entryMap = mutableMapOf()
|
||||
pageWithEntryMap = mutableMapOf()
|
||||
|
||||
val nullPage = NullPageProvider.createSettingsPage()
|
||||
val entryQueue = LinkedList<SettingsEntry>()
|
||||
for (page in sppRepository.getAllRootPages()) {
|
||||
val rootEntry =
|
||||
SettingsEntryBuilder.createRoot(owner = page).setLink(fromPage = nullPage).build()
|
||||
if (!entryMap.containsKey(rootEntry.id)) {
|
||||
entryQueue.push(rootEntry)
|
||||
entryMap.put(rootEntry.id, rootEntry)
|
||||
}
|
||||
}
|
||||
|
||||
while (entryQueue.isNotEmpty() && entryMap.size < MAX_ENTRY_SIZE) {
|
||||
val entry = entryQueue.pop()
|
||||
val page = entry.toPage
|
||||
if (page == null || pageWithEntryMap.containsKey(page.id)) continue
|
||||
val spp = sppRepository.getProviderOrNull(page.sppName) ?: continue
|
||||
val newEntries = spp.buildEntry(page.arguments)
|
||||
// The page id could be existed already, if there are 2+ pages go to the same one.
|
||||
// For now, override the previous ones, which means only the last from-page is kept.
|
||||
// TODO: support multiple from-pages if necessary.
|
||||
pageWithEntryMap[page.id] = SettingsPageWithEntry(
|
||||
page = page,
|
||||
entries = newEntries,
|
||||
injectEntry = entry
|
||||
)
|
||||
for (newEntry in newEntries) {
|
||||
if (!entryMap.containsKey(newEntry.id)) {
|
||||
entryQueue.push(newEntry)
|
||||
entryMap.put(newEntry.id, newEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Initialize Completed: ${entryMap.size} entries in ${pageWithEntryMap.size} pages"
|
||||
)
|
||||
}
|
||||
|
||||
fun getAllPageWithEntry(): Collection<SettingsPageWithEntry> {
|
||||
return pageWithEntryMap.values
|
||||
}
|
||||
|
||||
fun getPageWithEntry(pageId: String): SettingsPageWithEntry? {
|
||||
return pageWithEntryMap[pageId]
|
||||
}
|
||||
|
||||
fun getAllEntries(): Collection<SettingsEntry> {
|
||||
return entryMap.values
|
||||
}
|
||||
|
||||
fun getEntry(entryId: String): SettingsEntry? {
|
||||
return entryMap[entryId]
|
||||
}
|
||||
|
||||
private fun getEntryPath(entryId: String): List<SettingsEntry> {
|
||||
val entryPath = ArrayList<SettingsEntry>()
|
||||
var currentEntry = entryMap[entryId]
|
||||
while (currentEntry != null && entryPath.size < MAX_ENTRY_DEPTH) {
|
||||
entryPath.add(currentEntry)
|
||||
val currentPage = currentEntry.containerPage()
|
||||
currentEntry = pageWithEntryMap[currentPage.id]?.injectEntry
|
||||
}
|
||||
return entryPath
|
||||
}
|
||||
|
||||
fun getEntryPathWithLabel(entryId: String): List<String> {
|
||||
val entryPath = getEntryPath(entryId)
|
||||
return entryPath.map { it.label }
|
||||
}
|
||||
|
||||
fun getEntryPathWithTitle(entryId: String, defaultTitle: String): List<String> {
|
||||
val entryPath = getEntryPath(entryId)
|
||||
return entryPath.map {
|
||||
if (it.toPage == null)
|
||||
defaultTitle
|
||||
else {
|
||||
it.toPage.getTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import com.android.settingslib.spa.framework.util.genPageId
|
||||
import com.android.settingslib.spa.framework.util.isRuntimeParam
|
||||
import com.android.settingslib.spa.framework.util.navLink
|
||||
|
||||
/**
|
||||
* Defines data to identify a Settings page.
|
||||
*/
|
||||
data class SettingsPage(
|
||||
// The unique id of this page, which is computed by sppName + normalized(arguments)
|
||||
val id: String,
|
||||
|
||||
// The name of the page provider, who creates this page. It is used to compute the unique id.
|
||||
val sppName: String,
|
||||
|
||||
// The display name of the page, for better readability.
|
||||
val displayName: String,
|
||||
|
||||
// The parameters defined in its page provider.
|
||||
val parameter: List<NamedNavArgument> = emptyList(),
|
||||
|
||||
// The arguments of this page.
|
||||
val arguments: Bundle? = null,
|
||||
) {
|
||||
companion object {
|
||||
// TODO: cleanup it once all its usage in Settings are switched to Spp.createSettingsPage
|
||||
fun create(
|
||||
name: String,
|
||||
displayName: String? = null,
|
||||
parameter: List<NamedNavArgument> = emptyList(),
|
||||
arguments: Bundle? = null
|
||||
): SettingsPage {
|
||||
return SettingsPage(
|
||||
id = genPageId(name, parameter, arguments),
|
||||
sppName = name,
|
||||
displayName = displayName ?: name,
|
||||
parameter = parameter,
|
||||
arguments = arguments
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns if this Settings Page is created by the given Spp.
|
||||
fun isCreateBy(SppName: String): Boolean {
|
||||
return sppName == SppName
|
||||
}
|
||||
|
||||
fun buildRoute(): String {
|
||||
return sppName + parameter.navLink(arguments)
|
||||
}
|
||||
|
||||
fun isBrowsable(): Boolean {
|
||||
if (sppName == NullPageProvider.name) return false
|
||||
for (navArg in parameter) {
|
||||
if (navArg.isRuntimeParam()) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun isEnabled(): Boolean =
|
||||
SpaEnvironment.IS_DEBUG || getPageProvider(sppName)?.isEnabled(arguments) ?: false
|
||||
|
||||
fun getTitle(): String {
|
||||
return getPageProvider(sppName)?.getTitle(arguments) ?: ""
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UiLayout() {
|
||||
getPageProvider(sppName)?.Page(arguments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import com.android.settingslib.spa.framework.util.genPageId
|
||||
import com.android.settingslib.spa.framework.util.normalizeArgList
|
||||
import com.android.settingslib.spa.widget.scaffold.RegularScaffold
|
||||
|
||||
private const val NULL_PAGE_NAME = "NULL"
|
||||
|
||||
/**
|
||||
* An SettingsPageProvider which is used to create Settings page instances.
|
||||
*/
|
||||
interface SettingsPageProvider {
|
||||
|
||||
/** The page provider name, needs to be *unique* and *stable*. */
|
||||
val name: String
|
||||
|
||||
enum class NavType {
|
||||
Page,
|
||||
Dialog,
|
||||
}
|
||||
|
||||
val navType: NavType
|
||||
get() = NavType.Page
|
||||
|
||||
/** The display name of this page provider, for better readability. */
|
||||
val displayName: String
|
||||
get() = name
|
||||
|
||||
/** The page parameters, default is no parameters. */
|
||||
val parameter: List<NamedNavArgument>
|
||||
get() = emptyList()
|
||||
|
||||
/**
|
||||
* The API to indicate whether the page is enabled or not.
|
||||
* During SPA page migration, one can use it to enable certain pages in one release.
|
||||
* When the page is disabled, all its related functionalities, such as browsing, search,
|
||||
* slice provider, are disabled as well.
|
||||
*/
|
||||
fun isEnabled(arguments: Bundle?): Boolean = true
|
||||
|
||||
fun getTitle(arguments: Bundle?): String = displayName
|
||||
|
||||
fun buildEntry(arguments: Bundle?): List<SettingsEntry> = emptyList()
|
||||
|
||||
/** The [Composable] used to render this page. */
|
||||
@Composable
|
||||
fun Page(arguments: Bundle?) {
|
||||
val title = remember { getTitle(arguments) }
|
||||
val entries = remember { buildEntry(arguments) }
|
||||
RegularScaffold(title) {
|
||||
for (entry in entries) {
|
||||
entry.UiLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SettingsPageProvider.createSettingsPage(arguments: Bundle? = null): SettingsPage {
|
||||
return SettingsPage(
|
||||
id = genPageId(name, parameter, arguments),
|
||||
sppName = name,
|
||||
displayName = displayName + parameter.normalizeArgList(arguments, eraseRuntimeValues = true)
|
||||
.joinToString("") { arg -> "/$arg" },
|
||||
parameter = parameter,
|
||||
arguments = arguments,
|
||||
)
|
||||
}
|
||||
|
||||
internal object NullPageProvider : SettingsPageProvider {
|
||||
override val name = NULL_PAGE_NAME
|
||||
}
|
||||
|
||||
fun getPageProvider(sppName: String): SettingsPageProvider? {
|
||||
if (!SpaEnvironmentFactory.isReady()) return null
|
||||
val pageProviderRepository by SpaEnvironmentFactory.instance.pageProviderRepository
|
||||
return pageProviderRepository.getProviderOrNull(sppName)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.util.Log
|
||||
|
||||
private const val TAG = "SppRepository"
|
||||
|
||||
class SettingsPageProviderRepository(
|
||||
allPageProviders: List<SettingsPageProvider>,
|
||||
private val rootPages: List<SettingsPage> = emptyList(),
|
||||
) {
|
||||
// Map of page name to its provider.
|
||||
private val pageProviderMap: Map<String, SettingsPageProvider>
|
||||
|
||||
init {
|
||||
pageProviderMap = allPageProviders.associateBy { it.name }
|
||||
Log.d(TAG, "Initialize Completed: ${pageProviderMap.size} spp")
|
||||
}
|
||||
|
||||
fun getDefaultStartPage(): String {
|
||||
return if (rootPages.isEmpty()) "" else rootPages[0].buildRoute()
|
||||
}
|
||||
|
||||
fun getAllRootPages(): Collection<SettingsPage> {
|
||||
return rootPages
|
||||
}
|
||||
|
||||
fun getAllProviders(): Collection<SettingsPageProvider> {
|
||||
return pageProviderMap.values
|
||||
}
|
||||
|
||||
fun getProviderOrNull(name: String): SettingsPageProvider? {
|
||||
return pageProviderMap[name]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.android.settingslib.spa.slice.SettingsSliceDataRepository
|
||||
|
||||
private const val TAG = "SpaEnvironment"
|
||||
|
||||
object SpaEnvironmentFactory {
|
||||
private var spaEnvironment: SpaEnvironment? = null
|
||||
|
||||
fun reset() {
|
||||
spaEnvironment = null
|
||||
}
|
||||
|
||||
fun reset(env: SpaEnvironment) {
|
||||
spaEnvironment = env
|
||||
Log.d(TAG, "reset")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun resetForPreview() {
|
||||
val context = LocalContext.current
|
||||
spaEnvironment = object : SpaEnvironment(context) {
|
||||
override val pageProviderRepository = lazy {
|
||||
SettingsPageProviderRepository(
|
||||
allPageProviders = emptyList(),
|
||||
rootPages = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "resetForPreview")
|
||||
}
|
||||
|
||||
fun isReady(): Boolean {
|
||||
return spaEnvironment != null
|
||||
}
|
||||
|
||||
val instance: SpaEnvironment
|
||||
get() {
|
||||
if (spaEnvironment == null)
|
||||
throw UnsupportedOperationException("Spa environment is not set")
|
||||
return spaEnvironment!!
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SpaEnvironment(context: Context) {
|
||||
abstract val pageProviderRepository: Lazy<SettingsPageProviderRepository>
|
||||
|
||||
val entryRepository = lazy { SettingsEntryRepository(pageProviderRepository.value) }
|
||||
|
||||
val sliceDataRepository = lazy { SettingsSliceDataRepository(entryRepository.value) }
|
||||
|
||||
// The application context. Use local context as fallback when applicationContext is not
|
||||
// available (e.g. in Robolectric test).
|
||||
val appContext: Context = context.applicationContext ?: context
|
||||
|
||||
// Set your SpaLogger implementation, for any SPA events logging.
|
||||
open val logger: SpaLogger = object : SpaLogger {}
|
||||
|
||||
// Specify class name of browse activity and slice broadcast receiver, which is used to
|
||||
// generate the necessary intents.
|
||||
open val browseActivityClass: Class<out Activity>? = null
|
||||
open val sliceBroadcastReceiverClass: Class<out BroadcastReceiver>? = null
|
||||
|
||||
// Specify provider authorities for debugging purpose.
|
||||
open val searchProviderAuthorities: String? = null
|
||||
open val sliceProviderAuthorities: String? = null
|
||||
|
||||
// TODO: add other environment setup here.
|
||||
companion object {
|
||||
/**
|
||||
* Whether debug mode is on or off.
|
||||
*
|
||||
* If set to true, this will also enable all the pages under development (allows browsing
|
||||
* and searching).
|
||||
*/
|
||||
const val IS_DEBUG = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.common
|
||||
|
||||
import android.os.Bundle
|
||||
|
||||
// Defines the category of the log, for quick filter
|
||||
enum class LogCategory {
|
||||
// The default category, for logs from Pages & their Models.
|
||||
DEFAULT,
|
||||
|
||||
// For logs from Spa Framework, such as BrowseActivity, EntryProvider
|
||||
FRAMEWORK,
|
||||
|
||||
// For logs from Spa UI components, such as Widgets, Scaffold
|
||||
VIEW,
|
||||
}
|
||||
|
||||
// Defines the log events in Spa.
|
||||
enum class LogEvent {
|
||||
// Page related events.
|
||||
PAGE_ENTER,
|
||||
PAGE_LEAVE,
|
||||
|
||||
// Entry related events.
|
||||
ENTRY_CLICK,
|
||||
ENTRY_SWITCH,
|
||||
}
|
||||
|
||||
internal const val LOG_DATA_DISPLAY_NAME = "name"
|
||||
internal const val LOG_DATA_SWITCH_STATUS = "switch"
|
||||
|
||||
const val LOG_DATA_SESSION_NAME = "session"
|
||||
|
||||
/**
|
||||
* The interface of logger in Spa
|
||||
*/
|
||||
interface SpaLogger {
|
||||
// log a message, usually for debug purpose.
|
||||
fun message(tag: String, msg: String, category: LogCategory = LogCategory.DEFAULT) {}
|
||||
|
||||
// log a user event.
|
||||
fun event(
|
||||
id: String,
|
||||
event: LogEvent,
|
||||
category: LogCategory = LogCategory.DEFAULT,
|
||||
extraData: Bundle = Bundle.EMPTY
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDeepLink
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.android.settingslib.spa.framework.common.NullPageProvider
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder] with animation
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param content composable for the destination
|
||||
*/
|
||||
internal fun NavGraphBuilder.animatedComposable(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) = composable(
|
||||
route = route,
|
||||
arguments = arguments,
|
||||
deepLinks = deepLinks,
|
||||
enterTransition = {
|
||||
if (initialState.destination.route != NullPageProvider.name) {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = slideInEffect,
|
||||
initialOffset = offsetFunc,
|
||||
) + fadeIn(animationSpec = fadeInEffect)
|
||||
} else null
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = slideOutEffect,
|
||||
targetOffset = offsetFunc,
|
||||
) + fadeOut(animationSpec = fadeOutEffect)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
||||
animationSpec = slideInEffect,
|
||||
initialOffset = offsetFunc,
|
||||
) + fadeIn(animationSpec = fadeInEffect)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
||||
animationSpec = slideOutEffect,
|
||||
targetOffset = offsetFunc,
|
||||
) + fadeOut(animationSpec = fadeOutEffect)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
|
||||
private const val FADE_OUT_MILLIS = 75
|
||||
private const val FADE_IN_MILLIS = 300
|
||||
|
||||
private val slideInEffect = tween<IntOffset>(
|
||||
durationMillis = FADE_IN_MILLIS,
|
||||
delayMillis = FADE_OUT_MILLIS,
|
||||
easing = LinearOutSlowInEasing,
|
||||
)
|
||||
private val slideOutEffect = tween<IntOffset>(durationMillis = FADE_IN_MILLIS)
|
||||
private val fadeOutEffect = tween<Float>(
|
||||
durationMillis = FADE_OUT_MILLIS,
|
||||
easing = FastOutLinearInEasing,
|
||||
)
|
||||
private val fadeInEffect = tween<Float>(
|
||||
durationMillis = FADE_IN_MILLIS,
|
||||
delayMillis = FADE_OUT_MILLIS,
|
||||
easing = LinearOutSlowInEasing,
|
||||
)
|
||||
private val offsetFunc: (offsetForFullSlide: Int) -> Int = { it.div(5) }
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.RememberObserver
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.asAndroidColorFilter
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.withSave
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* *************************************************************************************************
|
||||
* This file was forked from
|
||||
* https://github.com/google/accompanist/blob/main/drawablepainter/src/main/java/com/google/accompanist/drawablepainter/DrawablePainter.kt
|
||||
* and will be removed once it lands in AndroidX.
|
||||
*/
|
||||
|
||||
private val MAIN_HANDLER by lazy(LazyThreadSafetyMode.NONE) {
|
||||
Handler(Looper.getMainLooper())
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Painter] which draws an Android [Drawable] and supports [Animatable] drawables. Instances
|
||||
* should be remembered to be able to start and stop [Animatable] animations.
|
||||
*
|
||||
* Instances are usually retrieved from [rememberDrawablePainter].
|
||||
*/
|
||||
class DrawablePainter(
|
||||
val drawable: Drawable
|
||||
) : Painter(), RememberObserver {
|
||||
private var drawInvalidateTick by mutableStateOf(0)
|
||||
private var drawableIntrinsicSize by mutableStateOf(drawable.intrinsicSize)
|
||||
|
||||
private val callback: Drawable.Callback by lazy {
|
||||
object : Drawable.Callback {
|
||||
override fun invalidateDrawable(d: Drawable) {
|
||||
// Update the tick so that we get re-drawn
|
||||
drawInvalidateTick++
|
||||
// Update our intrinsic size too
|
||||
drawableIntrinsicSize = drawable.intrinsicSize
|
||||
}
|
||||
|
||||
override fun scheduleDrawable(d: Drawable, what: Runnable, time: Long) {
|
||||
MAIN_HANDLER.postAtTime(what, time)
|
||||
}
|
||||
|
||||
override fun unscheduleDrawable(d: Drawable, what: Runnable) {
|
||||
MAIN_HANDLER.removeCallbacks(what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (drawable.intrinsicWidth >= 0 && drawable.intrinsicHeight >= 0) {
|
||||
// Update the drawable's bounds to match the intrinsic size
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemembered() {
|
||||
drawable.callback = callback
|
||||
drawable.setVisible(true, true)
|
||||
if (drawable is Animatable) drawable.start()
|
||||
}
|
||||
|
||||
override fun onAbandoned() = onForgotten()
|
||||
|
||||
override fun onForgotten() {
|
||||
if (drawable is Animatable) drawable.stop()
|
||||
drawable.setVisible(false, false)
|
||||
drawable.callback = null
|
||||
}
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean {
|
||||
drawable.alpha = (alpha * 255).roundToInt().coerceIn(0, 255)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
|
||||
drawable.colorFilter = colorFilter?.asAndroidColorFilter()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
return drawable.setLayoutDirection(
|
||||
when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> View.LAYOUT_DIRECTION_LTR
|
||||
LayoutDirection.Rtl -> View.LAYOUT_DIRECTION_RTL
|
||||
}
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override val intrinsicSize: Size get() = drawableIntrinsicSize
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
drawIntoCanvas { canvas ->
|
||||
// Reading this ensures that we invalidate when invalidateDrawable() is called
|
||||
drawInvalidateTick
|
||||
|
||||
// Update the Drawable's bounds
|
||||
drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt())
|
||||
|
||||
canvas.withSave {
|
||||
drawable.draw(canvas.nativeCanvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers [Drawable] wrapped up as a [Painter]. This function attempts to un-wrap the
|
||||
* drawable contents and use Compose primitives where possible.
|
||||
*
|
||||
* If the provided [drawable] is `null`, an empty no-op painter is returned.
|
||||
*
|
||||
* This function tries to dispatch lifecycle events to [drawable] as much as possible from
|
||||
* within Compose.
|
||||
*
|
||||
* @sample com.google.accompanist.sample.drawablepainter.BasicSample
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDrawablePainter(drawable: Drawable?): Painter = remember(drawable) {
|
||||
when (drawable) {
|
||||
null -> EmptyPainter
|
||||
is BitmapDrawable -> BitmapPainter(drawable.bitmap.asImageBitmap())
|
||||
is ColorDrawable -> ColorPainter(Color(drawable.color))
|
||||
// Since the DrawablePainter will be remembered and it implements RememberObserver, it
|
||||
// will receive the necessary events
|
||||
else -> DrawablePainter(drawable.mutate())
|
||||
}
|
||||
}
|
||||
|
||||
private val Drawable.intrinsicSize: Size
|
||||
get() = when {
|
||||
// Only return a finite size if the drawable has an intrinsic size
|
||||
intrinsicWidth >= 0 && intrinsicHeight >= 0 -> {
|
||||
Size(width = intrinsicWidth.toFloat(), height = intrinsicHeight.toFloat())
|
||||
}
|
||||
else -> Size.Unspecified
|
||||
}
|
||||
|
||||
internal object EmptyPainter : Painter() {
|
||||
override val intrinsicSize: Size get() = Size.Unspecified
|
||||
override fun DrawScope.onDraw() {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
/**
|
||||
* An action when run, hides the keyboard if it's open.
|
||||
*/
|
||||
@Composable
|
||||
fun hideKeyboardAction(): () -> Unit {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
return { keyboardController?.hide() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [LazyListState] that is remembered across compositions.
|
||||
*
|
||||
* And when user scrolling the lazy list, hides the keyboard if it's open.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLazyListStateAndHideKeyboardWhenStartScroll(): LazyListState {
|
||||
val listState = rememberLazyListState()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.isScrollInProgress }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { keyboardController?.hide() }
|
||||
}
|
||||
return listState
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
|
||||
@Composable
|
||||
fun LifecycleEffect(
|
||||
onStart: () -> Unit = {},
|
||||
onStop: () -> Unit = {},
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_START) {
|
||||
onStart()
|
||||
} else if (event == Lifecycle.Event.ON_STOP) {
|
||||
onStop()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
const val ENABLE_LOG_COMPOSITIONS = false
|
||||
|
||||
data class LogCompositionsRef(var count: Int)
|
||||
|
||||
// Note the inline function below which ensures that this function is essentially
|
||||
// copied at the call site to ensure that its logging only recompositions from the
|
||||
// original call site.
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Composable
|
||||
inline fun LogCompositions(tag: String, msg: String) {
|
||||
if (ENABLE_LOG_COMPOSITIONS) {
|
||||
val ref = remember { LogCompositionsRef(0) }
|
||||
SideEffect { ref.count++ }
|
||||
Log.d(tag, "Compositions $msg: ${ref.count}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ProvidedValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NavHostController
|
||||
|
||||
interface NavControllerWrapper {
|
||||
fun navigate(route: String, popUpCurrent: Boolean = false)
|
||||
fun navigateBack()
|
||||
|
||||
val highlightEntryId: String?
|
||||
get() = null
|
||||
|
||||
val sessionSourceName: String?
|
||||
get() = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavHostController.localNavController(): ProvidedValue<NavControllerWrapper> {
|
||||
val onBackPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
|
||||
return LocalNavController provides remember {
|
||||
NavControllerWrapperImpl(
|
||||
navController = this,
|
||||
onBackPressedDispatcher = onBackPressedDispatcherOwner?.onBackPressedDispatcher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavControllerWrapper> {
|
||||
object : NavControllerWrapper {
|
||||
override fun navigate(route: String, popUpCurrent: Boolean) {}
|
||||
|
||||
override fun navigateBack() {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun navigator(route: String?, popUpCurrent: Boolean = false): () -> Unit {
|
||||
if (route == null) return {}
|
||||
val navController = LocalNavController.current
|
||||
return { navController.navigate(route, popUpCurrent) }
|
||||
}
|
||||
|
||||
internal class NavControllerWrapperImpl(
|
||||
val navController: NavHostController,
|
||||
private val onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
) : NavControllerWrapper {
|
||||
var highlightId: String? = null
|
||||
var sessionName: String? = null
|
||||
|
||||
override fun navigate(route: String, popUpCurrent: Boolean) {
|
||||
navController.navigate(route) {
|
||||
if (popUpCurrent) {
|
||||
navController.currentDestination?.let { currentDestination ->
|
||||
popUpTo(currentDestination.id) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateBack() {
|
||||
onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
|
||||
override val highlightEntryId: String?
|
||||
get() = highlightId
|
||||
|
||||
override val sessionSourceName: String?
|
||||
get() = sessionName
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
|
||||
/**
|
||||
* An effect for detecting presses of the system back button, and the back event will not be
|
||||
* consumed by this effect.
|
||||
*
|
||||
* Calling this in your composable adds the given lambda to the [OnBackPressedDispatcher] of the
|
||||
* [LocalOnBackPressedDispatcherOwner].
|
||||
*
|
||||
* @param onBack the action invoked by pressing the system back
|
||||
*/
|
||||
@Composable
|
||||
fun OnBackEffect(onBack: () -> Unit) {
|
||||
val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
|
||||
"No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
|
||||
}.onBackPressedDispatcher
|
||||
|
||||
// Safely update the current `onBack` lambda when a new one is provided
|
||||
val currentOnBack by rememberUpdatedState(onBack)
|
||||
// Remember in Composition a back callback that calls the `onBack` lambda
|
||||
val backCallback = remember {
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
remove()
|
||||
currentOnBack()
|
||||
backDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, backDispatcher) {
|
||||
// Add callback to the backDispatcher
|
||||
backDispatcher.addCallback(lifecycleOwner, backCallback)
|
||||
// When the effect leaves the Composition, remove the callback
|
||||
onDispose {
|
||||
backCallback.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
/**
|
||||
* A flow which result is overridable.
|
||||
*/
|
||||
class OverridableFlow<T>(flow: Flow<T>) {
|
||||
private val overrideChannel = Channel<T>()
|
||||
|
||||
val flow = merge(overrideChannel.receiveAsFlow(), flow)
|
||||
|
||||
fun override(value: T) {
|
||||
overrideChannel.trySend(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal fun PaddingValues.horizontalValues(): PaddingValues = HorizontalPaddingValues(this)
|
||||
|
||||
internal fun PaddingValues.verticalValues(): PaddingValues = VerticalPaddingValues(this)
|
||||
|
||||
private class HorizontalPaddingValues(private val paddingValues: PaddingValues) : PaddingValues {
|
||||
override fun calculateLeftPadding(layoutDirection: LayoutDirection) =
|
||||
paddingValues.calculateLeftPadding(layoutDirection)
|
||||
|
||||
override fun calculateTopPadding(): Dp = 0.dp
|
||||
|
||||
override fun calculateRightPadding(layoutDirection: LayoutDirection) =
|
||||
paddingValues.calculateRightPadding(layoutDirection)
|
||||
|
||||
override fun calculateBottomPadding() = 0.dp
|
||||
}
|
||||
|
||||
private class VerticalPaddingValues(private val paddingValues: PaddingValues) : PaddingValues {
|
||||
override fun calculateLeftPadding(layoutDirection: LayoutDirection) = 0.dp
|
||||
|
||||
override fun calculateTopPadding(): Dp = paddingValues.calculateTopPadding()
|
||||
|
||||
override fun calculateRightPadding(layoutDirection: LayoutDirection) = 0.dp
|
||||
|
||||
override fun calculateBottomPadding() = paddingValues.calculateBottomPadding()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun <T> rememberContext(constructor: (Context) -> T): T {
|
||||
val context = LocalContext.current
|
||||
return remember(context) { constructor(context) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new [State] initialized with the passed in [value].
|
||||
*/
|
||||
fun <T> stateOf(value: T) = object : State<T> {
|
||||
override val value = value
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTime::class)
|
||||
|
||||
package com.android.settingslib.spa.framework.compose
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
const val ENABLE_MEASURE_TIME = false
|
||||
|
||||
interface TimeMeasurer {
|
||||
fun log(msg: String) {}
|
||||
fun logFirst(msg: String) {}
|
||||
|
||||
companion object {
|
||||
private object EmptyTimeMeasurer : TimeMeasurer
|
||||
|
||||
@Composable
|
||||
fun rememberTimeMeasurer(tag: String): TimeMeasurer = remember {
|
||||
if (ENABLE_MEASURE_TIME) TimeMeasurerImpl(tag) else EmptyTimeMeasurer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TimeMeasurerImpl(private val tag: String) : TimeMeasurer {
|
||||
private val mark = TimeSource.Monotonic.markNow()
|
||||
private val msgLogged = mutableSetOf<String>()
|
||||
|
||||
override fun log(msg: String) {
|
||||
Log.d(tag, "Timer $msg: ${mark.elapsedNow()}")
|
||||
}
|
||||
|
||||
override fun logFirst(msg: String) {
|
||||
if (msgLogged.add(msg)) {
|
||||
Log.d(tag, "Timer $msg: ${mark.elapsedNow()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
internal fun materialColorScheme(isDarkTheme: Boolean): ColorScheme {
|
||||
val context = LocalContext.current
|
||||
return remember(isDarkTheme) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (isDarkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
isDarkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ColorScheme.divider: Color
|
||||
get() = onSurface.copy(SettingsOpacity.Divider)
|
||||
|
||||
val ColorScheme.surfaceTone: Color
|
||||
get() = primary.copy(SettingsOpacity.SurfaceTone)
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
data class SettingsColorScheme(
|
||||
val background: Color = Color.Unspecified,
|
||||
val categoryTitle: Color = Color.Unspecified,
|
||||
val surface: Color = Color.Unspecified,
|
||||
val surfaceHeader: Color = Color.Unspecified,
|
||||
val secondaryText: Color = Color.Unspecified,
|
||||
val primaryContainer: Color = Color.Unspecified,
|
||||
val onPrimaryContainer: Color = Color.Unspecified,
|
||||
val spinnerHeaderContainer: Color = Color.Unspecified,
|
||||
val onSpinnerHeaderContainer: Color = Color.Unspecified,
|
||||
val spinnerItemContainer: Color = Color.Unspecified,
|
||||
val onSpinnerItemContainer: Color = Color.Unspecified,
|
||||
)
|
||||
|
||||
internal val LocalColorScheme = staticCompositionLocalOf { SettingsColorScheme() }
|
||||
|
||||
@Composable
|
||||
internal fun settingsColorScheme(isDarkTheme: Boolean): SettingsColorScheme {
|
||||
val context = LocalContext.current
|
||||
return remember(isDarkTheme) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (isDarkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
isDarkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a light dynamic color scheme.
|
||||
*
|
||||
* Use this function to create a color scheme based off the system wallpaper. If the developer
|
||||
* changes the wallpaper this color scheme will change accordingly. This dynamic scheme is a
|
||||
* light theme variant.
|
||||
*
|
||||
* @param context The context required to get system resource data.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun dynamicLightColorScheme(context: Context): SettingsColorScheme {
|
||||
val tonalPalette = dynamicTonalPalette(context)
|
||||
return SettingsColorScheme(
|
||||
background = tonalPalette.neutral95,
|
||||
categoryTitle = tonalPalette.primary40,
|
||||
surface = tonalPalette.neutral99,
|
||||
surfaceHeader = tonalPalette.neutral90,
|
||||
secondaryText = tonalPalette.neutralVariant30,
|
||||
primaryContainer = tonalPalette.primary90,
|
||||
onPrimaryContainer = tonalPalette.neutral10,
|
||||
spinnerHeaderContainer = tonalPalette.primary90,
|
||||
onSpinnerHeaderContainer = tonalPalette.neutral10,
|
||||
spinnerItemContainer = tonalPalette.secondary90,
|
||||
onSpinnerItemContainer = tonalPalette.neutralVariant30,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dark dynamic color scheme.
|
||||
*
|
||||
* Use this function to create a color scheme based off the system wallpaper. If the developer
|
||||
* changes the wallpaper this color scheme will change accordingly. This dynamic scheme is a dark
|
||||
* theme variant.
|
||||
*
|
||||
* @param context The context required to get system resource data.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun dynamicDarkColorScheme(context: Context): SettingsColorScheme {
|
||||
val tonalPalette = dynamicTonalPalette(context)
|
||||
return SettingsColorScheme(
|
||||
background = tonalPalette.neutral10,
|
||||
categoryTitle = tonalPalette.primary90,
|
||||
surface = tonalPalette.neutral20,
|
||||
surfaceHeader = tonalPalette.neutral30,
|
||||
secondaryText = tonalPalette.neutralVariant80,
|
||||
primaryContainer = tonalPalette.secondary90,
|
||||
onPrimaryContainer = tonalPalette.neutral10,
|
||||
spinnerHeaderContainer = tonalPalette.primary90,
|
||||
onSpinnerHeaderContainer = tonalPalette.neutral10,
|
||||
spinnerItemContainer = tonalPalette.secondary90,
|
||||
onSpinnerItemContainer = tonalPalette.neutralVariant30,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun darkColorScheme(): SettingsColorScheme {
|
||||
val tonalPalette = tonalPalette()
|
||||
return SettingsColorScheme(
|
||||
background = tonalPalette.neutral10,
|
||||
categoryTitle = tonalPalette.primary90,
|
||||
surface = tonalPalette.neutral20,
|
||||
surfaceHeader = tonalPalette.neutral30,
|
||||
secondaryText = tonalPalette.neutralVariant80,
|
||||
primaryContainer = tonalPalette.secondary90,
|
||||
onPrimaryContainer = tonalPalette.neutral10,
|
||||
spinnerHeaderContainer = tonalPalette.primary90,
|
||||
onSpinnerHeaderContainer = tonalPalette.neutral10,
|
||||
spinnerItemContainer = tonalPalette.secondary90,
|
||||
onSpinnerItemContainer = tonalPalette.neutralVariant30,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun lightColorScheme(): SettingsColorScheme {
|
||||
val tonalPalette = tonalPalette()
|
||||
return SettingsColorScheme(
|
||||
background = tonalPalette.neutral95,
|
||||
categoryTitle = tonalPalette.primary40,
|
||||
surface = tonalPalette.neutral99,
|
||||
surfaceHeader = tonalPalette.neutral90,
|
||||
secondaryText = tonalPalette.neutralVariant30,
|
||||
primaryContainer = tonalPalette.primary90,
|
||||
onPrimaryContainer = tonalPalette.neutral10,
|
||||
spinnerHeaderContainer = tonalPalette.primary90,
|
||||
onSpinnerHeaderContainer = tonalPalette.neutral10,
|
||||
spinnerItemContainer = tonalPalette.secondary90,
|
||||
onSpinnerItemContainer = tonalPalette.neutralVariant30,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object SettingsDimension {
|
||||
val paddingTiny = 2.dp
|
||||
val paddingSmall = 4.dp
|
||||
|
||||
val itemIconSize = 24.dp
|
||||
val itemIconContainerSize = 72.dp
|
||||
val itemPaddingStart = 24.dp
|
||||
val itemPaddingEnd = 16.dp
|
||||
val itemPaddingVertical = 16.dp
|
||||
val itemPadding = PaddingValues(
|
||||
start = itemPaddingStart,
|
||||
top = itemPaddingVertical,
|
||||
end = itemPaddingEnd,
|
||||
bottom = itemPaddingVertical,
|
||||
)
|
||||
val textFieldPadding = PaddingValues(
|
||||
start = itemPaddingStart,
|
||||
end = itemPaddingEnd,
|
||||
)
|
||||
val menuFieldPadding = PaddingValues(
|
||||
start = itemPaddingStart,
|
||||
end = itemPaddingEnd,
|
||||
bottom = itemPaddingVertical,
|
||||
)
|
||||
val itemPaddingAround = 8.dp
|
||||
val itemDividerHeight = 32.dp
|
||||
|
||||
val iconLarge = 48.dp
|
||||
|
||||
/** The size when app icon is displayed in list. */
|
||||
val appIconItemSize = 32.dp
|
||||
|
||||
/** The size when app icon is displayed in App info page. */
|
||||
val appIconInfoSize = iconLarge
|
||||
|
||||
/** The vertical padding for buttons. */
|
||||
val buttonPaddingVertical = 12.dp
|
||||
|
||||
/** The [PaddingValues] for buttons. */
|
||||
val buttonPadding = PaddingValues(horizontal = itemPaddingEnd, vertical = buttonPaddingVertical)
|
||||
|
||||
/** The horizontal padding for dialog items. */
|
||||
val dialogItemPaddingHorizontal = itemPaddingStart
|
||||
|
||||
/** The [PaddingValues] for dialog items. */
|
||||
val dialogItemPadding =
|
||||
PaddingValues(horizontal = dialogItemPaddingHorizontal, vertical = buttonPaddingVertical)
|
||||
|
||||
/** The sizes info of illustration widget. */
|
||||
val illustrationMaxWidth = 412.dp
|
||||
val illustrationMaxHeight = 300.dp
|
||||
val illustrationPadding = 16.dp
|
||||
val illustrationCornerRadius = 28.dp
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTextApi::class)
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.font.DeviceFontFamilyName
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.android.settingslib.spa.framework.compose.rememberContext
|
||||
|
||||
internal data class SettingsFontFamily(
|
||||
val brand: FontFamily,
|
||||
val plain: FontFamily,
|
||||
)
|
||||
|
||||
private fun Context.getSettingsFontFamily(): SettingsFontFamily {
|
||||
return SettingsFontFamily(
|
||||
brand = getFontFamily(
|
||||
configFontFamilyNormal = "config_headlineFontFamily",
|
||||
configFontFamilyMedium = "config_headlineFontFamilyMedium",
|
||||
),
|
||||
plain = getFontFamily(
|
||||
configFontFamilyNormal = "config_bodyFontFamily",
|
||||
configFontFamilyMedium = "config_bodyFontFamilyMedium",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Context.getFontFamily(
|
||||
configFontFamilyNormal: String,
|
||||
configFontFamilyMedium: String,
|
||||
): FontFamily {
|
||||
val fontFamilyNormal = getAndroidConfig(configFontFamilyNormal)
|
||||
val fontFamilyMedium = getAndroidConfig(configFontFamilyMedium)
|
||||
if (fontFamilyNormal.isEmpty() || fontFamilyMedium.isEmpty()) return FontFamily.Default
|
||||
return FontFamily(
|
||||
Font(DeviceFontFamilyName(fontFamilyNormal), FontWeight.Normal),
|
||||
Font(DeviceFontFamilyName(fontFamilyMedium), FontWeight.Medium),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Context.getAndroidConfig(configName: String): String {
|
||||
@SuppressLint("DiscouragedApi")
|
||||
val configId = resources.getIdentifier(configName, "string", "android")
|
||||
return resources.getString(configId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberSettingsFontFamily(): SettingsFontFamily {
|
||||
return rememberContext(Context::getSettingsFontFamily)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
|
||||
object SettingsOpacity {
|
||||
const val Full = 1f
|
||||
const val Disabled = 0.38f
|
||||
const val Divider = 0.2f
|
||||
const val SurfaceTone = 0.14f
|
||||
const val Hint = 0.9f
|
||||
|
||||
fun Modifier.alphaForEnabled(enabled: Boolean) = alpha(if (enabled) Full else Disabled)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object SettingsShape {
|
||||
val CornerExtraSmall = RoundedCornerShape(4.dp)
|
||||
|
||||
val CornerMedium = RoundedCornerShape(12.dp)
|
||||
|
||||
val CornerExtraLarge = RoundedCornerShape(28.dp)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
|
||||
/**
|
||||
* The Material 3 Theme for Settings.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsTheme(content: @Composable () -> Unit) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val settingsColorScheme = settingsColorScheme(isDarkTheme)
|
||||
val colorScheme = materialColorScheme(isDarkTheme).copy(
|
||||
background = settingsColorScheme.background,
|
||||
)
|
||||
|
||||
MaterialTheme(colorScheme = colorScheme, typography = rememberSettingsTypography()) {
|
||||
CompositionLocalProvider(
|
||||
LocalColorScheme provides settingsColorScheme(isDarkTheme),
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SettingsTheme {
|
||||
val colorScheme: SettingsColorScheme
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalColorScheme.current
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import android.R
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DoNotInline
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Tonal Palette structure in Material.
|
||||
*
|
||||
* A tonal palette is comprised of 5 tonal ranges. Each tonal range includes the 13 stops, or
|
||||
* tonal swatches.
|
||||
*
|
||||
* Tonal range names are:
|
||||
* - Neutral (N)
|
||||
* - Neutral variant (NV)
|
||||
* - Primary (P)
|
||||
* - Secondary (S)
|
||||
* - Tertiary (T)
|
||||
*/
|
||||
internal class SettingsTonalPalette(
|
||||
// The neutral tonal range from the generated dynamic color palette.
|
||||
// Ordered from the lightest shade [neutral100] to the darkest shade [neutral0].
|
||||
val neutral100: Color,
|
||||
val neutral99: Color,
|
||||
val neutral95: Color,
|
||||
val neutral90: Color,
|
||||
val neutral80: Color,
|
||||
val neutral70: Color,
|
||||
val neutral60: Color,
|
||||
val neutral50: Color,
|
||||
val neutral40: Color,
|
||||
val neutral30: Color,
|
||||
val neutral20: Color,
|
||||
val neutral10: Color,
|
||||
val neutral0: Color,
|
||||
|
||||
// The neutral variant tonal range, sometimes called "neutral 2", from the
|
||||
// generated dynamic color palette.
|
||||
// Ordered from the lightest shade [neutralVariant100] to the darkest shade [neutralVariant0].
|
||||
val neutralVariant100: Color,
|
||||
val neutralVariant99: Color,
|
||||
val neutralVariant95: Color,
|
||||
val neutralVariant90: Color,
|
||||
val neutralVariant80: Color,
|
||||
val neutralVariant70: Color,
|
||||
val neutralVariant60: Color,
|
||||
val neutralVariant50: Color,
|
||||
val neutralVariant40: Color,
|
||||
val neutralVariant30: Color,
|
||||
val neutralVariant20: Color,
|
||||
val neutralVariant10: Color,
|
||||
val neutralVariant0: Color,
|
||||
|
||||
// The primary tonal range from the generated dynamic color palette.
|
||||
// Ordered from the lightest shade [primary100] to the darkest shade [primary0].
|
||||
val primary100: Color,
|
||||
val primary99: Color,
|
||||
val primary95: Color,
|
||||
val primary90: Color,
|
||||
val primary80: Color,
|
||||
val primary70: Color,
|
||||
val primary60: Color,
|
||||
val primary50: Color,
|
||||
val primary40: Color,
|
||||
val primary30: Color,
|
||||
val primary20: Color,
|
||||
val primary10: Color,
|
||||
val primary0: Color,
|
||||
|
||||
// The secondary tonal range from the generated dynamic color palette.
|
||||
// Ordered from the lightest shade [secondary100] to the darkest shade [secondary0].
|
||||
val secondary100: Color,
|
||||
val secondary99: Color,
|
||||
val secondary95: Color,
|
||||
val secondary90: Color,
|
||||
val secondary80: Color,
|
||||
val secondary70: Color,
|
||||
val secondary60: Color,
|
||||
val secondary50: Color,
|
||||
val secondary40: Color,
|
||||
val secondary30: Color,
|
||||
val secondary20: Color,
|
||||
val secondary10: Color,
|
||||
val secondary0: Color,
|
||||
|
||||
// The tertiary tonal range from the generated dynamic color palette.
|
||||
// Ordered from the lightest shade [tertiary100] to the darkest shade [tertiary0].
|
||||
val tertiary100: Color,
|
||||
val tertiary99: Color,
|
||||
val tertiary95: Color,
|
||||
val tertiary90: Color,
|
||||
val tertiary80: Color,
|
||||
val tertiary70: Color,
|
||||
val tertiary60: Color,
|
||||
val tertiary50: Color,
|
||||
val tertiary40: Color,
|
||||
val tertiary30: Color,
|
||||
val tertiary20: Color,
|
||||
val tertiary10: Color,
|
||||
val tertiary0: Color,
|
||||
)
|
||||
|
||||
/** Static colors in Material. */
|
||||
internal fun tonalPalette() = SettingsTonalPalette(
|
||||
// The neutral static tonal range.
|
||||
neutral100 = Color(red = 255, green = 255, blue = 255),
|
||||
neutral99 = Color(red = 255, green = 251, blue = 254),
|
||||
neutral95 = Color(red = 244, green = 239, blue = 244),
|
||||
neutral90 = Color(red = 230, green = 225, blue = 229),
|
||||
neutral80 = Color(red = 201, green = 197, blue = 202),
|
||||
neutral70 = Color(red = 174, green = 170, blue = 174),
|
||||
neutral60 = Color(red = 147, green = 144, blue = 148),
|
||||
neutral50 = Color(red = 120, green = 117, blue = 121),
|
||||
neutral40 = Color(red = 96, green = 93, blue = 98),
|
||||
neutral30 = Color(red = 72, green = 70, blue = 73),
|
||||
neutral20 = Color(red = 49, green = 48, blue = 51),
|
||||
neutral10 = Color(red = 28, green = 27, blue = 31),
|
||||
neutral0 = Color(red = 0, green = 0, blue = 0),
|
||||
|
||||
// The neutral variant static tonal range, sometimes called "neutral 2".
|
||||
neutralVariant100 = Color(red = 255, green = 255, blue = 255),
|
||||
neutralVariant99 = Color(red = 255, green = 251, blue = 254),
|
||||
neutralVariant95 = Color(red = 245, green = 238, blue = 250),
|
||||
neutralVariant90 = Color(red = 231, green = 224, blue = 236),
|
||||
neutralVariant80 = Color(red = 202, green = 196, blue = 208),
|
||||
neutralVariant70 = Color(red = 174, green = 169, blue = 180),
|
||||
neutralVariant60 = Color(red = 147, green = 143, blue = 153),
|
||||
neutralVariant50 = Color(red = 121, green = 116, blue = 126),
|
||||
neutralVariant40 = Color(red = 96, green = 93, blue = 102),
|
||||
neutralVariant30 = Color(red = 73, green = 69, blue = 79),
|
||||
neutralVariant20 = Color(red = 50, green = 47, blue = 55),
|
||||
neutralVariant10 = Color(red = 29, green = 26, blue = 34),
|
||||
neutralVariant0 = Color(red = 0, green = 0, blue = 0),
|
||||
|
||||
// The primary static tonal range.
|
||||
primary100 = Color(red = 255, green = 255, blue = 255),
|
||||
primary99 = Color(red = 255, green = 251, blue = 254),
|
||||
primary95 = Color(red = 246, green = 237, blue = 255),
|
||||
primary90 = Color(red = 234, green = 221, blue = 255),
|
||||
primary80 = Color(red = 208, green = 188, blue = 255),
|
||||
primary70 = Color(red = 182, green = 157, blue = 248),
|
||||
primary60 = Color(red = 154, green = 130, blue = 219),
|
||||
primary50 = Color(red = 127, green = 103, blue = 190),
|
||||
primary40 = Color(red = 103, green = 80, blue = 164),
|
||||
primary30 = Color(red = 79, green = 55, blue = 139),
|
||||
primary20 = Color(red = 56, green = 30, blue = 114),
|
||||
primary10 = Color(red = 33, green = 0, blue = 93),
|
||||
primary0 = Color(red = 33, green = 0, blue = 93),
|
||||
|
||||
// The secondary static tonal range.
|
||||
secondary100 = Color(red = 255, green = 255, blue = 255),
|
||||
secondary99 = Color(red = 255, green = 251, blue = 254),
|
||||
secondary95 = Color(red = 246, green = 237, blue = 255),
|
||||
secondary90 = Color(red = 232, green = 222, blue = 248),
|
||||
secondary80 = Color(red = 204, green = 194, blue = 220),
|
||||
secondary70 = Color(red = 176, green = 167, blue = 192),
|
||||
secondary60 = Color(red = 149, green = 141, blue = 165),
|
||||
secondary50 = Color(red = 122, green = 114, blue = 137),
|
||||
secondary40 = Color(red = 98, green = 91, blue = 113),
|
||||
secondary30 = Color(red = 74, green = 68, blue = 88),
|
||||
secondary20 = Color(red = 51, green = 45, blue = 65),
|
||||
secondary10 = Color(red = 29, green = 25, blue = 43),
|
||||
secondary0 = Color(red = 0, green = 0, blue = 0),
|
||||
|
||||
// The tertiary static tonal range.
|
||||
tertiary100 = Color(red = 255, green = 255, blue = 255),
|
||||
tertiary99 = Color(red = 255, green = 251, blue = 250),
|
||||
tertiary95 = Color(red = 255, green = 236, blue = 241),
|
||||
tertiary90 = Color(red = 255, green = 216, blue = 228),
|
||||
tertiary80 = Color(red = 239, green = 184, blue = 200),
|
||||
tertiary70 = Color(red = 210, green = 157, blue = 172),
|
||||
tertiary60 = Color(red = 181, green = 131, blue = 146),
|
||||
tertiary50 = Color(red = 152, green = 105, blue = 119),
|
||||
tertiary40 = Color(red = 125, green = 82, blue = 96),
|
||||
tertiary30 = Color(red = 99, green = 59, blue = 72),
|
||||
tertiary20 = Color(red = 73, green = 37, blue = 50),
|
||||
tertiary10 = Color(red = 49, green = 17, blue = 29),
|
||||
tertiary0 = Color(red = 0, green = 0, blue = 0),
|
||||
)
|
||||
|
||||
/** Dynamic colors in Material. */
|
||||
internal fun dynamicTonalPalette(context: Context) = SettingsTonalPalette(
|
||||
// The neutral tonal range from the generated dynamic color palette.
|
||||
neutral100 = ColorResourceHelper.getColor(context, R.color.system_neutral1_0),
|
||||
neutral99 = ColorResourceHelper.getColor(context, R.color.system_neutral1_10),
|
||||
neutral95 = ColorResourceHelper.getColor(context, R.color.system_neutral1_50),
|
||||
neutral90 = ColorResourceHelper.getColor(context, R.color.system_neutral1_100),
|
||||
neutral80 = ColorResourceHelper.getColor(context, R.color.system_neutral1_200),
|
||||
neutral70 = ColorResourceHelper.getColor(context, R.color.system_neutral1_300),
|
||||
neutral60 = ColorResourceHelper.getColor(context, R.color.system_neutral1_400),
|
||||
neutral50 = ColorResourceHelper.getColor(context, R.color.system_neutral1_500),
|
||||
neutral40 = ColorResourceHelper.getColor(context, R.color.system_neutral1_600),
|
||||
neutral30 = ColorResourceHelper.getColor(context, R.color.system_neutral1_700),
|
||||
neutral20 = ColorResourceHelper.getColor(context, R.color.system_neutral1_800),
|
||||
neutral10 = ColorResourceHelper.getColor(context, R.color.system_neutral1_900),
|
||||
neutral0 = ColorResourceHelper.getColor(context, R.color.system_neutral1_1000),
|
||||
|
||||
// The neutral variant tonal range, sometimes called "neutral 2", from the
|
||||
// generated dynamic color palette.
|
||||
neutralVariant100 = ColorResourceHelper.getColor(context, R.color.system_neutral2_0),
|
||||
neutralVariant99 = ColorResourceHelper.getColor(context, R.color.system_neutral2_10),
|
||||
neutralVariant95 = ColorResourceHelper.getColor(context, R.color.system_neutral2_50),
|
||||
neutralVariant90 = ColorResourceHelper.getColor(context, R.color.system_neutral2_100),
|
||||
neutralVariant80 = ColorResourceHelper.getColor(context, R.color.system_neutral2_200),
|
||||
neutralVariant70 = ColorResourceHelper.getColor(context, R.color.system_neutral2_300),
|
||||
neutralVariant60 = ColorResourceHelper.getColor(context, R.color.system_neutral2_400),
|
||||
neutralVariant50 = ColorResourceHelper.getColor(context, R.color.system_neutral2_500),
|
||||
neutralVariant40 = ColorResourceHelper.getColor(context, R.color.system_neutral2_600),
|
||||
neutralVariant30 = ColorResourceHelper.getColor(context, R.color.system_neutral2_700),
|
||||
neutralVariant20 = ColorResourceHelper.getColor(context, R.color.system_neutral2_800),
|
||||
neutralVariant10 = ColorResourceHelper.getColor(context, R.color.system_neutral2_900),
|
||||
neutralVariant0 = ColorResourceHelper.getColor(context, R.color.system_neutral2_1000),
|
||||
|
||||
// The primary tonal range from the generated dynamic color palette.
|
||||
primary100 = ColorResourceHelper.getColor(context, R.color.system_accent1_0),
|
||||
primary99 = ColorResourceHelper.getColor(context, R.color.system_accent1_10),
|
||||
primary95 = ColorResourceHelper.getColor(context, R.color.system_accent1_50),
|
||||
primary90 = ColorResourceHelper.getColor(context, R.color.system_accent1_100),
|
||||
primary80 = ColorResourceHelper.getColor(context, R.color.system_accent1_200),
|
||||
primary70 = ColorResourceHelper.getColor(context, R.color.system_accent1_300),
|
||||
primary60 = ColorResourceHelper.getColor(context, R.color.system_accent1_400),
|
||||
primary50 = ColorResourceHelper.getColor(context, R.color.system_accent1_500),
|
||||
primary40 = ColorResourceHelper.getColor(context, R.color.system_accent1_600),
|
||||
primary30 = ColorResourceHelper.getColor(context, R.color.system_accent1_700),
|
||||
primary20 = ColorResourceHelper.getColor(context, R.color.system_accent1_800),
|
||||
primary10 = ColorResourceHelper.getColor(context, R.color.system_accent1_900),
|
||||
primary0 = ColorResourceHelper.getColor(context, R.color.system_accent1_1000),
|
||||
|
||||
// The secondary tonal range from the generated dynamic color palette.
|
||||
secondary100 = ColorResourceHelper.getColor(context, R.color.system_accent2_0),
|
||||
secondary99 = ColorResourceHelper.getColor(context, R.color.system_accent2_10),
|
||||
secondary95 = ColorResourceHelper.getColor(context, R.color.system_accent2_50),
|
||||
secondary90 = ColorResourceHelper.getColor(context, R.color.system_accent2_100),
|
||||
secondary80 = ColorResourceHelper.getColor(context, R.color.system_accent2_200),
|
||||
secondary70 = ColorResourceHelper.getColor(context, R.color.system_accent2_300),
|
||||
secondary60 = ColorResourceHelper.getColor(context, R.color.system_accent2_400),
|
||||
secondary50 = ColorResourceHelper.getColor(context, R.color.system_accent2_500),
|
||||
secondary40 = ColorResourceHelper.getColor(context, R.color.system_accent2_600),
|
||||
secondary30 = ColorResourceHelper.getColor(context, R.color.system_accent2_700),
|
||||
secondary20 = ColorResourceHelper.getColor(context, R.color.system_accent2_800),
|
||||
secondary10 = ColorResourceHelper.getColor(context, R.color.system_accent2_900),
|
||||
secondary0 = ColorResourceHelper.getColor(context, R.color.system_accent2_1000),
|
||||
|
||||
// The tertiary tonal range from the generated dynamic color palette.
|
||||
tertiary100 = ColorResourceHelper.getColor(context, R.color.system_accent3_0),
|
||||
tertiary99 = ColorResourceHelper.getColor(context, R.color.system_accent3_10),
|
||||
tertiary95 = ColorResourceHelper.getColor(context, R.color.system_accent3_50),
|
||||
tertiary90 = ColorResourceHelper.getColor(context, R.color.system_accent3_100),
|
||||
tertiary80 = ColorResourceHelper.getColor(context, R.color.system_accent3_200),
|
||||
tertiary70 = ColorResourceHelper.getColor(context, R.color.system_accent3_300),
|
||||
tertiary60 = ColorResourceHelper.getColor(context, R.color.system_accent3_400),
|
||||
tertiary50 = ColorResourceHelper.getColor(context, R.color.system_accent3_500),
|
||||
tertiary40 = ColorResourceHelper.getColor(context, R.color.system_accent3_600),
|
||||
tertiary30 = ColorResourceHelper.getColor(context, R.color.system_accent3_700),
|
||||
tertiary20 = ColorResourceHelper.getColor(context, R.color.system_accent3_800),
|
||||
tertiary10 = ColorResourceHelper.getColor(context, R.color.system_accent3_900),
|
||||
tertiary0 = ColorResourceHelper.getColor(context, R.color.system_accent3_1000),
|
||||
)
|
||||
|
||||
private object ColorResourceHelper {
|
||||
@DoNotInline
|
||||
fun getColor(context: Context, @ColorRes id: Int): Color {
|
||||
return Color(context.resources.getColor(id, context.theme))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.Hyphens
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private class SettingsTypography(settingsFontFamily: SettingsFontFamily) {
|
||||
private val brand = settingsFontFamily.brand
|
||||
private val plain = settingsFontFamily.plain
|
||||
|
||||
val typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.2).sp,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.0.sp,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.0.sp,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.0.sp,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.0.sp,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.0.sp,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.02.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.02.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = brand,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.02.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = plain,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.01.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = plain,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.01.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = plain,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.01.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = plain,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.01.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = plain,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.01.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = plain,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.01.em,
|
||||
hyphens = Hyphens.Auto,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberSettingsTypography(): Typography {
|
||||
val settingsFontFamily = rememberSettingsFontFamily()
|
||||
return remember { SettingsTypography(settingsFontFamily).typography }
|
||||
}
|
||||
|
||||
/** Creates a new [TextStyle] which font weight set to medium. */
|
||||
internal fun TextStyle.toMediumWeight() =
|
||||
copy(fontWeight = FontWeight.Medium, letterSpacing = 0.01.em)
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.URLSpan
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
const val URL_SPAN_TAG = "URL_SPAN_TAG"
|
||||
|
||||
@Composable
|
||||
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
|
||||
val resources = LocalContext.current.resources
|
||||
val urlSpanColor = MaterialTheme.colorScheme.primary
|
||||
return remember(id) {
|
||||
val text = resources.getText(id)
|
||||
spannableStringToAnnotatedString(text, urlSpanColor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun spannableStringToAnnotatedString(text: CharSequence, urlSpanColor: Color) =
|
||||
if (text is Spanned) {
|
||||
buildAnnotatedString {
|
||||
append((text.toString()))
|
||||
for (span in text.getSpans(0, text.length, Any::class.java)) {
|
||||
val start = text.getSpanStart(span)
|
||||
val end = text.getSpanEnd(span)
|
||||
when (span) {
|
||||
is StyleSpan -> addStyleSpan(span, start, end)
|
||||
is URLSpan -> addUrlSpan(span, urlSpanColor, start, end)
|
||||
else -> addStyle(SpanStyle(), start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnnotatedString(text.toString())
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.addStyleSpan(styleSpan: StyleSpan, start: Int, end: Int) {
|
||||
when (styleSpan.style) {
|
||||
Typeface.NORMAL -> addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Normal, fontStyle = FontStyle.Normal),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
Typeface.BOLD -> addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Normal),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
Typeface.ITALIC -> addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
Typeface.BOLD_ITALIC -> addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.addUrlSpan(
|
||||
urlSpan: URLSpan,
|
||||
urlSpanColor: Color,
|
||||
start: Int,
|
||||
end: Int,
|
||||
) {
|
||||
addStyle(
|
||||
SpanStyle(color = urlSpanColor, textDecoration = TextDecoration.Underline),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
if (!urlSpan.url.isNullOrEmpty()) {
|
||||
addStringAnnotation(URL_SPAN_TAG, urlSpan.url, start, end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Performs the given [action] on each element asynchronously.
|
||||
*/
|
||||
suspend inline fun <T> Iterable<T>.asyncForEach(crossinline action: (T) -> Unit) {
|
||||
coroutineScope {
|
||||
forEach {
|
||||
launch { action(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list containing the results of asynchronously applying the given [transform] function
|
||||
* to each element in the original collection.
|
||||
*/
|
||||
suspend inline fun <R, T> Iterable<T>.asyncMap(crossinline transform: (T) -> R): List<R> =
|
||||
coroutineScope {
|
||||
map { item ->
|
||||
async { transform(item) }
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list containing only elements matching the given [predicate].
|
||||
*
|
||||
* The filter operation is done asynchronously.
|
||||
*/
|
||||
suspend inline fun <T> Iterable<T>.asyncFilter(crossinline predicate: (T) -> Boolean): List<T> =
|
||||
asyncMap { item -> item to predicate(item) }
|
||||
.filter { it.second }
|
||||
.map { it.first }
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.repeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.android.settingslib.spa.framework.common.LocalEntryDataProvider
|
||||
|
||||
@Composable
|
||||
internal fun EntryHighlight(content: @Composable () -> Unit) {
|
||||
val entryData = LocalEntryDataProvider.current
|
||||
val entryIsHighlighted = rememberSaveable { entryData.isHighlighted }
|
||||
var localHighlighted by rememberSaveable { mutableStateOf(false) }
|
||||
SideEffect {
|
||||
localHighlighted = entryIsHighlighted
|
||||
}
|
||||
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
localHighlighted -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> Color.Transparent
|
||||
},
|
||||
animationSpec = repeatable(
|
||||
iterations = 3,
|
||||
animation = tween(durationMillis = 500),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "BackgroundColorAnimation",
|
||||
)
|
||||
Box(modifier = Modifier.background(color = backgroundColor)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.os.bundleOf
|
||||
import com.android.settingslib.spa.framework.common.LOG_DATA_SWITCH_STATUS
|
||||
import com.android.settingslib.spa.framework.common.LocalEntryDataProvider
|
||||
import com.android.settingslib.spa.framework.common.LogCategory
|
||||
import com.android.settingslib.spa.framework.common.LogEvent
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
|
||||
@Composable
|
||||
fun logEntryEvent(): (event: LogEvent, extraData: Bundle) -> Unit {
|
||||
val entryId = LocalEntryDataProvider.current.entryId ?: return { _, _ -> }
|
||||
val arguments = LocalEntryDataProvider.current.arguments
|
||||
return { event, extraData ->
|
||||
SpaEnvironmentFactory.instance.logger.event(
|
||||
entryId, event, category = LogCategory.VIEW, extraData = extraData.apply {
|
||||
if (arguments != null) putAll(arguments)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun wrapOnClickWithLog(onClick: (() -> Unit)?): (() -> Unit)? {
|
||||
if (onClick == null) return null
|
||||
val logEvent = logEntryEvent()
|
||||
return {
|
||||
logEvent(LogEvent.ENTRY_CLICK, bundleOf())
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun wrapOnSwitchWithLog(onSwitch: ((checked: Boolean) -> Unit)?): ((checked: Boolean) -> Unit)? {
|
||||
if (onSwitch == null) return null
|
||||
val logEvent = logEntryEvent()
|
||||
return {
|
||||
logEvent(LogEvent.ENTRY_SWITCH, bundleOf(LOG_DATA_SWITCH_STATUS to it))
|
||||
onSwitch(it)
|
||||
}
|
||||
}
|
||||
70
spa/src/com/android/settingslib/spa/framework/util/Flows.kt
Normal file
70
spa/src/com/android/settingslib/spa/framework/util/Flows.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Returns a [Flow] whose values are a list which containing the results of applying the given
|
||||
* [transform] function to each element in the original flow's list.
|
||||
*/
|
||||
inline fun <T, R> Flow<List<T>>.mapItem(crossinline transform: (T) -> R): Flow<List<R>> =
|
||||
map { list -> list.map(transform) }
|
||||
|
||||
/**
|
||||
* Returns a [Flow] whose values are a list which containing the results of asynchronously applying
|
||||
* the given [transform] function to each element in the original flow's list.
|
||||
*/
|
||||
inline fun <T, R> Flow<List<T>>.asyncMapItem(crossinline transform: (T) -> R): Flow<List<R>> =
|
||||
map { list -> list.asyncMap(transform) }
|
||||
|
||||
/**
|
||||
* Returns a [Flow] whose values are a list containing only elements matching the given [predicate].
|
||||
*/
|
||||
inline fun <T> Flow<List<T>>.filterItem(crossinline predicate: (T) -> Boolean): Flow<List<T>> =
|
||||
map { list -> list.filter(predicate) }
|
||||
|
||||
/**
|
||||
* Delays the flow a little bit, wait the other flow's first value.
|
||||
*/
|
||||
fun <T1, T2> Flow<T1>.waitFirst(otherFlow: Flow<T2>): Flow<T1> =
|
||||
combine(otherFlow.take(1)) { value, _ -> value }
|
||||
|
||||
|
||||
/**
|
||||
* Collects the latest value of given flow with a provided action with [LifecycleOwner].
|
||||
*/
|
||||
fun <T> Flow<T>.collectLatestWithLifecycle(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
|
||||
action: suspend (value: T) -> Unit,
|
||||
) {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
lifecycleOwner.repeatOnLifecycle(minActiveState) {
|
||||
collectLatest(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.icu.text.MessageFormat
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import java.util.Locale
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
@SafeVarargs
|
||||
fun Context.formatString(@StringRes resId: Int, vararg arguments: Pair<String, Any>): String =
|
||||
resources.formatString(resId, *arguments)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
@SafeVarargs
|
||||
fun Resources.formatString(@StringRes resId: Int, vararg arguments: Pair<String, Any>): String =
|
||||
MessageFormat(getString(resId), Locale.getDefault(Locale.Category.FORMAT))
|
||||
.format(mapOf(*arguments))
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.os.bundleOf
|
||||
import com.android.settingslib.spa.framework.common.LOG_DATA_DISPLAY_NAME
|
||||
import com.android.settingslib.spa.framework.common.LOG_DATA_SESSION_NAME
|
||||
import com.android.settingslib.spa.framework.common.LogCategory
|
||||
import com.android.settingslib.spa.framework.common.LogEvent
|
||||
import com.android.settingslib.spa.framework.common.SettingsPage
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.compose.LifecycleEffect
|
||||
import com.android.settingslib.spa.framework.compose.LocalNavController
|
||||
import com.android.settingslib.spa.framework.compose.NavControllerWrapper
|
||||
|
||||
@Composable
|
||||
internal fun SettingsPage.PageLogger() {
|
||||
val navController = LocalNavController.current
|
||||
LifecycleEffect(
|
||||
onStart = { logPageEvent(LogEvent.PAGE_ENTER, navController) },
|
||||
onStop = { logPageEvent(LogEvent.PAGE_LEAVE, navController) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun SettingsPage.logPageEvent(event: LogEvent, navController: NavControllerWrapper) {
|
||||
SpaEnvironmentFactory.instance.logger.event(
|
||||
id = id,
|
||||
event = event,
|
||||
category = LogCategory.FRAMEWORK,
|
||||
extraData = bundleOf(
|
||||
LOG_DATA_DISPLAY_NAME to displayName,
|
||||
LOG_DATA_SESSION_NAME to navController.sessionSourceName,
|
||||
).apply {
|
||||
val normArguments = parameter.normalize(arguments)
|
||||
if (normArguments != null) putAll(normArguments)
|
||||
}
|
||||
)
|
||||
}
|
||||
120
spa/src/com/android/settingslib/spa/framework/util/Parameter.kt
Normal file
120
spa/src/com/android/settingslib/spa/framework/util/Parameter.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavType
|
||||
|
||||
const val RUNTIME_PARAM_PREFIX = "rt_"
|
||||
const val UNSET_PARAM_PREFIX = "unset_"
|
||||
const val UNSET_PARAM_VALUE = "[unset]"
|
||||
|
||||
fun List<NamedNavArgument>.navRoute(): String {
|
||||
return this.joinToString("") { argument -> "/{${argument.name}}" }
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.navLink(arguments: Bundle? = null): String {
|
||||
return normalizeArgList(arguments).joinToString("") { arg -> "/$arg" }
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.normalizeArgList(
|
||||
arguments: Bundle? = null,
|
||||
eraseRuntimeValues: Boolean = false
|
||||
): List<String> {
|
||||
val argsArray = mutableListOf<String>()
|
||||
for (navArg in this) {
|
||||
if (eraseRuntimeValues && navArg.isRuntimeParam()) continue
|
||||
if (arguments == null || !arguments.containsKey(navArg.name)) {
|
||||
argsArray.add(UNSET_PARAM_VALUE)
|
||||
continue
|
||||
}
|
||||
when (navArg.argument.type) {
|
||||
NavType.StringType -> {
|
||||
argsArray.add(arguments.getString(navArg.name, ""))
|
||||
}
|
||||
NavType.IntType -> {
|
||||
argsArray.add(arguments.getInt(navArg.name).toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
return argsArray
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.normalize(
|
||||
arguments: Bundle? = null,
|
||||
eraseRuntimeValues: Boolean = false
|
||||
): Bundle? {
|
||||
if (this.isEmpty()) return null
|
||||
val normArgs = Bundle()
|
||||
for (navArg in this) {
|
||||
// Erase value of runtime parameters.
|
||||
if (eraseRuntimeValues && navArg.isRuntimeParam()) {
|
||||
normArgs.putString(navArg.name, null)
|
||||
continue
|
||||
}
|
||||
|
||||
when (navArg.argument.type) {
|
||||
NavType.StringType -> {
|
||||
val value = arguments?.getString(navArg.name)
|
||||
if (value != null)
|
||||
normArgs.putString(navArg.name, value)
|
||||
else
|
||||
normArgs.putString(UNSET_PARAM_PREFIX + navArg.name, null)
|
||||
}
|
||||
NavType.IntType -> {
|
||||
if (arguments != null && arguments.containsKey(navArg.name))
|
||||
normArgs.putInt(navArg.name, arguments.getInt(navArg.name))
|
||||
else
|
||||
normArgs.putString(UNSET_PARAM_PREFIX + navArg.name, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return normArgs
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.getStringArg(name: String, arguments: Bundle? = null): String? {
|
||||
if (this.containsStringArg(name) && arguments != null) {
|
||||
return arguments.getString(name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.getIntArg(name: String, arguments: Bundle? = null): Int? {
|
||||
if (this.containsIntArg(name) && arguments != null && arguments.containsKey(name)) {
|
||||
return arguments.getInt(name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.containsStringArg(name: String): Boolean {
|
||||
for (navArg in this) {
|
||||
if (navArg.argument.type == NavType.StringType && navArg.name == name) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun List<NamedNavArgument>.containsIntArg(name: String): Boolean {
|
||||
for (navArg in this) {
|
||||
if (navArg.argument.type == NavType.IntType && navArg.name == name) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun NamedNavArgument.isRuntimeParam(): Boolean {
|
||||
return this.name.startsWith(RUNTIME_PARAM_PREFIX)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntry
|
||||
import com.android.settingslib.spa.framework.common.SettingsPage
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
|
||||
const val SESSION_UNKNOWN = "unknown"
|
||||
const val SESSION_BROWSE = "browse"
|
||||
const val SESSION_SEARCH = "search"
|
||||
const val SESSION_SLICE = "slice"
|
||||
const val SESSION_EXTERNAL = "external"
|
||||
|
||||
const val KEY_DESTINATION = "spaActivityDestination"
|
||||
const val KEY_HIGHLIGHT_ENTRY = "highlightEntry"
|
||||
const val KEY_SESSION_SOURCE_NAME = "sessionSource"
|
||||
|
||||
val SPA_INTENT_RESERVED_KEYS = listOf(
|
||||
KEY_DESTINATION,
|
||||
KEY_HIGHLIGHT_ENTRY,
|
||||
KEY_SESSION_SOURCE_NAME
|
||||
)
|
||||
|
||||
private fun createBaseIntent(): Intent? {
|
||||
val context = SpaEnvironmentFactory.instance.appContext
|
||||
val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
|
||||
return Intent().setComponent(ComponentName(context, browseActivityClass))
|
||||
}
|
||||
|
||||
fun SettingsPage.createIntent(sessionName: String? = null): Intent? {
|
||||
if (!isBrowsable()) return null
|
||||
return createBaseIntent()?.appendSpaParams(
|
||||
destination = buildRoute(),
|
||||
sessionName = sessionName
|
||||
)
|
||||
}
|
||||
|
||||
fun SettingsEntry.createIntent(sessionName: String? = null): Intent? {
|
||||
val sp = containerPage()
|
||||
if (!sp.isBrowsable()) return null
|
||||
return createBaseIntent()?.appendSpaParams(
|
||||
destination = sp.buildRoute(),
|
||||
entryId = id,
|
||||
sessionName = sessionName
|
||||
)
|
||||
}
|
||||
|
||||
fun Intent.appendSpaParams(
|
||||
destination: String? = null,
|
||||
entryId: String? = null,
|
||||
sessionName: String? = null
|
||||
): Intent {
|
||||
return apply {
|
||||
if (destination != null) putExtra(KEY_DESTINATION, destination)
|
||||
if (entryId != null) putExtra(KEY_HIGHLIGHT_ENTRY, entryId)
|
||||
if (sessionName != null) putExtra(KEY_SESSION_SOURCE_NAME, sessionName)
|
||||
}
|
||||
}
|
||||
|
||||
fun Intent.getDestination(): String? {
|
||||
return getStringExtra(KEY_DESTINATION)
|
||||
}
|
||||
|
||||
fun Intent.getEntryId(): String? {
|
||||
return getStringExtra(KEY_HIGHLIGHT_ENTRY)
|
||||
}
|
||||
|
||||
fun Intent.getSessionName(): String? {
|
||||
return getStringExtra(KEY_SESSION_SOURCE_NAME)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
||||
/** A StateFlow holder which value could be set or sync from callback. */
|
||||
class StateFlowBridge<T> {
|
||||
private val stateFlow = MutableStateFlow<T?>(null)
|
||||
val flow = stateFlow.filterNotNull()
|
||||
|
||||
fun setIfAbsent(value: T) {
|
||||
if (stateFlow.value == null) {
|
||||
stateFlow.value = value
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Sync(callback: () -> T) {
|
||||
val value = callback()
|
||||
LaunchedEffect(value) {
|
||||
stateFlow.value = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.framework.util
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import com.android.settingslib.spa.framework.common.SettingsPage
|
||||
|
||||
fun genPageId(
|
||||
sppName: String,
|
||||
parameter: List<NamedNavArgument> = emptyList(),
|
||||
arguments: Bundle? = null
|
||||
): String {
|
||||
val normArguments = parameter.normalize(arguments, eraseRuntimeValues = true)
|
||||
return "$sppName:${normArguments?.toString()}".toHashId()
|
||||
}
|
||||
|
||||
fun genEntryId(
|
||||
name: String,
|
||||
owner: SettingsPage,
|
||||
fromPage: SettingsPage? = null,
|
||||
toPage: SettingsPage? = null
|
||||
): String {
|
||||
return "$name:${owner.id}(${fromPage?.id}-${toPage?.id})".toHashId()
|
||||
}
|
||||
|
||||
// TODO: implement a better hash function
|
||||
private fun String.toHashId(): String {
|
||||
return this.hashCode().toUInt().toString(36)
|
||||
}
|
||||
28
spa/src/com/android/settingslib/spa/lifecycle/FlowExt.kt
Normal file
28
spa/src/com/android/settingslib/spa/lifecycle/FlowExt.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.lifecycle
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Composable
|
||||
fun <T> Flow<T>.collectAsCallbackWithLifecycle(): () -> T? {
|
||||
val value by collectAsStateWithLifecycle(initialValue = null)
|
||||
return { value }
|
||||
}
|
||||
34
spa/src/com/android/settingslib/spa/livedata/LiveDataExt.kt
Normal file
34
spa/src/com/android/settingslib/spa/livedata/LiveDataExt.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.livedata
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.lifecycle.LiveData
|
||||
|
||||
/**
|
||||
* Starts observing this LiveData and represents its values via State and Callback.
|
||||
*
|
||||
* Every time there would be new value posted into the LiveData the returned State will be updated
|
||||
* causing recomposition of every Callback usage.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> LiveData<T>.observeAsCallback(): () -> T? {
|
||||
val isAllowed by observeAsState()
|
||||
return { isAllowed }
|
||||
}
|
||||
137
spa/src/com/android/settingslib/spa/search/SpaSearchContract.kt
Normal file
137
spa/src/com/android/settingslib/spa/search/SpaSearchContract.kt
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.android.settingslib.spa.search
|
||||
|
||||
/**
|
||||
* Intent action used to identify SpaSearchProvider instances. This is used in the {@code
|
||||
* <intent-filter>} of a {@code <provider>}.
|
||||
*/
|
||||
const val PROVIDER_INTERFACE = "android.content.action.SPA_SEARCH_PROVIDER"
|
||||
|
||||
/** ContentProvider path for search static data */
|
||||
const val SEARCH_STATIC_DATA = "search_static_data"
|
||||
|
||||
/** ContentProvider path for search dynamic data */
|
||||
const val SEARCH_DYNAMIC_DATA = "search_dynamic_data"
|
||||
|
||||
/** ContentProvider path for search immutable status */
|
||||
const val SEARCH_IMMUTABLE_STATUS = "search_immutable_status"
|
||||
|
||||
/** ContentProvider path for search mutable status */
|
||||
const val SEARCH_MUTABLE_STATUS = "search_mutable_status"
|
||||
|
||||
/** ContentProvider path for search static row */
|
||||
const val SEARCH_STATIC_ROW = "search_static_row"
|
||||
|
||||
/** ContentProvider path for search dynamic row */
|
||||
const val SEARCH_DYNAMIC_ROW = "search_dynamic_row"
|
||||
|
||||
|
||||
/** Enum to define all column names in provider. */
|
||||
enum class ColumnEnum(val id: String) {
|
||||
ENTRY_ID("entryId"),
|
||||
ENTRY_LABEL("entryLabel"),
|
||||
SEARCH_TITLE("searchTitle"),
|
||||
SEARCH_KEYWORD("searchKw"),
|
||||
SEARCH_PATH("searchPath"),
|
||||
INTENT_TARGET_PACKAGE("intentTargetPackage"),
|
||||
INTENT_TARGET_CLASS("intentTargetClass"),
|
||||
INTENT_EXTRAS("intentExtras"),
|
||||
SLICE_URI("sliceUri"),
|
||||
ENTRY_DISABLED("entryDisabled"),
|
||||
}
|
||||
|
||||
/** Enum to define all queries supported in the provider. */
|
||||
@SuppressWarnings("Immutable")
|
||||
enum class QueryEnum(
|
||||
val queryPath: String,
|
||||
val columnNames: List<ColumnEnum>
|
||||
) {
|
||||
SEARCH_STATIC_DATA_QUERY(
|
||||
SEARCH_STATIC_DATA,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.SEARCH_TITLE,
|
||||
ColumnEnum.SEARCH_KEYWORD,
|
||||
ColumnEnum.SEARCH_PATH,
|
||||
ColumnEnum.INTENT_TARGET_PACKAGE,
|
||||
ColumnEnum.INTENT_TARGET_CLASS,
|
||||
ColumnEnum.INTENT_EXTRAS,
|
||||
ColumnEnum.SLICE_URI,
|
||||
)
|
||||
),
|
||||
SEARCH_DYNAMIC_DATA_QUERY(
|
||||
SEARCH_DYNAMIC_DATA,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.SEARCH_TITLE,
|
||||
ColumnEnum.SEARCH_KEYWORD,
|
||||
ColumnEnum.SEARCH_PATH,
|
||||
ColumnEnum.INTENT_TARGET_PACKAGE,
|
||||
ColumnEnum.INTENT_TARGET_CLASS,
|
||||
ColumnEnum.INTENT_EXTRAS,
|
||||
ColumnEnum.SLICE_URI,
|
||||
)
|
||||
),
|
||||
SEARCH_IMMUTABLE_STATUS_DATA_QUERY(
|
||||
SEARCH_IMMUTABLE_STATUS,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.ENTRY_DISABLED,
|
||||
)
|
||||
),
|
||||
SEARCH_MUTABLE_STATUS_DATA_QUERY(
|
||||
SEARCH_MUTABLE_STATUS,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.ENTRY_DISABLED,
|
||||
)
|
||||
),
|
||||
SEARCH_STATIC_ROW_QUERY(
|
||||
SEARCH_STATIC_ROW,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.SEARCH_TITLE,
|
||||
ColumnEnum.SEARCH_KEYWORD,
|
||||
ColumnEnum.SEARCH_PATH,
|
||||
ColumnEnum.INTENT_TARGET_PACKAGE,
|
||||
ColumnEnum.INTENT_TARGET_CLASS,
|
||||
ColumnEnum.INTENT_EXTRAS,
|
||||
ColumnEnum.SLICE_URI,
|
||||
ColumnEnum.ENTRY_DISABLED,
|
||||
)
|
||||
),
|
||||
SEARCH_DYNAMIC_ROW_QUERY(
|
||||
SEARCH_DYNAMIC_ROW,
|
||||
listOf(
|
||||
ColumnEnum.ENTRY_ID,
|
||||
ColumnEnum.ENTRY_LABEL,
|
||||
ColumnEnum.SEARCH_TITLE,
|
||||
ColumnEnum.SEARCH_KEYWORD,
|
||||
ColumnEnum.SEARCH_PATH,
|
||||
ColumnEnum.INTENT_TARGET_PACKAGE,
|
||||
ColumnEnum.INTENT_TARGET_CLASS,
|
||||
ColumnEnum.INTENT_EXTRAS,
|
||||
ColumnEnum.SLICE_URI,
|
||||
ColumnEnum.ENTRY_DISABLED,
|
||||
)
|
||||
),
|
||||
}
|
||||
277
spa/src/com/android/settingslib/spa/search/SpaSearchProvider.kt
Normal file
277
spa/src/com/android/settingslib/spa/search/SpaSearchProvider.kt
Normal file
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.search
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.UriMatcher
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntry
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.util.SESSION_SEARCH
|
||||
import com.android.settingslib.spa.framework.util.createIntent
|
||||
import com.android.settingslib.spa.slice.fromEntry
|
||||
|
||||
|
||||
private const val TAG = "SpaSearchProvider"
|
||||
|
||||
/**
|
||||
* The content provider to return entry related data, which can be used for search and hierarchy.
|
||||
* One can query the provider result by:
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/<QueryPath>
|
||||
* For gallery, AuthorityPath = com.android.spa.gallery.search.provider
|
||||
* For Settings, AuthorityPath = com.android.settings.spa.search.provider
|
||||
* Some examples:
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/search_static_data
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/search_dynamic_data
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/search_immutable_status
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/search_mutable_status
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/search_static_row
|
||||
* $ adb shell content query --uri content://<AuthorityPath>/search_dynamic_row
|
||||
*/
|
||||
class SpaSearchProvider : ContentProvider() {
|
||||
private val spaEnvironment get() = SpaEnvironmentFactory.instance
|
||||
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
|
||||
|
||||
private val queryMatchCode = mapOf(
|
||||
SEARCH_STATIC_DATA to 301,
|
||||
SEARCH_DYNAMIC_DATA to 302,
|
||||
SEARCH_MUTABLE_STATUS to 303,
|
||||
SEARCH_IMMUTABLE_STATUS to 304,
|
||||
SEARCH_STATIC_ROW to 305,
|
||||
SEARCH_DYNAMIC_ROW to 306
|
||||
)
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
|
||||
TODO("Implement this to handle requests to delete one or more rows")
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String? {
|
||||
TODO(
|
||||
"Implement this to handle requests for the MIME type of the data" +
|
||||
"at the given URI"
|
||||
)
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||
TODO("Implement this to handle requests to insert a new row.")
|
||||
}
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): Int {
|
||||
TODO("Implement this to handle requests to update one or more rows.")
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
Log.d(TAG, "onCreate")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun attachInfo(context: Context?, info: ProviderInfo?) {
|
||||
if (info != null) {
|
||||
for (entry in queryMatchCode) {
|
||||
uriMatcher.addURI(info.authority, entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
super.attachInfo(context, info)
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? {
|
||||
return try {
|
||||
when (uriMatcher.match(uri)) {
|
||||
queryMatchCode[SEARCH_STATIC_DATA] -> querySearchStaticData()
|
||||
queryMatchCode[SEARCH_DYNAMIC_DATA] -> querySearchDynamicData()
|
||||
queryMatchCode[SEARCH_MUTABLE_STATUS] ->
|
||||
querySearchMutableStatusData()
|
||||
queryMatchCode[SEARCH_IMMUTABLE_STATUS] ->
|
||||
querySearchImmutableStatusData()
|
||||
queryMatchCode[SEARCH_STATIC_ROW] -> querySearchStaticRow()
|
||||
queryMatchCode[SEARCH_DYNAMIC_ROW] -> querySearchDynamicRow()
|
||||
else -> throw UnsupportedOperationException("Unknown Uri $uri")
|
||||
}
|
||||
} catch (e: UnsupportedOperationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Provider querying exception:", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun querySearchImmutableStatusData(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.SEARCH_IMMUTABLE_STATUS_DATA_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
if (!entry.isAllowSearch || entry.hasMutableStatus) continue
|
||||
fetchStatusData(entry, cursor)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun querySearchMutableStatusData(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.SEARCH_MUTABLE_STATUS_DATA_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
if (!entry.isAllowSearch || !entry.hasMutableStatus) continue
|
||||
fetchStatusData(entry, cursor)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun querySearchStaticData(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.SEARCH_STATIC_DATA_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
if (!entry.isAllowSearch || entry.isSearchDataDynamic) continue
|
||||
fetchSearchData(entry, cursor)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun querySearchDynamicData(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.SEARCH_DYNAMIC_DATA_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
if (!entry.isAllowSearch || !entry.isSearchDataDynamic) continue
|
||||
fetchSearchData(entry, cursor)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun querySearchStaticRow(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.SEARCH_STATIC_ROW_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
if (!entry.isAllowSearch || entry.isSearchDataDynamic || entry.hasMutableStatus)
|
||||
continue
|
||||
fetchSearchRow(entry, cursor)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun querySearchDynamicRow(): Cursor {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
val cursor = MatrixCursor(QueryEnum.SEARCH_DYNAMIC_ROW_QUERY.getColumns())
|
||||
for (entry in entryRepository.getAllEntries()) {
|
||||
if (!entry.isAllowSearch || (!entry.isSearchDataDynamic && !entry.hasMutableStatus))
|
||||
continue
|
||||
fetchSearchRow(entry, cursor)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
|
||||
private fun fetchSearchData(entry: SettingsEntry, cursor: MatrixCursor) {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
|
||||
// Fetch search data. We can add runtime arguments later if necessary
|
||||
val searchData = entry.getSearchData() ?: return
|
||||
val intent = entry.createIntent(SESSION_SEARCH)
|
||||
val row = cursor.newRow().add(ColumnEnum.ENTRY_ID.id, entry.id)
|
||||
.add(ColumnEnum.ENTRY_LABEL.id, entry.label)
|
||||
.add(ColumnEnum.SEARCH_TITLE.id, searchData.title)
|
||||
.add(ColumnEnum.SEARCH_KEYWORD.id, searchData.keyword)
|
||||
.add(
|
||||
ColumnEnum.SEARCH_PATH.id,
|
||||
entryRepository.getEntryPathWithTitle(entry.id, searchData.title)
|
||||
)
|
||||
intent?.let {
|
||||
row.add(ColumnEnum.INTENT_TARGET_PACKAGE.id, spaEnvironment.appContext.packageName)
|
||||
.add(ColumnEnum.INTENT_TARGET_CLASS.id, spaEnvironment.browseActivityClass?.name)
|
||||
.add(ColumnEnum.INTENT_EXTRAS.id, marshall(intent.extras))
|
||||
}
|
||||
if (entry.hasSliceSupport)
|
||||
row.add(
|
||||
ColumnEnum.SLICE_URI.id, Uri.Builder()
|
||||
.fromEntry(entry, spaEnvironment.sliceProviderAuthorities)
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchStatusData(entry: SettingsEntry, cursor: MatrixCursor) {
|
||||
// Fetch status data. We can add runtime arguments later if necessary
|
||||
val statusData = entry.getStatusData() ?: return
|
||||
cursor.newRow()
|
||||
.add(ColumnEnum.ENTRY_ID.id, entry.id)
|
||||
.add(ColumnEnum.ENTRY_LABEL.id, entry.label)
|
||||
.add(ColumnEnum.ENTRY_DISABLED.id, statusData.isDisabled)
|
||||
}
|
||||
|
||||
private fun fetchSearchRow(entry: SettingsEntry, cursor: MatrixCursor) {
|
||||
val entryRepository by spaEnvironment.entryRepository
|
||||
|
||||
// Fetch search data. We can add runtime arguments later if necessary
|
||||
val searchData = entry.getSearchData() ?: return
|
||||
val intent = entry.createIntent(SESSION_SEARCH)
|
||||
val row = cursor.newRow().add(ColumnEnum.ENTRY_ID.id, entry.id)
|
||||
.add(ColumnEnum.ENTRY_LABEL.id, entry.label)
|
||||
.add(ColumnEnum.SEARCH_TITLE.id, searchData.title)
|
||||
.add(ColumnEnum.SEARCH_KEYWORD.id, searchData.keyword)
|
||||
.add(
|
||||
ColumnEnum.SEARCH_PATH.id,
|
||||
entryRepository.getEntryPathWithTitle(entry.id, searchData.title)
|
||||
)
|
||||
intent?.let {
|
||||
row.add(ColumnEnum.INTENT_TARGET_PACKAGE.id, spaEnvironment.appContext.packageName)
|
||||
.add(ColumnEnum.INTENT_TARGET_CLASS.id, spaEnvironment.browseActivityClass?.name)
|
||||
.add(ColumnEnum.INTENT_EXTRAS.id, marshall(intent.extras))
|
||||
}
|
||||
if (entry.hasSliceSupport)
|
||||
row.add(
|
||||
ColumnEnum.SLICE_URI.id, Uri.Builder()
|
||||
.fromEntry(entry, spaEnvironment.sliceProviderAuthorities)
|
||||
)
|
||||
// Fetch status data. We can add runtime arguments later if necessary
|
||||
val statusData = entry.getStatusData() ?: return
|
||||
row.add(ColumnEnum.ENTRY_DISABLED.id, statusData.isDisabled)
|
||||
}
|
||||
|
||||
private fun QueryEnum.getColumns(): Array<String> {
|
||||
return columnNames.map { it.id }.toTypedArray()
|
||||
}
|
||||
|
||||
private fun marshall(parcelable: Parcelable?): ByteArray? {
|
||||
if (parcelable == null) return null
|
||||
val parcel = Parcel.obtain()
|
||||
parcelable.writeToParcel(parcel, 0)
|
||||
val bytes = parcel.marshall()
|
||||
parcel.recycle()
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.slice
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.android.settingslib.spa.framework.common.EntrySliceData
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntryRepository
|
||||
import com.android.settingslib.spa.framework.util.getEntryId
|
||||
|
||||
private const val TAG = "SliceDataRepository"
|
||||
|
||||
class SettingsSliceDataRepository(private val entryRepository: SettingsEntryRepository) {
|
||||
// The map of slice uri to its EntrySliceData, a.k.a. LiveData<Slice?>
|
||||
private val sliceDataMap: MutableMap<String, EntrySliceData> = mutableMapOf()
|
||||
|
||||
// Note: mark this function synchronized, so that we can get the same livedata during the
|
||||
// whole lifecycle of a Slice.
|
||||
@Synchronized
|
||||
fun getOrBuildSliceData(sliceUri: Uri): EntrySliceData? {
|
||||
val sliceString = sliceUri.getSliceId() ?: return null
|
||||
return sliceDataMap[sliceString] ?: buildLiveDataImpl(sliceUri)?.let {
|
||||
sliceDataMap[sliceString] = it
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
fun getActiveSliceData(sliceUri: Uri): EntrySliceData? {
|
||||
val sliceString = sliceUri.getSliceId() ?: return null
|
||||
val sliceData = sliceDataMap[sliceString] ?: return null
|
||||
return if (sliceData.isActive()) sliceData else null
|
||||
}
|
||||
|
||||
private fun buildLiveDataImpl(sliceUri: Uri): EntrySliceData? {
|
||||
Log.d(TAG, "buildLiveData: $sliceUri")
|
||||
|
||||
val entryId = sliceUri.getEntryId() ?: return null
|
||||
val entry = entryRepository.getEntry(entryId) ?: return null
|
||||
if (!entry.hasSliceSupport) return null
|
||||
val arguments = sliceUri.getRuntimeArguments()
|
||||
return entry.getSliceData(runtimeArguments = arguments, sliceUri = sliceUri)
|
||||
}
|
||||
}
|
||||
150
spa/src/com/android/settingslib/spa/slice/SliceUtil.kt
Normal file
150
spa/src/com/android/settingslib/spa/slice/SliceUtil.kt
Normal file
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.slice
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import com.android.settingslib.spa.framework.common.SettingsEntry
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.framework.util.KEY_DESTINATION
|
||||
import com.android.settingslib.spa.framework.util.KEY_HIGHLIGHT_ENTRY
|
||||
import com.android.settingslib.spa.framework.util.SESSION_SLICE
|
||||
import com.android.settingslib.spa.framework.util.SPA_INTENT_RESERVED_KEYS
|
||||
import com.android.settingslib.spa.framework.util.appendSpaParams
|
||||
import com.android.settingslib.spa.framework.util.getDestination
|
||||
import com.android.settingslib.spa.framework.util.getEntryId
|
||||
|
||||
// Defines SliceUri, which contains special query parameters:
|
||||
// -- KEY_DESTINATION: The route that this slice is navigated to.
|
||||
// -- KEY_HIGHLIGHT_ENTRY: The entry id of this slice
|
||||
// Other parameters can considered as runtime parameters.
|
||||
// Use {entryId, runtimeParams} as the unique Id of this Slice.
|
||||
typealias SliceUri = Uri
|
||||
|
||||
fun SliceUri.getEntryId(): String? {
|
||||
return getQueryParameter(KEY_HIGHLIGHT_ENTRY)
|
||||
}
|
||||
|
||||
fun SliceUri.getDestination(): String? {
|
||||
return getQueryParameter(KEY_DESTINATION)
|
||||
}
|
||||
|
||||
fun SliceUri.getRuntimeArguments(): Bundle {
|
||||
val params = Bundle()
|
||||
for (queryName in queryParameterNames) {
|
||||
if (SPA_INTENT_RESERVED_KEYS.contains(queryName)) continue
|
||||
params.putString(queryName, getQueryParameter(queryName))
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
fun SliceUri.getSliceId(): String? {
|
||||
val entryId = getEntryId() ?: return null
|
||||
val params = getRuntimeArguments()
|
||||
return "${entryId}_$params"
|
||||
}
|
||||
|
||||
fun Uri.Builder.appendSpaParams(
|
||||
destination: String? = null,
|
||||
entryId: String? = null,
|
||||
runtimeArguments: Bundle? = null
|
||||
): Uri.Builder {
|
||||
if (destination != null) appendQueryParameter(KEY_DESTINATION, destination)
|
||||
if (entryId != null) appendQueryParameter(KEY_HIGHLIGHT_ENTRY, entryId)
|
||||
if (runtimeArguments != null) {
|
||||
for (key in runtimeArguments.keySet()) {
|
||||
appendQueryParameter(key, runtimeArguments.getString(key, ""))
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun Uri.Builder.fromEntry(
|
||||
entry: SettingsEntry,
|
||||
authority: String?,
|
||||
runtimeArguments: Bundle? = null
|
||||
): Uri.Builder {
|
||||
if (authority == null) return this
|
||||
val sp = entry.containerPage()
|
||||
return scheme("content").authority(authority).appendSpaParams(
|
||||
destination = sp.buildRoute(),
|
||||
entryId = entry.id,
|
||||
runtimeArguments = runtimeArguments
|
||||
)
|
||||
}
|
||||
|
||||
fun SliceUri.createBroadcastPendingIntent(): PendingIntent? {
|
||||
val context = SpaEnvironmentFactory.instance.appContext
|
||||
val sliceBroadcastClass =
|
||||
SpaEnvironmentFactory.instance.sliceBroadcastReceiverClass ?: return null
|
||||
val entryId = getEntryId() ?: return null
|
||||
return createBroadcastPendingIntent(context, sliceBroadcastClass, entryId)
|
||||
}
|
||||
|
||||
fun SliceUri.createBrowsePendingIntent(): PendingIntent? {
|
||||
val context = SpaEnvironmentFactory.instance.appContext
|
||||
val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
|
||||
val destination = getDestination() ?: return null
|
||||
val entryId = getEntryId()
|
||||
return createBrowsePendingIntent(context, browseActivityClass, destination, entryId)
|
||||
}
|
||||
|
||||
fun Intent.createBrowsePendingIntent(): PendingIntent? {
|
||||
val context = SpaEnvironmentFactory.instance.appContext
|
||||
val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null
|
||||
val destination = getDestination() ?: return null
|
||||
val entryId = getEntryId()
|
||||
return createBrowsePendingIntent(context, browseActivityClass, destination, entryId)
|
||||
}
|
||||
|
||||
private fun createBrowsePendingIntent(
|
||||
context: Context,
|
||||
browseActivityClass: Class<out Activity>,
|
||||
destination: String,
|
||||
entryId: String?
|
||||
): PendingIntent {
|
||||
val intent = Intent().setComponent(ComponentName(context, browseActivityClass))
|
||||
.appendSpaParams(destination, entryId, SESSION_SLICE)
|
||||
.apply {
|
||||
// Set both extra and data (which is a Uri) in Slice Intent:
|
||||
// 1) extra is used in SPA navigation framework
|
||||
// 2) data is used in Slice framework
|
||||
data = Uri.Builder().appendSpaParams(destination, entryId).build()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
|
||||
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
private fun createBroadcastPendingIntent(
|
||||
context: Context,
|
||||
sliceBroadcastClass: Class<out BroadcastReceiver>,
|
||||
entryId: String
|
||||
): PendingIntent {
|
||||
val intent = Intent().setComponent(ComponentName(context, sliceBroadcastClass))
|
||||
.apply { data = Uri.Builder().appendSpaParams(entryId = entryId).build() }
|
||||
return PendingIntent.getBroadcast(
|
||||
context, 0 /* requestCode */, intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.slice
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
|
||||
class SpaSliceBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val sliceRepository by SpaEnvironmentFactory.instance.sliceDataRepository
|
||||
val sliceUri = intent?.data ?: return
|
||||
val sliceData = sliceRepository.getActiveSliceData(sliceUri) ?: return
|
||||
sliceData.doAction()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.slice
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.slice.Slice
|
||||
import androidx.slice.SliceProvider
|
||||
import com.android.settingslib.spa.framework.common.EntrySliceData
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TAG = "SpaSliceProvider"
|
||||
|
||||
class SpaSliceProvider : SliceProvider(), Observer<Slice?> {
|
||||
private fun getOrPutSliceData(sliceUri: Uri): EntrySliceData? {
|
||||
if (!SpaEnvironmentFactory.isReady()) return null
|
||||
val sliceRepository by SpaEnvironmentFactory.instance.sliceDataRepository
|
||||
return sliceRepository.getOrBuildSliceData(sliceUri)
|
||||
}
|
||||
|
||||
override fun onBindSlice(sliceUri: Uri): Slice? {
|
||||
if (context == null) return null
|
||||
Log.d(TAG, "onBindSlice: $sliceUri")
|
||||
return getOrPutSliceData(sliceUri)?.value
|
||||
}
|
||||
|
||||
override fun onSlicePinned(sliceUri: Uri) {
|
||||
Log.d(TAG, "onSlicePinned: $sliceUri")
|
||||
super.onSlicePinned(sliceUri)
|
||||
val sliceLiveData = getOrPutSliceData(sliceUri) ?: return
|
||||
runBlocking {
|
||||
withContext(Dispatchers.Main) {
|
||||
sliceLiveData.observeForever(this@SpaSliceProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSliceUnpinned(sliceUri: Uri) {
|
||||
Log.d(TAG, "onSliceUnpinned: $sliceUri")
|
||||
super.onSliceUnpinned(sliceUri)
|
||||
val sliceLiveData = getOrPutSliceData(sliceUri) ?: return
|
||||
runBlocking {
|
||||
withContext(Dispatchers.Main) {
|
||||
sliceLiveData.removeObserver(this@SpaSliceProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChanged(value: Slice?) {
|
||||
val uri = value?.uri ?: return
|
||||
Log.d(TAG, "onChanged: $uri")
|
||||
context?.contentResolver?.notifyChange(uri, null)
|
||||
}
|
||||
|
||||
override fun onCreateSliceProvider(): Boolean {
|
||||
Log.d(TAG, "onCreateSliceProvider")
|
||||
return true
|
||||
}
|
||||
}
|
||||
47
spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt
Normal file
47
spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.slice.presenter
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.slice.widget.SliceLiveData
|
||||
import androidx.slice.widget.SliceView
|
||||
|
||||
@Composable
|
||||
fun SliceDemo(sliceUri: Uri) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val sliceData = remember {
|
||||
SliceLiveData.fromUri(context, sliceUri)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
AndroidView(
|
||||
factory = { localContext ->
|
||||
val view = SliceView(localContext)
|
||||
view.setShowTitleItems(true)
|
||||
view.isScrollable = false
|
||||
view
|
||||
},
|
||||
update = { view -> sliceData.observe(lifecycleOwner, view) }
|
||||
)
|
||||
}
|
||||
60
spa/src/com/android/settingslib/spa/slice/provider/Demo.kt
Normal file
60
spa/src/com/android/settingslib/spa/slice/provider/Demo.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.slice.provider
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.R
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.slice.Slice
|
||||
import androidx.slice.SliceManager
|
||||
import androidx.slice.builders.ListBuilder
|
||||
import androidx.slice.builders.SliceAction
|
||||
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
|
||||
import com.android.settingslib.spa.slice.createBroadcastPendingIntent
|
||||
import com.android.settingslib.spa.slice.createBrowsePendingIntent
|
||||
|
||||
fun createDemoBrowseSlice(sliceUri: Uri, title: String, summary: String): Slice? {
|
||||
val intent = sliceUri.createBrowsePendingIntent() ?: return null
|
||||
return createDemoSlice(sliceUri, title, summary, intent)
|
||||
}
|
||||
|
||||
fun createDemoActionSlice(sliceUri: Uri, title: String, summary: String): Slice? {
|
||||
val intent = sliceUri.createBroadcastPendingIntent() ?: return null
|
||||
return createDemoSlice(sliceUri, title, summary, intent)
|
||||
}
|
||||
|
||||
fun createDemoSlice(sliceUri: Uri, title: String, summary: String, intent: PendingIntent): Slice? {
|
||||
val context = SpaEnvironmentFactory.instance.appContext
|
||||
if (!SliceManager.getInstance(context).pinnedSlices.contains(sliceUri)) return null
|
||||
return ListBuilder(context, sliceUri, ListBuilder.INFINITY)
|
||||
.addRow(ListBuilder.RowBuilder().apply {
|
||||
setPrimaryAction(createSliceAction(context, intent))
|
||||
setTitle(title)
|
||||
setSubtitle(summary)
|
||||
}).build()
|
||||
}
|
||||
|
||||
private fun createSliceAction(context: Context, intent: PendingIntent): SliceAction {
|
||||
return SliceAction.create(
|
||||
intent,
|
||||
IconCompat.createWithResource(context, R.drawable.notification_action_background),
|
||||
ListBuilder.ICON_IMAGE,
|
||||
"Enter app"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.button
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.WarningAmber
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsShape
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.theme.divider
|
||||
import androidx.compose.material.icons.automirrored.outlined.Launch
|
||||
|
||||
data class ActionButton(
|
||||
val text: String,
|
||||
val imageVector: ImageVector,
|
||||
val enabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ActionButtons(actionButtons: List<ActionButton>) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(SettingsDimension.buttonPadding)
|
||||
.clip(SettingsShape.CornerExtraLarge)
|
||||
.height(IntrinsicSize.Min)
|
||||
) {
|
||||
for ((index, actionButton) in actionButtons.withIndex()) {
|
||||
if (index > 0) ButtonDivider()
|
||||
ActionButton(actionButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.ActionButton(actionButton: ActionButton) {
|
||||
FilledTonalButton(
|
||||
onClick = actionButton.onClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
enabled = actionButton.enabled,
|
||||
// Because buttons could appear, disappear or change positions, reset the interaction source
|
||||
// to prevent highlight the wrong button.
|
||||
interactionSource = remember(actionButton) { MutableInteractionSource() },
|
||||
shape = RectangleShape,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = SettingsTheme.colorScheme.surface,
|
||||
contentColor = SettingsTheme.colorScheme.categoryTitle,
|
||||
disabledContainerColor = SettingsTheme.colorScheme.surface,
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 20.dp),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = actionButton.imageVector,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SettingsDimension.itemIconSize),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = actionButton.text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.width(1.dp)
|
||||
.background(color = MaterialTheme.colorScheme.divider)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ActionButtonsPreview() {
|
||||
SettingsTheme {
|
||||
ActionButtons(
|
||||
listOf(
|
||||
ActionButton(text = "Open", imageVector = Icons.AutoMirrored.Outlined.Launch) {},
|
||||
ActionButton(text = "Uninstall", imageVector = Icons.Outlined.Delete) {},
|
||||
ActionButton(text = "Force stop", imageVector = Icons.Outlined.WarningAmber) {},
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
50
spa/src/com/android/settingslib/spa/widget/card/CardModel.kt
Normal file
50
spa/src/com/android/settingslib/spa/widget/card/CardModel.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.card
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
data class CardButton(
|
||||
val text: String,
|
||||
val contentDescription: String? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
data class CardModel(
|
||||
val title: String,
|
||||
val text: String,
|
||||
val imageVector: ImageVector? = null,
|
||||
val isVisible: () -> Boolean = { true },
|
||||
|
||||
/**
|
||||
* A dismiss button will be displayed if this is not null.
|
||||
*
|
||||
* And this callback will be called when user clicks the button.
|
||||
*/
|
||||
val onDismiss: (() -> Unit)? = null,
|
||||
|
||||
val buttons: List<CardButton> = emptyList(),
|
||||
|
||||
/** If specified, this color will be used to tint the icon and the buttons. */
|
||||
val tintColor: Color = Color.Unspecified,
|
||||
|
||||
/** If specified, this color will be used to tint the icon and the buttons. */
|
||||
val containerColor: Color = Color.Unspecified,
|
||||
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
213
spa/src/com/android/settingslib/spa/widget/card/SettingsCard.kt
Normal file
213
spa/src/com/android/settingslib/spa/widget/card/SettingsCard.kt
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.card
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.WarningAmber
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.takeOrElse
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.debug.UiModePreviews
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsShape.CornerExtraLarge
|
||||
import com.android.settingslib.spa.framework.theme.SettingsShape.CornerExtraSmall
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.widget.ui.SettingsBody
|
||||
import com.android.settingslib.spa.widget.ui.SettingsTitle
|
||||
|
||||
@Composable
|
||||
fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
shape = CornerExtraLarge,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = SettingsDimension.itemPaddingEnd,
|
||||
vertical = SettingsDimension.itemPaddingAround,
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsCardContent(
|
||||
containerColor: Color = Color.Unspecified,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Card(
|
||||
shape = CornerExtraSmall,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor.takeOrElse { SettingsTheme.colorScheme.surface },
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 1.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsCard(model: CardModel) {
|
||||
SettingsCard {
|
||||
SettingsCardImpl(model)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsCardImpl(model: CardModel) {
|
||||
AnimatedVisibility(visible = model.isVisible()) {
|
||||
SettingsCardContent(containerColor = model.containerColor) {
|
||||
Column(
|
||||
modifier = (model.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier)
|
||||
.padding(
|
||||
horizontal = SettingsDimension.dialogItemPaddingHorizontal,
|
||||
vertical = SettingsDimension.itemPaddingAround,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(SettingsDimension.itemPaddingAround)
|
||||
) {
|
||||
CardHeader(model.imageVector, model.tintColor, model.onDismiss)
|
||||
SettingsTitle(model.title)
|
||||
SettingsBody(model.text)
|
||||
Buttons(model.buttons, model.tintColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardHeader(imageVector: ImageVector?, iconColor: Color, onDismiss: (() -> Unit)? = null) {
|
||||
if (imageVector != null || onDismiss != null) {
|
||||
Spacer(Modifier.height(SettingsDimension.buttonPaddingVertical))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
CardIcon(imageVector, iconColor)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
DismissButton(onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardIcon(imageVector: ImageVector?, color: Color) {
|
||||
if (imageVector != null) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SettingsDimension.itemIconSize),
|
||||
tint = color.takeOrElse { MaterialTheme.colorScheme.primary },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DismissButton(onDismiss: (() -> Unit)?) {
|
||||
if (onDismiss == null) return
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.size(SettingsDimension.itemIconSize)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = stringResource(
|
||||
androidx.compose.material3.R.string.m3c_snackbar_dismiss
|
||||
),
|
||||
modifier = Modifier.padding(SettingsDimension.paddingSmall),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Buttons(buttons: List<CardButton>, color: Color) {
|
||||
if (buttons.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = SettingsDimension.itemPaddingEnd,
|
||||
alignment = Alignment.End,
|
||||
),
|
||||
) {
|
||||
for (button in buttons) {
|
||||
Button(button, color)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(SettingsDimension.itemPaddingAround))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Button(button: CardButton, color: Color) {
|
||||
TextButton(
|
||||
onClick = button.onClick,
|
||||
modifier =
|
||||
Modifier.semantics { button.contentDescription?.let { this.contentDescription = it } }
|
||||
) {
|
||||
Text(text = button.text, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@UiModePreviews
|
||||
@Composable
|
||||
private fun SettingsCardPreview() {
|
||||
SettingsTheme {
|
||||
SettingsCard(
|
||||
CardModel(
|
||||
title = "Lorem ipsum",
|
||||
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
imageVector = Icons.Outlined.WarningAmber,
|
||||
buttons = listOf(
|
||||
CardButton(text = "Action") {},
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.card
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Error
|
||||
import androidx.compose.material.icons.outlined.PowerOff
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.android.settingslib.spa.debug.UiModePreviews
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsShape
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.widget.ui.ExpandIcon
|
||||
import com.android.settingslib.spa.widget.ui.SettingsDialogItem
|
||||
import com.android.settingslib.spa.widget.ui.SettingsTitleSmall
|
||||
|
||||
@Composable
|
||||
fun SettingsCollapsibleCard(
|
||||
title: String,
|
||||
imageVector: ImageVector,
|
||||
models: List<CardModel>,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
SettingsCard {
|
||||
SettingsCardContent {
|
||||
Header(title, imageVector, models.count { it.isVisible() }, expanded) { expanded = it }
|
||||
}
|
||||
AnimatedVisibility(expanded) {
|
||||
Column {
|
||||
for (model in models) {
|
||||
SettingsCardImpl(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(
|
||||
title: String,
|
||||
imageVector: ImageVector,
|
||||
cardCount: Int,
|
||||
expanded: Boolean,
|
||||
setExpanded: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { setExpanded(!expanded) }
|
||||
.padding(
|
||||
horizontal = SettingsDimension.itemPaddingStart,
|
||||
vertical = SettingsDimension.itemPaddingVertical,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(SettingsDimension.itemPaddingStart),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SettingsDimension.itemIconSize),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
SettingsTitleSmall(title, useMediumWeight = true)
|
||||
}
|
||||
CardCount(cardCount, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardCount(modelSize: Int, expanded: Boolean) {
|
||||
Surface(
|
||||
shape = SettingsShape.CornerExtraLarge,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(SettingsDimension.paddingSmall),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = Modifier.padding(SettingsDimension.paddingSmall))
|
||||
SettingsDialogItem(modelSize.toString())
|
||||
ExpandIcon(expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiModePreviews
|
||||
@Composable
|
||||
private fun SettingsCollapsibleCardPreview() {
|
||||
SettingsTheme {
|
||||
SettingsCollapsibleCard(
|
||||
title = "More alerts",
|
||||
imageVector = Icons.Outlined.Error,
|
||||
models = listOf(
|
||||
CardModel(
|
||||
title = "Lorem ipsum",
|
||||
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
imageVector = Icons.Outlined.PowerOff,
|
||||
buttons = listOf(
|
||||
CardButton(text = "Action") {},
|
||||
)
|
||||
),
|
||||
CardModel(
|
||||
title = "Lorem ipsum",
|
||||
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
imageVector = Icons.Outlined.Shield,
|
||||
buttons = listOf(
|
||||
CardButton(text = "Action") {},
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
220
spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt
Normal file
220
spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.chart
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.android.settingslib.spa.framework.theme.divider
|
||||
import com.github.mikephil.charting.charts.BarChart
|
||||
import com.github.mikephil.charting.components.XAxis
|
||||
import com.github.mikephil.charting.components.YAxis
|
||||
import com.github.mikephil.charting.components.YAxis.AxisDependency
|
||||
import com.github.mikephil.charting.data.BarData
|
||||
import com.github.mikephil.charting.data.BarDataSet
|
||||
import com.github.mikephil.charting.data.BarEntry
|
||||
import com.github.mikephil.charting.formatter.IAxisValueFormatter
|
||||
import com.github.mikephil.charting.formatter.ValueFormatter
|
||||
|
||||
/**
|
||||
* The chart settings model for [BarChart].
|
||||
*/
|
||||
interface BarChartModel {
|
||||
/**
|
||||
* The chart data list for [BarChart].
|
||||
*/
|
||||
val chartDataList: List<BarChartData>
|
||||
|
||||
/**
|
||||
* The color list for [BarChart].
|
||||
*/
|
||||
val colors: List<Color>
|
||||
|
||||
/**
|
||||
* The label text formatter for x value.
|
||||
*/
|
||||
val xValueFormatter: IAxisValueFormatter?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The label text formatter for y value.
|
||||
*/
|
||||
val yValueFormatter: IAxisValueFormatter?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The minimum value for y-axis.
|
||||
*/
|
||||
val yAxisMinValue: Float
|
||||
get() = 0f
|
||||
|
||||
/**
|
||||
* The maximum value for y-axis.
|
||||
*/
|
||||
val yAxisMaxValue: Float
|
||||
get() = 1f
|
||||
|
||||
/**
|
||||
* The label count for y-axis.
|
||||
*/
|
||||
val yAxisLabelCount: Int
|
||||
get() = 3
|
||||
|
||||
/** If set to true, touch gestures are enabled on the [BarChart]. */
|
||||
val enableBarchartTouch: Boolean
|
||||
get() = true
|
||||
|
||||
/** The renderer provider for x-axis. */
|
||||
val xAxisRendererProvider: XAxisRendererProvider?
|
||||
get() = null
|
||||
}
|
||||
|
||||
data class BarChartData(
|
||||
var x: Float,
|
||||
var y: List<Float>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BarChart(barChartModel: BarChartModel) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.height(170.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val labelTextColor = colorScheme.onSurfaceVariant.toArgb()
|
||||
val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value
|
||||
Crossfade(
|
||||
targetState = barChartModel.chartDataList,
|
||||
label = "chartDataList",
|
||||
) { barChartData ->
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
BarChart(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
description.isEnabled = false
|
||||
legend.isEnabled = false
|
||||
extraBottomOffset = 4f
|
||||
setScaleEnabled(false)
|
||||
setTouchEnabled(barChartModel.enableBarchartTouch)
|
||||
|
||||
xAxis.apply {
|
||||
position = XAxis.XAxisPosition.BOTTOM
|
||||
setDrawAxisLine(false)
|
||||
setDrawGridLines(false)
|
||||
textColor = labelTextColor
|
||||
textSize = labelTextSize
|
||||
valueFormatter = barChartModel.xValueFormatter as ValueFormatter?
|
||||
yOffset = 10f
|
||||
}
|
||||
|
||||
barChartModel.xAxisRendererProvider?.let {
|
||||
setXAxisRenderer(
|
||||
it.provideXAxisRenderer(
|
||||
getViewPortHandler(),
|
||||
getXAxis(),
|
||||
getTransformer(YAxis.AxisDependency.LEFT)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
axisLeft.apply {
|
||||
axisMaximum = barChartModel.yAxisMaxValue
|
||||
axisMinimum = barChartModel.yAxisMinValue
|
||||
isEnabled = false
|
||||
}
|
||||
|
||||
axisRight.apply {
|
||||
axisMaximum = barChartModel.yAxisMaxValue
|
||||
axisMinimum = barChartModel.yAxisMinValue
|
||||
gridColor = colorScheme.divider.toArgb()
|
||||
setDrawAxisLine(false)
|
||||
setLabelCount(barChartModel.yAxisLabelCount, true)
|
||||
textColor = labelTextColor
|
||||
textSize = labelTextSize
|
||||
valueFormatter = barChartModel.yValueFormatter as ValueFormatter?
|
||||
xOffset = 10f
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.padding(4.dp),
|
||||
update = { barChart ->
|
||||
updateBarChartWithData(
|
||||
chart = barChart,
|
||||
data = barChartData,
|
||||
colorList = barChartModel.colors,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBarChartWithData(
|
||||
chart: BarChart,
|
||||
data: List<BarChartData>,
|
||||
colorList: List<Color>,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
val entries = data.map { item ->
|
||||
BarEntry(item.x, item.y.toFloatArray())
|
||||
}
|
||||
|
||||
val ds = BarDataSet(entries, "").apply {
|
||||
colors = colorList.map(Color::toArgb)
|
||||
setDrawValues(false)
|
||||
isHighlightEnabled = true
|
||||
highLightColor = colorScheme.primary.toArgb()
|
||||
highLightAlpha = 255
|
||||
}
|
||||
// TODO: Sets round corners for bars.
|
||||
|
||||
chart.data = BarData(ds)
|
||||
chart.invalidate()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.chart
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object ColorPalette {
|
||||
// Alpha = 1
|
||||
val red: Color = Color(0xffd93025)
|
||||
val orange: Color = Color(0xffe8710a)
|
||||
val yellow: Color = Color(0xfff9ab00)
|
||||
val green: Color = Color(0xff1e8e3e)
|
||||
val cyan: Color = Color(0xff12b5cb)
|
||||
val blue: Color = Color(0xff1a73e8)
|
||||
}
|
||||
218
spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt
Normal file
218
spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.chart
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.android.settingslib.spa.framework.theme.divider
|
||||
import com.github.mikephil.charting.charts.LineChart
|
||||
import com.github.mikephil.charting.components.XAxis
|
||||
import com.github.mikephil.charting.data.Entry
|
||||
import com.github.mikephil.charting.data.LineData
|
||||
import com.github.mikephil.charting.data.LineDataSet
|
||||
import com.github.mikephil.charting.formatter.IAxisValueFormatter
|
||||
import com.github.mikephil.charting.formatter.ValueFormatter
|
||||
|
||||
/**
|
||||
* The chart settings model for [LineChart].
|
||||
*/
|
||||
interface LineChartModel {
|
||||
/**
|
||||
* The chart data list for [LineChart].
|
||||
*/
|
||||
val chartDataList: List<LineChartData>
|
||||
|
||||
/**
|
||||
* The label text formatter for x value.
|
||||
*/
|
||||
val xValueFormatter: IAxisValueFormatter?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The label text formatter for y value.
|
||||
*/
|
||||
val yValueFormatter: IAxisValueFormatter?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The minimum value for y-axis.
|
||||
*/
|
||||
val yAxisMinValue: Float
|
||||
get() = 0f
|
||||
|
||||
/**
|
||||
* The maximum value for y-axis.
|
||||
*/
|
||||
val yAxisMaxValue: Float
|
||||
get() = 1f
|
||||
|
||||
/**
|
||||
* The label count for y-axis.
|
||||
*/
|
||||
val yAxisLabelCount: Int
|
||||
get() = 3
|
||||
|
||||
/**
|
||||
* Indicates whether to smooth the line.
|
||||
*/
|
||||
val showSmoothLine: Boolean
|
||||
get() = true
|
||||
}
|
||||
|
||||
data class LineChartData(
|
||||
var x: Float?,
|
||||
var y: Float?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LineChart(lineChartModel: LineChartModel) {
|
||||
LineChart(
|
||||
chartDataList = lineChartModel.chartDataList,
|
||||
xValueFormatter = lineChartModel.xValueFormatter,
|
||||
yValueFormatter = lineChartModel.yValueFormatter,
|
||||
yAxisMinValue = lineChartModel.yAxisMinValue,
|
||||
yAxisMaxValue = lineChartModel.yAxisMaxValue,
|
||||
yAxisLabelCount = lineChartModel.yAxisLabelCount,
|
||||
showSmoothLine = lineChartModel.showSmoothLine,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LineChart(
|
||||
chartDataList: List<LineChartData>,
|
||||
modifier: Modifier = Modifier,
|
||||
xValueFormatter: IAxisValueFormatter? = null,
|
||||
yValueFormatter: IAxisValueFormatter? = null,
|
||||
yAxisMinValue: Float = 0f,
|
||||
yAxisMaxValue: Float = 1f,
|
||||
yAxisLabelCount: Int = 3,
|
||||
showSmoothLine: Boolean = true,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.height(170.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val labelTextColor = colorScheme.onSurfaceVariant.toArgb()
|
||||
val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value
|
||||
Crossfade(targetState = chartDataList) { lineChartData ->
|
||||
AndroidView(factory = { context ->
|
||||
LineChart(context).apply {
|
||||
// Fixed Settings.
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
this.description.isEnabled = false
|
||||
this.legend.isEnabled = false
|
||||
this.extraBottomOffset = 4f
|
||||
this.setTouchEnabled(false)
|
||||
|
||||
this.xAxis.position = XAxis.XAxisPosition.BOTTOM
|
||||
this.xAxis.setDrawGridLines(false)
|
||||
this.xAxis.setDrawAxisLine(false)
|
||||
this.xAxis.textColor = labelTextColor
|
||||
this.xAxis.textSize = labelTextSize
|
||||
this.xAxis.yOffset = 10f
|
||||
|
||||
this.axisLeft.isEnabled = false
|
||||
|
||||
this.axisRight.setDrawAxisLine(false)
|
||||
this.axisRight.textSize = labelTextSize
|
||||
this.axisRight.textColor = labelTextColor
|
||||
this.axisRight.gridColor = colorScheme.divider.toArgb()
|
||||
this.axisRight.xOffset = 10f
|
||||
this.axisRight.isGranularityEnabled = true
|
||||
|
||||
// Customizable Settings.
|
||||
this.xAxis.valueFormatter = xValueFormatter as ValueFormatter?
|
||||
this.axisRight.valueFormatter = yValueFormatter as ValueFormatter?
|
||||
|
||||
this.axisLeft.axisMinimum =
|
||||
yAxisMinValue - 0.01f * (yAxisMaxValue - yAxisMinValue)
|
||||
this.axisRight.axisMinimum =
|
||||
yAxisMinValue - 0.01f * (yAxisMaxValue - yAxisMinValue)
|
||||
this.axisRight.granularity =
|
||||
(yAxisMaxValue - yAxisMinValue) / (yAxisLabelCount - 1)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.padding(4.dp),
|
||||
update = {
|
||||
updateLineChartWithData(it, lineChartData, colorScheme, showSmoothLine)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLineChartWithData(
|
||||
chart: LineChart,
|
||||
data: List<LineChartData>,
|
||||
colorScheme: ColorScheme,
|
||||
showSmoothLine: Boolean
|
||||
) {
|
||||
val entries = ArrayList<Entry>()
|
||||
for (i in data.indices) {
|
||||
val item = data[i]
|
||||
entries.add(Entry(item.x ?: 0.toFloat(), item.y ?: 0.toFloat()))
|
||||
}
|
||||
|
||||
val ds = LineDataSet(entries, "")
|
||||
ds.colors = arrayListOf(colorScheme.primary.toArgb())
|
||||
ds.lineWidth = 2f
|
||||
if (showSmoothLine) {
|
||||
ds.mode = LineDataSet.Mode.CUBIC_BEZIER
|
||||
}
|
||||
ds.setDrawValues(false)
|
||||
ds.setDrawCircles(false)
|
||||
ds.setDrawFilled(true)
|
||||
ds.fillColor = colorScheme.primary.toArgb()
|
||||
ds.fillAlpha = 38
|
||||
// TODO: enable gradient fill color for line chart.
|
||||
|
||||
val d = LineData(ds)
|
||||
chart.data = d
|
||||
chart.invalidate()
|
||||
}
|
||||
163
spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt
Normal file
163
spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.chart
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.github.mikephil.charting.charts.PieChart
|
||||
import com.github.mikephil.charting.data.PieData
|
||||
import com.github.mikephil.charting.data.PieDataSet
|
||||
import com.github.mikephil.charting.data.PieEntry
|
||||
|
||||
/**
|
||||
* The chart settings model for [PieChart].
|
||||
*/
|
||||
interface PieChartModel {
|
||||
/**
|
||||
* The chart data list for [PieChart].
|
||||
*/
|
||||
val chartDataList: List<PieChartData>
|
||||
|
||||
/**
|
||||
* The center text in the hole of [PieChart].
|
||||
*/
|
||||
val centerText: String?
|
||||
get() = null
|
||||
}
|
||||
|
||||
val colorPalette = arrayListOf(
|
||||
ColorPalette.blue.toArgb(),
|
||||
ColorPalette.red.toArgb(),
|
||||
ColorPalette.yellow.toArgb(),
|
||||
ColorPalette.green.toArgb(),
|
||||
ColorPalette.orange.toArgb(),
|
||||
ColorPalette.cyan.toArgb(),
|
||||
Color.Blue.toArgb()
|
||||
)
|
||||
|
||||
data class PieChartData(
|
||||
var value: Float?,
|
||||
var label: String?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PieChart(pieChartModel: PieChartModel) {
|
||||
PieChart(
|
||||
chartDataList = pieChartModel.chartDataList,
|
||||
centerText = pieChartModel.centerText,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PieChart(
|
||||
chartDataList: List<PieChartData>,
|
||||
modifier: Modifier = Modifier,
|
||||
centerText: String? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.height(280.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val labelTextColor = colorScheme.onSurfaceVariant.toArgb()
|
||||
val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value
|
||||
val centerTextSize = MaterialTheme.typography.titleLarge.fontSize.value
|
||||
Crossfade(targetState = chartDataList) { pieChartData ->
|
||||
AndroidView(factory = { context ->
|
||||
PieChart(context).apply {
|
||||
// Fixed settings.`
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
this.isRotationEnabled = false
|
||||
this.description.isEnabled = false
|
||||
this.legend.isEnabled = false
|
||||
this.setTouchEnabled(false)
|
||||
|
||||
this.isDrawHoleEnabled = true
|
||||
this.holeRadius = 90.0f
|
||||
this.setHoleColor(Color.Transparent.toArgb())
|
||||
this.setEntryLabelColor(labelTextColor)
|
||||
this.setEntryLabelTextSize(labelTextSize)
|
||||
this.setCenterTextSize(centerTextSize)
|
||||
this.setCenterTextColor(colorScheme.onSurface.toArgb())
|
||||
|
||||
// Customizable settings.
|
||||
this.centerText = centerText
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.padding(4.dp), update = {
|
||||
updatePieChartWithData(it, pieChartData)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePieChartWithData(
|
||||
chart: PieChart,
|
||||
data: List<PieChartData>,
|
||||
) {
|
||||
val entries = ArrayList<PieEntry>()
|
||||
for (i in data.indices) {
|
||||
val item = data[i]
|
||||
entries.add(PieEntry(item.value ?: 0.toFloat(), item.label ?: ""))
|
||||
}
|
||||
|
||||
val ds = PieDataSet(entries, "")
|
||||
ds.setDrawValues(false)
|
||||
ds.colors = colorPalette
|
||||
ds.sliceSpace = 2f
|
||||
ds.yValuePosition = PieDataSet.ValuePosition.OUTSIDE_SLICE
|
||||
ds.xValuePosition = PieDataSet.ValuePosition.OUTSIDE_SLICE
|
||||
ds.valueLineColor = Color.Transparent.toArgb()
|
||||
ds.valueLinePart1Length = 0.1f
|
||||
ds.valueLinePart2Length = 0f
|
||||
|
||||
val d = PieData(ds)
|
||||
chart.data = d
|
||||
chart.invalidate()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.chart
|
||||
|
||||
import com.github.mikephil.charting.components.XAxis
|
||||
import com.github.mikephil.charting.renderer.XAxisRenderer
|
||||
import com.github.mikephil.charting.utils.Transformer
|
||||
import com.github.mikephil.charting.utils.ViewPortHandler
|
||||
|
||||
/** A provider for [XAxisRenderer] objects. */
|
||||
fun interface XAxisRendererProvider {
|
||||
|
||||
/** Provides an object of [XAxisRenderer] type. */
|
||||
fun provideXAxisRenderer(
|
||||
viewPortHandler: ViewPortHandler,
|
||||
xAxis: XAxis,
|
||||
transformer: Transformer
|
||||
): XAxisRenderer
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.dialog
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
data class AlertDialogButton(
|
||||
val text: String,
|
||||
val onClick: () -> Unit = {},
|
||||
)
|
||||
|
||||
interface AlertDialogPresenter {
|
||||
/** Opens the dialog. */
|
||||
fun open()
|
||||
|
||||
/** Closes the dialog. */
|
||||
fun close()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAlertDialogPresenter(
|
||||
confirmButton: AlertDialogButton? = null,
|
||||
dismissButton: AlertDialogButton? = null,
|
||||
title: String? = null,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
): AlertDialogPresenter {
|
||||
var openDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val alertDialogPresenter = remember {
|
||||
object : AlertDialogPresenter {
|
||||
override fun open() {
|
||||
openDialog = true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
openDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (openDialog) {
|
||||
alertDialogPresenter.SettingsAlertDialog(confirmButton, dismissButton, title, text)
|
||||
}
|
||||
return alertDialogPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertDialogPresenter.SettingsAlertDialog(
|
||||
confirmButton: AlertDialogButton?,
|
||||
dismissButton: AlertDialogButton?,
|
||||
title: String?,
|
||||
text: @Composable (() -> Unit)?,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = ::close,
|
||||
modifier = Modifier.width(getDialogWidth()),
|
||||
confirmButton = { confirmButton?.let { Button(it) } },
|
||||
dismissButton = dismissButton?.let { { Button(it) } },
|
||||
title = title?.let { { Text(it) } },
|
||||
text = text?.let {
|
||||
{
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
text()
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getDialogWidth(): Dp {
|
||||
val configuration = LocalConfiguration.current
|
||||
return configuration.screenWidthDp.dp * when (configuration.orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> 0.65f
|
||||
else -> 0.85f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertDialogPresenter.Button(button: AlertDialogButton) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
close()
|
||||
button.onClick()
|
||||
},
|
||||
) {
|
||||
Text(button.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun SettingsAlertDialogWithIcon(
|
||||
onDismissRequest: () -> Unit,
|
||||
confirmButton: AlertDialogButton?,
|
||||
dismissButton: AlertDialogButton?,
|
||||
title: String?,
|
||||
text: @Composable (() -> Unit)?,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
icon = { Icon(Icons.Default.WarningAmber, contentDescription = null) },
|
||||
modifier = Modifier.width(getDialogWidth()),
|
||||
confirmButton = {
|
||||
confirmButton?.let {
|
||||
Button(
|
||||
onClick = {
|
||||
it.onClick()
|
||||
},
|
||||
) {
|
||||
Text(it.text)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = dismissButton?.let {
|
||||
{
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
it.onClick()
|
||||
},
|
||||
) {
|
||||
Text(it.text)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = title?.let {
|
||||
{
|
||||
Text(
|
||||
it,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
},
|
||||
text = text?.let {
|
||||
{
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
text()
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.compose.NavHost
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsShape
|
||||
import com.android.settingslib.spa.widget.ui.SettingsTitle
|
||||
|
||||
@Composable
|
||||
fun SettingsDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
SettingsDialogCard(title, content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for dialog, suitable for independent dialog in the [NavHost].
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsDialogCard(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
shape = SettingsShape.CornerExtraLarge,
|
||||
colors = CardDefaults.cardColors(containerColor = AlertDialogDefaults.containerColor),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = SettingsDimension.itemPaddingAround)) {
|
||||
Box(modifier = Modifier.padding(SettingsDimension.dialogItemPadding)) {
|
||||
SettingsTitle(title = title, useMediumWeight = true)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.editor
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
|
||||
internal interface DropdownTextBoxScope {
|
||||
fun dismiss()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DropdownTextBox(
|
||||
label: String,
|
||||
text: String,
|
||||
enabled: Boolean = true,
|
||||
errorMessage: String? = null,
|
||||
content: @Composable DropdownTextBoxScope.() -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val scope = remember {
|
||||
object : DropdownTextBoxScope {
|
||||
override fun dismiss() {
|
||||
expanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = enabled && it },
|
||||
modifier = Modifier
|
||||
.padding(SettingsDimension.menuFieldPadding)
|
||||
.width(Width),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
// The `menuAnchor` modifier must be passed to the text field for correctness.
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth(),
|
||||
value = text,
|
||||
onValueChange = { },
|
||||
label = { Text(text = label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
singleLine = true,
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
isError = errorMessage != null,
|
||||
supportingText = errorMessage?.let { { Text(text = it) } },
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
modifier = Modifier.width(Width),
|
||||
onDismissRequest = { expanded = false },
|
||||
) { scope.content() }
|
||||
}
|
||||
}
|
||||
|
||||
private val Width = 310.dp
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.editor
|
||||
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun SettingsDropdownBox(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedOptionIndex: Int,
|
||||
enabled: Boolean = true,
|
||||
onSelectedOptionChange: (Int) -> Unit,
|
||||
) {
|
||||
DropdownTextBox(
|
||||
label = label,
|
||||
text = options.getOrElse(selectedOptionIndex) { "" },
|
||||
enabled = enabled && options.isNotEmpty(),
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
dismiss()
|
||||
onSelectedOptionChange(index)
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsDropdownBoxPreview() {
|
||||
val item1 = "item1"
|
||||
val item2 = "item2"
|
||||
val item3 = "item3"
|
||||
val options = listOf(item1, item2, item3)
|
||||
SettingsTheme {
|
||||
SettingsDropdownBox(
|
||||
label = "ExposedDropdownMenuBoxLabel",
|
||||
options = options,
|
||||
selectedOptionIndex = 0,
|
||||
enabled = true,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.editor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsOpacity.alphaForEnabled
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.widget.editor.SettingsDropdownCheckOption.Companion.changeable
|
||||
|
||||
data class SettingsDropdownCheckOption(
|
||||
/** The displayed text of this option. */
|
||||
val text: String,
|
||||
|
||||
/** If true, check / uncheck this item will check / uncheck all enabled options. */
|
||||
val isSelectAll: Boolean = false,
|
||||
|
||||
/** If not changeable, cannot check or uncheck this option. */
|
||||
val changeable: Boolean = true,
|
||||
|
||||
/** The selected state of this option. */
|
||||
val selected: MutableState<Boolean> = mutableStateOf(false),
|
||||
|
||||
/** Get called when the option is clicked, no matter if it's changeable. */
|
||||
val onClick: () -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
val List<SettingsDropdownCheckOption>.changeable: Boolean
|
||||
get() = filter { !it.isSelectAll }.any { it.changeable }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsDropdownCheckBox(
|
||||
label: String,
|
||||
options: List<SettingsDropdownCheckOption>,
|
||||
emptyText: String = "",
|
||||
enabled: Boolean = true,
|
||||
errorMessage: String? = null,
|
||||
onSelectedStateChange: () -> Unit = {},
|
||||
) {
|
||||
DropdownTextBox(
|
||||
label = label,
|
||||
text = getDisplayText(options) ?: emptyText,
|
||||
enabled = enabled && options.changeable,
|
||||
errorMessage = errorMessage,
|
||||
) {
|
||||
for (option in options) {
|
||||
CheckboxItem(option) {
|
||||
option.onClick()
|
||||
if (option.changeable) {
|
||||
checkboxItemOnClick(options, option)
|
||||
onSelectedStateChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayText(options: List<SettingsDropdownCheckOption>): String? {
|
||||
val selectedOptions = options.filter { it.selected.value }
|
||||
if (selectedOptions.isEmpty()) return null
|
||||
return selectedOptions.filter { it.isSelectAll }.ifEmpty { selectedOptions }
|
||||
.joinToString { it.text }
|
||||
}
|
||||
|
||||
private fun checkboxItemOnClick(
|
||||
options: List<SettingsDropdownCheckOption>,
|
||||
clickedOption: SettingsDropdownCheckOption,
|
||||
) {
|
||||
if (!clickedOption.changeable) return
|
||||
val newChecked = !clickedOption.selected.value
|
||||
if (clickedOption.isSelectAll) {
|
||||
for (option in options.filter { it.changeable }) option.selected.value = newChecked
|
||||
} else {
|
||||
clickedOption.selected.value = newChecked
|
||||
}
|
||||
val (selectAllOptions, regularOptions) = options.partition { it.isSelectAll }
|
||||
val isAllRegularOptionsChecked = regularOptions.all { it.selected.value }
|
||||
selectAllOptions.forEach { it.selected.value = isAllRegularOptionsChecked }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckboxItem(
|
||||
option: SettingsDropdownCheckOption,
|
||||
onClick: (SettingsDropdownCheckOption) -> Unit,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onClick(option) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(SettingsDimension.itemPaddingAround),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = option.selected.value,
|
||||
onCheckedChange = null,
|
||||
enabled = option.changeable,
|
||||
)
|
||||
Text(text = option.text, modifier = Modifier.alphaForEnabled(option.changeable))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ActionButtonsPreview() {
|
||||
val item1 = SettingsDropdownCheckOption("item1")
|
||||
val item2 = SettingsDropdownCheckOption("item2")
|
||||
val item3 = SettingsDropdownCheckOption("item3")
|
||||
val options = listOf(item1, item2, item3)
|
||||
SettingsTheme {
|
||||
SettingsDropdownCheckBox(
|
||||
label = "label",
|
||||
options = options,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.editor
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
|
||||
@Composable
|
||||
fun SettingsOutlinedTextField(
|
||||
value: String,
|
||||
label: String,
|
||||
errorMessage: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape = OutlinedTextFieldDefaults.shape,
|
||||
onTextChange: (String) -> Unit
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SettingsDimension.textFieldPadding),
|
||||
value = value,
|
||||
onValueChange = onTextChange,
|
||||
label = {
|
||||
Text(text = label)
|
||||
},
|
||||
singleLine = singleLine,
|
||||
enabled = enabled,
|
||||
isError = errorMessage != null,
|
||||
supportingText = {
|
||||
if (errorMessage != null) {
|
||||
Text(text = errorMessage)
|
||||
}
|
||||
},
|
||||
shape = shape
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsOutlinedTextFieldPreview() {
|
||||
var value by remember { mutableStateOf("Enabled Value") }
|
||||
SettingsTheme {
|
||||
SettingsOutlinedTextField(
|
||||
value = value,
|
||||
label = "OutlinedTextField Enabled",
|
||||
enabled = true,
|
||||
onTextChange = {value = it})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.editor
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
|
||||
@Composable
|
||||
fun SettingsTextFieldPassword(
|
||||
value: String,
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
onTextChange: (String) -> Unit,
|
||||
) {
|
||||
var visibility by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.padding(SettingsDimension.menuFieldPadding)
|
||||
.fillMaxWidth(),
|
||||
value = value,
|
||||
onValueChange = onTextChange,
|
||||
label = { Text(text = label) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
enabled = enabled,
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = if (visibility) Icons.Outlined.VisibilityOff
|
||||
else Icons.Outlined.Visibility,
|
||||
contentDescription = "Visibility Icon",
|
||||
modifier = Modifier
|
||||
.testTag("Visibility Icon")
|
||||
.size(SettingsDimension.itemIconSize)
|
||||
.toggleable(visibility) {
|
||||
visibility = !visibility
|
||||
},
|
||||
)
|
||||
},
|
||||
visualTransformation = if (visibility) VisualTransformation.None
|
||||
else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SettingsTextFieldPasswordPreview() {
|
||||
var value by remember { mutableStateOf("value") }
|
||||
SettingsTheme {
|
||||
SettingsTextFieldPassword(
|
||||
value = value,
|
||||
label = "label",
|
||||
onTextChange = { value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.illustration
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Color
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.widget.ui.ImageBox
|
||||
import com.android.settingslib.spa.widget.ui.Lottie
|
||||
enum class ResourceType { IMAGE, LOTTIE }
|
||||
|
||||
/**
|
||||
* The widget model for [Illustration] widget.
|
||||
*/
|
||||
interface IllustrationModel {
|
||||
/**
|
||||
* The resource id of this [Illustration].
|
||||
*/
|
||||
val resId: Int
|
||||
|
||||
/**
|
||||
* The resource type of the [Illustration].
|
||||
*
|
||||
* It should be Lottie or Image.
|
||||
*/
|
||||
val resourceType: ResourceType
|
||||
}
|
||||
|
||||
/**
|
||||
* Illustration widget.
|
||||
*
|
||||
* Data is provided through [IllustrationModel].
|
||||
*/
|
||||
@Composable
|
||||
fun Illustration(model: IllustrationModel) {
|
||||
Illustration(
|
||||
resId = model.resId,
|
||||
resourceType = model.resourceType,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Illustration(
|
||||
resId: Int,
|
||||
resourceType: ResourceType,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SettingsDimension.illustrationPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val illustrationModifier = modifier
|
||||
.sizeIn(
|
||||
maxWidth = SettingsDimension.illustrationMaxWidth,
|
||||
maxHeight = SettingsDimension.illustrationMaxHeight,
|
||||
)
|
||||
.clip(RoundedCornerShape(SettingsDimension.illustrationCornerRadius))
|
||||
.background(color = Color.Transparent)
|
||||
|
||||
when (resourceType) {
|
||||
ResourceType.LOTTIE -> {
|
||||
Lottie(
|
||||
resId = resId,
|
||||
modifier = illustrationModifier,
|
||||
)
|
||||
}
|
||||
ResourceType.IMAGE -> {
|
||||
ImageBox(
|
||||
resId = resId,
|
||||
contentDescription = null,
|
||||
modifier = illustrationModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsOpacity.alphaForEnabled
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.widget.ui.SettingsTitle
|
||||
|
||||
@Composable
|
||||
internal fun BaseLayout(
|
||||
title: String,
|
||||
subTitle: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
enabled: () -> Boolean = { true },
|
||||
paddingStart: Dp = SettingsDimension.itemPaddingStart,
|
||||
paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
|
||||
paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
|
||||
widget: @Composable () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = paddingEnd),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val alphaModifier = Modifier.alphaForEnabled(enabled())
|
||||
BaseIcon(icon, alphaModifier, paddingStart)
|
||||
Titles(
|
||||
title = title,
|
||||
subTitle = subTitle,
|
||||
modifier = alphaModifier
|
||||
.weight(1f)
|
||||
.padding(vertical = paddingVertical),
|
||||
)
|
||||
widget()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BaseIcon(
|
||||
icon: @Composable (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
paddingStart: Dp,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Box(
|
||||
modifier = modifier.size(SettingsDimension.itemIconContainerSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(width = paddingStart))
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts a scope to avoid frequent recompose outside scope.
|
||||
@Composable
|
||||
private fun Titles(title: String, subTitle: @Composable () -> Unit, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
SettingsTitle(title)
|
||||
subTitle()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BaseLayoutPreview() {
|
||||
SettingsTheme {
|
||||
BaseLayout(
|
||||
title = "Title",
|
||||
subTitle = {
|
||||
HorizontalDivider(thickness = 10.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.widget.ui.SettingsBody
|
||||
|
||||
@Composable
|
||||
internal fun BasePreference(
|
||||
title: String,
|
||||
summary: () -> String,
|
||||
modifier: Modifier = Modifier,
|
||||
singleLineSummary: Boolean = false,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
enabled: () -> Boolean = { true },
|
||||
paddingStart: Dp = SettingsDimension.itemPaddingStart,
|
||||
paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
|
||||
paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
|
||||
widget: @Composable () -> Unit = {},
|
||||
) {
|
||||
BaseLayout(
|
||||
title = title,
|
||||
subTitle = {
|
||||
SettingsBody(
|
||||
body = summary(),
|
||||
maxLines = if (singleLineSummary) 1 else Int.MAX_VALUE,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
icon = icon,
|
||||
enabled = enabled,
|
||||
paddingStart = paddingStart,
|
||||
paddingEnd = paddingEnd,
|
||||
paddingVertical = paddingVertical,
|
||||
widget = widget,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BasePreferencePreview() {
|
||||
SettingsTheme {
|
||||
BasePreference(
|
||||
title = "Screen Saver",
|
||||
summary = { "Clock" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BasePreferenceIconPreview() {
|
||||
SettingsTheme {
|
||||
BasePreference(
|
||||
title = "Screen Saver",
|
||||
summary = { "Clock" },
|
||||
icon = {
|
||||
Icon(imageVector = Icons.Outlined.BatteryChargingFull, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AirplanemodeActive
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
import com.android.settingslib.spa.framework.util.wrapOnSwitchWithLog
|
||||
import com.android.settingslib.spa.widget.ui.SettingsCheckbox
|
||||
import com.android.settingslib.spa.widget.ui.SettingsIcon
|
||||
|
||||
/**
|
||||
* The widget model for [CheckboxPreference] widget.
|
||||
*/
|
||||
interface CheckboxPreferenceModel {
|
||||
/**
|
||||
* The title of this [CheckboxPreference].
|
||||
*/
|
||||
val title: String
|
||||
|
||||
/**
|
||||
* The summary of this [CheckboxPreference].
|
||||
*/
|
||||
val summary: () -> String
|
||||
get() = { "" }
|
||||
|
||||
/**
|
||||
* The icon of this [Preference].
|
||||
*
|
||||
* Default is `null` which means no icon.
|
||||
*/
|
||||
val icon: (@Composable () -> Unit)?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* Indicates whether this [CheckboxPreference] is checked.
|
||||
*
|
||||
* This can be `null` during the data loading before the data is available.
|
||||
*/
|
||||
val checked: () -> Boolean?
|
||||
|
||||
/**
|
||||
* Indicates whether this [CheckboxPreference] is changeable.
|
||||
*
|
||||
* Not changeable [CheckboxPreference] will be displayed in disabled style.
|
||||
*/
|
||||
val changeable: () -> Boolean
|
||||
get() = { true }
|
||||
|
||||
/**
|
||||
* The checkbox change handler of this [CheckboxPreference].
|
||||
*
|
||||
* If `null`, this [CheckboxPreference] is not [toggleable].
|
||||
*/
|
||||
val onCheckedChange: ((newChecked: Boolean) -> Unit)?
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckboxPreference widget.
|
||||
*
|
||||
* Data is provided through [CheckboxPreferenceModel].
|
||||
*/
|
||||
@Composable
|
||||
fun CheckboxPreference(model: CheckboxPreferenceModel) {
|
||||
EntryHighlight {
|
||||
InternalCheckboxPreference(
|
||||
title = model.title,
|
||||
summary = model.summary,
|
||||
icon = model.icon,
|
||||
checked = model.checked(),
|
||||
changeable = model.changeable(),
|
||||
onCheckedChange = model.onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InternalCheckboxPreference(
|
||||
title: String,
|
||||
summary: () -> String = { "" },
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
checked: Boolean?,
|
||||
changeable: Boolean = true,
|
||||
paddingStart: Dp = SettingsDimension.itemPaddingStart,
|
||||
paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
|
||||
paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
|
||||
onCheckedChange: ((newChecked: Boolean) -> Unit)?,
|
||||
) {
|
||||
val indication = LocalIndication.current
|
||||
val onChangeWithLog = wrapOnSwitchWithLog(onCheckedChange)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val modifier = remember(checked, changeable) {
|
||||
if (checked != null && onChangeWithLog != null) {
|
||||
Modifier.toggleable(
|
||||
value = checked,
|
||||
interactionSource = interactionSource,
|
||||
indication = indication,
|
||||
enabled = changeable,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = onChangeWithLog,
|
||||
)
|
||||
} else Modifier
|
||||
}
|
||||
BasePreference(
|
||||
title = title,
|
||||
summary = summary,
|
||||
modifier = modifier,
|
||||
enabled = { changeable },
|
||||
paddingStart = paddingStart,
|
||||
paddingEnd = paddingEnd,
|
||||
paddingVertical = paddingVertical,
|
||||
icon = icon,
|
||||
) {
|
||||
Spacer(Modifier.width(SettingsDimension.itemPaddingEnd))
|
||||
SettingsCheckbox(
|
||||
checked = checked,
|
||||
changeable = { changeable },
|
||||
// The onCheckedChange is handled on the whole CheckboxPreference.
|
||||
// DO NOT set it on SettingsCheckbox.
|
||||
onCheckedChange = null,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CheckboxPreferencePreview() {
|
||||
SettingsTheme {
|
||||
Column {
|
||||
InternalCheckboxPreference(
|
||||
title = "Use Dark theme",
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
InternalCheckboxPreference(
|
||||
title = "Use Dark theme",
|
||||
summary = { "Summary" },
|
||||
checked = false,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
InternalCheckboxPreference(
|
||||
title = "Use Dark theme",
|
||||
summary = { "Summary" },
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
icon = @Composable {
|
||||
SettingsIcon(imageVector = Icons.Outlined.AirplanemodeActive)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.RadioButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.IntState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.widget.dialog.SettingsDialog
|
||||
import com.android.settingslib.spa.widget.ui.SettingsBody
|
||||
import com.android.settingslib.spa.widget.ui.SettingsDialogItem
|
||||
|
||||
data class ListPreferenceOption(
|
||||
val id: Int,
|
||||
val text: String,
|
||||
val summary: String = String()
|
||||
)
|
||||
|
||||
/**
|
||||
* The widget model for [ListPreference] widget.
|
||||
*/
|
||||
interface ListPreferenceModel {
|
||||
/**
|
||||
* The title of this [ListPreference].
|
||||
*/
|
||||
val title: String
|
||||
|
||||
/**
|
||||
* The icon of this [ListPreference].
|
||||
*
|
||||
* Default is `null` which means no icon.
|
||||
*/
|
||||
val icon: (@Composable () -> Unit)?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* Indicates whether this [ListPreference] is enabled.
|
||||
*
|
||||
* Disabled [ListPreference] will be displayed in disabled style.
|
||||
*/
|
||||
val enabled: () -> Boolean
|
||||
get() = { true }
|
||||
|
||||
val options: List<ListPreferenceOption>
|
||||
|
||||
val selectedId: IntState
|
||||
|
||||
val onIdSelected: (id: Int) -> Unit
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListPreference(model: ListPreferenceModel) {
|
||||
var dialogOpened by rememberSaveable { mutableStateOf(false) }
|
||||
if (dialogOpened) {
|
||||
SettingsDialog(
|
||||
title = model.title,
|
||||
onDismissRequest = { dialogOpened = false },
|
||||
) {
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
for (option in model.options) {
|
||||
Radio(option, model.selectedId.intValue, model.enabled()) {
|
||||
dialogOpened = false
|
||||
model.onIdSelected(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Preference(model = remember(model) {
|
||||
object : PreferenceModel {
|
||||
override val title = model.title
|
||||
override val summary = {
|
||||
model.options.find { it.id == model.selectedId.intValue }?.text ?: ""
|
||||
}
|
||||
override val icon = model.icon
|
||||
override val enabled = model.enabled
|
||||
override val onClick = { dialogOpened = true }.takeIf { model.options.isNotEmpty() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Radio(
|
||||
option: ListPreferenceOption,
|
||||
selectedId: Int,
|
||||
enabled: Boolean,
|
||||
onIdSelected: (id: Int) -> Unit,
|
||||
) {
|
||||
val selected = option.id == selectedId
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = enabled,
|
||||
onClick = { onIdSelected(option.id) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(SettingsDimension.dialogItemPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = null, enabled = enabled)
|
||||
Spacer(modifier = Modifier.width(SettingsDimension.itemPaddingEnd))
|
||||
Column {
|
||||
SettingsDialogItem(text = option.text, enabled = enabled)
|
||||
if (option.summary != String()) {
|
||||
SettingsBody(
|
||||
body = option.summary,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsShape
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
|
||||
@Composable
|
||||
fun MainSwitchPreference(model: SwitchPreferenceModel) {
|
||||
EntryHighlight {
|
||||
Surface(
|
||||
modifier = Modifier.padding(SettingsDimension.itemPaddingEnd),
|
||||
color = when (model.checked()) {
|
||||
true -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.secondaryContainer
|
||||
},
|
||||
shape = SettingsShape.CornerExtraLarge,
|
||||
) {
|
||||
InternalSwitchPreference(
|
||||
title = model.title,
|
||||
checked = model.checked(),
|
||||
changeable = model.changeable(),
|
||||
onCheckedChange = model.onCheckedChange,
|
||||
paddingStart = 20.dp,
|
||||
paddingEnd = 20.dp,
|
||||
paddingVertical = 18.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MainSwitchPreferencePreview() {
|
||||
SettingsTheme {
|
||||
Column {
|
||||
MainSwitchPreference(object : SwitchPreferenceModel {
|
||||
override val title = "Use Dark theme"
|
||||
override val checked = { true }
|
||||
override val onCheckedChange: (Boolean) -> Unit = {}
|
||||
})
|
||||
MainSwitchPreference(object : SwitchPreferenceModel {
|
||||
override val title = "Use Dark theme"
|
||||
override val checked = { false }
|
||||
override val onCheckedChange: (Boolean) -> Unit = {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.android.settingslib.spa.framework.common.EntryMacro
|
||||
import com.android.settingslib.spa.framework.common.EntrySearchData
|
||||
import com.android.settingslib.spa.framework.compose.navigator
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
import com.android.settingslib.spa.framework.util.wrapOnClickWithLog
|
||||
import com.android.settingslib.spa.widget.ui.createSettingsIcon
|
||||
|
||||
data class SimplePreferenceMacro(
|
||||
val title: String,
|
||||
val summary: String? = null,
|
||||
val icon: ImageVector? = null,
|
||||
val disabled: Boolean = false,
|
||||
val clickRoute: String? = null,
|
||||
val searchKeywords: List<String> = emptyList(),
|
||||
) : EntryMacro {
|
||||
@Composable
|
||||
override fun UiLayout() {
|
||||
Preference(model = object : PreferenceModel {
|
||||
override val title: String = this@SimplePreferenceMacro.title
|
||||
override val summary = { this@SimplePreferenceMacro.summary ?: "" }
|
||||
override val icon = createSettingsIcon(this@SimplePreferenceMacro.icon)
|
||||
override val enabled = { !disabled }
|
||||
override val onClick = navigator(clickRoute)
|
||||
})
|
||||
}
|
||||
|
||||
override fun getSearchData(): EntrySearchData {
|
||||
return EntrySearchData(
|
||||
title = this@SimplePreferenceMacro.title,
|
||||
keyword = searchKeywords
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget model for [Preference] widget.
|
||||
*/
|
||||
interface PreferenceModel {
|
||||
/**
|
||||
* The title of this [Preference].
|
||||
*/
|
||||
val title: String
|
||||
|
||||
/**
|
||||
* The summary of this [Preference].
|
||||
*/
|
||||
val summary: () -> String
|
||||
get() = { "" }
|
||||
|
||||
/**
|
||||
* The icon of this [Preference].
|
||||
*
|
||||
* Default is `null` which means no icon.
|
||||
*/
|
||||
val icon: (@Composable () -> Unit)?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* Indicates whether this [Preference] is enabled.
|
||||
*
|
||||
* Disabled [Preference] will be displayed in disabled style.
|
||||
*/
|
||||
val enabled: () -> Boolean
|
||||
get() = { true }
|
||||
|
||||
/**
|
||||
* The on click handler of this [Preference].
|
||||
*
|
||||
* This also indicates whether this [Preference] is clickable.
|
||||
*/
|
||||
val onClick: (() -> Unit)?
|
||||
get() = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Preference widget.
|
||||
*
|
||||
* Data is provided through [PreferenceModel].
|
||||
*/
|
||||
@Composable
|
||||
fun Preference(
|
||||
model: PreferenceModel,
|
||||
singleLineSummary: Boolean = false,
|
||||
) {
|
||||
val onClickWithLog = wrapOnClickWithLog(model.onClick)
|
||||
val enabled = model.enabled()
|
||||
val modifier = if (onClickWithLog != null) {
|
||||
Modifier.clickable(enabled = enabled, onClick = onClickWithLog)
|
||||
} else Modifier
|
||||
EntryHighlight {
|
||||
BasePreference(
|
||||
title = model.title,
|
||||
summary = model.summary,
|
||||
singleLineSummary = singleLineSummary,
|
||||
modifier = modifier,
|
||||
icon = model.icon,
|
||||
enabled = model.enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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 com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.widget.ui.LinearProgressBar
|
||||
import com.android.settingslib.spa.widget.ui.SettingsTitle
|
||||
|
||||
/**
|
||||
* The widget model for [ProgressBarPreference] widget.
|
||||
*/
|
||||
interface ProgressBarPreferenceModel {
|
||||
/**
|
||||
* The title of this [ProgressBarPreference].
|
||||
*/
|
||||
val title: String
|
||||
|
||||
/**
|
||||
* The progress fraction of the ProgressBar. Should be float in range [0f, 1f]
|
||||
*/
|
||||
val progress: Float
|
||||
|
||||
/**
|
||||
* The icon image for [ProgressBarPreference]. If not specified, hides the icon by default.
|
||||
*/
|
||||
val icon: ImageVector?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The height of the ProgressBar.
|
||||
*/
|
||||
val height: Float
|
||||
get() = 4f
|
||||
|
||||
/**
|
||||
* Indicates whether to use rounded corner for the progress bars.
|
||||
*/
|
||||
val roundedCorner: Boolean
|
||||
get() = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar preference widget.
|
||||
*
|
||||
* Data is provided through [ProgressBarPreferenceModel].
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressBarPreference(model: ProgressBarPreferenceModel) {
|
||||
ProgressBarPreference(
|
||||
title = model.title,
|
||||
progress = model.progress,
|
||||
icon = model.icon,
|
||||
height = model.height,
|
||||
roundedCorner = model.roundedCorner,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar with data preference widget.
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressBarWithDataPreference(model: ProgressBarPreferenceModel, data: String) {
|
||||
val icon = model.icon
|
||||
ProgressBarWithDataPreference(
|
||||
title = model.title,
|
||||
data = data,
|
||||
progress = model.progress,
|
||||
icon = if (icon != null) ({
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
}) else null,
|
||||
height = model.height,
|
||||
roundedCorner = model.roundedCorner,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ProgressBarPreference(
|
||||
title: String,
|
||||
progress: Float,
|
||||
icon: ImageVector? = null,
|
||||
height: Float = 4f,
|
||||
roundedCorner: Boolean = true,
|
||||
) {
|
||||
BaseLayout(
|
||||
title = title,
|
||||
subTitle = {
|
||||
LinearProgressBar(progress, height, roundedCorner)
|
||||
},
|
||||
icon = if (icon != null) ({
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
}) else null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun ProgressBarWithDataPreference(
|
||||
title: String,
|
||||
data: String,
|
||||
progress: Float,
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
height: Float = 4f,
|
||||
roundedCorner: Boolean = true,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = SettingsDimension.itemPaddingEnd),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BaseIcon(icon, Modifier, SettingsDimension.itemPaddingStart)
|
||||
TitleWithData(
|
||||
title = title,
|
||||
data = data,
|
||||
subTitle = {
|
||||
LinearProgressBar(progress, height, roundedCorner)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = SettingsDimension.itemPaddingVertical),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleWithData(
|
||||
title: String,
|
||||
data: String,
|
||||
subTitle: @Composable () -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Column(modifier) {
|
||||
Row {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
SettingsTitle(title)
|
||||
}
|
||||
Text(
|
||||
text = data,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
subTitle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.RadioButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.widget.ui.CategoryTitle
|
||||
import com.android.settingslib.spa.widget.ui.SettingsListItem
|
||||
|
||||
@Composable
|
||||
fun RadioPreferences(model: ListPreferenceModel) {
|
||||
CategoryTitle(title = model.title)
|
||||
Spacer(modifier = Modifier.width(SettingsDimension.itemDividerHeight))
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
for (option in model.options) {
|
||||
Radio2(option, model.selectedId.intValue, model.enabled()) {
|
||||
model.onIdSelected(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Radio2(
|
||||
option: ListPreferenceOption,
|
||||
selectedId: Int,
|
||||
enabled: Boolean,
|
||||
onIdSelected: (id: Int) -> Unit,
|
||||
) {
|
||||
val selected = option.id == selectedId
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = enabled,
|
||||
onClick = { onIdSelected(option.id) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(SettingsDimension.dialogItemPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = null, enabled = enabled)
|
||||
Spacer(modifier = Modifier.width(SettingsDimension.itemDividerHeight))
|
||||
SettingsListItem(text = option.text, enabled = enabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AccessAlarm
|
||||
import androidx.compose.material.icons.outlined.MusicNote
|
||||
import androidx.compose.material.icons.outlined.MusicOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
import com.android.settingslib.spa.widget.ui.SettingsSlider
|
||||
|
||||
/**
|
||||
* The widget model for [SliderPreference] widget.
|
||||
*/
|
||||
interface SliderPreferenceModel {
|
||||
/**
|
||||
* The title of this [SliderPreference].
|
||||
*/
|
||||
val title: String
|
||||
|
||||
/**
|
||||
* The initial position of the [SliderPreference].
|
||||
*/
|
||||
val initValue: Int
|
||||
|
||||
/**
|
||||
* The value range for this [SliderPreference].
|
||||
*/
|
||||
val valueRange: IntRange
|
||||
get() = 0..100
|
||||
|
||||
/**
|
||||
* The lambda to be invoked during the value change by dragging or a click. This callback is
|
||||
* used to get the real time value of the [SliderPreference].
|
||||
*/
|
||||
val onValueChange: ((value: Int) -> Unit)?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The lambda to be invoked when value change has ended. This callback is used to get when the
|
||||
* user has completed selecting a new value by ending a drag or a click.
|
||||
*/
|
||||
val onValueChangeFinished: (() -> Unit)?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* The icon image for [SliderPreference]. If not specified, the slider hides the icon by default.
|
||||
*/
|
||||
val icon: ImageVector?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* Indicates whether to show step marks. If show step marks, when user finish sliding,
|
||||
* the slider will automatically jump to the nearest step mark. Otherwise, the slider hides
|
||||
* the step marks by default.
|
||||
*
|
||||
* The step is fixed to 1.
|
||||
*/
|
||||
val showSteps: Boolean
|
||||
get() = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings slider widget.
|
||||
*
|
||||
* Data is provided through [SliderPreferenceModel].
|
||||
*/
|
||||
@Composable
|
||||
fun SliderPreference(model: SliderPreferenceModel) {
|
||||
EntryHighlight {
|
||||
SliderPreference(
|
||||
title = model.title,
|
||||
initValue = model.initValue,
|
||||
valueRange = model.valueRange,
|
||||
onValueChange = model.onValueChange,
|
||||
onValueChangeFinished = model.onValueChangeFinished,
|
||||
icon = model.icon,
|
||||
showSteps = model.showSteps,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SliderPreference(
|
||||
title: String,
|
||||
initValue: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
valueRange: IntRange = 0..100,
|
||||
onValueChange: ((value: Int) -> Unit)? = null,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
icon: ImageVector? = null,
|
||||
showSteps: Boolean = false,
|
||||
) {
|
||||
BaseLayout(
|
||||
title = title,
|
||||
subTitle = {
|
||||
SettingsSlider(
|
||||
initValue,
|
||||
modifier,
|
||||
valueRange,
|
||||
onValueChange,
|
||||
onValueChangeFinished,
|
||||
showSteps
|
||||
)
|
||||
},
|
||||
icon = if (icon != null) ({
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
}) else null,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SliderPreferencePreview() {
|
||||
SettingsTheme {
|
||||
val initValue = 30
|
||||
var sliderPosition by rememberSaveable { mutableStateOf(initValue) }
|
||||
SliderPreference(
|
||||
title = "Alarm Volume",
|
||||
initValue = 30,
|
||||
onValueChange = { sliderPosition = it },
|
||||
onValueChangeFinished = {
|
||||
println("onValueChangeFinished: the value is $sliderPosition")
|
||||
},
|
||||
icon = Icons.Outlined.AccessAlarm,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SliderPreferenceIconChangePreview() {
|
||||
SettingsTheme {
|
||||
var icon by remember { mutableStateOf(Icons.Outlined.MusicNote) }
|
||||
SliderPreference(
|
||||
title = "Media Volume",
|
||||
initValue = 40,
|
||||
onValueChange = { it: Int ->
|
||||
icon = if (it > 0) Icons.Outlined.MusicNote else Icons.Outlined.MusicOff
|
||||
},
|
||||
icon = icon,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SliderPreferenceStepsPreview() {
|
||||
SettingsTheme {
|
||||
SliderPreference(
|
||||
title = "Display Text",
|
||||
initValue = 2,
|
||||
valueRange = 1..5,
|
||||
showSteps = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AirplanemodeActive
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
import com.android.settingslib.spa.framework.util.wrapOnSwitchWithLog
|
||||
import com.android.settingslib.spa.widget.ui.SettingsIcon
|
||||
import com.android.settingslib.spa.widget.ui.SettingsSwitch
|
||||
|
||||
/**
|
||||
* The widget model for [SwitchPreference] widget.
|
||||
*/
|
||||
interface SwitchPreferenceModel {
|
||||
/**
|
||||
* The title of this [SwitchPreference].
|
||||
*/
|
||||
val title: String
|
||||
|
||||
/**
|
||||
* The summary of this [SwitchPreference].
|
||||
*/
|
||||
val summary: () -> String
|
||||
get() = { "" }
|
||||
|
||||
/**
|
||||
* The icon of this [Preference].
|
||||
*
|
||||
* Default is `null` which means no icon.
|
||||
*/
|
||||
val icon: (@Composable () -> Unit)?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* Indicates whether this [SwitchPreference] is checked.
|
||||
*
|
||||
* This can be `null` during the data loading before the data is available.
|
||||
*/
|
||||
val checked: () -> Boolean?
|
||||
|
||||
/**
|
||||
* Indicates whether this [SwitchPreference] is changeable.
|
||||
*
|
||||
* Not changeable [SwitchPreference] will be displayed in disabled style.
|
||||
*/
|
||||
val changeable: () -> Boolean
|
||||
get() = { true }
|
||||
|
||||
/**
|
||||
* The switch change handler of this [SwitchPreference].
|
||||
*
|
||||
* If `null`, this [SwitchPreference] is not [toggleable].
|
||||
*/
|
||||
val onCheckedChange: ((newChecked: Boolean) -> Unit)?
|
||||
}
|
||||
|
||||
/**
|
||||
* SwitchPreference widget.
|
||||
*
|
||||
* Data is provided through [SwitchPreferenceModel].
|
||||
*/
|
||||
@Composable
|
||||
fun SwitchPreference(model: SwitchPreferenceModel) {
|
||||
EntryHighlight {
|
||||
InternalSwitchPreference(
|
||||
title = model.title,
|
||||
summary = model.summary,
|
||||
icon = model.icon,
|
||||
checked = model.checked(),
|
||||
changeable = model.changeable(),
|
||||
onCheckedChange = model.onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun InternalSwitchPreference(
|
||||
title: String,
|
||||
summary: () -> String = { "" },
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
checked: Boolean?,
|
||||
changeable: Boolean = true,
|
||||
paddingStart: Dp = SettingsDimension.itemPaddingStart,
|
||||
paddingEnd: Dp = SettingsDimension.itemPaddingEnd,
|
||||
paddingVertical: Dp = SettingsDimension.itemPaddingVertical,
|
||||
onCheckedChange: ((newChecked: Boolean) -> Unit)?,
|
||||
) {
|
||||
val indication = LocalIndication.current
|
||||
val onChangeWithLog = wrapOnSwitchWithLog(onCheckedChange)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val modifier = remember(checked, changeable) {
|
||||
if (checked != null && onChangeWithLog != null) {
|
||||
Modifier.toggleable(
|
||||
value = checked,
|
||||
interactionSource = interactionSource,
|
||||
indication = indication,
|
||||
enabled = changeable,
|
||||
role = Role.Switch,
|
||||
onValueChange = onChangeWithLog,
|
||||
)
|
||||
} else Modifier
|
||||
}
|
||||
BasePreference(
|
||||
title = title,
|
||||
summary = summary,
|
||||
modifier = modifier,
|
||||
enabled = { changeable },
|
||||
paddingStart = paddingStart,
|
||||
paddingEnd = paddingEnd,
|
||||
paddingVertical = paddingVertical,
|
||||
icon = icon,
|
||||
) {
|
||||
Spacer(Modifier.width(SettingsDimension.itemPaddingEnd))
|
||||
SettingsSwitch(
|
||||
checked = checked,
|
||||
changeable = { changeable },
|
||||
// The onCheckedChange is handled on the whole SwitchPreference.
|
||||
// DO NOT set it on SettingsSwitch.
|
||||
onCheckedChange = null,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SwitchPreferencePreview() {
|
||||
SettingsTheme {
|
||||
Column {
|
||||
InternalSwitchPreference(
|
||||
title = "Use Dark theme",
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
InternalSwitchPreference(
|
||||
title = "Use Dark theme",
|
||||
summary = { "Summary" },
|
||||
checked = false,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
InternalSwitchPreference(
|
||||
title = "Use Dark theme",
|
||||
summary = { "Summary" },
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
icon = @Composable {
|
||||
SettingsIcon(imageVector = Icons.Outlined.AirplanemodeActive)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
|
||||
@Composable
|
||||
fun TwoTargetButtonPreference(
|
||||
title: String,
|
||||
summary: () -> String,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
buttonIcon: ImageVector,
|
||||
buttonIconDescription: String,
|
||||
onButtonClick: () -> Unit
|
||||
) {
|
||||
EntryHighlight {
|
||||
TwoTargetPreference(
|
||||
title = title,
|
||||
summary = summary,
|
||||
onClick = onClick,
|
||||
icon = icon,
|
||||
) {
|
||||
IconButton(onClick = onButtonClick) {
|
||||
Icon(imageVector = buttonIcon, contentDescription = buttonIconDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.divider
|
||||
|
||||
@Composable
|
||||
internal fun TwoTargetPreference(
|
||||
title: String,
|
||||
summary: () -> String,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
widget: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = SettingsDimension.itemPaddingEnd),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Preference(
|
||||
object : PreferenceModel {
|
||||
override val title = title
|
||||
override val summary = summary
|
||||
override val icon = icon
|
||||
override val onClick = onClick
|
||||
}
|
||||
)
|
||||
}
|
||||
PreferenceDivider()
|
||||
widget()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferenceDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = SettingsDimension.itemPaddingEnd)
|
||||
.size(width = 1.dp, height = SettingsDimension.itemDividerHeight)
|
||||
.background(color = MaterialTheme.colorScheme.divider)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.preference
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.android.settingslib.spa.framework.util.EntryHighlight
|
||||
import com.android.settingslib.spa.widget.ui.SettingsSwitch
|
||||
|
||||
@Composable
|
||||
fun TwoTargetSwitchPreference(
|
||||
model: SwitchPreferenceModel,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
EntryHighlight {
|
||||
TwoTargetPreference(
|
||||
title = model.title,
|
||||
summary = model.summary,
|
||||
onClick = onClick,
|
||||
icon = icon,
|
||||
) {
|
||||
SettingsSwitch(
|
||||
checked = model.checked(),
|
||||
changeable = model.changeable,
|
||||
onCheckedChange = model.onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.scaffold
|
||||
|
||||
import androidx.appcompat.R
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.FindInPage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.android.settingslib.spa.framework.compose.LocalNavController
|
||||
|
||||
/** Action that navigates back to last page. */
|
||||
@Composable
|
||||
internal fun NavigateBack() {
|
||||
val navController = LocalNavController.current
|
||||
val contentDescription = stringResource(R.string.abc_action_bar_up_description)
|
||||
BackAction(contentDescription) {
|
||||
navController.navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
/** Action that collapses the search bar. */
|
||||
@Composable
|
||||
internal fun CollapseAction(onClick: () -> Unit) {
|
||||
val contentDescription = stringResource(R.string.abc_toolbar_collapse_description)
|
||||
BackAction(contentDescription, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackAction(contentDescription: String, onClick: () -> Unit) {
|
||||
IconButton(onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Action that expends the search bar. */
|
||||
@Composable
|
||||
internal fun SearchAction(onClick: () -> Unit) {
|
||||
IconButton(onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.FindInPage,
|
||||
contentDescription = stringResource(R.string.search_menu_title),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Action that clear the search query. */
|
||||
@Composable
|
||||
internal fun ClearAction(onClick: () -> Unit) {
|
||||
IconButton(onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Clear,
|
||||
contentDescription = stringResource(R.string.abc_searchview_description_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.settingslib.spa.widget.scaffold
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.animateDecay
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.TopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.layout.AlignmentLine
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.settingslib.spa.framework.theme.SettingsDimension
|
||||
import com.android.settingslib.spa.framework.theme.SettingsTheme
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CustomizedTopAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
SingleRowTopAppBar(
|
||||
title = title,
|
||||
titleTextStyle = MaterialTheme.typography.titleMedium,
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
windowInsets = TopAppBarDefaults.windowInsets,
|
||||
colors = topAppBarColors(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The customized LargeTopAppBar for Settings.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CustomizedLargeTopAppBar(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
TwoRowsTopAppBar(
|
||||
title = { Title(title = title, maxLines = 3) },
|
||||
titleTextStyle = MaterialTheme.typography.displaySmall,
|
||||
smallTitleTextStyle = MaterialTheme.typography.titleMedium,
|
||||
titleBottomPadding = LargeTitleBottomPadding,
|
||||
smallTitle = { Title(title = title, maxLines = 1) },
|
||||
modifier = modifier,
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
colors = topAppBarColors(),
|
||||
windowInsets = TopAppBarDefaults.windowInsets,
|
||||
pinnedHeight = ContainerHeight,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Title(title: String, maxLines: Int = Int.MAX_VALUE) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(
|
||||
start = SettingsDimension.itemPaddingAround,
|
||||
end = SettingsDimension.itemPaddingEnd,
|
||||
)
|
||||
.semantics { heading() },
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = maxLines,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun topAppBarColors() = TopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
scrolledContainerColor = SettingsTheme.colorScheme.surfaceHeader,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the colors used by a top app bar in different states.
|
||||
* This implementation animates the container color according to the top app bar scroll state. It
|
||||
* does not animate the leading, headline, or trailing colors.
|
||||
*
|
||||
* @constructor create an instance with arbitrary colors, see [TopAppBarColors] for a
|
||||
* factory method using the default material3 spec
|
||||
* @param containerColor the color used for the background of this BottomAppBar. Use
|
||||
* [Color.Transparent] to have no color.
|
||||
* @param scrolledContainerColor the container color when content is scrolled behind it
|
||||
* @param navigationIconContentColor the content color used for the navigation icon
|
||||
* @param titleContentColor the content color used for the title
|
||||
* @param actionIconContentColor the content color used for actions
|
||||
*/
|
||||
@Stable
|
||||
private class TopAppBarColors(
|
||||
val containerColor: Color,
|
||||
val scrolledContainerColor: Color,
|
||||
val navigationIconContentColor: Color,
|
||||
val titleContentColor: Color,
|
||||
val actionIconContentColor: Color,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Represents the container color used for the top app bar.
|
||||
*
|
||||
* A [colorTransitionFraction] provides a percentage value that can be used to generate a color.
|
||||
* Usually, an app bar implementation will pass in a [colorTransitionFraction] read from
|
||||
* the [TopAppBarState.collapsedFraction] or the [TopAppBarState.overlappedFraction].
|
||||
*
|
||||
* @param colorTransitionFraction a `0.0` to `1.0` value that represents a color transition
|
||||
* percentage
|
||||
*/
|
||||
@Stable
|
||||
fun containerColor(colorTransitionFraction: Float): Color {
|
||||
return lerp(
|
||||
containerColor,
|
||||
scrolledContainerColor,
|
||||
FastOutLinearInEasing.transform(colorTransitionFraction)
|
||||
)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || other !is TopAppBarColors) return false
|
||||
|
||||
if (containerColor != other.containerColor) return false
|
||||
if (scrolledContainerColor != other.scrolledContainerColor) return false
|
||||
if (navigationIconContentColor != other.navigationIconContentColor) return false
|
||||
if (titleContentColor != other.titleContentColor) return false
|
||||
if (actionIconContentColor != other.actionIconContentColor) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = containerColor.hashCode()
|
||||
result = 31 * result + scrolledContainerColor.hashCode()
|
||||
result = 31 * result + navigationIconContentColor.hashCode()
|
||||
result = 31 * result + titleContentColor.hashCode()
|
||||
result = 31 * result + actionIconContentColor.hashCode()
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-row top app bar that is designed to be called by the small and center aligned top app
|
||||
* bar composables.
|
||||
*/
|
||||
@Composable
|
||||
private fun SingleRowTopAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
titleTextStyle: TextStyle,
|
||||
navigationIcon: @Composable () -> Unit,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
windowInsets: WindowInsets,
|
||||
colors: TopAppBarColors,
|
||||
) {
|
||||
// Wrap the given actions in a Row.
|
||||
val actionsRow = @Composable {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
|
||||
// Compose a Surface with a TopAppBarLayout content.
|
||||
Surface(color = colors.scrolledContainerColor) {
|
||||
val height = LocalDensity.current.run { ContainerHeight.toPx() }
|
||||
TopAppBarLayout(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(windowInsets)
|
||||
// clip after padding so we don't show the title over the inset area
|
||||
.clipToBounds(),
|
||||
heightPx = height,
|
||||
navigationIconContentColor = colors.navigationIconContentColor,
|
||||
titleContentColor = colors.titleContentColor,
|
||||
actionIconContentColor = colors.actionIconContentColor,
|
||||
title = title,
|
||||
titleTextStyle = titleTextStyle,
|
||||
titleAlpha = 1f,
|
||||
titleVerticalArrangement = Arrangement.Center,
|
||||
titleBottomPadding = 0,
|
||||
hideTitleSemantics = false,
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actionsRow,
|
||||
titleScaleDisabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-rows top app bar that is designed to be called by the Large and Medium top app bar
|
||||
* composables.
|
||||
*
|
||||
* @throws [IllegalArgumentException] if the given [MaxHeightWithoutTitle] is equal or smaller than
|
||||
* the [pinnedHeight]
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TwoRowsTopAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
title: @Composable () -> Unit,
|
||||
titleTextStyle: TextStyle,
|
||||
titleBottomPadding: Dp,
|
||||
smallTitle: @Composable () -> Unit,
|
||||
smallTitleTextStyle: TextStyle,
|
||||
navigationIcon: @Composable () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit,
|
||||
windowInsets: WindowInsets,
|
||||
colors: TopAppBarColors,
|
||||
pinnedHeight: Dp,
|
||||
scrollBehavior: TopAppBarScrollBehavior?
|
||||
) {
|
||||
if (MaxHeightWithoutTitle <= pinnedHeight) {
|
||||
throw IllegalArgumentException(
|
||||
"A TwoRowsTopAppBar max height should be greater than its pinned height"
|
||||
)
|
||||
}
|
||||
val pinnedHeightPx: Float
|
||||
val titleBottomPaddingPx: Int
|
||||
val defaultMaxHeightPx: Float
|
||||
val density = LocalDensity.current
|
||||
density.run {
|
||||
pinnedHeightPx = pinnedHeight.toPx()
|
||||
titleBottomPaddingPx = titleBottomPadding.roundToPx()
|
||||
defaultMaxHeightPx = (MaxHeightWithoutTitle + DefaultTitleHeight).toPx()
|
||||
}
|
||||
|
||||
val maxHeightPx = remember(density) { mutableFloatStateOf(defaultMaxHeightPx) }
|
||||
|
||||
// Sets the app bar's height offset limit to hide just the bottom title area and keep top title
|
||||
// visible when collapsed.
|
||||
SideEffect {
|
||||
if (scrollBehavior?.state?.heightOffsetLimit != pinnedHeightPx - maxHeightPx.floatValue) {
|
||||
scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx.floatValue
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain the container Color from the TopAppBarColors using the `collapsedFraction`, as the
|
||||
// bottom part of this TwoRowsTopAppBar changes color at the same rate the app bar expands or
|
||||
// collapse.
|
||||
// This will potentially animate or interpolate a transition between the container color and the
|
||||
// container's scrolled color according to the app bar's scroll state.
|
||||
val colorTransitionFraction = scrollBehavior?.state?.collapsedFraction ?: 0f
|
||||
val appBarContainerColor = colors.containerColor(colorTransitionFraction)
|
||||
|
||||
// Wrap the given actions in a Row.
|
||||
val actionsRow = @Composable {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
val topTitleAlpha = TopTitleAlphaEasing.transform(colorTransitionFraction)
|
||||
val bottomTitleAlpha = 1f - colorTransitionFraction
|
||||
// Hide the top row title semantics when its alpha value goes below 0.5 threshold.
|
||||
// Hide the bottom row title semantics when the top title semantics are active.
|
||||
val hideTopRowSemantics = colorTransitionFraction < 0.5f
|
||||
val hideBottomRowSemantics = !hideTopRowSemantics
|
||||
|
||||
// Set up support for resizing the top app bar when vertically dragging the bar itself.
|
||||
val appBarDragModifier = if (scrollBehavior != null && !scrollBehavior.isPinned) {
|
||||
Modifier.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
scrollBehavior.state.heightOffset = scrollBehavior.state.heightOffset + delta
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
settleAppBar(
|
||||
scrollBehavior.state,
|
||||
velocity,
|
||||
scrollBehavior.flingAnimationSpec,
|
||||
scrollBehavior.snapAnimationSpec
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) {
|
||||
Column {
|
||||
TopAppBarLayout(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(windowInsets)
|
||||
// clip after padding so we don't show the title over the inset area
|
||||
.clipToBounds(),
|
||||
heightPx = pinnedHeightPx,
|
||||
navigationIconContentColor = colors.navigationIconContentColor,
|
||||
titleContentColor = colors.titleContentColor,
|
||||
actionIconContentColor = colors.actionIconContentColor,
|
||||
title = smallTitle,
|
||||
titleTextStyle = smallTitleTextStyle,
|
||||
titleAlpha = topTitleAlpha,
|
||||
titleVerticalArrangement = Arrangement.Center,
|
||||
titleBottomPadding = 0,
|
||||
hideTitleSemantics = hideTopRowSemantics,
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actionsRow,
|
||||
)
|
||||
TopAppBarLayout(
|
||||
modifier = Modifier
|
||||
// only apply the horizontal sides of the window insets padding, since the top
|
||||
// padding will always be applied by the layout above
|
||||
.windowInsetsPadding(windowInsets.only(WindowInsetsSides.Horizontal))
|
||||
.clipToBounds(),
|
||||
heightPx = maxHeightPx.floatValue - pinnedHeightPx +
|
||||
(scrollBehavior?.state?.heightOffset ?: 0f),
|
||||
navigationIconContentColor = colors.navigationIconContentColor,
|
||||
titleContentColor = colors.titleContentColor,
|
||||
actionIconContentColor = colors.actionIconContentColor,
|
||||
title = {
|
||||
Box(modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val measuredMaxHeightPx = density.run {
|
||||
MaxHeightWithoutTitle.toPx() + coordinates.size.height.toFloat()
|
||||
}
|
||||
// Allow larger max height for multi-line title, but do not reduce
|
||||
// max height to prevent flaky.
|
||||
if (measuredMaxHeightPx > defaultMaxHeightPx) {
|
||||
maxHeightPx.floatValue = measuredMaxHeightPx
|
||||
}
|
||||
}) { title() }
|
||||
},
|
||||
titleTextStyle = titleTextStyle,
|
||||
titleAlpha = bottomTitleAlpha,
|
||||
titleVerticalArrangement = Arrangement.Bottom,
|
||||
titleBottomPadding = titleBottomPaddingPx,
|
||||
hideTitleSemantics = hideBottomRowSemantics,
|
||||
navigationIcon = {},
|
||||
actions = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The base [Layout] for all top app bars. This function lays out a top app bar navigation icon
|
||||
* (leading icon), a title (header), and action icons (trailing icons). Note that the navigation and
|
||||
* the actions are optional.
|
||||
*
|
||||
* @param heightPx the total height this layout is capped to
|
||||
* @param navigationIconContentColor the content color that will be applied via a
|
||||
* [LocalContentColor] when composing the navigation icon
|
||||
* @param titleContentColor the color that will be applied via a [LocalContentColor] when composing
|
||||
* the title
|
||||
* @param actionIconContentColor the content color that will be applied via a [LocalContentColor]
|
||||
* when composing the action icons
|
||||
* @param title the top app bar title (header)
|
||||
* @param titleTextStyle the title's text style
|
||||
* @param modifier a [Modifier]
|
||||
* @param titleAlpha the title's alpha
|
||||
* @param titleVerticalArrangement the title's vertical arrangement
|
||||
* @param titleBottomPadding the title's bottom padding
|
||||
* @param hideTitleSemantics hides the title node from the semantic tree. Apply this
|
||||
* boolean when this layout is part of a [TwoRowsTopAppBar] to hide the title's semantics
|
||||
* from accessibility services. This is needed to avoid having multiple titles visible to
|
||||
* accessibility services at the same time, when animating between collapsed / expanded states.
|
||||
* @param navigationIcon a navigation icon [Composable]
|
||||
* @param actions actions [Composable]
|
||||
* @param titleScaleDisabled whether the title font scaling is disabled. Default is disabled.
|
||||
*/
|
||||
@Composable
|
||||
private fun TopAppBarLayout(
|
||||
modifier: Modifier,
|
||||
heightPx: Float,
|
||||
navigationIconContentColor: Color,
|
||||
titleContentColor: Color,
|
||||
actionIconContentColor: Color,
|
||||
title: @Composable () -> Unit,
|
||||
titleTextStyle: TextStyle,
|
||||
titleAlpha: Float,
|
||||
titleVerticalArrangement: Arrangement.Vertical,
|
||||
titleBottomPadding: Int,
|
||||
hideTitleSemantics: Boolean,
|
||||
navigationIcon: @Composable () -> Unit,
|
||||
actions: @Composable () -> Unit,
|
||||
titleScaleDisabled: Boolean = true,
|
||||
) {
|
||||
Layout(
|
||||
{
|
||||
Box(
|
||||
Modifier
|
||||
.layoutId("navigationIcon")
|
||||
.padding(start = TopAppBarHorizontalPadding)
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides navigationIconContentColor,
|
||||
content = navigationIcon
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.layoutId("title")
|
||||
.padding(horizontal = TopAppBarHorizontalPadding)
|
||||
.then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier)
|
||||
.graphicsLayer(alpha = titleAlpha)
|
||||
) {
|
||||
ProvideTextStyle(value = titleTextStyle) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides titleContentColor,
|
||||
LocalDensity provides with(LocalDensity.current) {
|
||||
Density(
|
||||
density = density,
|
||||
fontScale = if (titleScaleDisabled) 1f else fontScale,
|
||||
)
|
||||
},
|
||||
content = title
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.layoutId("actionIcons")
|
||||
.padding(end = TopAppBarHorizontalPadding)
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides actionIconContentColor,
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) { measurables, constraints ->
|
||||
val navigationIconPlaceable =
|
||||
measurables.first { it.layoutId == "navigationIcon" }
|
||||
.measure(constraints.copy(minWidth = 0))
|
||||
val actionIconsPlaceable =
|
||||
measurables.first { it.layoutId == "actionIcons" }
|
||||
.measure(constraints.copy(minWidth = 0))
|
||||
|
||||
val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) {
|
||||
constraints.maxWidth
|
||||
} else {
|
||||
(constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width)
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val titlePlaceable =
|
||||
measurables.first { it.layoutId == "title" }
|
||||
.measure(constraints.copy(minWidth = 0, maxWidth = maxTitleWidth))
|
||||
|
||||
// Locate the title's baseline.
|
||||
val titleBaseline =
|
||||
if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) {
|
||||
titlePlaceable[LastBaseline]
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
val layoutHeight = if (heightPx > 0) heightPx.roundToInt() else 0
|
||||
|
||||
layout(constraints.maxWidth, layoutHeight) {
|
||||
// Navigation icon
|
||||
navigationIconPlaceable.placeRelative(
|
||||
x = 0,
|
||||
y = (layoutHeight - navigationIconPlaceable.height) / 2
|
||||
)
|
||||
|
||||
// Title
|
||||
titlePlaceable.placeRelative(
|
||||
x = max(TopAppBarTitleInset.roundToPx(), navigationIconPlaceable.width),
|
||||
y = when (titleVerticalArrangement) {
|
||||
Arrangement.Center -> (layoutHeight - titlePlaceable.height) / 2
|
||||
// Apply bottom padding from the title's baseline only when the Arrangement is
|
||||
// "Bottom".
|
||||
Arrangement.Bottom ->
|
||||
if (titleBottomPadding == 0) layoutHeight - titlePlaceable.height
|
||||
else layoutHeight - titlePlaceable.height - max(
|
||||
0,
|
||||
titleBottomPadding - titlePlaceable.height + titleBaseline
|
||||
)
|
||||
// Arrangement.Top
|
||||
else -> 0
|
||||
}
|
||||
)
|
||||
|
||||
// Action icons
|
||||
actionIconsPlaceable.placeRelative(
|
||||
x = constraints.maxWidth - actionIconsPlaceable.width,
|
||||
y = (layoutHeight - actionIconsPlaceable.height) / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Settles the app bar by flinging, in case the given velocity is greater than zero, and snapping
|
||||
* after the fling settles.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private suspend fun settleAppBar(
|
||||
state: TopAppBarState,
|
||||
velocity: Float,
|
||||
flingAnimationSpec: DecayAnimationSpec<Float>?,
|
||||
snapAnimationSpec: AnimationSpec<Float>?
|
||||
): Velocity {
|
||||
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
|
||||
// and just return Zero Velocity.
|
||||
// Note that we don't check for 0f due to float precision with the collapsedFraction
|
||||
// calculation.
|
||||
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
|
||||
return Velocity.Zero
|
||||
}
|
||||
var remainingVelocity = velocity
|
||||
// In case there is an initial velocity that was left after a previous user fling, animate to
|
||||
// continue the motion to expand or collapse the app bar.
|
||||
if (flingAnimationSpec != null && abs(velocity) > 1f) {
|
||||
var lastValue = 0f
|
||||
AnimationState(
|
||||
initialValue = 0f,
|
||||
initialVelocity = velocity,
|
||||
)
|
||||
.animateDecay(flingAnimationSpec) {
|
||||
val delta = value - lastValue
|
||||
val initialHeightOffset = state.heightOffset
|
||||
state.heightOffset = initialHeightOffset + delta
|
||||
val consumed = abs(initialHeightOffset - state.heightOffset)
|
||||
lastValue = value
|
||||
remainingVelocity = this.velocity
|
||||
// avoid rounding errors and stop if anything is unconsumed
|
||||
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
|
||||
}
|
||||
}
|
||||
// Snap if animation specs were provided.
|
||||
if (snapAnimationSpec != null) {
|
||||
if (state.heightOffset < 0 &&
|
||||
state.heightOffset > state.heightOffsetLimit
|
||||
) {
|
||||
AnimationState(initialValue = state.heightOffset).animateTo(
|
||||
if (state.collapsedFraction < 0.5f) {
|
||||
0f
|
||||
} else {
|
||||
state.heightOffsetLimit
|
||||
},
|
||||
animationSpec = snapAnimationSpec
|
||||
) { state.heightOffset = value }
|
||||
}
|
||||
}
|
||||
|
||||
return Velocity(0f, remainingVelocity)
|
||||
}
|
||||
|
||||
// An easing function used to compute the alpha value that is applied to the top title part of a
|
||||
// Medium or Large app bar.
|
||||
private val TopTitleAlphaEasing = CubicBezierEasing(.8f, 0f, .8f, .15f)
|
||||
|
||||
internal val MaxHeightWithoutTitle = 124.dp
|
||||
internal val DefaultTitleHeight = 52.dp
|
||||
internal val ContainerHeight = 56.dp
|
||||
private val LargeTitleBottomPadding = 28.dp
|
||||
private val TopAppBarHorizontalPadding = 4.dp
|
||||
|
||||
// A title inset when the App-Bar is a Medium or Large one. Also used to size a spacer when the
|
||||
// navigation icon is missing.
|
||||
private val TopAppBarTitleInset = 16.dp - TopAppBarHorizontalPadding
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user