summaryrefslogtreecommitdiff
path: root/src/com/android/customization/picker/clock/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/customization/picker/clock/ui')
-rw-r--r--src/com/android/customization/picker/clock/ui/adapter/ClockSettingsTabAdapter.kt69
-rw-r--r--src/com/android/customization/picker/clock/ui/binder/ClockCarouselViewBinder.kt110
-rw-r--r--src/com/android/customization/picker/clock/ui/binder/ClockSectionViewBinder.kt53
-rw-r--r--src/com/android/customization/picker/clock/ui/binder/ClockSettingsBinder.kt181
-rw-r--r--src/com/android/customization/picker/clock/ui/fragment/ClockCustomDemoFragment.kt93
-rw-r--r--src/com/android/customization/picker/clock/ui/fragment/ClockSettingsFragment.kt135
-rw-r--r--src/com/android/customization/picker/clock/ui/section/ClockSectionController.kt62
-rw-r--r--src/com/android/customization/picker/clock/ui/view/ClockCarouselView.kt86
-rw-r--r--src/com/android/customization/picker/clock/ui/view/ClockSectionView.kt23
-rw-r--r--src/com/android/customization/picker/clock/ui/view/ClockSizeRadioButtonGroup.kt50
-rw-r--r--src/com/android/customization/picker/clock/ui/view/ClockViewFactory.kt75
-rw-r--r--src/com/android/customization/picker/clock/ui/view/SaturationProgressDrawable.kt107
-rw-r--r--src/com/android/customization/picker/clock/ui/viewmodel/ClockCarouselViewModel.kt98
-rw-r--r--src/com/android/customization/picker/clock/ui/viewmodel/ClockColorViewModel.kt69
-rw-r--r--src/com/android/customization/picker/clock/ui/viewmodel/ClockSectionViewModel.kt51
-rw-r--r--src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsTabViewModel.kt28
-rw-r--r--src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsViewModel.kt309
17 files changed, 1599 insertions, 0 deletions
diff --git a/src/com/android/customization/picker/clock/ui/adapter/ClockSettingsTabAdapter.kt b/src/com/android/customization/picker/clock/ui/adapter/ClockSettingsTabAdapter.kt
new file mode 100644
index 00000000..746fdb30
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/adapter/ClockSettingsTabAdapter.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.customization.picker.clock.ui.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.clock.ui.viewmodel.ClockSettingsTabViewModel
+import com.android.wallpaper.R
+
+/** Adapter for the tab recycler view on the clock settings screen. */
+class ClockSettingsTabAdapter : RecyclerView.Adapter<ClockSettingsTabAdapter.ViewHolder>() {
+
+ private val items = mutableListOf<ClockSettingsTabViewModel>()
+
+ fun setItems(items: List<ClockSettingsTabViewModel>) {
+ this.items.clear()
+ this.items.addAll(items)
+ notifyDataSetChanged()
+ }
+
+ override fun getItemCount(): Int {
+ return items.size
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ return ViewHolder(
+ LayoutInflater.from(parent.context)
+ .inflate(
+ R.layout.picker_fragment_tab,
+ parent,
+ false,
+ )
+ )
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val item = items[position]
+ holder.itemView.isSelected = item.isSelected
+ holder.textView.text = item.name
+ holder.textView.setOnClickListener(
+ if (item.onClicked != null) {
+ View.OnClickListener { item.onClicked.invoke() }
+ } else {
+ null
+ }
+ )
+ }
+
+ class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val textView: TextView = itemView.requireViewById(R.id.text)
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/binder/ClockCarouselViewBinder.kt b/src/com/android/customization/picker/clock/ui/binder/ClockCarouselViewBinder.kt
new file mode 100644
index 00000000..9ad735d3
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/binder/ClockCarouselViewBinder.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.customization.picker.clock.ui.binder
+
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.customization.picker.clock.ui.view.ClockCarouselView
+import com.android.customization.picker.clock.ui.view.ClockViewFactory
+import com.android.customization.picker.clock.ui.viewmodel.ClockCarouselViewModel
+import com.android.wallpaper.R
+import kotlinx.coroutines.launch
+
+object ClockCarouselViewBinder {
+ /**
+ * The binding is used by the view where there is an action executed from another view, e.g.
+ * toggling show/hide of the view that the binder is holding.
+ */
+ interface Binding {
+ fun show()
+ fun hide()
+ }
+
+ @JvmStatic
+ fun bind(
+ carouselView: ClockCarouselView,
+ singleClockView: ViewGroup,
+ viewModel: ClockCarouselViewModel,
+ clockViewFactory: ClockViewFactory,
+ lifecycleOwner: LifecycleOwner,
+ ): Binding {
+ val singleClockHostView =
+ singleClockView.requireViewById<FrameLayout>(R.id.single_clock_host_view)
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch { viewModel.isCarouselVisible.collect { carouselView.isVisible = it } }
+
+ launch {
+ viewModel.allClockIds.collect { allClockIds ->
+ carouselView.setUpClockCarouselView(
+ clockIds = allClockIds,
+ onGetClockPreview = { clockId -> clockViewFactory.getView(clockId) },
+ onClockSelected = { clockId -> viewModel.setSelectedClock(clockId) },
+ )
+ }
+ }
+
+ launch {
+ viewModel.selectedIndex.collect { selectedIndex ->
+ carouselView.setSelectedClockIndex(selectedIndex)
+ }
+ }
+
+ launch {
+ viewModel.seedColor.collect { clockViewFactory.updateColorForAllClocks(it) }
+ }
+
+ launch {
+ viewModel.isSingleClockViewVisible.collect { singleClockView.isVisible = it }
+ }
+
+ launch {
+ viewModel.clockId.collect { clockId ->
+ singleClockHostView.removeAllViews()
+ val clockView = clockViewFactory.getView(clockId)
+ // The clock view might still be attached to an existing parent. Detach
+ // before adding to another parent.
+ (clockView.parent as? ViewGroup)?.removeView(clockView)
+ singleClockHostView.addView(clockView)
+ }
+ }
+ }
+ }
+
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ clockViewFactory.registerTimeTicker()
+ }
+ // When paused
+ clockViewFactory.unregisterTimeTicker()
+ }
+
+ return object : Binding {
+ override fun show() {
+ viewModel.showClockCarousel(true)
+ }
+
+ override fun hide() {
+ viewModel.showClockCarousel(false)
+ }
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/binder/ClockSectionViewBinder.kt b/src/com/android/customization/picker/clock/ui/binder/ClockSectionViewBinder.kt
new file mode 100644
index 00000000..7dc0d0c4
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/binder/ClockSectionViewBinder.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.customization.picker.clock.ui.binder
+
+import android.view.View
+import android.widget.TextView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.customization.picker.clock.ui.viewmodel.ClockSectionViewModel
+import com.android.wallpaper.R
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+object ClockSectionViewBinder {
+ fun bind(
+ view: View,
+ viewModel: ClockSectionViewModel,
+ lifecycleOwner: LifecycleOwner,
+ onClicked: () -> Unit,
+ ) {
+ view.setOnClickListener { onClicked() }
+
+ val selectedClockColorAndSize: TextView =
+ view.requireViewById(R.id.selected_clock_color_and_size)
+
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.selectedClockColorAndSizeText.collectLatest {
+ selectedClockColorAndSize.text = it
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/binder/ClockSettingsBinder.kt b/src/com/android/customization/picker/clock/ui/binder/ClockSettingsBinder.kt
new file mode 100644
index 00000000..66d92513
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/binder/ClockSettingsBinder.kt
@@ -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.customization.picker.clock.ui.binder
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.SeekBar
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.picker.clock.shared.ClockSize
+import com.android.customization.picker.clock.ui.adapter.ClockSettingsTabAdapter
+import com.android.customization.picker.clock.ui.view.ClockSizeRadioButtonGroup
+import com.android.customization.picker.clock.ui.view.ClockViewFactory
+import com.android.customization.picker.clock.ui.viewmodel.ClockSettingsViewModel
+import com.android.customization.picker.color.ui.adapter.ColorOptionAdapter
+import com.android.customization.picker.common.ui.view.ItemSpacing
+import com.android.wallpaper.R
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.launch
+
+/** Bind between the clock settings screen and its view model. */
+object ClockSettingsBinder {
+ fun bind(
+ view: View,
+ viewModel: ClockSettingsViewModel,
+ clockViewFactory: ClockViewFactory,
+ lifecycleOwner: LifecycleOwner,
+ ) {
+ val clockHostView: FrameLayout = view.requireViewById(R.id.clock_host_view)
+
+ val tabView: RecyclerView = view.requireViewById(R.id.tabs)
+ val tabAdapter = ClockSettingsTabAdapter()
+ tabView.adapter = tabAdapter
+ tabView.layoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+ tabView.addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP))
+
+ val colorOptionContainerView: RecyclerView = view.requireViewById(R.id.color_options)
+ val colorOptionAdapter = ColorOptionAdapter()
+ colorOptionContainerView.adapter = colorOptionAdapter
+ colorOptionContainerView.layoutManager =
+ LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
+ colorOptionContainerView.addItemDecoration(ItemSpacing(ItemSpacing.ITEM_SPACING_DP))
+
+ val slider: SeekBar = view.requireViewById(R.id.slider)
+ slider.setOnSeekBarChangeListener(
+ object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(p0: SeekBar?, progress: Int, fromUser: Boolean) {
+ if (fromUser) {
+ viewModel.onSliderProgressChanged(progress)
+ }
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {
+ seekBar?.progress?.let { viewModel.onSliderProgressStop(it) }
+ }
+ }
+ )
+
+ val sizeOptions =
+ view.requireViewById<ClockSizeRadioButtonGroup>(R.id.clock_size_radio_button_group)
+ sizeOptions.onRadioButtonClickListener =
+ object : ClockSizeRadioButtonGroup.OnRadioButtonClickListener {
+ override fun onClick(size: ClockSize) {
+ viewModel.setClockSize(size)
+ }
+ }
+
+ val colorOptionContainer = view.requireViewById<View>(R.id.color_picker_container)
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.selectedClockId
+ .mapNotNull { it }
+ .collect { clockId ->
+ val clockView = clockViewFactory.getView(clockId)
+ (clockView.parent as? ViewGroup)?.removeView(clockView)
+ clockHostView.removeAllViews()
+ clockHostView.addView(clockView)
+ }
+ }
+
+ launch {
+ viewModel.seedColor.collect { seedColor ->
+ viewModel.selectedClockId.value?.let { selectedClockId ->
+ clockViewFactory.updateColor(selectedClockId, seedColor)
+ }
+ }
+ }
+
+ launch { viewModel.tabs.collect { tabAdapter.setItems(it) } }
+
+ launch {
+ viewModel.selectedTab.collect { tab ->
+ when (tab) {
+ ClockSettingsViewModel.Tab.COLOR -> {
+ colorOptionContainer.isVisible = true
+ sizeOptions.isInvisible = true
+ }
+ ClockSettingsViewModel.Tab.SIZE -> {
+ colorOptionContainer.isInvisible = true
+ sizeOptions.isVisible = true
+ }
+ }
+ }
+ }
+
+ launch {
+ viewModel.colorOptions.collect { colorOptions ->
+ colorOptionAdapter.setItems(colorOptions)
+ }
+ }
+
+ launch {
+ viewModel.selectedColorOptionPosition.collect { selectedPosition ->
+ if (selectedPosition != -1) {
+ // We use "post" because we need to give the adapter item a pass to
+ // update the view.
+ colorOptionContainerView.post {
+ colorOptionContainerView.smoothScrollToPosition(selectedPosition)
+ }
+ }
+ }
+ }
+
+ launch {
+ viewModel.selectedClockSize.collect { size ->
+ when (size) {
+ ClockSize.DYNAMIC -> {
+ sizeOptions.radioButtonDynamic.isChecked = true
+ sizeOptions.radioButtonSmall.isChecked = false
+ }
+ ClockSize.SMALL -> {
+ sizeOptions.radioButtonDynamic.isChecked = false
+ sizeOptions.radioButtonSmall.isChecked = true
+ }
+ }
+ }
+ }
+
+ launch {
+ viewModel.sliderProgress.collect { progress ->
+ slider.setProgress(progress, true)
+ }
+ }
+
+ launch {
+ viewModel.isSliderEnabled.collect { isEnabled -> slider.isEnabled = isEnabled }
+ }
+ }
+ }
+
+ lifecycleOwner.lifecycleScope.launch {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ clockViewFactory.registerTimeTicker()
+ }
+ // When paused
+ clockViewFactory.unregisterTimeTicker()
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/fragment/ClockCustomDemoFragment.kt b/src/com/android/customization/picker/clock/ui/fragment/ClockCustomDemoFragment.kt
new file mode 100644
index 00000000..7e53ac44
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/fragment/ClockCustomDemoFragment.kt
@@ -0,0 +1,93 @@
+package com.android.customization.picker.clock.ui.fragment
+
+import android.content.Context
+import android.os.Bundle
+import android.util.TypedValue
+import android.view.ContextThemeWrapper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.FrameLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.view.setPadding
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.customization.module.ThemePickerInjector
+import com.android.internal.annotations.VisibleForTesting
+import com.android.systemui.plugins.ClockMetadata
+import com.android.systemui.shared.clocks.ClockRegistry
+import com.android.wallpaper.R
+import com.android.wallpaper.module.InjectorProvider
+import com.android.wallpaper.picker.AppbarFragment
+
+class ClockCustomDemoFragment : AppbarFragment() {
+ @VisibleForTesting lateinit var recyclerView: RecyclerView
+ @VisibleForTesting lateinit var clockRegistry: ClockRegistry
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view = inflater.inflate(R.layout.fragment_clock_custom_picker_demo, container, false)
+ setUpToolbar(view)
+ clockRegistry =
+ (InjectorProvider.getInjector() as ThemePickerInjector).getClockRegistry(
+ requireContext()
+ )
+ val listInUse = clockRegistry.getClocks().filter { "NOT_IN_USE" !in it.clockId }
+
+ recyclerView = view.requireViewById(R.id.clock_preview_card_list_demo)
+ recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
+ recyclerView.adapter =
+ ClockRecyclerAdapter(listInUse, requireContext()) {
+ clockRegistry.currentClockId = it.clockId
+ Toast.makeText(context, "${it.name} selected", Toast.LENGTH_SHORT).show()
+ }
+ return view
+ }
+
+ override fun getDefaultTitle(): CharSequence {
+ return getString(R.string.clock_title)
+ }
+
+ internal class ClockRecyclerAdapter(
+ val list: List<ClockMetadata>,
+ val context: Context,
+ val onClockSelected: (ClockMetadata) -> Unit
+ ) : RecyclerView.Adapter<ClockRecyclerAdapter.ViewHolder>() {
+ class ViewHolder(val view: View, val textView: TextView, val onItemClicked: (Int) -> Unit) :
+ RecyclerView.ViewHolder(view) {
+ init {
+ itemView.setOnClickListener { onItemClicked(absoluteAdapterPosition) }
+ }
+ }
+
+ override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
+ val rootView = FrameLayout(viewGroup.context)
+ val textView =
+ TextView(ContextThemeWrapper(viewGroup.context, R.style.SectionTitleTextStyle))
+ textView.setPadding(ITEM_PADDING)
+ rootView.addView(textView)
+ val outValue = TypedValue()
+ context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true)
+ rootView.setBackgroundResource(outValue.resourceId)
+ val lp = RecyclerView.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ rootView.layoutParams = lp
+ return ViewHolder(rootView, textView) { onClockSelected(list[it]) }
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
+ viewHolder.textView.text = list[position].name
+ }
+
+ override fun getItemCount() = list.size
+
+ companion object {
+ val ITEM_PADDING = 40
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/fragment/ClockSettingsFragment.kt b/src/com/android/customization/picker/clock/ui/fragment/ClockSettingsFragment.kt
new file mode 100644
index 00000000..c5cde530
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/fragment/ClockSettingsFragment.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.customization.picker.clock.ui.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.cardview.widget.CardView
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.get
+import com.android.customization.module.ThemePickerInjector
+import com.android.customization.picker.clock.ui.binder.ClockSettingsBinder
+import com.android.systemui.shared.clocks.shared.model.ClockPreviewConstants
+import com.android.wallpaper.R
+import com.android.wallpaper.module.InjectorProvider
+import com.android.wallpaper.picker.AppbarFragment
+import com.android.wallpaper.picker.customization.ui.binder.ScreenPreviewBinder
+import com.android.wallpaper.picker.customization.ui.viewmodel.ScreenPreviewViewModel
+import com.android.wallpaper.util.PreviewUtils
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ClockSettingsFragment : AppbarFragment() {
+ companion object {
+ const val DESTINATION_ID = "clock_settings"
+
+ @JvmStatic
+ fun newInstance(): ClockSettingsFragment {
+ return ClockSettingsFragment()
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view =
+ inflater.inflate(
+ R.layout.fragment_clock_settings,
+ container,
+ false,
+ )
+ setUpToolbar(view)
+
+ val context = requireContext()
+ val activity = requireActivity()
+ val injector = InjectorProvider.getInjector() as ThemePickerInjector
+
+ val lockScreenView: CardView = view.requireViewById(R.id.lock_preview)
+ val colorViewModel = injector.getWallpaperColorsViewModel()
+ val displayUtils = injector.getDisplayUtils(context)
+ ScreenPreviewBinder.bind(
+ activity = activity,
+ previewView = lockScreenView,
+ viewModel =
+ ScreenPreviewViewModel(
+ previewUtils =
+ PreviewUtils(
+ context = context,
+ authority =
+ resources.getString(
+ R.string.lock_screen_preview_provider_authority,
+ ),
+ ),
+ wallpaperInfoProvider = {
+ suspendCancellableCoroutine { continuation ->
+ injector
+ .getCurrentWallpaperInfoFactory(context)
+ .createCurrentWallpaperInfos(
+ { homeWallpaper, lockWallpaper, _ ->
+ continuation.resume(
+ homeWallpaper ?: lockWallpaper,
+ null,
+ )
+ },
+ /* forceRefresh= */ true,
+ )
+ }
+ },
+ onWallpaperColorChanged = { colors ->
+ colorViewModel.setLockWallpaperColors(colors)
+ },
+ initialExtrasProvider = {
+ Bundle().apply {
+ // Hide the clock from the system UI rendered preview so we can
+ // place the carousel on top of it.
+ putBoolean(
+ ClockPreviewConstants.KEY_HIDE_CLOCK,
+ true,
+ )
+ }
+ },
+ ),
+ lifecycleOwner = this,
+ offsetToStart = displayUtils.isSingleDisplayOrUnfoldedHorizontalHinge(activity),
+ )
+ .show()
+
+ ClockSettingsBinder.bind(
+ view,
+ ViewModelProvider(
+ requireActivity(),
+ injector.getClockSettingsViewModelFactory(
+ context,
+ injector.getWallpaperColorsViewModel(),
+ ),
+ )
+ .get(),
+ injector.getClockViewFactory(activity),
+ this@ClockSettingsFragment,
+ )
+
+ return view
+ }
+
+ override fun getDefaultTitle(): CharSequence {
+ return requireContext().getString(R.string.clock_settings_title)
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/section/ClockSectionController.kt b/src/com/android/customization/picker/clock/ui/section/ClockSectionController.kt
new file mode 100644
index 00000000..b47c2433
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/section/ClockSectionController.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.customization.picker.clock.ui.section
+
+import android.content.Context
+import android.view.LayoutInflater
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.android.customization.picker.clock.ui.binder.ClockSectionViewBinder
+import com.android.customization.picker.clock.ui.fragment.ClockSettingsFragment
+import com.android.customization.picker.clock.ui.view.ClockSectionView
+import com.android.customization.picker.clock.ui.viewmodel.ClockSectionViewModel
+import com.android.wallpaper.R
+import com.android.wallpaper.config.BaseFlags
+import com.android.wallpaper.model.CustomizationSectionController
+import com.android.wallpaper.model.CustomizationSectionController.CustomizationSectionNavigationController
+import kotlinx.coroutines.launch
+
+/** A [CustomizationSectionController] for clock customization. */
+class ClockSectionController(
+ private val navigationController: CustomizationSectionNavigationController,
+ private val lifecycleOwner: LifecycleOwner,
+ private val flag: BaseFlags,
+ private val viewModel: ClockSectionViewModel,
+) : CustomizationSectionController<ClockSectionView> {
+
+ override fun isAvailable(context: Context): Boolean {
+ return flag.isCustomClocksEnabled(context!!)
+ }
+
+ override fun createView(context: Context): ClockSectionView {
+ val view =
+ LayoutInflater.from(context)
+ .inflate(
+ R.layout.clock_section_view,
+ null,
+ ) as ClockSectionView
+ lifecycleOwner.lifecycleScope.launch {
+ ClockSectionViewBinder.bind(
+ view = view,
+ viewModel = viewModel,
+ lifecycleOwner = lifecycleOwner
+ ) {
+ navigationController.navigateTo(ClockSettingsFragment())
+ }
+ }
+ return view
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/view/ClockCarouselView.kt b/src/com/android/customization/picker/clock/ui/view/ClockCarouselView.kt
new file mode 100644
index 00000000..90d7c42d
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/view/ClockCarouselView.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.customization.picker.clock.ui.view
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.constraintlayout.helper.widget.Carousel
+import androidx.core.view.get
+import com.android.wallpaper.R
+
+class ClockCarouselView(
+ context: Context,
+ attrs: AttributeSet,
+) :
+ FrameLayout(
+ context,
+ attrs,
+ ) {
+
+ private val carousel: Carousel
+ private lateinit var adapter: ClockCarouselAdapter
+
+ init {
+ val view = LayoutInflater.from(context).inflate(R.layout.clock_carousel, this)
+ carousel = view.requireViewById(R.id.carousel)
+ }
+
+ fun setUpClockCarouselView(
+ clockIds: List<String>,
+ onGetClockPreview: (clockId: String) -> View,
+ onClockSelected: (clockId: String) -> Unit,
+ ) {
+ adapter = ClockCarouselAdapter(clockIds, onGetClockPreview, onClockSelected)
+ carousel.setAdapter(adapter)
+ carousel.refresh()
+ }
+
+ fun setSelectedClockIndex(
+ index: Int,
+ ) {
+ carousel.jumpToIndex(index)
+ }
+
+ class ClockCarouselAdapter(
+ val clockIds: List<String>,
+ val onGetClockPreview: (clockId: String) -> View,
+ val onClockSelected: (clockId: String) -> Unit,
+ ) : Carousel.Adapter {
+
+ override fun count(): Int {
+ return clockIds.size
+ }
+
+ override fun populate(view: View?, index: Int) {
+ val viewRoot = view as ViewGroup
+ val clockHostView = viewRoot[0] as ViewGroup
+ clockHostView.removeAllViews()
+ val clockView = onGetClockPreview(clockIds[index])
+ // The clock view might still be attached to an existing parent. Detach before adding to
+ // another parent.
+ (clockView.parent as? ViewGroup)?.removeView(clockView)
+ clockHostView.addView(clockView)
+ }
+
+ override fun onNewItem(index: Int) {
+ onClockSelected.invoke(clockIds[index])
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/view/ClockSectionView.kt b/src/com/android/customization/picker/clock/ui/view/ClockSectionView.kt
new file mode 100644
index 00000000..cca107c4
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/view/ClockSectionView.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.customization.picker.clock.ui.view
+
+import android.content.Context
+import android.util.AttributeSet
+import com.android.wallpaper.picker.SectionView
+
+/** The [SectionView] for app clock. */
+class ClockSectionView(context: Context?, attrs: AttributeSet?) : SectionView(context, attrs)
diff --git a/src/com/android/customization/picker/clock/ui/view/ClockSizeRadioButtonGroup.kt b/src/com/android/customization/picker/clock/ui/view/ClockSizeRadioButtonGroup.kt
new file mode 100644
index 00000000..909491a3
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/view/ClockSizeRadioButtonGroup.kt
@@ -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.customization.picker.clock.ui.view
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.RadioButton
+import com.android.customization.picker.clock.shared.ClockSize
+import com.android.wallpaper.R
+
+/** The radio button group to pick the clock size. */
+class ClockSizeRadioButtonGroup(
+ context: Context,
+ attrs: AttributeSet?,
+) : FrameLayout(context, attrs) {
+
+ interface OnRadioButtonClickListener {
+ fun onClick(size: ClockSize)
+ }
+
+ val radioButtonDynamic: RadioButton
+ val radioButtonSmall: RadioButton
+ var onRadioButtonClickListener: OnRadioButtonClickListener? = null
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.clock_size_radio_button_group, this, true)
+ radioButtonDynamic = requireViewById(R.id.radio_button_dynamic)
+ val buttonDynamic = requireViewById<View>(R.id.button_container_dynamic)
+ buttonDynamic.setOnClickListener { onRadioButtonClickListener?.onClick(ClockSize.DYNAMIC) }
+ radioButtonSmall = requireViewById(R.id.radio_button_large)
+ val buttonLarge = requireViewById<View>(R.id.button_container_small)
+ buttonLarge.setOnClickListener { onRadioButtonClickListener?.onClick(ClockSize.SMALL) }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/view/ClockViewFactory.kt b/src/com/android/customization/picker/clock/ui/view/ClockViewFactory.kt
new file mode 100644
index 00000000..7f480dee
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/view/ClockViewFactory.kt
@@ -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.customization.picker.clock.ui.view
+
+import android.app.Activity
+import android.view.View
+import androidx.annotation.ColorInt
+import com.android.systemui.plugins.ClockController
+import com.android.systemui.shared.clocks.ClockRegistry
+import com.android.wallpaper.R
+import com.android.wallpaper.util.ScreenSizeCalculator
+import com.android.wallpaper.util.TimeUtils.TimeTicker
+
+class ClockViewFactory(
+ private val activity: Activity,
+ private val registry: ClockRegistry,
+) {
+ private val clockControllers: HashMap<String, ClockController> = HashMap()
+ private var ticker: TimeTicker? = null
+
+ fun getView(clockId: String): View {
+ return (clockControllers[clockId] ?: initClockController(clockId)).largeClock.view
+ }
+
+ fun updateColorForAllClocks(@ColorInt seedColor: Int?) {
+ clockControllers.values.forEach { it.events.onSeedColorChanged(seedColor = seedColor) }
+ }
+
+ fun updateColor(clockId: String, @ColorInt seedColor: Int?) {
+ return (clockControllers[clockId] ?: initClockController(clockId))
+ .events
+ .onSeedColorChanged(seedColor)
+ }
+
+ fun registerTimeTicker() {
+ ticker =
+ TimeTicker.registerNewReceiver(activity.applicationContext) {
+ clockControllers.values.forEach { it.largeClock.events.onTimeTick() }
+ }
+ }
+
+ fun unregisterTimeTicker() {
+ activity.applicationContext.unregisterReceiver(ticker)
+ }
+
+ private fun initClockController(clockId: String): ClockController {
+ val controller =
+ registry.createExampleClock(clockId).also { it?.initialize(activity.resources, 0f, 0f) }
+ checkNotNull(controller)
+ val screenSizeCalculator = ScreenSizeCalculator.getInstance()
+ val screenSize = screenSizeCalculator.getScreenSize(activity.windowManager.defaultDisplay)
+ val ratio =
+ activity.resources.getDimensionPixelSize(R.dimen.screen_preview_height).toFloat() /
+ screenSize.y.toFloat()
+ controller.largeClock.events.onFontSettingChanged(
+ activity.resources.getDimensionPixelSize(R.dimen.large_clock_text_size).toFloat() *
+ ratio
+ )
+ clockControllers[clockId] = controller
+ return controller
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/view/SaturationProgressDrawable.kt b/src/com/android/customization/picker/clock/ui/view/SaturationProgressDrawable.kt
new file mode 100644
index 00000000..9e1d7890
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/view/SaturationProgressDrawable.kt
@@ -0,0 +1,107 @@
+/*
+ * 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.customization.picker.clock.ui.view
+
+import android.content.pm.ActivityInfo
+import android.content.res.Resources
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.DrawableWrapper
+import android.graphics.drawable.InsetDrawable
+
+/**
+ * [DrawableWrapper] to use in the progress of brightness slider.
+ *
+ * This drawable is used to change the bounds of the enclosed drawable depending on the level to
+ * simulate a sliding progress, instead of using clipping or scaling. That way, the shape of the
+ * edges is maintained.
+ *
+ * Meant to be used with a rounded ends background, it will also prevent deformation when the slider
+ * is meant to be smaller than the rounded corner. The background should have rounded corners that
+ * are half of the height.
+ *
+ * This class also assumes that a "thumb" icon exists within the end's edge of the progress
+ * drawable, and the slider's width, when interacted on, if offset by half the size of the thumb
+ * icon which puts the icon directly underneath the user's finger.
+ */
+class SaturationProgressDrawable @JvmOverloads constructor(drawable: Drawable? = null) :
+ InsetDrawable(drawable, 0) {
+
+ companion object {
+ private const val MAX_LEVEL = 10000
+ }
+
+ override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
+ onLevelChange(level)
+ return super.onLayoutDirectionChanged(layoutDirection)
+ }
+
+ override fun onBoundsChange(bounds: Rect) {
+ super.onBoundsChange(bounds)
+ onLevelChange(level)
+ }
+
+ override fun onLevelChange(level: Int): Boolean {
+ val drawableBounds = drawable?.bounds!!
+
+ // The thumb offset shifts the sun icon directly under the user's thumb
+ val thumbOffset = bounds.height() / 2
+ val width = bounds.width() * level / MAX_LEVEL + thumbOffset
+
+ // On 0, the width is bounds.height (a circle), and on MAX_LEVEL, the width is bounds.width
+ // TODO (b/268541542) Test if RTL devices also works for the slider
+ drawable?.setBounds(
+ bounds.left,
+ drawableBounds.top,
+ width.coerceAtMost(bounds.width()).coerceAtLeast(bounds.height()),
+ drawableBounds.bottom
+ )
+ return super.onLevelChange(level)
+ }
+
+ override fun getConstantState(): ConstantState {
+ // This should not be null as it was created with a state in the constructor.
+ return RoundedCornerState(super.getConstantState()!!)
+ }
+
+ override fun getChangingConfigurations(): Int {
+ return super.getChangingConfigurations() or ActivityInfo.CONFIG_DENSITY
+ }
+
+ override fun canApplyTheme(): Boolean {
+ return (drawable?.canApplyTheme() ?: false) || super.canApplyTheme()
+ }
+
+ private class RoundedCornerState(private val wrappedState: ConstantState) : ConstantState() {
+ override fun newDrawable(): Drawable {
+ return newDrawable(null, null)
+ }
+
+ override fun newDrawable(res: Resources?, theme: Resources.Theme?): Drawable {
+ val wrapper = wrappedState.newDrawable(res, theme) as DrawableWrapper
+ return SaturationProgressDrawable(wrapper.drawable)
+ }
+
+ override fun getChangingConfigurations(): Int {
+ return wrappedState.changingConfigurations
+ }
+
+ override fun canApplyTheme(): Boolean {
+ return true
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/viewmodel/ClockCarouselViewModel.kt b/src/com/android/customization/picker/clock/ui/viewmodel/ClockCarouselViewModel.kt
new file mode 100644
index 00000000..60a9e851
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/viewmodel/ClockCarouselViewModel.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.customization.picker.clock.ui.viewmodel
+
+import com.android.customization.picker.clock.domain.interactor.ClockPickerInteractor
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.mapNotNull
+
+/**
+ * Clock carousel view model that provides data for the carousel of clock previews. When there is
+ * only one item, we should show a single clock preview instead of a carousel.
+ */
+class ClockCarouselViewModel(
+ private val interactor: ClockPickerInteractor,
+) {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val allClockIds: Flow<List<String>> =
+ interactor.allClocks.mapLatest { allClocks ->
+ // Delay to avoid the case that the full list of clocks is not initiated.
+ delay(CLOCKS_EVENT_UPDATE_DELAY_MILLIS)
+ allClocks.map { it.clockId }
+ }
+
+ val seedColor: Flow<Int?> = interactor.seedColor
+
+ private val shouldShowCarousel = MutableStateFlow(false)
+ val isCarouselVisible: Flow<Boolean> =
+ combine(allClockIds.map { it.size > 1 }.distinctUntilChanged(), shouldShowCarousel) {
+ hasMoreThanOneClock,
+ shouldShowCarousel ->
+ hasMoreThanOneClock && shouldShowCarousel
+ }
+ .distinctUntilChanged()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val selectedIndex: Flow<Int> =
+ allClockIds
+ .flatMapLatest { allClockIds ->
+ interactor.selectedClockId.map { selectedClockId ->
+ val index = allClockIds.indexOf(selectedClockId)
+ if (index >= 0) {
+ index
+ } else {
+ null
+ }
+ }
+ }
+ .mapNotNull { it }
+
+ // Handle the case when there is only one clock in the carousel
+ private val shouldShowSingleClock = MutableStateFlow(false)
+ val isSingleClockViewVisible: Flow<Boolean> =
+ combine(allClockIds.map { it.size == 1 }.distinctUntilChanged(), shouldShowSingleClock) {
+ hasOneClock,
+ shouldShowSingleClock ->
+ hasOneClock && shouldShowSingleClock
+ }
+ .distinctUntilChanged()
+
+ val clockId: Flow<String> =
+ allClockIds
+ .map { allClockIds -> if (allClockIds.size == 1) allClockIds[0] else null }
+ .mapNotNull { it }
+
+ fun setSelectedClock(clockId: String) {
+ interactor.setSelectedClock(clockId)
+ }
+
+ fun showClockCarousel(shouldShow: Boolean) {
+ shouldShowCarousel.value = shouldShow
+ shouldShowSingleClock.value = shouldShow
+ }
+
+ companion object {
+ const val CLOCKS_EVENT_UPDATE_DELAY_MILLIS: Long = 100
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/viewmodel/ClockColorViewModel.kt b/src/com/android/customization/picker/clock/ui/viewmodel/ClockColorViewModel.kt
new file mode 100644
index 00000000..ea60ae39
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/viewmodel/ClockColorViewModel.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.customization.picker.clock.ui.viewmodel
+
+import android.annotation.ColorInt
+import android.content.res.Resources
+import android.graphics.Color
+import com.android.wallpaper.R
+
+/** The view model that defines custom clock colors. */
+data class ClockColorViewModel(
+ val colorId: String,
+ val colorName: String?,
+ @ColorInt val color: Int,
+ private val colorToneMin: Double,
+ private val colorToneMax: Double,
+) {
+
+ fun getColorTone(progress: Int): Double {
+ return colorToneMin + (progress.toDouble() * (colorToneMax - colorToneMin)) / 100
+ }
+
+ companion object {
+ const val DEFAULT_COLOR_TONE_MIN = 0
+ const val DEFAULT_COLOR_TONE_MAX = 100
+
+ fun getPresetColorMap(resources: Resources): Map<String, ClockColorViewModel> {
+ val ids = resources.getStringArray(R.array.clock_color_ids)
+ val names = resources.obtainTypedArray(R.array.clock_color_names)
+ val colors = resources.obtainTypedArray(R.array.clock_colors)
+ val colorToneMinList = resources.obtainTypedArray(R.array.clock_color_tone_min)
+ val colorToneMaxList = resources.obtainTypedArray(R.array.clock_color_tone_max)
+ return buildList {
+ ids.indices.forEach { index ->
+ add(
+ ClockColorViewModel(
+ ids[index],
+ names.getString(index),
+ colors.getColor(index, Color.TRANSPARENT),
+ colorToneMinList.getInt(index, DEFAULT_COLOR_TONE_MIN).toDouble(),
+ colorToneMaxList.getInt(index, DEFAULT_COLOR_TONE_MAX).toDouble(),
+ )
+ )
+ }
+ }
+ .associateBy { it.colorId }
+ .also {
+ names.recycle()
+ colors.recycle()
+ colorToneMinList.recycle()
+ colorToneMaxList.recycle()
+ }
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/viewmodel/ClockSectionViewModel.kt b/src/com/android/customization/picker/clock/ui/viewmodel/ClockSectionViewModel.kt
new file mode 100644
index 00000000..008a1252
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/viewmodel/ClockSectionViewModel.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.customization.picker.clock.ui.viewmodel
+
+import android.content.Context
+import com.android.customization.picker.clock.domain.interactor.ClockPickerInteractor
+import com.android.customization.picker.clock.shared.ClockSize
+import com.android.wallpaper.R
+import java.util.Locale
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+
+/** View model for the clock section view on the lockscreen customization surface. */
+class ClockSectionViewModel(context: Context, interactor: ClockPickerInteractor) {
+ val appContext: Context = context.applicationContext
+ val clockColorMap: Map<String, ClockColorViewModel> =
+ ClockColorViewModel.getPresetColorMap(appContext.resources)
+ val selectedClockColorAndSizeText: Flow<String> =
+ combine(interactor.selectedColorId, interactor.selectedClockSize, ::Pair).map {
+ (selectedColorId, selectedClockSize) ->
+ val colorText =
+ clockColorMap[selectedColorId]?.colorName
+ ?: context.getString(R.string.default_theme_title)
+ val sizeText =
+ when (selectedClockSize) {
+ ClockSize.SMALL -> appContext.getString(R.string.clock_size_small)
+ ClockSize.DYNAMIC -> appContext.getString(R.string.clock_size_dynamic)
+ }
+ appContext
+ .getString(R.string.clock_color_and_size_description, colorText, sizeText)
+ .lowercase()
+ .replaceFirstChar {
+ if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
+ }
+ }
+}
diff --git a/src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsTabViewModel.kt b/src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsTabViewModel.kt
new file mode 100644
index 00000000..7c30ca2b
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsTabViewModel.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.customization.picker.clock.ui.viewmodel
+
+/** View model for the tabs on the clock settings screen. */
+class ClockSettingsTabViewModel(
+ /** User-visible name for the tab. */
+ val name: String,
+
+ /** Whether this is the currently-selected tab in the picker. */
+ val isSelected: Boolean,
+
+ /** Notifies that the tab has been clicked by the user. */
+ val onClicked: (() -> Unit)?,
+)
diff --git a/src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsViewModel.kt b/src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsViewModel.kt
new file mode 100644
index 00000000..c3cd2170
--- /dev/null
+++ b/src/com/android/customization/picker/clock/ui/viewmodel/ClockSettingsViewModel.kt
@@ -0,0 +1,309 @@
+/*
+ * 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.customization.picker.clock.ui.viewmodel
+
+import android.content.Context
+import androidx.core.graphics.ColorUtils
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.android.customization.model.color.ColorBundle
+import com.android.customization.model.color.ColorSeedOption
+import com.android.customization.picker.clock.domain.interactor.ClockPickerInteractor
+import com.android.customization.picker.clock.shared.ClockSize
+import com.android.customization.picker.clock.shared.model.ClockMetadataModel
+import com.android.customization.picker.color.domain.interactor.ColorPickerInteractor
+import com.android.customization.picker.color.shared.model.ColorType
+import com.android.customization.picker.color.ui.viewmodel.ColorOptionViewModel
+import com.android.wallpaper.R
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/** View model for the clock settings screen. */
+class ClockSettingsViewModel
+private constructor(
+ context: Context,
+ private val clockPickerInteractor: ClockPickerInteractor,
+ private val colorPickerInteractor: ColorPickerInteractor,
+) : ViewModel() {
+
+ enum class Tab {
+ COLOR,
+ SIZE,
+ }
+
+ val colorMap = ClockColorViewModel.getPresetColorMap(context.resources)
+
+ val selectedClockId: StateFlow<String?> =
+ clockPickerInteractor.selectedClockId
+ .distinctUntilChanged()
+ .stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
+ val selectedColorId: StateFlow<String?> =
+ clockPickerInteractor.selectedColorId.stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
+ private val sliderColorToneProgress =
+ MutableStateFlow(ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS)
+ val isSliderEnabled: Flow<Boolean> =
+ clockPickerInteractor.selectedColorId.map { it != null }.distinctUntilChanged()
+ val sliderProgress: Flow<Int> =
+ merge(clockPickerInteractor.colorToneProgress, sliderColorToneProgress)
+
+ private val _seedColor: MutableStateFlow<Int?> = MutableStateFlow(null)
+ val seedColor: Flow<Int?> = merge(clockPickerInteractor.seedColor, _seedColor)
+
+ /**
+ * The slider color tone updates are quick. Do not set color tone and the blended color to the
+ * settings until [onSliderProgressStop] is called. Update to a locally cached temporary
+ * [sliderColorToneProgress] and [_seedColor] instead.
+ */
+ fun onSliderProgressChanged(progress: Int) {
+ sliderColorToneProgress.value = progress
+ val selectedColorId = selectedColorId.value ?: return
+ val clockColorViewModel = colorMap[selectedColorId] ?: return
+ _seedColor.value =
+ blendColorWithTone(
+ color = clockColorViewModel.color,
+ colorTone = clockColorViewModel.getColorTone(progress),
+ )
+ }
+
+ fun onSliderProgressStop(progress: Int) {
+ val selectedColorId = selectedColorId.value ?: return
+ val clockColorViewModel = colorMap[selectedColorId] ?: return
+ clockPickerInteractor.setClockColor(
+ selectedColorId = selectedColorId,
+ colorToneProgress = progress,
+ seedColor =
+ blendColorWithTone(
+ color = clockColorViewModel.color,
+ colorTone = clockColorViewModel.getColorTone(progress),
+ )
+ )
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val colorOptions: StateFlow<List<ColorOptionViewModel>> =
+ combine(colorPickerInteractor.colorOptions, clockPickerInteractor.selectedColorId, ::Pair)
+ .mapLatest { (colorOptions, selectedColorId) ->
+ // Use mapLatest and delay(100) here to prevent too many selectedClockColor update
+ // events from ClockRegistry upstream, caused by sliding the saturation level bar.
+ delay(COLOR_OPTIONS_EVENT_UPDATE_DELAY_MILLIS)
+ buildList {
+ val defaultThemeColorOptionViewModel =
+ (colorOptions[ColorType.WALLPAPER_COLOR]
+ ?.find { it.isSelected }
+ ?.colorOption as? ColorSeedOption)
+ ?.toColorOptionViewModel(
+ context,
+ selectedColorId,
+ )
+ ?: (colorOptions[ColorType.PRESET_COLOR]
+ ?.find { it.isSelected }
+ ?.colorOption as? ColorBundle)
+ ?.toColorOptionViewModel(
+ context,
+ selectedColorId,
+ )
+ if (defaultThemeColorOptionViewModel != null) {
+ add(defaultThemeColorOptionViewModel)
+ }
+
+ val selectedColorPosition = colorMap.keys.indexOf(selectedColorId)
+
+ colorMap.values.forEachIndexed { index, colorModel ->
+ val isSelected = selectedColorPosition == index
+ val colorToneProgress = ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS
+ add(
+ ColorOptionViewModel(
+ color0 = colorModel.color,
+ color1 = colorModel.color,
+ color2 = colorModel.color,
+ color3 = colorModel.color,
+ contentDescription =
+ context.getString(
+ R.string.content_description_color_option,
+ index,
+ ),
+ isSelected = isSelected,
+ onClick =
+ if (isSelected) {
+ null
+ } else {
+ {
+ clockPickerInteractor.setClockColor(
+ selectedColorId = colorModel.colorId,
+ colorToneProgress = colorToneProgress,
+ seedColor =
+ blendColorWithTone(
+ color = colorModel.color,
+ colorTone =
+ colorModel.getColorTone(
+ colorToneProgress,
+ ),
+ ),
+ )
+ }
+ },
+ )
+ )
+ }
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = emptyList(),
+ )
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val selectedColorOptionPosition: Flow<Int> =
+ colorOptions.mapLatest { it.indexOfFirst { colorOption -> colorOption.isSelected } }
+
+ private fun ColorSeedOption.toColorOptionViewModel(
+ context: Context,
+ selectedColorId: String?,
+ ): ColorOptionViewModel {
+ val colors = previewInfo.resolveColors(context.resources)
+ return ColorOptionViewModel(
+ color0 = colors[0],
+ color1 = colors[1],
+ color2 = colors[2],
+ color3 = colors[3],
+ contentDescription = getContentDescription(context).toString(),
+ title = context.getString(R.string.default_theme_title),
+ isSelected = selectedColorId == null,
+ onClick =
+ if (selectedColorId == null) {
+ null
+ } else {
+ {
+ clockPickerInteractor.setClockColor(
+ selectedColorId = null,
+ colorToneProgress = ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS,
+ seedColor = null,
+ )
+ }
+ },
+ )
+ }
+
+ private fun ColorBundle.toColorOptionViewModel(
+ context: Context,
+ selectedColorId: String?
+ ): ColorOptionViewModel {
+ val primaryColor = previewInfo.resolvePrimaryColor(context.resources)
+ val secondaryColor = previewInfo.resolveSecondaryColor(context.resources)
+ return ColorOptionViewModel(
+ color0 = primaryColor,
+ color1 = secondaryColor,
+ color2 = primaryColor,
+ color3 = secondaryColor,
+ contentDescription = getContentDescription(context).toString(),
+ title = context.getString(R.string.default_theme_title),
+ isSelected = selectedColorId == null,
+ onClick =
+ if (selectedColorId == null) {
+ null
+ } else {
+ {
+ clockPickerInteractor.setClockColor(
+ selectedColorId = null,
+ colorToneProgress = ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS,
+ seedColor = null,
+ )
+ }
+ },
+ )
+ }
+
+ val selectedClockSize: Flow<ClockSize> = clockPickerInteractor.selectedClockSize
+
+ fun setClockSize(size: ClockSize) {
+ viewModelScope.launch { clockPickerInteractor.setClockSize(size) }
+ }
+
+ private val _selectedTabPosition = MutableStateFlow(Tab.COLOR)
+ val selectedTab: StateFlow<Tab> = _selectedTabPosition.asStateFlow()
+ val tabs: Flow<List<ClockSettingsTabViewModel>> =
+ selectedTab.map {
+ listOf(
+ ClockSettingsTabViewModel(
+ name = context.resources.getString(R.string.clock_color),
+ isSelected = it == Tab.COLOR,
+ onClicked =
+ if (it == Tab.COLOR) {
+ null
+ } else {
+ { _selectedTabPosition.tryEmit(Tab.COLOR) }
+ }
+ ),
+ ClockSettingsTabViewModel(
+ name = context.resources.getString(R.string.clock_size),
+ isSelected = it == Tab.SIZE,
+ onClicked =
+ if (it == Tab.SIZE) {
+ null
+ } else {
+ { _selectedTabPosition.tryEmit(Tab.SIZE) }
+ }
+ ),
+ )
+ }
+
+ companion object {
+ private val helperColorLab: DoubleArray by lazy { DoubleArray(3) }
+
+ fun blendColorWithTone(color: Int, colorTone: Double): Int {
+ ColorUtils.colorToLAB(color, helperColorLab)
+ return ColorUtils.LABToColor(
+ colorTone,
+ helperColorLab[1],
+ helperColorLab[2],
+ )
+ }
+
+ const val COLOR_OPTIONS_EVENT_UPDATE_DELAY_MILLIS: Long = 100
+ }
+
+ class Factory(
+ private val context: Context,
+ private val clockPickerInteractor: ClockPickerInteractor,
+ private val colorPickerInteractor: ColorPickerInteractor,
+ ) : ViewModelProvider.Factory {
+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
+ @Suppress("UNCHECKED_CAST")
+ return ClockSettingsViewModel(
+ context = context,
+ clockPickerInteractor = clockPickerInteractor,
+ colorPickerInteractor = colorPickerInteractor,
+ )
+ as T
+ }
+ }
+}