diff options
| author | George Zacharia <george.zcharia@gmail.com> | 2023-07-02 14:33:47 +0530 |
|---|---|---|
| committer | George Zacharia <george.zcharia@gmail.com> | 2023-07-02 14:33:47 +0530 |
| commit | 913b11dfd2b52e445c773838c766f0d4f8ba0d05 (patch) | |
| tree | adb07f584833593bad6fca5495927c276ceef531 /src/com/android/customization/picker/clock | |
| parent | b2d9a4961b3804f79c151630421d480846fd0176 (diff) | |
| parent | cc6f666d7c0bc3b6927f6e9e3c7e46123be6263d (diff) | |
Merge tag 'android-13.0.0_r52' of https://android.googlesource.com/platform/packages/apps/ThemePicker into HEADHEADt13.0
Android 13.0.0 Release 52 (TQ3A.230605.012)
Change-Id: I2cea11fa2f1f02fbd3c9d21cfc1697a79d42a5b7
Diffstat (limited to 'src/com/android/customization/picker/clock')
28 files changed, 2067 insertions, 561 deletions
diff --git a/src/com/android/customization/picker/clock/ClockCustomDemoFragment.kt b/src/com/android/customization/picker/clock/ClockCustomDemoFragment.kt deleted file mode 100644 index 8648dca8..00000000 --- a/src/com/android/customization/picker/clock/ClockCustomDemoFragment.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.android.customization.picker.clock - -import android.app.NotificationManager -import android.content.ComponentName -import android.content.Context -import android.os.Bundle -import android.os.Handler -import android.os.UserHandle -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 androidx.core.view.setPadding -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.android.internal.annotations.VisibleForTesting -import com.android.systemui.plugins.ClockMetadata -import com.android.systemui.plugins.ClockProviderPlugin -import com.android.systemui.plugins.Plugin -import com.android.systemui.plugins.PluginListener -import com.android.systemui.plugins.PluginManager -import com.android.systemui.shared.clocks.ClockRegistry -import com.android.systemui.shared.clocks.DefaultClockProvider -import com.android.systemui.shared.plugins.PluginActionManager -import com.android.systemui.shared.plugins.PluginEnabler -import com.android.systemui.shared.plugins.PluginEnabler.ENABLED -import com.android.systemui.shared.plugins.PluginInstance -import com.android.systemui.shared.plugins.PluginManagerImpl -import com.android.systemui.shared.plugins.PluginPrefs -import com.android.systemui.shared.system.UncaughtExceptionPreHandlerManager_Factory -import com.android.wallpaper.R -import com.android.wallpaper.picker.AppbarFragment -import java.util.concurrent.Executors - -private val TAG = ClockCustomDemoFragment::class.simpleName - -class ClockCustomDemoFragment : AppbarFragment() { - @VisibleForTesting lateinit var clockRegistry: ClockRegistry - val isDebugDevice = true - val privilegedPlugins = listOf<String>() - val action = ClockProviderPlugin.ACTION - lateinit var view: ViewGroup - @VisibleForTesting lateinit var recyclerView: RecyclerView - lateinit var pluginManager: PluginManager - @VisibleForTesting - val pluginListener = - object : PluginListener<ClockProviderPlugin> { - override fun onPluginConnected(plugin: ClockProviderPlugin, context: Context) { - val listInUse = clockRegistry.getClocks().filter { "NOT_IN_USE" !in it.clockId } - recyclerView.adapter = ClockRecyclerAdapter(listInUse, context, clockRegistry) - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - val defaultClockProvider = - DefaultClockProvider(context, LayoutInflater.from(context), context.resources) - pluginManager = createPluginManager(context) - clockRegistry = - ClockRegistry( - context, - pluginManager, - Handler.getMain(), - isEnabled = true, - userHandle = UserHandle.USER_OWNER, - defaultClockProvider - ) - pluginManager.addPluginListener(pluginListener, ClockProviderPlugin::class.java, true) - } - - 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) - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - recyclerView = view.requireViewById(R.id.clock_preview_card_list_demo) - recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - super.onViewCreated(view, savedInstanceState) - } - - override fun getDefaultTitle(): CharSequence { - return getString(R.string.clock_title) - } - - private fun createPluginManager(context: Context): PluginManager { - val instanceFactory = - PluginInstance.Factory( - this::class.java.classLoader, - PluginInstance.InstanceFactory<Plugin>(), - PluginInstance.VersionChecker(), - privilegedPlugins, - isDebugDevice - ) - - /* - * let SystemUI handle plugin, in this class assume plugins are enabled - */ - val pluginEnabler = - object : PluginEnabler { - override fun setEnabled(component: ComponentName) {} - - override fun setDisabled( - component: ComponentName, - @PluginEnabler.DisableReason reason: Int - ) {} - - override fun isEnabled(component: ComponentName): Boolean { - return true - } - - @PluginEnabler.DisableReason - override fun getDisableReason(componentName: ComponentName): Int { - return ENABLED - } - } - - val pluginActionManager = - PluginActionManager.Factory( - context, - context.packageManager, - context.mainExecutor, - Executors.newSingleThreadExecutor(), - context.getSystemService(NotificationManager::class.java), - pluginEnabler, - privilegedPlugins, - instanceFactory - ) - return PluginManagerImpl( - context, - pluginActionManager, - isDebugDevice, - uncaughtExceptionPreHandlerManager, - pluginEnabler, - PluginPrefs(context), - listOf() - ) - } - - companion object { - private val uncaughtExceptionPreHandlerManager = - UncaughtExceptionPreHandlerManager_Factory.create().get() - } - - internal class ClockRecyclerAdapter( - val list: List<ClockMetadata>, - val context: Context, - val clockRegistry: ClockRegistry - ) : 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 lp = RecyclerView.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - rootView.setLayoutParams(lp) - return ViewHolder( - rootView, - textView, - { clockRegistry.currentClockId = list[it].clockId } - ) - } - - 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/ClockCustomFragment.java b/src/com/android/customization/picker/clock/ClockCustomFragment.java deleted file mode 100644 index 56860fea..00000000 --- a/src/com/android/customization/picker/clock/ClockCustomFragment.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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; - -import static com.android.wallpaper.widget.BottomActionBar.BottomAction.APPLY; -import static com.android.wallpaper.widget.BottomActionBar.BottomAction.INFORMATION; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.android.customization.model.clock.custom.ClockCustomManager; -import com.android.customization.model.clock.custom.ClockOption; -import com.android.customization.widget.OptionSelectorController; -import com.android.wallpaper.R; -import com.android.wallpaper.picker.AppbarFragment; -import com.android.wallpaper.widget.BottomActionBar; - -import com.google.common.collect.Lists; - -/** - * Fragment that contains the main UI for selecting and applying a custom clock. - */ -public class ClockCustomFragment extends AppbarFragment { - - OptionSelectorController<ClockOption> mClockSelectorController; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_clock_custom_picker, container, false); - - setUpToolbar(view); - - RecyclerView clockPreviewCardList = view.requireViewById(R.id.clock_preview_card_list); - - mClockSelectorController = new OptionSelectorController<>(clockPreviewCardList, - Lists.newArrayList(new ClockOption(), new ClockOption(), new ClockOption(), - new ClockOption(), new ClockOption()), false, - OptionSelectorController.CheckmarkStyle.CENTER_CHANGE_COLOR_WHEN_NOT_SELECTED); - mClockSelectorController.initOptions(new ClockCustomManager()); - - return view; - } - - @Override - public CharSequence getDefaultTitle() { - return getString(R.string.clock_title); - } - - @Override - protected void onBottomActionBarReady(BottomActionBar bottomActionBar) { - super.onBottomActionBarReady(bottomActionBar); - bottomActionBar.showActionsOnly(INFORMATION, APPLY); - bottomActionBar.show(); - } -} diff --git a/src/com/android/customization/picker/clock/ClockFacePickerActivity.java b/src/com/android/customization/picker/clock/ClockFacePickerActivity.java deleted file mode 100644 index 5e512341..00000000 --- a/src/com/android/customization/picker/clock/ClockFacePickerActivity.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (C) 2019 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; - -import android.content.Intent; -import android.os.Bundle; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; -import com.android.customization.model.clock.BaseClockManager; -import com.android.customization.model.clock.Clockface; -import com.android.customization.model.clock.ContentProviderClockProvider; -import com.android.customization.picker.clock.ClockFragment.ClockFragmentHost; -import com.android.wallpaper.R; - -/** - * Activity allowing for the clock face picker to be linked to from other setup flows. - * - * This should be used with startActivityForResult. The resulting intent contains an extra - * "clock_face_name" with the id of the picked clock face. - */ -public class ClockFacePickerActivity extends FragmentActivity implements ClockFragmentHost { - - private static final String EXTRA_CLOCK_FACE_NAME = "clock_face_name"; - - private BaseClockManager mClockManager; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_clock_face_picker); - - // Creating a class that overrides {@link ClockManager#apply} to return the clock id to the - // calling activity instead of putting the value into settings. - // - mClockManager = new BaseClockManager( - new ContentProviderClockProvider(ClockFacePickerActivity.this)) { - - @Override - protected void handleApply(Clockface option, Callback callback) { - Intent result = new Intent(); - result.putExtra(EXTRA_CLOCK_FACE_NAME, option.getId()); - setResult(RESULT_OK, result); - callback.onSuccess(); - finish(); - } - - @Override - protected String lookUpCurrentClock() { - return getIntent().getStringExtra(EXTRA_CLOCK_FACE_NAME); - } - }; - if (!mClockManager.isAvailable()) { - finish(); - } else { - final FragmentManager fm = getSupportFragmentManager(); - final FragmentTransaction fragmentTransaction = fm.beginTransaction(); - final ClockFragment clockFragment = ClockFragment.newInstance( - getString(R.string.clock_title)); - fragmentTransaction.replace(R.id.fragment_container, clockFragment); - fragmentTransaction.commitNow(); - } - } - - @Override - public BaseClockManager getClockManager() { - return mClockManager; - } -} diff --git a/src/com/android/customization/picker/clock/ClockFragment.java b/src/com/android/customization/picker/clock/ClockFragment.java deleted file mode 100644 index bc02ae34..00000000 --- a/src/com/android/customization/picker/clock/ClockFragment.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright (C) 2019 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; - -import android.app.Activity; -import android.content.Context; -import android.content.res.Resources; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.widget.ContentLoadingProgressBar; -import androidx.recyclerview.widget.RecyclerView; - -import com.android.customization.model.CustomizationManager.Callback; -import com.android.customization.model.CustomizationManager.OptionsFetchedListener; -import com.android.customization.model.clock.BaseClockManager; -import com.android.customization.model.clock.Clockface; -import com.android.customization.module.ThemesUserEventLogger; -import com.android.customization.picker.BasePreviewAdapter; -import com.android.customization.picker.BasePreviewAdapter.PreviewPage; -import com.android.customization.widget.OptionSelectorController; -import com.android.wallpaper.R; -import com.android.wallpaper.asset.Asset; -import com.android.wallpaper.module.InjectorProvider; -import com.android.wallpaper.picker.AppbarFragment; -import com.android.wallpaper.widget.PreviewPager; - -import java.util.List; - -/** - * Fragment that contains the main UI for selecting and applying a Clockface. - */ -public class ClockFragment extends AppbarFragment { - - private static final String TAG = "ClockFragment"; - - /** - * Interface to be implemented by an Activity hosting a {@link ClockFragment} - */ - public interface ClockFragmentHost { - BaseClockManager getClockManager(); - } - - public static ClockFragment newInstance(CharSequence title) { - ClockFragment fragment = new ClockFragment(); - fragment.setArguments(AppbarFragment.createArguments(title)); - return fragment; - } - - private RecyclerView mOptionsContainer; - private OptionSelectorController<Clockface> mOptionsController; - private Clockface mSelectedOption; - private BaseClockManager mClockManager; - private PreviewPager mPreviewPager; - private ContentLoadingProgressBar mLoading; - private View mContent; - private View mError; - private ThemesUserEventLogger mEventLogger; - - @Override - public void onAttach(Context context) { - super.onAttach(context); - mClockManager = ((ClockFragmentHost) context).getClockManager(); - mEventLogger = (ThemesUserEventLogger) - InjectorProvider.getInjector().getUserEventLogger(context); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View view = inflater.inflate( - R.layout.fragment_clock_picker, container, /* attachToRoot */ false); - setUpToolbar(view); - mContent = view.findViewById(R.id.content_section); - mPreviewPager = view.findViewById(R.id.clock_preview_pager); - mOptionsContainer = view.findViewById(R.id.options_container); - mLoading = view.findViewById(R.id.loading_indicator); - mError = view.findViewById(R.id.error_section); - setUpOptions(); - view.findViewById(R.id.apply_button).setOnClickListener(v -> { - mClockManager.apply(mSelectedOption, new Callback() { - @Override - public void onSuccess() { - mOptionsController.setAppliedOption(mSelectedOption); - Toast.makeText(getContext(), R.string.applied_clock_msg, - Toast.LENGTH_SHORT).show(); - } - - @Override - public void onError(@Nullable Throwable throwable) { - if (throwable != null) { - Log.e(TAG, "Error loading clockfaces", throwable); - } - //TODO(santie): handle - } - }); - - }); - return view; - } - - private void createAdapter() { - mPreviewPager.setAdapter(new ClockPreviewAdapter(getActivity(), mSelectedOption)); - } - - private void setUpOptions() { - hideError(); - mLoading.show(); - mClockManager.fetchOptions(new OptionsFetchedListener<Clockface>() { - @Override - public void onOptionsLoaded(List<Clockface> options) { - mLoading.hide(); - mOptionsController = new OptionSelectorController<>(mOptionsContainer, options); - - mOptionsController.addListener(selected -> { - mSelectedOption = (Clockface) selected; - mEventLogger.logClockSelected(mSelectedOption); - createAdapter(); - }); - mOptionsController.initOptions(mClockManager); - for (Clockface option : options) { - if (option.isActive(mClockManager)) { - mSelectedOption = option; - } - } - // For development only, as there should always be a grid set. - if (mSelectedOption == null) { - mSelectedOption = options.get(0); - } - createAdapter(); - } - @Override - public void onError(@Nullable Throwable throwable) { - if (throwable != null) { - Log.e(TAG, "Error loading clockfaces", throwable); - } - showError(); - } - }, false); - } - - private void hideError() { - mContent.setVisibility(View.VISIBLE); - mError.setVisibility(View.GONE); - } - - private void showError() { - mLoading.hide(); - mContent.setVisibility(View.GONE); - mError.setVisibility(View.VISIBLE); - } - - private static class ClockfacePreviewPage extends PreviewPage { - - private final Asset mPreviewAsset; - - public ClockfacePreviewPage(String title, Activity activity, Asset previewAsset) { - super(title, activity); - mPreviewAsset = previewAsset; - } - - @Override - public void bindPreviewContent() { - ImageView previewImage = card.findViewById(R.id.clock_preview_image); - Context context = previewImage.getContext(); - Resources res = previewImage.getResources(); - mPreviewAsset.loadDrawableWithTransition(context, previewImage, - 100 /* transitionDurationMillis */, - null /* drawableLoadedListener */, - res.getColor(android.R.color.transparent, null) /* placeholderColor */); - card.setContentDescription(card.getResources().getString( - R.string.clock_preview_content_description, title)); - } - } - - /** - * Adapter class for mPreviewPager. - * This is a ViewPager as it allows for a nice pagination effect (ie, pages snap on swipe, - * we don't want to just scroll) - */ - private static class ClockPreviewAdapter extends BasePreviewAdapter<ClockfacePreviewPage> { - ClockPreviewAdapter(Activity activity, Clockface clockface) { - super(activity, R.layout.clock_preview_card); - addPage(new ClockfacePreviewPage( - clockface.getTitle(), activity , clockface.getPreviewAsset())); - } - } -} diff --git a/src/com/android/customization/picker/clock/data/repository/ClockPickerRepository.kt b/src/com/android/customization/picker/clock/data/repository/ClockPickerRepository.kt new file mode 100644 index 00000000..ae66ce3d --- /dev/null +++ b/src/com/android/customization/picker/clock/data/repository/ClockPickerRepository.kt @@ -0,0 +1,52 @@ +/* + * 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.data.repository + +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import com.android.customization.picker.clock.shared.ClockSize +import com.android.customization.picker.clock.shared.model.ClockMetadataModel +import kotlinx.coroutines.flow.Flow + +/** + * Repository for accessing application clock settings, as well as selecting and configuring custom + * clocks. + */ +interface ClockPickerRepository { + val allClocks: Flow<List<ClockMetadataModel>> + + val selectedClock: Flow<ClockMetadataModel> + + val selectedClockSize: Flow<ClockSize> + + fun setSelectedClock(clockId: String) + + /** + * Set clock color to the settings. + * + * @param selectedColor selected color in the color option list. + * @param colorToneProgress color tone from 0 to 100 to apply to the selected color + * @param seedColor the actual clock color after blending the selected color and color tone + */ + fun setClockColor( + selectedColorId: String?, + @IntRange(from = 0, to = 100) colorToneProgress: Int, + @ColorInt seedColor: Int?, + ) + + suspend fun setClockSize(size: ClockSize) +} diff --git a/src/com/android/customization/picker/clock/data/repository/ClockPickerRepositoryImpl.kt b/src/com/android/customization/picker/clock/data/repository/ClockPickerRepositoryImpl.kt new file mode 100644 index 00000000..880a00be --- /dev/null +++ b/src/com/android/customization/picker/clock/data/repository/ClockPickerRepositoryImpl.kt @@ -0,0 +1,193 @@ +/* + * 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.data.repository + +import android.provider.Settings +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import com.android.customization.picker.clock.shared.ClockSize +import com.android.customization.picker.clock.shared.model.ClockMetadataModel +import com.android.systemui.plugins.ClockMetadata +import com.android.systemui.shared.clocks.ClockRegistry +import com.android.wallpaper.settings.data.repository.SecureSettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.shareIn +import org.json.JSONObject + +/** Implementation of [ClockPickerRepository], using [ClockRegistry]. */ +class ClockPickerRepositoryImpl( + private val secureSettingsRepository: SecureSettingsRepository, + private val registry: ClockRegistry, + scope: CoroutineScope, +) : ClockPickerRepository { + + @OptIn(ExperimentalCoroutinesApi::class) + override val allClocks: Flow<List<ClockMetadataModel>> = + callbackFlow { + fun send() { + val allClocks = + registry + .getClocks() + .filter { "NOT_IN_USE" !in it.clockId } + .map { it.toModel() } + trySend(allClocks) + } + + val listener = + object : ClockRegistry.ClockChangeListener { + override fun onAvailableClocksChanged() { + send() + } + } + registry.registerClockChangeListener(listener) + send() + awaitClose { registry.unregisterClockChangeListener(listener) } + } + .mapLatest { allClocks -> + // Loading list of clock plugins can cause many consecutive calls of + // onAvailableClocksChanged(). We only care about the final fully-initiated clock + // list. Delay to avoid unnecessary too many emits. + delay(100) + allClocks + } + + /** The currently-selected clock. This also emits the clock color information. */ + override val selectedClock: Flow<ClockMetadataModel> = + callbackFlow { + fun send() { + val currentClockId = registry.currentClockId + val metadata = registry.settings?.metadata + val model = + registry + .getClocks() + .find { clockMetadata -> clockMetadata.clockId == currentClockId } + ?.toModel( + selectedColorId = metadata?.getSelectedColorId(), + colorTone = metadata?.getColorTone() + ?: ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS, + seedColor = registry.seedColor + ) + trySend(model) + } + + val listener = + object : ClockRegistry.ClockChangeListener { + override fun onCurrentClockChanged() { + send() + } + + override fun onAvailableClocksChanged() { + send() + } + } + registry.registerClockChangeListener(listener) + send() + awaitClose { registry.unregisterClockChangeListener(listener) } + } + .mapNotNull { it } + + override fun setSelectedClock(clockId: String) { + registry.mutateSetting { oldSettings -> + val newSettings = oldSettings.copy(clockId = clockId) + newSettings.metadata = oldSettings.metadata + newSettings + } + } + + override fun setClockColor( + selectedColorId: String?, + @IntRange(from = 0, to = 100) colorToneProgress: Int, + @ColorInt seedColor: Int?, + ) { + registry.mutateSetting { oldSettings -> + val newSettings = oldSettings.copy(seedColor = seedColor) + newSettings.metadata = + oldSettings.metadata + .put(KEY_METADATA_SELECTED_COLOR_ID, selectedColorId) + .put(KEY_METADATA_COLOR_TONE_PROGRESS, colorToneProgress) + newSettings + } + } + + override val selectedClockSize: SharedFlow<ClockSize> = + secureSettingsRepository + .intSetting( + name = Settings.Secure.LOCKSCREEN_USE_DOUBLE_LINE_CLOCK, + ) + .map { setting -> setting == 1 } + .map { isDynamic -> if (isDynamic) ClockSize.DYNAMIC else ClockSize.SMALL } + .shareIn( + scope = scope, + started = SharingStarted.WhileSubscribed(), + replay = 1, + ) + + override suspend fun setClockSize(size: ClockSize) { + secureSettingsRepository.set( + name = Settings.Secure.LOCKSCREEN_USE_DOUBLE_LINE_CLOCK, + value = if (size == ClockSize.DYNAMIC) 1 else 0, + ) + } + + private fun JSONObject.getSelectedColorId(): String? { + return if (this.isNull(KEY_METADATA_SELECTED_COLOR_ID)) { + null + } else { + this.getString(KEY_METADATA_SELECTED_COLOR_ID) + } + } + + private fun JSONObject.getColorTone(): Int { + return this.optInt( + KEY_METADATA_COLOR_TONE_PROGRESS, + ClockMetadataModel.DEFAULT_COLOR_TONE_PROGRESS + ) + } + + /** By default, [ClockMetadataModel] has no color information unless specified. */ + private fun ClockMetadata.toModel( + selectedColorId: String? = null, + @IntRange(from = 0, to = 100) colorTone: Int = 0, + @ColorInt seedColor: Int? = null, + ): ClockMetadataModel { + return ClockMetadataModel( + clockId = clockId, + name = name, + selectedColorId = selectedColorId, + colorToneProgress = colorTone, + seedColor = seedColor, + ) + } + + companion object { + // The selected color in the color option list + private const val KEY_METADATA_SELECTED_COLOR_ID = "metadataSelectedColorId" + + // The color tone to apply to the selected color + private const val KEY_METADATA_COLOR_TONE_PROGRESS = "metadataColorToneProgress" + } +} diff --git a/src/com/android/customization/picker/clock/data/repository/ClockRegistryProvider.kt b/src/com/android/customization/picker/clock/data/repository/ClockRegistryProvider.kt new file mode 100644 index 00000000..bfe87c9c --- /dev/null +++ b/src/com/android/customization/picker/clock/data/repository/ClockRegistryProvider.kt @@ -0,0 +1,121 @@ +/* + * 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.data.repository + +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Context +import android.view.LayoutInflater +import com.android.systemui.plugins.Plugin +import com.android.systemui.plugins.PluginManager +import com.android.systemui.shared.clocks.ClockRegistry +import com.android.systemui.shared.clocks.DefaultClockProvider +import com.android.systemui.shared.plugins.PluginActionManager +import com.android.systemui.shared.plugins.PluginEnabler +import com.android.systemui.shared.plugins.PluginInstance +import com.android.systemui.shared.plugins.PluginManagerImpl +import com.android.systemui.shared.plugins.PluginPrefs +import com.android.systemui.shared.system.UncaughtExceptionPreHandlerManager_Factory +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope + +/** + * Provide the [ClockRegistry] singleton. Note that we need to make sure that the [PluginManager] + * needs to be connected before [ClockRegistry] is ready to use. + */ +class ClockRegistryProvider( + private val context: Context, + private val coroutineScope: CoroutineScope, + private val mainDispatcher: CoroutineDispatcher, + private val backgroundDispatcher: CoroutineDispatcher, +) { + private val pluginManager: PluginManager by lazy { createPluginManager(context) } + private val clockRegistry: ClockRegistry by lazy { + ClockRegistry( + context, + pluginManager, + coroutineScope, + mainDispatcher, + backgroundDispatcher, + isEnabled = true, + handleAllUsers = false, + DefaultClockProvider(context, LayoutInflater.from(context), context.resources) + ) + .apply { registerListeners() } + } + + fun get(): ClockRegistry { + return clockRegistry + } + + private fun createPluginManager(context: Context): PluginManager { + val privilegedPlugins = listOf<String>() + val isDebugDevice = true + + val instanceFactory = + PluginInstance.Factory( + this::class.java.classLoader, + PluginInstance.InstanceFactory<Plugin>(), + PluginInstance.VersionChecker(), + privilegedPlugins, + isDebugDevice, + ) + + /* + * let SystemUI handle plugin, in this class assume plugins are enabled + */ + val pluginEnabler = + object : PluginEnabler { + override fun setEnabled(component: ComponentName) = Unit + + override fun setDisabled( + component: ComponentName, + @PluginEnabler.DisableReason reason: Int + ) = Unit + + override fun isEnabled(component: ComponentName): Boolean { + return true + } + + @PluginEnabler.DisableReason + override fun getDisableReason(componentName: ComponentName): Int { + return PluginEnabler.ENABLED + } + } + + val pluginActionManager = + PluginActionManager.Factory( + context, + context.packageManager, + context.mainExecutor, + Executors.newSingleThreadExecutor(), + context.getSystemService(NotificationManager::class.java), + pluginEnabler, + privilegedPlugins, + instanceFactory, + ) + return PluginManagerImpl( + context, + pluginActionManager, + isDebugDevice, + UncaughtExceptionPreHandlerManager_Factory.create().get(), + pluginEnabler, + PluginPrefs(context), + listOf(), + ) + } +} diff --git a/src/com/android/customization/picker/clock/domain/interactor/ClockPickerInteractor.kt b/src/com/android/customization/picker/clock/domain/interactor/ClockPickerInteractor.kt new file mode 100644 index 00000000..6f3657ab --- /dev/null +++ b/src/com/android/customization/picker/clock/domain/interactor/ClockPickerInteractor.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.customization.picker.clock.domain.interactor + +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import com.android.customization.picker.clock.data.repository.ClockPickerRepository +import com.android.customization.picker.clock.shared.ClockSize +import com.android.customization.picker.clock.shared.model.ClockMetadataModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Interactor for accessing application clock settings, as well as selecting and configuring custom + * clocks. + */ +class ClockPickerInteractor(private val repository: ClockPickerRepository) { + + val allClocks: Flow<List<ClockMetadataModel>> = repository.allClocks + + val selectedClockId: Flow<String> = + repository.selectedClock.map { clock -> clock.clockId }.distinctUntilChanged() + + val selectedColorId: Flow<String?> = + repository.selectedClock.map { clock -> clock.selectedColorId }.distinctUntilChanged() + + val colorToneProgress: Flow<Int> = + repository.selectedClock.map { clock -> clock.colorToneProgress } + + val seedColor: Flow<Int?> = repository.selectedClock.map { clock -> clock.seedColor } + + val selectedClockSize: Flow<ClockSize> = repository.selectedClockSize + + fun setSelectedClock(clockId: String) { + repository.setSelectedClock(clockId) + } + + fun setClockColor( + selectedColorId: String?, + @IntRange(from = 0, to = 100) colorToneProgress: Int, + @ColorInt seedColor: Int?, + ) { + repository.setClockColor(selectedColorId, colorToneProgress, seedColor) + } + + suspend fun setClockSize(size: ClockSize) { + repository.setClockSize(size) + } +} diff --git a/src/com/android/customization/picker/clock/domain/interactor/ClocksSnapshotRestorer.kt b/src/com/android/customization/picker/clock/domain/interactor/ClocksSnapshotRestorer.kt index 63b4a9ba..7bb3232b 100644 --- a/src/com/android/customization/picker/clock/domain/interactor/ClocksSnapshotRestorer.kt +++ b/src/com/android/customization/picker/clock/domain/interactor/ClocksSnapshotRestorer.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * 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. @@ -18,12 +18,13 @@ package com.android.customization.picker.clock.domain.interactor import com.android.wallpaper.picker.undo.domain.interactor.SnapshotRestorer +import com.android.wallpaper.picker.undo.domain.interactor.SnapshotStore import com.android.wallpaper.picker.undo.shared.model.RestorableSnapshot /** Handles state restoration for clocks. */ class ClocksSnapshotRestorer : SnapshotRestorer { override suspend fun setUpSnapshotRestorer( - updater: (RestorableSnapshot) -> Unit, + store: SnapshotStore, ): RestorableSnapshot { // TODO(b/262924055): implement as part of the clock settings screen. return RestorableSnapshot(mapOf()) diff --git a/src/com/android/customization/picker/clock/shared/ClockSize.kt b/src/com/android/customization/picker/clock/shared/ClockSize.kt new file mode 100644 index 00000000..279ee54b --- /dev/null +++ b/src/com/android/customization/picker/clock/shared/ClockSize.kt @@ -0,0 +1,22 @@ +/* + * 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.shared + +enum class ClockSize { + DYNAMIC, + SMALL, +} diff --git a/src/com/android/customization/picker/clock/shared/model/ClockMetadataModel.kt b/src/com/android/customization/picker/clock/shared/model/ClockMetadataModel.kt new file mode 100644 index 00000000..bd87ba6e --- /dev/null +++ b/src/com/android/customization/picker/clock/shared/model/ClockMetadataModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.customization.picker.clock.shared.model + +import androidx.annotation.ColorInt +import androidx.annotation.IntRange + +/** Model for clock metadata. */ +data class ClockMetadataModel( + val clockId: String, + val name: String, + val selectedColorId: String?, + @IntRange(from = 0, to = 100) val colorToneProgress: Int, + @ColorInt val seedColor: Int?, +) { + companion object { + const val DEFAULT_COLOR_TONE_PROGRESS = 75 + } +} 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/ClockSectionView.kt b/src/com/android/customization/picker/clock/ui/view/ClockSectionView.kt index fac975ab..cca107c4 100644 --- a/src/com/android/customization/picker/clock/ClockSectionView.kt +++ b/src/com/android/customization/picker/clock/ui/view/ClockSectionView.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.customization.picker.clock +package com.android.customization.picker.clock.ui.view import android.content.Context import android.util.AttributeSet 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 + } + } +} |
