diff options
Diffstat (limited to 'src/com/android/customization/picker/clock/ui')
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 + } + } +} |
