fix: 处理引用依赖问题

This commit is contained in:
2024-12-11 15:28:11 +08:00
parent df105485bd
commit 8454e55c4b
246 changed files with 14884 additions and 100 deletions

56
spa/Android.bp Normal file
View 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
View 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
View 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
View 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
View 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>

View 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"
}
}

View 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()

View 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")
}

View 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")
}
}

View 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"
}

View 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)
}

View 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

View 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
}
}
}
}

View File

@@ -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
}

View File

@@ -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(),
)

View File

@@ -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() }
}
}

View File

@@ -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,
)

View File

@@ -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?) {}
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}

View 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.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)
}

View File

@@ -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]
}
}

View File

@@ -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
}
}

View File

@@ -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
) {
}
}

View File

@@ -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) }

View File

@@ -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() {}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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}")
}
}

View File

@@ -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
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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
}

View 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.
*/
@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()}")
}
}
}

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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 }

View 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.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()
}
}

View File

@@ -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)
}
}

View 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)
}
}
}

View File

@@ -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))

View File

@@ -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)
}
)
}

View 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)
}

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View 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 }
}

View 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 }
}

View 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,
)
),
}

View 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
}
}

View File

@@ -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)
}
}

View 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
)
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View 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) }
)
}

View 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"
)
}

View File

@@ -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) {},
)
)
}
}

View 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,
)

View 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") {},
)
)
)
}
}

View File

@@ -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") {},
)
)
)
)
}
}

View 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()
}

View File

@@ -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)
}

View 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()
}

View 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()
}

View File

@@ -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
}

View 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.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)
}
}

View File

@@ -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),
)
}

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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,
) {}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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})
}
}

View File

@@ -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 },
)
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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)
}
)
}
}

View File

@@ -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)
},
)
}
}

View File

@@ -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)
},
)
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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 = {}
})
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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)
},
)
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
)
}

View File

@@ -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,
)
}
}
}

View File

@@ -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),
)
}
}

View File

@@ -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