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 | |
| 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')
79 files changed, 4789 insertions, 997 deletions
diff --git a/src/com/android/customization/picker/HorizontalTouchMovementAwareNestedScrollView.kt b/src/com/android/customization/picker/HorizontalTouchMovementAwareNestedScrollView.kt new file mode 100644 index 00000000..06cf7539 --- /dev/null +++ b/src/com/android/customization/picker/HorizontalTouchMovementAwareNestedScrollView.kt @@ -0,0 +1,64 @@ +/* + * 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 + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.core.widget.NestedScrollView +import kotlin.math.abs + +/** + * This nested scroll view will detect horizontal touch movements and stop vertical scrolls when a + * horizontal touch movement is detected. + */ +class HorizontalTouchMovementAwareNestedScrollView(context: Context, attrs: AttributeSet?) : + NestedScrollView(context, attrs) { + + private var startXPosition = 0f + private var startYPosition = 0f + private var isHorizontalTouchMovement = false + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startXPosition = event.x + startYPosition = event.y + isHorizontalTouchMovement = false + } + MotionEvent.ACTION_MOVE -> { + val xMoveDistance = abs(event.x - startXPosition) + val yMoveDistance = abs(event.y - startYPosition) + if ( + !isHorizontalTouchMovement && + xMoveDistance > yMoveDistance && + xMoveDistance > ViewConfiguration.get(context).scaledTouchSlop + ) { + isHorizontalTouchMovement = true + } + } + else -> {} + } + return if (isHorizontalTouchMovement) { + // We only want to intercept the touch event when the touch moves more vertically than + // horizontally. So we return false. + false + } else { + super.onInterceptTouchEvent(event) + } + } +} diff --git a/src/com/android/customization/picker/WallpaperPreviewer.java b/src/com/android/customization/picker/WallpaperPreviewer.java index 354eec26..1b9ea9fc 100644 --- a/src/com/android/customization/picker/WallpaperPreviewer.java +++ b/src/com/android/customization/picker/WallpaperPreviewer.java @@ -19,6 +19,8 @@ import android.app.Activity; import android.app.WallpaperColors; import android.content.Intent; import android.graphics.Rect; +import android.graphics.RenderEffect; +import android.graphics.Shader.TileMode; import android.service.wallpaper.WallpaperService; import android.view.Surface; import android.view.SurfaceView; @@ -38,6 +40,7 @@ import com.android.wallpaper.model.WallpaperInfo; import com.android.wallpaper.util.ResourceUtils; import com.android.wallpaper.util.ScreenSizeCalculator; import com.android.wallpaper.util.SizeCalculator; +import com.android.wallpaper.util.VideoWallpaperUtils; import com.android.wallpaper.util.WallpaperConnection; import com.android.wallpaper.util.WallpaperConnection.WallpaperConnectionListener; import com.android.wallpaper.util.WallpaperSurfaceCallback; @@ -53,6 +56,7 @@ public class WallpaperPreviewer implements LifecycleObserver { private final Activity mActivity; private final ImageView mHomePreview; private final SurfaceView mWallpaperSurface; + @Nullable private final ImageView mFadeInScrim; private WallpaperSurfaceCallback mWallpaperSurfaceCallback; private WallpaperInfo mWallpaper; @@ -67,11 +71,17 @@ public class WallpaperPreviewer implements LifecycleObserver { public WallpaperPreviewer(Lifecycle lifecycle, Activity activity, ImageView homePreview, SurfaceView wallpaperSurface) { + this(lifecycle, activity, homePreview, wallpaperSurface, null); + } + + public WallpaperPreviewer(Lifecycle lifecycle, Activity activity, ImageView homePreview, + SurfaceView wallpaperSurface, @Nullable ImageView fadeInScrim) { lifecycle.addObserver(this); mActivity = activity; mHomePreview = homePreview; mWallpaperSurface = wallpaperSurface; + mFadeInScrim = fadeInScrim; mWallpaperSurfaceCallback = new WallpaperSurfaceCallback(activity, mHomePreview, mWallpaperSurface, this::setUpWallpaperPreview); mWallpaperSurface.setZOrderMediaOverlay(true); @@ -139,6 +149,11 @@ public class WallpaperPreviewer implements LifecycleObserver { @Nullable WallpaperColorsListener listener) { mWallpaper = wallpaperInfo; mWallpaperColorsListener = listener; + if (mFadeInScrim != null && VideoWallpaperUtils.needsFadeIn(wallpaperInfo)) { + mFadeInScrim.animate().cancel(); + mFadeInScrim.setAlpha(1f); + mFadeInScrim.setVisibility(View.VISIBLE); + } setUpWallpaperPreview(); } @@ -157,10 +172,16 @@ public class WallpaperPreviewer implements LifecycleObserver { mActivity, android.R.attr.colorSecondary), /* offsetToStart= */ true); if (mWallpaper instanceof LiveWallpaperInfo) { + ImageView preview = homeImageWallpaper; + if (VideoWallpaperUtils.needsFadeIn(mWallpaper) && mFadeInScrim != null) { + preview = mFadeInScrim; + preview.setRenderEffect( + RenderEffect.createBlurEffect(150f, 150f, TileMode.CLAMP)); + } mWallpaper.getThumbAsset(mActivity.getApplicationContext()) .loadPreviewImage( mActivity, - homeImageWallpaper, + preview, ResourceUtils.getColorAttr( mActivity, android.R.attr.colorSecondary), /* offsetToStart= */ true); @@ -209,6 +230,17 @@ public class WallpaperPreviewer implements LifecycleObserver { mWallpaperColorsListener.onWallpaperColorsChanged(colors); } } + + @Override + public void onEngineShown() { + if (mFadeInScrim != null && VideoWallpaperUtils.needsFadeIn( + homeWallpaper)) { + mFadeInScrim.animate().alpha(0.0f) + .setDuration(VideoWallpaperUtils.TRANSITION_MILLIS) + .withEndAction( + () -> mFadeInScrim.setVisibility(View.INVISIBLE)); + } + } }, mWallpaperSurface); mWallpaperConnection.setVisibility(true); 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 + } + } +} diff --git a/src/com/android/customization/picker/color/ColorPickerFragment.kt b/src/com/android/customization/picker/color/ColorPickerFragment.kt new file mode 100644 index 00000000..c8ecb7f9 --- /dev/null +++ b/src/com/android/customization/picker/color/ColorPickerFragment.kt @@ -0,0 +1,41 @@ +/* + * 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.color + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.android.wallpaper.R +import com.android.wallpaper.picker.AppbarFragment + +// TODO (b/262924623): Color Picker Fragment +class ColorPickerFragment : AppbarFragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = + inflater.inflate( + R.layout.fragment_color_picker, + container, + false, + ) + setUpToolbar(view) + return view + } +} diff --git a/src/com/android/customization/picker/color/data/repository/ColorPickerRepository.kt b/src/com/android/customization/picker/color/data/repository/ColorPickerRepository.kt new file mode 100644 index 00000000..7cf9fd03 --- /dev/null +++ b/src/com/android/customization/picker/color/data/repository/ColorPickerRepository.kt @@ -0,0 +1,40 @@ +/* + * 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.color.data.repository + +import com.android.customization.picker.color.shared.model.ColorOptionModel +import com.android.customization.picker.color.shared.model.ColorType +import kotlinx.coroutines.flow.Flow + +/** + * Abstracts access to application state related to functionality for selecting, picking, or setting + * system color. + */ +interface ColorPickerRepository { + + /** List of wallpaper and preset color options on the device, categorized by Color Type */ + val colorOptions: Flow<Map<ColorType, List<ColorOptionModel>>> + + /** Selects a color option with optimistic update */ + suspend fun select(colorOptionModel: ColorOptionModel) + + /** Returns the current selected color option based on system settings */ + fun getCurrentColorOption(): ColorOptionModel + + /** Returns the current selected color source based on system settings */ + fun getCurrentColorSource(): String? +} diff --git a/src/com/android/customization/picker/color/data/repository/ColorPickerRepositoryImpl.kt b/src/com/android/customization/picker/color/data/repository/ColorPickerRepositoryImpl.kt new file mode 100644 index 00000000..512a5007 --- /dev/null +++ b/src/com/android/customization/picker/color/data/repository/ColorPickerRepositoryImpl.kt @@ -0,0 +1,146 @@ +/* + * 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.color.data.repository + +import android.app.WallpaperColors +import android.util.Log +import com.android.customization.model.CustomizationManager +import com.android.customization.model.color.ColorBundle +import com.android.customization.model.color.ColorCustomizationManager +import com.android.customization.model.color.ColorOption +import com.android.customization.model.color.ColorSeedOption +import com.android.customization.picker.color.shared.model.ColorOptionModel +import com.android.customization.picker.color.shared.model.ColorType +import com.android.systemui.monet.Style +import com.android.wallpaper.model.WallpaperColorsViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.suspendCancellableCoroutine + +// TODO (b/262924623): refactor to remove dependency on ColorCustomizationManager & ColorOption +// TODO (b/268203200): Create test for ColorPickerRepositoryImpl +class ColorPickerRepositoryImpl( + wallpaperColorsViewModel: WallpaperColorsViewModel, + private val colorManager: ColorCustomizationManager, +) : ColorPickerRepository { + + private val homeWallpaperColors: StateFlow<WallpaperColors?> = + wallpaperColorsViewModel.homeWallpaperColors + private val lockWallpaperColors: StateFlow<WallpaperColors?> = + wallpaperColorsViewModel.lockWallpaperColors + + override val colorOptions: Flow<Map<ColorType, List<ColorOptionModel>>> = + combine(homeWallpaperColors, lockWallpaperColors) { homeColors, lockColors -> + homeColors to lockColors + } + .map { (homeColors, lockColors) -> + suspendCancellableCoroutine { continuation -> + colorManager.setWallpaperColors(homeColors, lockColors) + colorManager.fetchOptions( + object : CustomizationManager.OptionsFetchedListener<ColorOption?> { + override fun onOptionsLoaded(options: MutableList<ColorOption?>?) { + val wallpaperColorOptions: MutableList<ColorOptionModel> = + mutableListOf() + val presetColorOptions: MutableList<ColorOptionModel> = + mutableListOf() + options?.forEach { option -> + when (option) { + is ColorSeedOption -> + wallpaperColorOptions.add(option.toModel()) + is ColorBundle -> presetColorOptions.add(option.toModel()) + } + } + continuation.resumeWith( + Result.success( + mapOf( + ColorType.WALLPAPER_COLOR to wallpaperColorOptions, + ColorType.PRESET_COLOR to presetColorOptions + ) + ) + ) + } + + override fun onError(throwable: Throwable?) { + Log.e(TAG, "Error loading theme bundles", throwable) + continuation.resumeWith( + Result.failure( + throwable ?: Throwable("Error loading theme bundles") + ) + ) + } + }, + /* reload= */ false + ) + } + } + + override suspend fun select(colorOptionModel: ColorOptionModel) = + suspendCancellableCoroutine { continuation -> + colorManager.apply( + colorOptionModel.colorOption, + object : CustomizationManager.Callback { + override fun onSuccess() { + continuation.resumeWith(Result.success(Unit)) + } + + override fun onError(throwable: Throwable?) { + Log.w(TAG, "Apply theme with error", throwable) + continuation.resumeWith( + Result.failure(throwable ?: Throwable("Error loading theme bundles")) + ) + } + } + ) + } + + override fun getCurrentColorOption(): ColorOptionModel { + val overlays = colorManager.currentOverlays + val styleOrNull = colorManager.currentStyle + val style = styleOrNull?.let { Style.valueOf(it) } ?: Style.TONAL_SPOT + val colorOptionBuilder = + // Does not matter whether ColorSeedOption or ColorBundle builder is used here + // because to apply the color, one just needs a generic ColorOption + ColorSeedOption.Builder().setSource(colorManager.currentColorSource).setStyle(style) + for (overlay in overlays) { + colorOptionBuilder.addOverlayPackage(overlay.key, overlay.value) + } + val colorOption = colorOptionBuilder.build() + return ColorOptionModel( + key = "${colorOption.style}::${colorOption.serializedPackages}", + colorOption = colorOption, + isSelected = false, + ) + } + + override fun getCurrentColorSource(): String? { + return colorManager.currentColorSource + } + + private fun ColorOption.toModel(): ColorOptionModel { + return ColorOptionModel( + key = "${this.style}::${this.serializedPackages}", + colorOption = this, + isSelected = isActive(colorManager), + ) + } + + companion object { + private const val TAG = "ColorPickerRepositoryImpl" + } +} diff --git a/src/com/android/customization/picker/color/data/repository/FakeColorPickerRepository.kt b/src/com/android/customization/picker/color/data/repository/FakeColorPickerRepository.kt new file mode 100644 index 00000000..edbf6dcf --- /dev/null +++ b/src/com/android/customization/picker/color/data/repository/FakeColorPickerRepository.kt @@ -0,0 +1,182 @@ +/* + * 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.color.data.repository + +import android.content.Context +import android.graphics.Color +import android.text.TextUtils +import com.android.customization.model.color.ColorBundle +import com.android.customization.model.color.ColorOptionsProvider +import com.android.customization.model.color.ColorSeedOption +import com.android.customization.picker.color.shared.model.ColorOptionModel +import com.android.customization.picker.color.shared.model.ColorType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeColorPickerRepository(private val context: Context) : ColorPickerRepository { + + private lateinit var selectedColorOption: ColorOptionModel + + private val _colorOptions = + MutableStateFlow( + mapOf<ColorType, List<ColorOptionModel>>( + ColorType.WALLPAPER_COLOR to listOf(), + ColorType.PRESET_COLOR to listOf() + ) + ) + override val colorOptions: StateFlow<Map<ColorType, List<ColorOptionModel>>> = + _colorOptions.asStateFlow() + + init { + setOptions(4, 4, ColorType.WALLPAPER_COLOR, 0) + } + + fun setOptions( + numWallpaperOptions: Int, + numPresetOptions: Int, + selectedColorOptionType: ColorType, + selectedColorOptionIndex: Int + ) { + _colorOptions.value = + mapOf( + ColorType.WALLPAPER_COLOR to + buildList { + repeat(times = numWallpaperOptions) { index -> + val isSelected = + selectedColorOptionType == ColorType.WALLPAPER_COLOR && + selectedColorOptionIndex == index + val colorOption = + ColorOptionModel( + key = "${ColorType.WALLPAPER_COLOR}::$index", + colorOption = buildWallpaperOption(index), + isSelected = isSelected, + ) + if (isSelected) { + selectedColorOption = colorOption + } + add(colorOption) + } + }, + ColorType.PRESET_COLOR to + buildList { + repeat(times = numPresetOptions) { index -> + val isSelected = + selectedColorOptionType == ColorType.PRESET_COLOR && + selectedColorOptionIndex == index + val colorOption = + ColorOptionModel( + key = "${ColorType.PRESET_COLOR}::$index", + colorOption = buildPresetOption(index), + isSelected = + selectedColorOptionType == ColorType.PRESET_COLOR && + selectedColorOptionIndex == index, + ) + if (isSelected) { + selectedColorOption = colorOption + } + add(colorOption) + } + } + ) + } + + private fun buildPresetOption(index: Int): ColorBundle { + return ColorBundle.Builder() + .addOverlayPackage("TEST_PACKAGE_TYPE", "preset_color") + .addOverlayPackage("TEST_PACKAGE_INDEX", "$index") + .setIndex(index) + .build(context) + } + + private fun buildWallpaperOption(index: Int): ColorSeedOption { + return ColorSeedOption.Builder() + .setLightColors( + intArrayOf( + Color.TRANSPARENT, + Color.TRANSPARENT, + Color.TRANSPARENT, + Color.TRANSPARENT + ) + ) + .setDarkColors( + intArrayOf( + Color.TRANSPARENT, + Color.TRANSPARENT, + Color.TRANSPARENT, + Color.TRANSPARENT + ) + ) + .addOverlayPackage("TEST_PACKAGE_TYPE", "wallpaper_color") + .addOverlayPackage("TEST_PACKAGE_INDEX", "$index") + .setIndex(index) + .build() + } + + override suspend fun select(colorOptionModel: ColorOptionModel) { + val colorOptions = _colorOptions.value + val wallpaperColorOptions = colorOptions[ColorType.WALLPAPER_COLOR]!! + val newWallpaperColorOptions = buildList { + wallpaperColorOptions.forEach { option -> + add( + ColorOptionModel( + key = option.key, + colorOption = option.colorOption, + isSelected = option.testEquals(colorOptionModel), + ) + ) + } + } + val basicColorOptions = colorOptions[ColorType.PRESET_COLOR]!! + val newBasicColorOptions = buildList { + basicColorOptions.forEach { option -> + add( + ColorOptionModel( + key = option.key, + colorOption = option.colorOption, + isSelected = option.testEquals(colorOptionModel), + ) + ) + } + } + _colorOptions.value = + mapOf( + ColorType.WALLPAPER_COLOR to newWallpaperColorOptions, + ColorType.PRESET_COLOR to newBasicColorOptions + ) + } + + override fun getCurrentColorOption(): ColorOptionModel = selectedColorOption + + override fun getCurrentColorSource(): String? = + when (selectedColorOption.colorOption) { + is ColorSeedOption -> ColorOptionsProvider.COLOR_SOURCE_HOME + is ColorBundle -> ColorOptionsProvider.COLOR_SOURCE_PRESET + else -> null + } + + private fun ColorOptionModel.testEquals(other: Any?): Boolean { + if (other == null) { + return false + } + return if (other is ColorOptionModel) { + TextUtils.equals(this.key, other.key) + } else { + false + } + } +} diff --git a/src/com/android/customization/picker/color/domain/interactor/ColorPickerInteractor.kt b/src/com/android/customization/picker/color/domain/interactor/ColorPickerInteractor.kt new file mode 100644 index 00000000..8c7a4b72 --- /dev/null +++ b/src/com/android/customization/picker/color/domain/interactor/ColorPickerInteractor.kt @@ -0,0 +1,49 @@ +/* + * 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.color.domain.interactor + +import com.android.customization.picker.color.data.repository.ColorPickerRepository +import com.android.customization.picker.color.shared.model.ColorOptionModel +import javax.inject.Provider +import kotlinx.coroutines.flow.MutableStateFlow + +/** Single entry-point for all application state and business logic related to system color. */ +class ColorPickerInteractor( + private val repository: ColorPickerRepository, + private val snapshotRestorer: Provider<ColorPickerSnapshotRestorer>, +) { + /** + * The newly selected color option for overwriting the current active option during an + * optimistic update, the value is set to null when update fails + */ + val activeColorOption = MutableStateFlow<ColorOptionModel?>(null) + + /** List of wallpaper and preset color options on the device, categorized by Color Type */ + val colorOptions = repository.colorOptions + + suspend fun select(colorOptionModel: ColorOptionModel) { + activeColorOption.value = colorOptionModel + try { + repository.select(colorOptionModel) + snapshotRestorer.get().storeSnapshot(colorOptionModel) + } catch (e: Exception) { + activeColorOption.value = null + } + } + + fun getCurrentColorOption(): ColorOptionModel = repository.getCurrentColorOption() +} diff --git a/src/com/android/customization/picker/color/domain/interactor/ColorPickerSnapshotRestorer.kt b/src/com/android/customization/picker/color/domain/interactor/ColorPickerSnapshotRestorer.kt new file mode 100644 index 00000000..dce59ebf --- /dev/null +++ b/src/com/android/customization/picker/color/domain/interactor/ColorPickerSnapshotRestorer.kt @@ -0,0 +1,81 @@ +/* + * 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.color.domain.interactor + +import android.util.Log +import com.android.customization.picker.color.shared.model.ColorOptionModel +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 the color picker system. */ +class ColorPickerSnapshotRestorer( + private val interactor: ColorPickerInteractor, +) : SnapshotRestorer { + + private var snapshotStore: SnapshotStore = SnapshotStore.NOOP + private var originalOption: ColorOptionModel? = null + + fun storeSnapshot(colorOptionModel: ColorOptionModel) { + snapshotStore.store(snapshot(colorOptionModel)) + } + + override suspend fun setUpSnapshotRestorer( + store: SnapshotStore, + ): RestorableSnapshot { + snapshotStore = store + originalOption = interactor.getCurrentColorOption() + return snapshot(originalOption) + } + + override suspend fun restoreToSnapshot(snapshot: RestorableSnapshot) { + val optionPackagesFromSnapshot: String? = snapshot.args[KEY_COLOR_OPTION_PACKAGES] + originalOption?.let { optionToRestore -> + if ( + optionToRestore.colorOption.serializedPackages != optionPackagesFromSnapshot || + optionToRestore.colorOption.style.toString() != + snapshot.args[KEY_COLOR_OPTION_STYLE] + ) { + Log.wtf( + TAG, + """ Original packages does not match snapshot packages to restore to. The + | current implementation doesn't support undo, only a reset back to the + | original color option.""" + .trimMargin(), + ) + } + + interactor.select(optionToRestore) + } + } + + private fun snapshot(colorOptionModel: ColorOptionModel? = null): RestorableSnapshot { + val snapshotMap = mutableMapOf<String, String>() + colorOptionModel?.let { + snapshotMap[KEY_COLOR_OPTION_PACKAGES] = colorOptionModel.colorOption.serializedPackages + snapshotMap[KEY_COLOR_OPTION_STYLE] = colorOptionModel.colorOption.style.toString() + } + return RestorableSnapshot(snapshotMap) + } + + companion object { + private const val TAG = "ColorPickerSnapshotRestorer" + private const val KEY_COLOR_OPTION_PACKAGES = "color_packages" + private const val KEY_COLOR_OPTION_STYLE = "color_style" + } +} diff --git a/src/com/android/customization/picker/color/shared/model/ColorOptionModel.kt b/src/com/android/customization/picker/color/shared/model/ColorOptionModel.kt new file mode 100644 index 00000000..5fde08e4 --- /dev/null +++ b/src/com/android/customization/picker/color/shared/model/ColorOptionModel.kt @@ -0,0 +1,31 @@ +/* + * 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.color.shared.model + +import com.android.customization.model.color.ColorOption + +/** Models application state for a color option in a picker experience. */ +data class ColorOptionModel( + val key: String, + + /** Colors for the color option. */ + val colorOption: ColorOption, + + /** Whether this color option is selected. */ + var isSelected: Boolean, +) diff --git a/src/com/android/customization/picker/color/shared/model/ColorType.kt b/src/com/android/customization/picker/color/shared/model/ColorType.kt new file mode 100644 index 00000000..c9a01d0f --- /dev/null +++ b/src/com/android/customization/picker/color/shared/model/ColorType.kt @@ -0,0 +1,25 @@ +/* + * 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.color.shared.model + +enum class ColorType { + /** Colors generated based on the current wallpaper */ + WALLPAPER_COLOR, + + /** Preset colors */ + PRESET_COLOR, +} diff --git a/src/com/android/customization/picker/color/ui/adapter/ColorOptionAdapter.kt b/src/com/android/customization/picker/color/ui/adapter/ColorOptionAdapter.kt new file mode 100644 index 00000000..7aa390df --- /dev/null +++ b/src/com/android/customization/picker/color/ui/adapter/ColorOptionAdapter.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.customization.picker.color.ui.adapter + +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.android.customization.picker.color.ui.viewmodel.ColorOptionViewModel +import com.android.wallpaper.R + +/** + * Adapts between color option items and views. + * + * TODO (b/272109171): Remove after clock settings is refactored to use OptionItemAdapter + */ +class ColorOptionAdapter : RecyclerView.Adapter<ColorOptionAdapter.ViewHolder>() { + + private val items = mutableListOf<ColorOptionViewModel>() + private var isTitleVisible = false + + fun setItems(items: List<ColorOptionViewModel>) { + this.items.clear() + this.items.addAll(items) + isTitleVisible = items.any { item -> item.title != null } + notifyDataSetChanged() + } + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val borderView: View = itemView.requireViewById(R.id.selection_border) + val backgroundView: View = itemView.requireViewById(R.id.background) + val color0View: ImageView = itemView.requireViewById(R.id.color_preview_0) + val color1View: ImageView = itemView.requireViewById(R.id.color_preview_1) + val color2View: ImageView = itemView.requireViewById(R.id.color_preview_2) + val color3View: ImageView = itemView.requireViewById(R.id.color_preview_3) + val optionTitleView: TextView = itemView.requireViewById(R.id.option_title) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + LayoutInflater.from(parent.context) + .inflate( + R.layout.color_option_with_background, + parent, + false, + ) + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + + holder.itemView.setOnClickListener( + if (item.onClick != null) { + View.OnClickListener { item.onClick.invoke() } + } else { + null + } + ) + if (item.isSelected) { + holder.borderView.alpha = 1f + holder.borderView.scaleX = 1f + holder.borderView.scaleY = 1f + holder.backgroundView.scaleX = 0.86f + holder.backgroundView.scaleY = 0.86f + } else { + holder.borderView.alpha = 0f + holder.backgroundView.scaleX = 1f + holder.backgroundView.scaleY = 1f + } + holder.color0View.drawable.colorFilter = BlendModeColorFilter(item.color0, BlendMode.SRC) + holder.color1View.drawable.colorFilter = BlendModeColorFilter(item.color1, BlendMode.SRC) + holder.color2View.drawable.colorFilter = BlendModeColorFilter(item.color2, BlendMode.SRC) + holder.color3View.drawable.colorFilter = BlendModeColorFilter(item.color3, BlendMode.SRC) + holder.itemView.contentDescription = item.contentDescription + holder.optionTitleView.isVisible = isTitleVisible + holder.optionTitleView.text = item.title + } +} diff --git a/src/com/android/customization/picker/color/ui/adapter/ColorTypeTabAdapter.kt b/src/com/android/customization/picker/color/ui/adapter/ColorTypeTabAdapter.kt new file mode 100644 index 00000000..bb9f0823 --- /dev/null +++ b/src/com/android/customization/picker/color/ui/adapter/ColorTypeTabAdapter.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.customization.picker.color.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.color.ui.viewmodel.ColorTypeTabViewModel +import com.android.wallpaper.R + +/** Adapts between color type items and views. */ +class ColorTypeTabAdapter : RecyclerView.Adapter<ColorTypeTabAdapter.ViewHolder>() { + + private val items = mutableListOf<ColorTypeTabViewModel>() + + fun setItems(items: List<ColorTypeTabViewModel>) { + 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.onClick != null) { + View.OnClickListener { item.onClick.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/color/ui/binder/ColorOptionIconBinder.kt b/src/com/android/customization/picker/color/ui/binder/ColorOptionIconBinder.kt new file mode 100644 index 00000000..1478cc40 --- /dev/null +++ b/src/com/android/customization/picker/color/ui/binder/ColorOptionIconBinder.kt @@ -0,0 +1,41 @@ +/* + * 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.color.ui.binder + +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.view.ViewGroup +import android.widget.ImageView +import com.android.customization.picker.color.ui.viewmodel.ColorOptionIconViewModel +import com.android.wallpaper.R + +object ColorOptionIconBinder { + fun bind( + view: ViewGroup, + viewModel: ColorOptionIconViewModel, + ) { + val color0View: ImageView = view.requireViewById(R.id.color_preview_0) + val color1View: ImageView = view.requireViewById(R.id.color_preview_1) + val color2View: ImageView = view.requireViewById(R.id.color_preview_2) + val color3View: ImageView = view.requireViewById(R.id.color_preview_3) + color0View.drawable.colorFilter = BlendModeColorFilter(viewModel.color0, BlendMode.SRC) + color1View.drawable.colorFilter = BlendModeColorFilter(viewModel.color1, BlendMode.SRC) + color2View.drawable.colorFilter = BlendModeColorFilter(viewModel.color2, BlendMode.SRC) + color3View.drawable.colorFilter = BlendModeColorFilter(viewModel.color3, BlendMode.SRC) + } +} diff --git a/src/com/android/customization/picker/color/ui/binder/ColorPickerBinder.kt b/src/com/android/customization/picker/color/ui/binder/ColorPickerBinder.kt new file mode 100644 index 00000000..7623048f --- /dev/null +++ b/src/com/android/customization/picker/color/ui/binder/ColorPickerBinder.kt @@ -0,0 +1,97 @@ +/* + * 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.color.ui.binder + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +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.color.ui.adapter.ColorTypeTabAdapter +import com.android.customization.picker.color.ui.viewmodel.ColorOptionIconViewModel +import com.android.customization.picker.color.ui.viewmodel.ColorPickerViewModel +import com.android.customization.picker.common.ui.view.ItemSpacing +import com.android.wallpaper.R +import com.android.wallpaper.picker.option.ui.adapter.OptionItemAdapter +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +object ColorPickerBinder { + + /** + * Binds view with view-model for a color picker experience. The view should include a Recycler + * View for color type tabs with id [R.id.color_type_tabs] and a Recycler View for color options + * with id [R.id.color_options] + */ + @JvmStatic + fun bind( + view: View, + viewModel: ColorPickerViewModel, + lifecycleOwner: LifecycleOwner, + ) { + val colorTypeTabView: RecyclerView = view.requireViewById(R.id.color_type_tabs) + val colorTypeTabSubheaderView: TextView = view.requireViewById(R.id.color_type_tab_subhead) + val colorOptionContainerView: RecyclerView = view.requireViewById(R.id.color_options) + + val colorTypeTabAdapter = ColorTypeTabAdapter() + colorTypeTabView.adapter = colorTypeTabAdapter + colorTypeTabView.layoutManager = + LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false) + colorTypeTabView.addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP)) + val colorOptionAdapter = + OptionItemAdapter( + layoutResourceId = R.layout.color_option_2, + lifecycleOwner = lifecycleOwner, + bindIcon = { foregroundView: View, colorIcon: ColorOptionIconViewModel -> + val viewGroup = foregroundView as? ViewGroup + viewGroup?.let { ColorOptionIconBinder.bind(viewGroup, colorIcon) } + } + ) + colorOptionContainerView.adapter = colorOptionAdapter + colorOptionContainerView.layoutManager = + LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false) + colorOptionContainerView.addItemDecoration(ItemSpacing(ItemSpacing.ITEM_SPACING_DP)) + + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.colorTypeTabs + .map { colorTypeById -> colorTypeById.values } + .collect { colorTypes -> colorTypeTabAdapter.setItems(colorTypes.toList()) } + } + + launch { + viewModel.colorTypeTabSubheader.collect { subhead -> + colorTypeTabSubheaderView.text = subhead + } + } + + launch { + viewModel.colorOptions.collect { colorOptions -> + colorOptionAdapter.setItems(colorOptions) + } + } + } + } + } +} diff --git a/src/com/android/customization/picker/color/ui/binder/ColorSectionViewBinder.kt b/src/com/android/customization/picker/color/ui/binder/ColorSectionViewBinder.kt new file mode 100644 index 00000000..05b0916e --- /dev/null +++ b/src/com/android/customization/picker/color/ui/binder/ColorSectionViewBinder.kt @@ -0,0 +1,131 @@ +/* + * 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.color.ui.binder + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +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.color.ui.viewmodel.ColorOptionIconViewModel +import com.android.customization.picker.color.ui.viewmodel.ColorPickerViewModel +import com.android.wallpaper.R +import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel +import kotlinx.coroutines.launch + +object ColorSectionViewBinder { + + /** + * Binds view with view-model for color picker section. The view should include a linear layout + * with id [R.id.color_section_option_container] + */ + @JvmStatic + fun bind( + view: View, + viewModel: ColorPickerViewModel, + lifecycleOwner: LifecycleOwner, + navigationOnClick: (View) -> Unit, + isConnectedHorizontallyToOtherSections: Boolean = false, + ) { + val optionContainer: LinearLayout = + view.requireViewById(R.id.color_section_option_container) + val moreColorsButton: View = view.requireViewById(R.id.more_colors) + if (isConnectedHorizontallyToOtherSections) { + moreColorsButton.isVisible = true + moreColorsButton.setOnClickListener(navigationOnClick) + } else { + moreColorsButton.isVisible = false + } + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.colorSectionOptions.collect { colorOptions -> + setOptions( + options = colorOptions, + view = optionContainer, + lifecycleOwner = lifecycleOwner, + addOverflowOption = !isConnectedHorizontallyToOtherSections, + overflowOnClick = navigationOnClick, + ) + } + } + } + } + } + + fun setOptions( + options: List<OptionItemViewModel<ColorOptionIconViewModel>>, + view: LinearLayout, + lifecycleOwner: LifecycleOwner, + addOverflowOption: Boolean = false, + overflowOnClick: (View) -> Unit = {}, + ) { + view.removeAllViews() + // Color option slot size is the minimum between the color option size and the view column + // count. When having an overflow option, a slot is reserved for the overflow option. + val colorOptionSlotSize = + (if (addOverflowOption) { + minOf(view.weightSum.toInt() - 1, options.size) + } else { + minOf(view.weightSum.toInt(), options.size) + }) + .let { if (it < 0) 0 else it } + options.subList(0, colorOptionSlotSize).forEach { item -> + val itemView = + LayoutInflater.from(view.context) + .inflate(R.layout.color_option_no_background, view, false) + item.payload?.let { ColorOptionIconBinder.bind(itemView as ViewGroup, item.payload) } + val optionSelectedView = itemView.findViewById<ImageView>(R.id.option_selected) + + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + item.isSelected.collect { isSelected -> + optionSelectedView.isVisible = isSelected + } + } + launch { + item.onClicked.collect { onClicked -> + itemView.setOnClickListener( + if (onClicked != null) { + View.OnClickListener { onClicked.invoke() } + } else { + null + } + ) + } + } + } + } + view.addView(itemView) + } + // add overflow option + if (addOverflowOption) { + val itemView = + LayoutInflater.from(view.context) + .inflate(R.layout.color_option_overflow_no_background, view, false) + itemView.setOnClickListener(overflowOnClick) + view.addView(itemView) + } + } +} diff --git a/src/com/android/customization/picker/color/ui/fragment/ColorPickerFragment.kt b/src/com/android/customization/picker/color/ui/fragment/ColorPickerFragment.kt new file mode 100644 index 00000000..c6b2023e --- /dev/null +++ b/src/com/android/customization/picker/color/ui/fragment/ColorPickerFragment.kt @@ -0,0 +1,164 @@ +/* + * 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.color.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.cardview.widget.CardView +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.get +import com.android.customization.model.mode.DarkModeSectionController +import com.android.customization.module.ThemePickerInjector +import com.android.customization.picker.color.ui.binder.ColorPickerBinder +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.DisplayUtils +import com.android.wallpaper.util.PreviewUtils +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +@OptIn(ExperimentalCoroutinesApi::class) +class ColorPickerFragment : AppbarFragment() { + companion object { + @JvmStatic + fun newInstance(): ColorPickerFragment { + return ColorPickerFragment() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = + inflater.inflate( + R.layout.fragment_color_picker, + container, + false, + ) + setUpToolbar(view) + val injector = InjectorProvider.getInjector() as ThemePickerInjector + val lockScreenView: CardView = view.requireViewById(R.id.lock_preview) + val homeScreenView: CardView = view.requireViewById(R.id.home_preview) + val wallpaperInfoFactory = injector.getCurrentWallpaperInfoFactory(requireContext()) + val displayUtils: DisplayUtils = injector.getDisplayUtils(requireContext()) + val wcViewModel = injector.getWallpaperColorsViewModel() + ColorPickerBinder.bind( + view = view, + viewModel = + ViewModelProvider( + requireActivity(), + injector.getColorPickerViewModelFactory( + context = requireContext(), + wallpaperColorsViewModel = wcViewModel, + ), + ) + .get(), + lifecycleOwner = this, + ) + ScreenPreviewBinder.bind( + activity = requireActivity(), + previewView = lockScreenView, + viewModel = + ScreenPreviewViewModel( + previewUtils = + PreviewUtils( + context = requireContext(), + authority = + requireContext() + .getString( + R.string.lock_screen_preview_provider_authority, + ), + ), + wallpaperInfoProvider = { + suspendCancellableCoroutine { continuation -> + wallpaperInfoFactory.createCurrentWallpaperInfos( + { homeWallpaper, lockWallpaper, _ -> + continuation.resume(lockWallpaper ?: homeWallpaper, null) + }, + /* forceRefresh= */ true, + ) + } + }, + onWallpaperColorChanged = { colors -> + wcViewModel.setLockWallpaperColors(colors) + }, + ), + lifecycleOwner = this, + offsetToStart = + displayUtils.isSingleDisplayOrUnfoldedHorizontalHinge(requireActivity()), + ) + ScreenPreviewBinder.bind( + activity = requireActivity(), + previewView = homeScreenView, + viewModel = + ScreenPreviewViewModel( + previewUtils = + PreviewUtils( + context = requireContext(), + authorityMetadataKey = + requireContext() + .getString( + R.string.grid_control_metadata_name, + ), + ), + wallpaperInfoProvider = { + suspendCancellableCoroutine { continuation -> + wallpaperInfoFactory.createCurrentWallpaperInfos( + { homeWallpaper, lockWallpaper, _ -> + continuation.resume(homeWallpaper ?: lockWallpaper, null) + }, + /* forceRefresh= */ true, + ) + } + }, + onWallpaperColorChanged = { colors -> + wcViewModel.setLockWallpaperColors(colors) + }, + ), + lifecycleOwner = this, + offsetToStart = + displayUtils.isSingleDisplayOrUnfoldedHorizontalHinge(requireActivity()), + ) + val darkModeToggleContainerView: FrameLayout = + view.requireViewById(R.id.dark_mode_toggle_container) + val darkModeSectionView = + DarkModeSectionController( + context, + lifecycle, + injector.getDarkModeSnapshotRestorer(requireContext()) + ) + .createView(requireContext()) + darkModeSectionView.background = null + darkModeToggleContainerView.addView(darkModeSectionView) + return view + } + + override fun getDefaultTitle(): CharSequence { + return requireContext().getString(R.string.color_picker_title) + } + + override fun getToolbarColorId(): Int { + return android.R.color.transparent + } +} diff --git a/src/com/android/customization/picker/color/ui/section/ColorSectionController2.kt b/src/com/android/customization/picker/color/ui/section/ColorSectionController2.kt new file mode 100644 index 00000000..f1c982b4 --- /dev/null +++ b/src/com/android/customization/picker/color/ui/section/ColorSectionController2.kt @@ -0,0 +1,67 @@ +/* + * 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.color.ui.section + +import android.content.Context +import android.view.LayoutInflater +import androidx.lifecycle.LifecycleOwner +import com.android.customization.picker.color.ui.binder.ColorSectionViewBinder +import com.android.customization.picker.color.ui.fragment.ColorPickerFragment +import com.android.customization.picker.color.ui.view.ColorSectionView2 +import com.android.customization.picker.color.ui.viewmodel.ColorPickerViewModel +import com.android.wallpaper.R +import com.android.wallpaper.model.CustomizationSectionController +import com.android.wallpaper.model.CustomizationSectionController.CustomizationSectionNavigationController as NavigationController + +class ColorSectionController2( + private val navigationController: NavigationController, + private val viewModel: ColorPickerViewModel, + private val lifecycleOwner: LifecycleOwner +) : CustomizationSectionController<ColorSectionView2> { + + override fun isAvailable(context: Context): Boolean { + return true + } + + override fun createView(context: Context): ColorSectionView2 { + return createView(context, CustomizationSectionController.ViewCreationParams()) + } + + override fun createView( + context: Context, + params: CustomizationSectionController.ViewCreationParams + ): ColorSectionView2 { + @SuppressWarnings("It is fine to inflate with null parent for our need.") + val view = + LayoutInflater.from(context) + .inflate( + R.layout.color_section_view2, + null, + ) as ColorSectionView2 + ColorSectionViewBinder.bind( + view = view, + viewModel = viewModel, + lifecycleOwner = lifecycleOwner, + navigationOnClick = { + navigationController.navigateTo(ColorPickerFragment.newInstance()) + }, + isConnectedHorizontallyToOtherSections = params.isConnectedHorizontallyToOtherSections, + ) + return view + } +} diff --git a/src/com/android/customization/picker/color/ui/view/ColorSectionView2.kt b/src/com/android/customization/picker/color/ui/view/ColorSectionView2.kt new file mode 100644 index 00000000..7a8f21af --- /dev/null +++ b/src/com/android/customization/picker/color/ui/view/ColorSectionView2.kt @@ -0,0 +1,26 @@ +/* + * 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.color.ui.view + +import android.content.Context +import android.util.AttributeSet +import com.android.wallpaper.picker.SectionView + +/** + * The class inherits from {@link SectionView} as the view representing the color section of the + * customization picker. It displays a list of color options and an overflow option. + */ +class ColorSectionView2(context: Context, attrs: AttributeSet?) : SectionView(context, attrs) diff --git a/src/com/android/customization/picker/color/ui/viewmodel/ColorOptionIconViewModel.kt b/src/com/android/customization/picker/color/ui/viewmodel/ColorOptionIconViewModel.kt new file mode 100644 index 00000000..d32538d8 --- /dev/null +++ b/src/com/android/customization/picker/color/ui/viewmodel/ColorOptionIconViewModel.kt @@ -0,0 +1,27 @@ +/* + * 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.color.ui.viewmodel + +import android.annotation.ColorInt + +data class ColorOptionIconViewModel( + @ColorInt val color0: Int, + @ColorInt val color1: Int, + @ColorInt val color2: Int, + @ColorInt val color3: Int, +) diff --git a/src/com/android/customization/picker/color/ui/viewmodel/ColorOptionViewModel.kt b/src/com/android/customization/picker/color/ui/viewmodel/ColorOptionViewModel.kt new file mode 100644 index 00000000..7af2aa5f --- /dev/null +++ b/src/com/android/customization/picker/color/ui/viewmodel/ColorOptionViewModel.kt @@ -0,0 +1,45 @@ +/* + * 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.color.ui.viewmodel + +import android.annotation.ColorInt + +/** + * Models UI state for a color options in a picker experience. + * + * TODO (b/272109171): Remove after clock settings is refactored to use OptionItemAdapter + */ +data class ColorOptionViewModel( + /** Colors for the color option. */ + @ColorInt val color0: Int, + @ColorInt val color1: Int, + @ColorInt val color2: Int, + @ColorInt val color3: Int, + + /** A content description for the color. */ + val contentDescription: String, + + /** Nullable option title. Null by default. */ + val title: String? = null, + + /** Whether this color is selected. */ + val isSelected: Boolean, + + /** Notifies that the color has been clicked by the user. */ + val onClick: (() -> Unit)?, +) diff --git a/src/com/android/customization/picker/color/ui/viewmodel/ColorPickerViewModel.kt b/src/com/android/customization/picker/color/ui/viewmodel/ColorPickerViewModel.kt new file mode 100644 index 00000000..81a58107 --- /dev/null +++ b/src/com/android/customization/picker/color/ui/viewmodel/ColorPickerViewModel.kt @@ -0,0 +1,253 @@ +/* + * 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.color.ui.viewmodel + +import android.content.Context +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.color.domain.interactor.ColorPickerInteractor +import com.android.customization.picker.color.shared.model.ColorType +import com.android.wallpaper.R +import com.android.wallpaper.picker.common.text.ui.viewmodel.Text +import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Models UI state for a color picker experience. */ +class ColorPickerViewModel +private constructor( + context: Context, + private val interactor: ColorPickerInteractor, +) : ViewModel() { + + private val selectedColorTypeTabId = MutableStateFlow<ColorType?>(null) + + /** View-models for each color tab. */ + val colorTypeTabs: Flow<Map<ColorType, ColorTypeTabViewModel>> = + combine( + interactor.colorOptions, + selectedColorTypeTabId, + ) { colorOptions, selectedColorTypeIdOrNull -> + colorOptions.keys + .mapIndexed { index, colorType -> + val isSelected = + (selectedColorTypeIdOrNull == null && index == 0) || + selectedColorTypeIdOrNull == colorType + colorType to + ColorTypeTabViewModel( + name = + when (colorType) { + ColorType.WALLPAPER_COLOR -> + context.resources.getString(R.string.wallpaper_color_tab) + ColorType.PRESET_COLOR -> + context.resources.getString(R.string.preset_color_tab_2) + }, + isSelected = isSelected, + onClick = + if (isSelected) { + null + } else { + { this.selectedColorTypeTabId.value = colorType } + }, + ) + } + .toMap() + } + + /** View-models for each color tab subheader */ + val colorTypeTabSubheader: Flow<String> = + selectedColorTypeTabId.map { selectedColorTypeIdOrNull -> + when (selectedColorTypeIdOrNull ?: ColorType.WALLPAPER_COLOR) { + ColorType.WALLPAPER_COLOR -> + context.resources.getString(R.string.wallpaper_color_subheader) + ColorType.PRESET_COLOR -> + context.resources.getString(R.string.preset_color_subheader) + } + } + + /** The list of all color options mapped by their color type */ + private val allColorOptions: + Flow<Map<ColorType, List<OptionItemViewModel<ColorOptionIconViewModel>>>> = + interactor.colorOptions.map { colorOptions -> + colorOptions + .map { colorOptionEntry -> + colorOptionEntry.key to + when (colorOptionEntry.key) { + ColorType.WALLPAPER_COLOR -> { + colorOptionEntry.value.map { colorOptionModel -> + val colorSeedOption: ColorSeedOption = + colorOptionModel.colorOption as ColorSeedOption + val colors = + colorSeedOption.previewInfo.resolveColors(context.resources) + val isSelectedFlow: StateFlow<Boolean> = + interactor.activeColorOption + .map { + it?.colorOption?.isEquivalent( + colorOptionModel.colorOption + ) + ?: colorOptionModel.isSelected + } + .stateIn(viewModelScope) + OptionItemViewModel<ColorOptionIconViewModel>( + key = + MutableStateFlow(colorOptionModel.key) + as StateFlow<String>, + payload = + ColorOptionIconViewModel( + colors[0], + colors[1], + colors[2], + colors[3] + ), + text = + Text.Loaded( + colorSeedOption + .getContentDescription(context) + .toString() + ), + isSelected = isSelectedFlow, + onClicked = + isSelectedFlow.map { isSelected -> + if (isSelected) { + null + } else { + { + viewModelScope.launch { + interactor.select(colorOptionModel) + } + } + } + }, + ) + } + } + ColorType.PRESET_COLOR -> { + colorOptionEntry.value.map { colorOptionModel -> + val colorBundle: ColorBundle = + colorOptionModel.colorOption as ColorBundle + val primaryColor = + colorBundle.previewInfo.resolvePrimaryColor( + context.resources + ) + val secondaryColor = + colorBundle.previewInfo.resolveSecondaryColor( + context.resources + ) + val isSelectedFlow: StateFlow<Boolean> = + interactor.activeColorOption + .map { + it?.colorOption?.isEquivalent( + colorOptionModel.colorOption + ) + ?: colorOptionModel.isSelected + } + .stateIn(viewModelScope) + OptionItemViewModel<ColorOptionIconViewModel>( + key = + MutableStateFlow(colorOptionModel.key) + as StateFlow<String>, + payload = + ColorOptionIconViewModel( + primaryColor, + secondaryColor, + primaryColor, + secondaryColor + ), + text = + Text.Loaded( + colorBundle + .getContentDescription(context) + .toString() + ), + isSelected = isSelectedFlow, + onClicked = + isSelectedFlow.map { isSelected -> + if (isSelected) { + null + } else { + { + viewModelScope.launch { + interactor.select(colorOptionModel) + } + } + } + }, + ) + } + } + } + } + .toMap() + } + + /** The list of all available color options for the selected Color Type. */ + val colorOptions: Flow<List<OptionItemViewModel<ColorOptionIconViewModel>>> = + combine(allColorOptions, selectedColorTypeTabId) { + allColorOptions: Map<ColorType, List<OptionItemViewModel<ColorOptionIconViewModel>>>, + selectedColorTypeIdOrNull -> + val selectedColorTypeId = selectedColorTypeIdOrNull ?: ColorType.WALLPAPER_COLOR + allColorOptions[selectedColorTypeId]!! + } + + /** The list of color options for the color section */ + val colorSectionOptions: Flow<List<OptionItemViewModel<ColorOptionIconViewModel>>> = + allColorOptions.map { allColorOptions -> + val wallpaperOptions = allColorOptions[ColorType.WALLPAPER_COLOR] + val presetOptions = allColorOptions[ColorType.PRESET_COLOR] + val subOptions = + wallpaperOptions!!.subList(0, min(COLOR_SECTION_OPTION_SIZE, wallpaperOptions.size)) + // Add additional options based on preset colors if size of wallpaper color options is + // less than COLOR_SECTION_OPTION_SIZE + val additionalSubOptions = + presetOptions!!.subList( + 0, + min( + max(0, COLOR_SECTION_OPTION_SIZE - wallpaperOptions.size), + presetOptions.size, + ) + ) + subOptions + additionalSubOptions + } + + class Factory( + private val context: Context, + private val interactor: ColorPickerInteractor, + ) : ViewModelProvider.Factory { + override fun <T : ViewModel> create(modelClass: Class<T>): T { + @Suppress("UNCHECKED_CAST") + return ColorPickerViewModel( + context = context, + interactor = interactor, + ) + as T + } + } + + companion object { + private const val COLOR_SECTION_OPTION_SIZE = 5 + } +} diff --git a/src/com/android/customization/picker/color/ui/viewmodel/ColorTypeTabViewModel.kt b/src/com/android/customization/picker/color/ui/viewmodel/ColorTypeTabViewModel.kt new file mode 100644 index 00000000..6a789cc5 --- /dev/null +++ b/src/com/android/customization/picker/color/ui/viewmodel/ColorTypeTabViewModel.kt @@ -0,0 +1,30 @@ +/* + * 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.color.ui.viewmodel + +/** Models UI state for a single color type in a picker experience. */ +data class ColorTypeTabViewModel( + /** User-visible name for the color type. */ + val name: String, + + /** Whether this is the currently-selected color type in the picker. */ + val isSelected: Boolean, + + /** Notifies that the color type has been clicked by the user. */ + val onClick: (() -> Unit)?, +) diff --git a/src/com/android/customization/picker/common/ui/view/ItemSpacing.kt b/src/com/android/customization/picker/common/ui/view/ItemSpacing.kt new file mode 100644 index 00000000..ca689aa2 --- /dev/null +++ b/src/com/android/customization/picker/common/ui/view/ItemSpacing.kt @@ -0,0 +1,49 @@ +/* + * 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.common.ui.view + +import android.graphics.Rect +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView + +/** Item spacing used by the RecyclerView. */ +class ItemSpacing( + private val itemSpacingDp: Int, +) : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, itemPosition: Int, parent: RecyclerView) { + val addSpacingToStart = itemPosition > 0 + val addSpacingToEnd = itemPosition < (parent.adapter?.itemCount ?: 0) - 1 + val isRtl = parent.layoutManager?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL + val density = parent.context.resources.displayMetrics.density + val halfItemSpacingPx = itemSpacingDp.toPx(density) / 2 + if (!isRtl) { + outRect.left = if (addSpacingToStart) halfItemSpacingPx else 0 + outRect.right = if (addSpacingToEnd) halfItemSpacingPx else 0 + } else { + outRect.left = if (addSpacingToEnd) halfItemSpacingPx else 0 + outRect.right = if (addSpacingToStart) halfItemSpacingPx else 0 + } + } + + private fun Int.toPx(density: Float): Int { + return (this * density).toInt() + } + + companion object { + const val TAB_ITEM_SPACING_DP = 12 + const val ITEM_SPACING_DP = 8 + } +} diff --git a/src/com/android/customization/picker/grid/GridFragment.java b/src/com/android/customization/picker/grid/GridFragment.java index d60ebcac..4de1dab7 100644 --- a/src/com/android/customization/picker/grid/GridFragment.java +++ b/src/com/android/customization/picker/grid/GridFragment.java @@ -82,6 +82,7 @@ public class GridFragment extends AppbarFragment { private final Callback mApplyGridCallback = new Callback() { @Override public void onSuccess() { + mGridManager.fetchOptions(unused -> {}, true); Toast.makeText(getContext(), R.string.applied_grid_msg, Toast.LENGTH_SHORT).show(); getActivity().overridePendingTransition(R.anim.fade_in, R.anim.fade_out); getActivity().finish(); @@ -157,7 +158,8 @@ public class GridFragment extends AppbarFragment { SurfaceView wallpaperSurface = view.findViewById(R.id.wallpaper_preview_surface); WallpaperPreviewer wallpaperPreviewer = new WallpaperPreviewer(getLifecycle(), - getActivity(), view.findViewById(R.id.wallpaper_preview_image), wallpaperSurface); + getActivity(), view.findViewById(R.id.wallpaper_preview_image), wallpaperSurface, + view.findViewById(R.id.grid_fadein_scrim)); // Loads current Wallpaper. CurrentWallpaperInfoFactory factory = InjectorProvider.getInjector() .getCurrentWallpaperInfoFactory(getContext().getApplicationContext()); diff --git a/src/com/android/customization/picker/notifications/data/repository/NotificationsRepository.kt b/src/com/android/customization/picker/notifications/data/repository/NotificationsRepository.kt new file mode 100644 index 00000000..c75ddce0 --- /dev/null +++ b/src/com/android/customization/picker/notifications/data/repository/NotificationsRepository.kt @@ -0,0 +1,74 @@ +/* + * 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.notifications.data.repository + +import android.provider.Settings +import com.android.customization.picker.notifications.shared.model.NotificationSettingsModel +import com.android.wallpaper.settings.data.repository.SecureSettingsRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.withContext + +/** Provides access to state related to notifications. */ +class NotificationsRepository( + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher, + private val secureSettingsRepository: SecureSettingsRepository, +) { + /** The current state of the notification setting. */ + val settings: SharedFlow<NotificationSettingsModel> = + secureSettingsRepository + .intSetting( + name = Settings.Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS, + ) + .map { lockScreenShowNotificationsInt -> + NotificationSettingsModel( + isShowNotificationsOnLockScreenEnabled = lockScreenShowNotificationsInt == 1, + ) + } + .shareIn( + scope = scope, + started = SharingStarted.WhileSubscribed(), + replay = 1, + ) + + suspend fun getSettings(): NotificationSettingsModel { + return withContext(backgroundDispatcher) { + NotificationSettingsModel( + isShowNotificationsOnLockScreenEnabled = + secureSettingsRepository.get( + name = Settings.Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS, + defaultValue = 0, + ) == 1 + ) + } + } + + suspend fun setSettings(model: NotificationSettingsModel) { + withContext(backgroundDispatcher) { + secureSettingsRepository.set( + name = Settings.Secure.LOCK_SCREEN_SHOW_NOTIFICATIONS, + value = if (model.isShowNotificationsOnLockScreenEnabled) 1 else 0, + ) + } + } +} diff --git a/src/com/android/customization/picker/notifications/domain/interactor/NotificationsInteractor.kt b/src/com/android/customization/picker/notifications/domain/interactor/NotificationsInteractor.kt new file mode 100644 index 00000000..1f892f0a --- /dev/null +++ b/src/com/android/customization/picker/notifications/domain/interactor/NotificationsInteractor.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.notifications.domain.interactor + +import com.android.customization.picker.notifications.data.repository.NotificationsRepository +import com.android.customization.picker.notifications.shared.model.NotificationSettingsModel +import javax.inject.Provider +import kotlinx.coroutines.flow.Flow + +/** Encapsulates business logic for interacting with notifications. */ +class NotificationsInteractor( + private val repository: NotificationsRepository, + private val snapshotRestorer: Provider<NotificationsSnapshotRestorer>, +) { + /** The current state of the notification setting. */ + val settings: Flow<NotificationSettingsModel> = repository.settings + + /** Toggles the setting to show or hide notifications on the lock screen. */ + suspend fun toggleShowNotificationsOnLockScreenEnabled() { + val currentModel = repository.getSettings() + setSettings( + currentModel.copy( + isShowNotificationsOnLockScreenEnabled = + !currentModel.isShowNotificationsOnLockScreenEnabled, + ) + ) + } + + suspend fun setSettings(model: NotificationSettingsModel) { + repository.setSettings(model) + snapshotRestorer.get().storeSnapshot(model) + } + + suspend fun getSettings(): NotificationSettingsModel { + return repository.getSettings() + } +} diff --git a/src/com/android/customization/picker/notifications/domain/interactor/NotificationsSnapshotRestorer.kt b/src/com/android/customization/picker/notifications/domain/interactor/NotificationsSnapshotRestorer.kt new file mode 100644 index 00000000..c782b74c --- /dev/null +++ b/src/com/android/customization/picker/notifications/domain/interactor/NotificationsSnapshotRestorer.kt @@ -0,0 +1,66 @@ +/* + * 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.notifications.domain.interactor + +import com.android.customization.picker.notifications.shared.model.NotificationSettingsModel +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 notification settings. */ +class NotificationsSnapshotRestorer( + private val interactor: NotificationsInteractor, +) : SnapshotRestorer { + + private var snapshotStore: SnapshotStore = SnapshotStore.NOOP + + fun storeSnapshot(model: NotificationSettingsModel) { + snapshotStore.store(snapshot(model)) + } + + override suspend fun setUpSnapshotRestorer( + store: SnapshotStore, + ): RestorableSnapshot { + snapshotStore = store + return snapshot(interactor.getSettings()) + } + + override suspend fun restoreToSnapshot(snapshot: RestorableSnapshot) { + val isShowNotificationsOnLockScreenEnabled = + snapshot.args[KEY_IS_SHOW_NOTIFICATIONS_ON_LOCK_SCREEN_ENABLED]?.toBoolean() ?: false + interactor.setSettings( + NotificationSettingsModel( + isShowNotificationsOnLockScreenEnabled = isShowNotificationsOnLockScreenEnabled, + ) + ) + } + + private fun snapshot(model: NotificationSettingsModel): RestorableSnapshot { + return RestorableSnapshot( + mapOf( + KEY_IS_SHOW_NOTIFICATIONS_ON_LOCK_SCREEN_ENABLED to + model.isShowNotificationsOnLockScreenEnabled.toString(), + ) + ) + } + + companion object { + private const val KEY_IS_SHOW_NOTIFICATIONS_ON_LOCK_SCREEN_ENABLED = + "is_show_notifications_on_lock_screen_enabled" + } +} diff --git a/src/com/android/customization/picker/notifications/shared/model/NotificationSettingsModel.kt b/src/com/android/customization/picker/notifications/shared/model/NotificationSettingsModel.kt new file mode 100644 index 00000000..7ce388b8 --- /dev/null +++ b/src/com/android/customization/picker/notifications/shared/model/NotificationSettingsModel.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.customization.picker.notifications.shared.model + +/** Models notification settings. */ +data class NotificationSettingsModel( + /** Whether notifications are shown on the lock screen. */ + val isShowNotificationsOnLockScreenEnabled: Boolean = false, +) diff --git a/src/com/android/customization/picker/notifications/ui/binder/NotificationSectionBinder.kt b/src/com/android/customization/picker/notifications/ui/binder/NotificationSectionBinder.kt new file mode 100644 index 00000000..54f9bf61 --- /dev/null +++ b/src/com/android/customization/picker/notifications/ui/binder/NotificationSectionBinder.kt @@ -0,0 +1,59 @@ +/* + * 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.notifications.ui.binder + +import android.annotation.SuppressLint +import android.view.View +import android.widget.Switch +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.notifications.ui.viewmodel.NotificationSectionViewModel +import com.android.wallpaper.R +import kotlinx.coroutines.launch + +/** + * Binds between view and view-model for a section that lets the user control notification settings. + */ +object NotificationSectionBinder { + @SuppressLint("UseSwitchCompatOrMaterialCode") // We're using Switch and that's okay for SysUI. + fun bind( + view: View, + viewModel: NotificationSectionViewModel, + lifecycleOwner: LifecycleOwner, + ) { + val subtitle: TextView = view.requireViewById(R.id.subtitle) + val switch: Switch = view.requireViewById(R.id.switcher) + + view.setOnClickListener { viewModel.onClicked() } + + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.subtitleStringResourceId.collect { + subtitle.text = view.context.getString(it) + } + } + + launch { viewModel.isSwitchOn.collect { switch.isChecked = it } } + } + } + } +} diff --git a/src/com/android/customization/picker/notifications/ui/section/NotificationSectionController.kt b/src/com/android/customization/picker/notifications/ui/section/NotificationSectionController.kt new file mode 100644 index 00000000..d35c3820 --- /dev/null +++ b/src/com/android/customization/picker/notifications/ui/section/NotificationSectionController.kt @@ -0,0 +1,57 @@ +/* + * 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.notifications.ui.section + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import androidx.lifecycle.LifecycleOwner +import com.android.customization.picker.notifications.ui.binder.NotificationSectionBinder +import com.android.customization.picker.notifications.ui.view.NotificationSectionView +import com.android.customization.picker.notifications.ui.viewmodel.NotificationSectionViewModel +import com.android.wallpaper.R +import com.android.wallpaper.model.CustomizationSectionController + +/** Controls a section with UI that lets the user toggle notification settings. */ +class NotificationSectionController( + private val viewModel: NotificationSectionViewModel, + private val lifecycleOwner: LifecycleOwner, +) : CustomizationSectionController<NotificationSectionView> { + + override fun isAvailable(context: Context): Boolean { + return true + } + + @SuppressLint("InflateParams") // We don't care that the parent is null. + override fun createView(context: Context): NotificationSectionView { + val view = + LayoutInflater.from(context) + .inflate( + R.layout.notification_section, + /* parent= */ null, + ) as NotificationSectionView + + NotificationSectionBinder.bind( + view = view, + viewModel = viewModel, + lifecycleOwner = lifecycleOwner, + ) + + return view + } +} diff --git a/src/com/android/customization/picker/notifications/ui/view/NotificationSectionView.kt b/src/com/android/customization/picker/notifications/ui/view/NotificationSectionView.kt new file mode 100644 index 00000000..29cce0ae --- /dev/null +++ b/src/com/android/customization/picker/notifications/ui/view/NotificationSectionView.kt @@ -0,0 +1,31 @@ +/* + * 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.notifications.ui.view + +import android.content.Context +import android.util.AttributeSet +import com.android.wallpaper.picker.SectionView + +class NotificationSectionView( + context: Context?, + attrs: AttributeSet?, +) : + SectionView( + context, + attrs, + ) diff --git a/src/com/android/customization/picker/notifications/ui/viewmodel/NotificationSectionViewModel.kt b/src/com/android/customization/picker/notifications/ui/viewmodel/NotificationSectionViewModel.kt new file mode 100644 index 00000000..97b04487 --- /dev/null +++ b/src/com/android/customization/picker/notifications/ui/viewmodel/NotificationSectionViewModel.kt @@ -0,0 +1,68 @@ +/* + * 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.notifications.ui.viewmodel + +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.android.customization.picker.notifications.domain.interactor.NotificationsInteractor +import com.android.wallpaper.R +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** Models UI state for a section that lets the user control the notification settings. */ +class NotificationSectionViewModel +@VisibleForTesting +constructor( + private val interactor: NotificationsInteractor, +) : ViewModel() { + + /** A string resource ID for the subtitle. */ + @StringRes + val subtitleStringResourceId: Flow<Int> = + interactor.settings.map { model -> + when (model.isShowNotificationsOnLockScreenEnabled) { + true -> R.string.show_notifications_on_lock_screen + false -> R.string.hide_notifications_on_lock_screen + } + } + + /** Whether the switch should be on. */ + val isSwitchOn: Flow<Boolean> = + interactor.settings.map { model -> model.isShowNotificationsOnLockScreenEnabled } + + /** Notifies that the section has been clicked. */ + fun onClicked() { + viewModelScope.launch { interactor.toggleShowNotificationsOnLockScreenEnabled() } + } + + class Factory( + private val interactor: NotificationsInteractor, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create(modelClass: Class<T>): T { + return NotificationSectionViewModel( + interactor = interactor, + ) + as T + } + } +} diff --git a/src/com/android/customization/picker/preview/ui/section/PreviewWithClockCarouselSectionController.kt b/src/com/android/customization/picker/preview/ui/section/PreviewWithClockCarouselSectionController.kt new file mode 100644 index 00000000..a2afc819 --- /dev/null +++ b/src/com/android/customization/picker/preview/ui/section/PreviewWithClockCarouselSectionController.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.customization.picker.preview.ui.section + +import android.app.Activity +import android.content.Context +import android.view.ViewGroup +import android.view.ViewStub +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.android.customization.picker.clock.ui.binder.ClockCarouselViewBinder +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 com.android.wallpaper.model.CustomizationSectionController +import com.android.wallpaper.model.WallpaperColorsViewModel +import com.android.wallpaper.module.CurrentWallpaperInfoFactory +import com.android.wallpaper.module.CustomizationSections +import com.android.wallpaper.picker.customization.domain.interactor.WallpaperInteractor +import com.android.wallpaper.picker.customization.ui.section.ScreenPreviewSectionController +import com.android.wallpaper.picker.customization.ui.section.ScreenPreviewView +import com.android.wallpaper.util.DisplayUtils +import kotlinx.coroutines.launch + +/** Controls the screen preview section. */ +class PreviewWithClockCarouselSectionController( + activity: Activity, + private val lifecycleOwner: LifecycleOwner, + private val initialScreen: CustomizationSections.Screen, + wallpaperInfoFactory: CurrentWallpaperInfoFactory, + colorViewModel: WallpaperColorsViewModel, + displayUtils: DisplayUtils, + private val clockCarouselViewModel: ClockCarouselViewModel, + private val clockViewFactory: ClockViewFactory, + navigator: CustomizationSectionController.CustomizationSectionNavigationController, + wallpaperInteractor: WallpaperInteractor, +) : + ScreenPreviewSectionController( + activity, + lifecycleOwner, + initialScreen, + wallpaperInfoFactory, + colorViewModel, + displayUtils, + navigator, + wallpaperInteractor, + ) { + + private var clockCarouselBinding: ClockCarouselViewBinder.Binding? = null + + override val hideLockScreenClockPreview = true + + override fun createView(context: Context): ScreenPreviewView { + val view = super.createView(context) + val carouselViewStub: ViewStub = view.requireViewById(R.id.clock_carousel_view_stub) + carouselViewStub.layoutResource = R.layout.clock_carousel_view + val carouselView = carouselViewStub.inflate() as ClockCarouselView + + // TODO (b/270716937) We should handle the single clock case in the clock carousel itself + val singleClockViewStub: ViewStub = view.requireViewById(R.id.single_clock_view_stub) + singleClockViewStub.layoutResource = R.layout.single_clock_view + val singleClockView = singleClockViewStub.inflate() as ViewGroup + lifecycleOwner.lifecycleScope.launch { + clockCarouselBinding = + ClockCarouselViewBinder.bind( + carouselView = carouselView, + singleClockView = singleClockView, + viewModel = clockCarouselViewModel, + clockViewFactory = clockViewFactory, + lifecycleOwner = lifecycleOwner, + ) + onScreenSwitched( + isOnLockScreen = initialScreen == CustomizationSections.Screen.LOCK_SCREEN + ) + } + return view + } + + override fun onScreenSwitched(isOnLockScreen: Boolean) { + super.onScreenSwitched(isOnLockScreen) + if (isOnLockScreen) { + clockCarouselBinding?.show() + } else { + clockCarouselBinding?.hide() + } + } +} diff --git a/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt b/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt index fd553fef..c432bd9a 100644 --- a/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt +++ b/src/com/android/customization/picker/quickaffordance/data/repository/KeyguardQuickAffordancePickerRepository.kt @@ -83,6 +83,7 @@ class KeyguardQuickAffordancePickerRepository( enablementInstructions = enablementInstructions ?: emptyList(), enablementActionText = enablementActionText, enablementActionComponentName = enablementActionComponentName, + configureIntent = configureIntent, ) } diff --git a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt index fbe303ba..f154de65 100644 --- a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt +++ b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt @@ -63,16 +63,6 @@ class KeyguardQuickAffordancePickerInteractor( snapshotRestorer.get().storeSnapshot() } - /** Unselects an affordance with the given ID from the slot with the given ID. */ - suspend fun unselect(slotId: String, affordanceId: String) { - client.deleteSelection( - slotId = slotId, - affordanceId = affordanceId, - ) - - snapshotRestorer.get().storeSnapshot() - } - /** Unselects all affordances from the slot with the given ID. */ suspend fun unselectAll(slotId: String) { client.deleteAllSelections( diff --git a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordanceSnapshotRestorer.kt b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordanceSnapshotRestorer.kt index cb2dbdc6..3c7928ce 100644 --- a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordanceSnapshotRestorer.kt +++ b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordanceSnapshotRestorer.kt @@ -19,6 +19,7 @@ package com.android.customization.picker.quickaffordance.domain.interactor import com.android.systemui.shared.customization.data.content.CustomizationProviderClient 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 the quick affordances system. */ @@ -27,16 +28,16 @@ class KeyguardQuickAffordanceSnapshotRestorer( private val client: CustomizationProviderClient, ) : SnapshotRestorer { - private lateinit var snapshotUpdater: (RestorableSnapshot) -> Unit + private var snapshotStore: SnapshotStore = SnapshotStore.NOOP suspend fun storeSnapshot() { - snapshotUpdater(snapshot()) + snapshotStore.store(snapshot()) } override suspend fun setUpSnapshotRestorer( - updater: (RestorableSnapshot) -> Unit, + store: SnapshotStore, ): RestorableSnapshot { - snapshotUpdater = updater + snapshotStore = store return snapshot() } diff --git a/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt index 1b18af74..7b04ff18 100644 --- a/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt +++ b/src/com/android/customization/picker/quickaffordance/shared/model/KeyguardQuickAffordancePickerAffordanceModel.kt @@ -17,6 +17,7 @@ package com.android.customization.picker.quickaffordance.shared.model +import android.content.Intent import androidx.annotation.DrawableRes /** Models a quick affordance. */ @@ -42,4 +43,6 @@ data class KeyguardQuickAffordancePickerAffordanceModel( * user to a destination where they can re-enable it. */ val enablementActionComponentName: String?, + /** Optional [Intent] to use to start an activity to configure this affordance. */ + val configureIntent: Intent?, ) diff --git a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt b/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt deleted file mode 100644 index b0dc350d..00000000 --- a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt +++ /dev/null @@ -1,95 +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.quickaffordance.ui.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel -import com.android.wallpaper.R - -/** Adapts between lock screen quick affordance items and views. */ -class AffordancesAdapter : RecyclerView.Adapter<AffordancesAdapter.ViewHolder>() { - - private val items = mutableListOf<KeyguardQuickAffordanceViewModel>() - - fun setItems(items: List<KeyguardQuickAffordanceViewModel>) { - this.items.clear() - this.items.addAll(items) - notifyDataSetChanged() - } - - class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val iconContainerView: View = itemView.requireViewById(R.id.icon_container) - val iconView: ImageView = itemView.requireViewById(R.id.icon) - val nameView: TextView = itemView.requireViewById(R.id.name) - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder( - LayoutInflater.from(parent.context) - .inflate( - R.layout.keyguard_quick_affordance, - parent, - false, - ) - ) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = items[position] - holder.itemView.alpha = - if (item.isEnabled) { - ALPHA_ENABLED - } else { - ALPHA_DISABLED - } - - holder.itemView.setOnClickListener( - if (item.onClicked != null) { - View.OnClickListener { item.onClicked.invoke() } - } else { - null - } - ) - holder.iconContainerView.setBackgroundResource( - if (item.isSelected) { - R.drawable.keyguard_quick_affordance_icon_container_background_selected - } else { - R.drawable.keyguard_quick_affordance_icon_container_background - } - ) - holder.iconView.isSelected = item.isSelected - holder.nameView.isSelected = item.isSelected - holder.iconView.setImageDrawable(item.icon) - holder.nameView.text = item.contentDescription - holder.nameView.isSelected = item.isSelected - } - - companion object { - private const val ALPHA_ENABLED = 1f - private const val ALPHA_DISABLED = 0.3f - } -} diff --git a/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt b/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt index acafef43..5203ed32 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/adapter/SlotTabAdapter.kt @@ -44,7 +44,7 @@ class SlotTabAdapter : RecyclerView.Adapter<SlotTabAdapter.ViewHolder>() { return ViewHolder( LayoutInflater.from(parent.context) .inflate( - R.layout.keyguard_quick_affordance_slot_tab, + R.layout.picker_fragment_tab, parent, false, ) diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceEnablementDialogBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceEnablementDialogBinder.kt deleted file mode 100644 index 809e09d6..00000000 --- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceEnablementDialogBinder.kt +++ /dev/null @@ -1,58 +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.quickaffordance.ui.binder - -import android.view.View -import android.widget.ImageView -import android.widget.TextView -import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel -import com.android.wallpaper.R - -object KeyguardQuickAffordanceEnablementDialogBinder { - - fun bind( - view: View, - viewModel: KeyguardQuickAffordancePickerViewModel.DialogViewModel, - onDismissed: () -> Unit, - ) { - view.requireViewById<ImageView>(R.id.icon).setImageDrawable(viewModel.icon) - view.requireViewById<TextView>(R.id.title).text = - view.context.getString( - R.string.keyguard_affordance_enablement_dialog_title, - viewModel.name - ) - view.requireViewById<TextView>(R.id.message).text = buildString { - viewModel.instructions.forEachIndexed { index, instruction -> - append(instruction) - if (index < viewModel.instructions.size - 1) { - append("\n") - } - } - } - view.requireViewById<TextView>(R.id.button).apply { - text = viewModel.actionText - setOnClickListener { - if (viewModel.intent != null) { - view.context.startActivity(viewModel.intent) - } else { - onDismissed() - } - } - } - } -} diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt index 389f8f62..4395f5e0 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt @@ -17,27 +17,33 @@ package com.android.customization.picker.quickaffordance.ui.binder -import android.app.AlertDialog import android.app.Dialog import android.content.Context -import android.graphics.Rect -import android.view.LayoutInflater import android.view.View -import androidx.core.view.ViewCompat +import android.widget.ImageView 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.quickaffordance.ui.adapter.AffordancesAdapter +import com.android.customization.picker.common.ui.view.ItemSpacing import com.android.customization.picker.quickaffordance.ui.adapter.SlotTabAdapter import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel import com.android.wallpaper.R +import com.android.wallpaper.picker.common.dialog.ui.viewbinder.DialogViewBinder +import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel +import com.android.wallpaper.picker.common.icon.ui.viewbinder.IconViewBinder +import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon +import com.android.wallpaper.picker.option.ui.adapter.OptionItemAdapter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +@OptIn(ExperimentalCoroutinesApi::class) object KeyguardQuickAffordancePickerBinder { /** Binds view with view-model for a lock screen quick affordance picker experience. */ @@ -54,12 +60,20 @@ object KeyguardQuickAffordancePickerBinder { slotTabView.adapter = slotTabAdapter slotTabView.layoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false) - slotTabView.addItemDecoration(ItemSpacing()) - val affordancesAdapter = AffordancesAdapter() + slotTabView.addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP)) + val affordancesAdapter = + OptionItemAdapter( + layoutResourceId = R.layout.keyguard_quick_affordance, + lifecycleOwner = lifecycleOwner, + bindIcon = { foregroundView: View, gridIcon: Icon -> + val imageView = foregroundView as? ImageView + imageView?.let { IconViewBinder.bind(imageView, gridIcon) } + } + ) affordancesView.adapter = affordancesAdapter affordancesView.layoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false) - affordancesView.addItemDecoration(ItemSpacing()) + affordancesView.addItemDecoration(ItemSpacing(ItemSpacing.ITEM_SPACING_DP)) var dialog: Dialog? = null @@ -78,6 +92,26 @@ object KeyguardQuickAffordancePickerBinder { } launch { + viewModel.quickAffordances + .flatMapLatest { affordances -> + combine(affordances.map { affordance -> affordance.isSelected }) { + selectedFlags -> + selectedFlags.indexOfFirst { it } + } + } + .collect { selectedPosition -> + // Scroll the view to show the first selected affordance. + if (selectedPosition != -1) { + // We use "post" because we need to give the adapter item a pass to + // update the view. + affordancesView.post { + affordancesView.smoothScrollToPosition(selectedPosition) + } + } + } + } + + launch { viewModel.dialog.distinctUntilChanged().collect { dialogRequest -> dialog?.dismiss() dialog = @@ -98,48 +132,13 @@ object KeyguardQuickAffordancePickerBinder { private fun showDialog( context: Context, - request: KeyguardQuickAffordancePickerViewModel.DialogViewModel, + request: DialogViewModel, onDismissed: () -> Unit, ): Dialog { - val view: View = - LayoutInflater.from(context) - .inflate( - R.layout.keyguard_quick_affordance_enablement_dialog, - null, - ) - KeyguardQuickAffordanceEnablementDialogBinder.bind( - view = view, + return DialogViewBinder.show( + context = context, viewModel = request, onDismissed = onDismissed, ) - - return AlertDialog.Builder(context, R.style.LightDialogTheme) - .setView(view) - .setOnDismissListener { onDismissed() } - .show() - } - - private class ItemSpacing : RecyclerView.ItemDecoration() { - override fun getItemOffsets(outRect: Rect, itemPosition: Int, parent: RecyclerView) { - val addSpacingToStart = itemPosition > 0 - val addSpacingToEnd = itemPosition < (parent.adapter?.itemCount ?: 0) - 1 - val isRtl = parent.layoutManager?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL - val density = parent.context.resources.displayMetrics.density - if (!isRtl) { - outRect.left = if (addSpacingToStart) ITEM_SPACING_DP.toPx(density) else 0 - outRect.right = if (addSpacingToEnd) ITEM_SPACING_DP.toPx(density) else 0 - } else { - outRect.left = if (addSpacingToEnd) ITEM_SPACING_DP.toPx(density) else 0 - outRect.right = if (addSpacingToStart) ITEM_SPACING_DP.toPx(density) else 0 - } - } - - private fun Int.toPx(density: Float): Int { - return (this * density).toInt() - } - - companion object { - private const val ITEM_SPACING_DP = 8 - } } } diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePreviewBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePreviewBinder.kt index 9248d66d..58a082dd 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePreviewBinder.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePreviewBinder.kt @@ -48,6 +48,7 @@ object KeyguardQuickAffordancePreviewBinder { viewModel = viewModel.preview, lifecycleOwner = lifecycleOwner, offsetToStart = offsetToStart, + dimWallpaper = true, ) previewView.contentDescription = diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt index c8880b9f..28ad51ac 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt @@ -27,6 +27,8 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel import com.android.wallpaper.R +import com.android.wallpaper.picker.common.icon.ui.viewbinder.IconViewBinder +import com.android.wallpaper.picker.common.text.ui.viewbinder.TextViewBinder import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -48,12 +50,25 @@ object KeyguardQuickAffordanceSectionViewBinder { viewModel.summary .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.RESUMED) .collectLatest { summary -> - descriptionView.text = summary.description + TextViewBinder.bind( + view = descriptionView, + viewModel = summary.description, + ) - icon1.setImageDrawable(summary.icon1) + if (summary.icon1 != null) { + IconViewBinder.bind( + view = icon1, + viewModel = summary.icon1, + ) + } icon1.isVisible = summary.icon1 != null - icon2.setImageDrawable(summary.icon2) + if (summary.icon2 != null) { + IconViewBinder.bind( + view = icon2, + viewModel = summary.icon2, + ) + } icon2.isVisible = summary.icon2 != null } } diff --git a/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt b/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt index 51b98efe..d5f0d33d 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/fragment/KeyguardQuickAffordancePickerFragment.kt @@ -30,7 +30,6 @@ import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQui import com.android.wallpaper.R import com.android.wallpaper.module.InjectorProvider import com.android.wallpaper.picker.AppbarFragment -import com.android.wallpaper.picker.undo.ui.binder.RevertToolbarButtonBinder import kotlinx.coroutines.ExperimentalCoroutinesApi @OptIn(ExperimentalCoroutinesApi::class) @@ -62,12 +61,6 @@ class KeyguardQuickAffordancePickerFragment : AppbarFragment() { injector.getKeyguardQuickAffordancePickerViewModelFactory(requireContext()), ) .get() - setUpToolbarMenu(R.menu.undoable_customization_menu) - RevertToolbarButtonBinder.bind( - view = view.requireViewById(toolbarId), - viewModel = viewModel.undo, - lifecycleOwner = this, - ) KeyguardQuickAffordancePreviewBinder.bind( activity = requireActivity(), @@ -75,7 +68,9 @@ class KeyguardQuickAffordancePickerFragment : AppbarFragment() { viewModel = viewModel, lifecycleOwner = this, offsetToStart = - injector.getDisplayUtils(requireActivity()).isOnWallpaperDisplay(requireActivity()) + requireActivity().let { + injector.getDisplayUtils(it).isSingleDisplayOrUnfoldedHorizontalHinge(it) + } ) KeyguardQuickAffordancePickerBinder.bind( view = view, @@ -88,4 +83,8 @@ class KeyguardQuickAffordancePickerFragment : AppbarFragment() { override fun getDefaultTitle(): CharSequence { return requireContext().getString(R.string.keyguard_quick_affordance_title) } + + override fun getToolbarColorId(): Int { + return android.R.color.transparent + } } diff --git a/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt b/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt index 6b35d7c9..e0beeff0 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/section/KeyguardQuickAffordanceSectionController.kt @@ -39,11 +39,11 @@ class KeyguardQuickAffordanceSectionController( private val isFeatureEnabled: Boolean = runBlocking { interactor.isFeatureEnabled() } - override fun isAvailable(context: Context?): Boolean { + override fun isAvailable(context: Context): Boolean { return isFeatureEnabled } - override fun createView(context: Context?): KeyguardQuickAffordanceSectionView { + override fun createView(context: Context): KeyguardQuickAffordanceSectionView { val view = LayoutInflater.from(context) .inflate( diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt index d8790451..14b6acca 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt @@ -32,17 +32,25 @@ import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordance import com.android.systemui.shared.quickaffordance.shared.model.KeyguardQuickAffordancePreviewConstants import com.android.wallpaper.R import com.android.wallpaper.module.CurrentWallpaperInfoFactory +import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonStyle +import com.android.wallpaper.picker.common.button.ui.viewmodel.ButtonViewModel +import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel +import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon +import com.android.wallpaper.picker.common.text.ui.viewmodel.Text import com.android.wallpaper.picker.customization.ui.viewmodel.ScreenPreviewViewModel -import com.android.wallpaper.picker.undo.domain.interactor.UndoInteractor -import com.android.wallpaper.picker.undo.ui.viewmodel.UndoViewModel +import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel import com.android.wallpaper.util.PreviewUtils import kotlinx.coroutines.ExperimentalCoroutinesApi 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.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -52,8 +60,8 @@ class KeyguardQuickAffordancePickerViewModel private constructor( context: Context, private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor, - undoInteractor: UndoInteractor, private val wallpaperInfoFactory: CurrentWallpaperInfoFactory, + activityStarter: (Intent) -> Unit, ) : ViewModel() { @SuppressLint("StaticFieldLeak") private val applicationContext = context.applicationContext @@ -74,6 +82,10 @@ private constructor( KeyguardQuickAffordancePreviewConstants.KEY_INITIALLY_SELECTED_SLOT_ID, selectedSlotId.value, ) + putBoolean( + KeyguardQuickAffordancePreviewConstants.KEY_HIGHLIGHT_QUICK_AFFORDANCES, + true, + ) } }, wallpaperInfoProvider = { @@ -88,13 +100,27 @@ private constructor( }, ) - val undo: UndoViewModel = - UndoViewModel( - interactor = undoInteractor, - ) - + /** A locally-selected slot, if the user ever switched from the original one. */ private val _selectedSlotId = MutableStateFlow<String?>(null) - val selectedSlotId: StateFlow<String?> = _selectedSlotId.asStateFlow() + /** The ID of the selected slot. */ + val selectedSlotId: StateFlow<String> = + combine( + quickAffordanceInteractor.slots, + _selectedSlotId, + ) { slots, selectedSlotIdOrNull -> + if (selectedSlotIdOrNull != null) { + slots.first { slot -> slot.id == selectedSlotIdOrNull } + } else { + // If we haven't yet selected a new slot locally, default to the first slot. + slots[0] + } + } + .map { selectedSlot -> selectedSlot.id } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = "", + ) /** View-models for each slot, keyed by slot ID. */ val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> = @@ -103,94 +129,132 @@ private constructor( quickAffordanceInteractor.affordances, quickAffordanceInteractor.selections, selectedSlotId, - ) { slots, affordances, selections, selectedSlotIdOrNull -> - slots - .mapIndexed { index, slot -> - val selectedAffordanceIds = - selections - .filter { selection -> selection.slotId == slot.id } - .map { selection -> selection.affordanceId } - .toSet() - val selectedAffordances = - affordances.filter { affordance -> - selectedAffordanceIds.contains(affordance.id) - } - val isSelected = - (selectedSlotIdOrNull == null && index == 0) || - selectedSlotIdOrNull == slot.id - slot.id to - KeyguardQuickAffordanceSlotViewModel( - name = getSlotName(slot.id), - isSelected = isSelected, - selectedQuickAffordances = - selectedAffordances.map { affordanceModel -> - KeyguardQuickAffordanceViewModel( - icon = getAffordanceIcon(affordanceModel.iconResourceId), - contentDescription = affordanceModel.name, - isSelected = true, - onClicked = null, - isEnabled = affordanceModel.isEnabled, - ) - }, - maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances, - onClicked = - if (isSelected) { - null - } else { - { _selectedSlotId.tryEmit(slot.id) } - }, - ) - } - .toMap() + ) { slots, affordances, selections, selectedSlotId -> + slots.associate { slot -> + val selectedAffordanceIds = + selections + .filter { selection -> selection.slotId == slot.id } + .map { selection -> selection.affordanceId } + .toSet() + val selectedAffordances = + affordances.filter { affordance -> + selectedAffordanceIds.contains(affordance.id) + } + val isSelected = selectedSlotId == slot.id + slot.id to + KeyguardQuickAffordanceSlotViewModel( + name = getSlotName(slot.id), + isSelected = isSelected, + selectedQuickAffordances = + selectedAffordances.map { affordanceModel -> + OptionItemViewModel<Icon>( + key = + MutableStateFlow("${slot.id}::${affordanceModel.id}") + as StateFlow<String>, + payload = + Icon.Loaded( + drawable = + getAffordanceIcon(affordanceModel.iconResourceId), + contentDescription = null, + ), + text = Text.Loaded(affordanceModel.name), + isSelected = MutableStateFlow(true) as StateFlow<Boolean>, + onClicked = flowOf(null), + onLongClicked = null, + isEnabled = true, + ) + }, + maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances, + onClicked = + if (isSelected) { + null + } else { + { _selectedSlotId.tryEmit(slot.id) } + }, + ) + } } - /** The list of all available quick affordances for the selected slot. */ - val quickAffordances: Flow<List<KeyguardQuickAffordanceViewModel>> = + /** + * The set of IDs of the currently-selected affordances. These change with user selection of new + * or different affordances in the currently-selected slot or when slot selection changes. + */ + private val selectedAffordanceIds: Flow<Set<String>> = combine( - quickAffordanceInteractor.slots, - quickAffordanceInteractor.affordances, - quickAffordanceInteractor.selections, - selectedSlotId, - ) { slots, affordances, selections, selectedSlotIdOrNull -> - val selectedSlot = - selectedSlotIdOrNull?.let { slots.find { slot -> slot.id == it } } ?: slots.first() - val selectedAffordanceIds = + quickAffordanceInteractor.selections, + selectedSlotId, + ) { selections, selectedSlotId -> selections - .filter { selection -> selection.slotId == selectedSlot.id } + .filter { selection -> selection.slotId == selectedSlotId } .map { selection -> selection.affordanceId } .toSet() + } + .shareIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + replay = 1, + ) + + /** The list of all available quick affordances for the selected slot. */ + val quickAffordances: Flow<List<OptionItemViewModel<Icon>>> = + quickAffordanceInteractor.affordances.map { affordances -> + val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }.stateIn(viewModelScope) listOf( none( - slotId = selectedSlot.id, - isSelected = selectedAffordanceIds.isEmpty(), + slotId = selectedSlotId, + isSelected = isNoneSelected, + onSelected = + combine( + isNoneSelected, + selectedSlotId, + ) { isSelected, selectedSlotId -> + if (!isSelected) { + { + viewModelScope.launch { + quickAffordanceInteractor.unselectAll(selectedSlotId) + } + } + } else { + null + } + } ) ) + affordances.map { affordance -> - val isSelected = selectedAffordanceIds.contains(affordance.id) val affordanceIcon = getAffordanceIcon(affordance.iconResourceId) - KeyguardQuickAffordanceViewModel( - icon = affordanceIcon, - contentDescription = affordance.name, - isSelected = isSelected, + val isSelectedFlow: StateFlow<Boolean> = + selectedAffordanceIds + .map { it.contains(affordance.id) } + .stateIn(viewModelScope) + OptionItemViewModel<Icon>( + key = + selectedSlotId + .map { slotId -> "$slotId::${affordance.id}" } + .stateIn(viewModelScope), + payload = Icon.Loaded(drawable = affordanceIcon, contentDescription = null), + text = Text.Loaded(affordance.name), + isSelected = isSelectedFlow, onClicked = if (affordance.isEnabled) { - { - viewModelScope.launch { - if (isSelected) { - quickAffordanceInteractor.unselect( - slotId = selectedSlot.id, - affordanceId = affordance.id, - ) - } else { - quickAffordanceInteractor.select( - slotId = selectedSlot.id, - affordanceId = affordance.id, - ) + combine( + isSelectedFlow, + selectedSlotId, + ) { isSelected, selectedSlotId -> + if (!isSelected) { + { + viewModelScope.launch { + quickAffordanceInteractor.select( + slotId = selectedSlotId, + affordanceId = affordance.id, + ) + } } + } else { + null } } } else { - { + flowOf { showEnablementDialog( icon = affordanceIcon, name = affordance.name, @@ -201,6 +265,12 @@ private constructor( ) } }, + onLongClicked = + if (affordance.configureIntent != null) { + { activityStarter(affordance.configureIntent) } + } else { + null + }, isEnabled = affordance.isEnabled, ) } @@ -210,21 +280,24 @@ private constructor( val summary: Flow<KeyguardQuickAffordanceSummaryViewModel> = slots.map { slots -> val icon2 = - slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END] - ?.selectedQuickAffordances - ?.firstOrNull() - ?.icon + (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END] + ?.selectedQuickAffordances + ?.firstOrNull()) + ?.payload val icon1 = - slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START] - ?.selectedQuickAffordances - ?.firstOrNull() - ?.icon + (slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START] + ?.selectedQuickAffordances + ?.firstOrNull()) + ?.payload KeyguardQuickAffordanceSummaryViewModel( description = toDescriptionText(context, slots), icon1 = icon1 ?: if (icon2 == null) { - context.getDrawable(R.drawable.link_off) + Icon.Resource( + res = R.drawable.link_off, + contentDescription = null, + ) } else { null }, @@ -254,28 +327,58 @@ private constructor( ) { _dialog.value = DialogViewModel( - icon = icon, - name = name, - instructions = instructions, - actionText = actionText - ?: applicationContext.getString( - R.string.keyguard_affordance_enablement_dialog_dismiss_button + icon = + Icon.Loaded( + drawable = icon, + contentDescription = null, + ), + title = Text.Loaded(name), + message = + Text.Loaded( + buildString { + instructions.forEachIndexed { index, instruction -> + if (index > 0) { + append('\n') + } + + append(instruction) + } + } + ), + buttons = + listOf( + ButtonViewModel( + text = actionText?.let { Text.Loaded(actionText) } + ?: Text.Resource( + R.string + .keyguard_affordance_enablement_dialog_dismiss_button, + ), + style = ButtonStyle.Primary, + onClicked = { + actionComponentName.toIntent()?.let { intent -> + applicationContext.startActivity(intent) + } + } ), - intent = actionComponentName.toIntent(), + ), ) } + /** Returns a view-model for the special "None" option. */ @SuppressLint("UseCompatLoadingForDrawables") - private fun none( - slotId: String, - isSelected: Boolean, - ): KeyguardQuickAffordanceViewModel { - return KeyguardQuickAffordanceViewModel.none( - context = applicationContext, + private suspend fun none( + slotId: StateFlow<String>, + isSelected: StateFlow<Boolean>, + onSelected: Flow<(() -> Unit)?>, + ): OptionItemViewModel<Icon> { + return OptionItemViewModel<Icon>( + key = slotId.map { "$it::none" }.stateIn(viewModelScope), + payload = Icon.Resource(res = R.drawable.link_off, contentDescription = null), + text = Text.Resource(res = R.string.keyguard_affordance_none), isSelected = isSelected, - onSelected = { - viewModelScope.launch { quickAffordanceInteractor.unselectAll(slotId) } - }, + onClicked = onSelected, + onLongClicked = null, + isEnabled = true, ) } @@ -318,70 +421,50 @@ private constructor( } } - /** Encapsulates a request to show a dialog. */ - data class DialogViewModel( - /** An icon to show. */ - val icon: Drawable, - - /** Name of the affordance. */ - val name: String, - - /** The set of instructions to show below the header. */ - val instructions: List<String>, - - /** Label for the dialog button. */ - val actionText: String, - - /** - * Optional [Intent] to use to start an activity when the dialog button is clicked. If - * `null`, the dialog should be dismissed. - */ - val intent: Intent?, - ) - private fun toDescriptionText( context: Context, slots: Map<String, KeyguardQuickAffordanceSlotViewModel>, - ): String { + ): Text { val bottomStartAffordanceName = slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START] ?.selectedQuickAffordances ?.firstOrNull() - ?.contentDescription + ?.text val bottomEndAffordanceName = slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END] ?.selectedQuickAffordances ?.firstOrNull() - ?.contentDescription + ?.text return when { - !bottomStartAffordanceName.isNullOrEmpty() && - !bottomEndAffordanceName.isNullOrEmpty() -> { - context.getString( - R.string.keyguard_quick_affordance_two_selected_template, - bottomStartAffordanceName, - bottomEndAffordanceName, + bottomStartAffordanceName != null && bottomEndAffordanceName != null -> { + Text.Loaded( + context.getString( + R.string.keyguard_quick_affordance_two_selected_template, + bottomStartAffordanceName.asString(context), + bottomEndAffordanceName.asString(context), + ) ) } - !bottomStartAffordanceName.isNullOrEmpty() -> bottomStartAffordanceName - !bottomEndAffordanceName.isNullOrEmpty() -> bottomEndAffordanceName - else -> context.getString(R.string.keyguard_quick_affordance_none_selected) + bottomStartAffordanceName != null -> bottomStartAffordanceName + bottomEndAffordanceName != null -> bottomEndAffordanceName + else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected) } } class Factory( private val context: Context, private val quickAffordanceInteractor: KeyguardQuickAffordancePickerInteractor, - private val undoInteractor: UndoInteractor, private val wallpaperInfoFactory: CurrentWallpaperInfoFactory, + private val activityStarter: (Intent) -> Unit, ) : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { @Suppress("UNCHECKED_CAST") return KeyguardQuickAffordancePickerViewModel( context = context, quickAffordanceInteractor = quickAffordanceInteractor, - undoInteractor = undoInteractor, wallpaperInfoFactory = wallpaperInfoFactory, + activityStarter = activityStarter, ) as T } diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt index bb9b29ba..4d11346f 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt @@ -17,6 +17,9 @@ package com.android.customization.picker.quickaffordance.ui.viewmodel +import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon +import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel + /** Models UI state for a single lock screen quick affordance slot in a picker experience. */ data class KeyguardQuickAffordanceSlotViewModel( /** User-visible name for the slot. */ @@ -30,7 +33,7 @@ data class KeyguardQuickAffordanceSlotViewModel( * * Useful for preview. */ - val selectedQuickAffordances: List<KeyguardQuickAffordanceViewModel>, + val selectedQuickAffordances: List<OptionItemViewModel<Icon>>, /** * The maximum number of quick affordances that can be selected for this slot. diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt index d5fc79b2..ee89d3ea 100644 --- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt +++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt @@ -17,10 +17,11 @@ package com.android.customization.picker.quickaffordance.ui.viewmodel -import android.graphics.drawable.Drawable +import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon +import com.android.wallpaper.picker.common.text.ui.viewmodel.Text data class KeyguardQuickAffordanceSummaryViewModel( - val description: String, - val icon1: Drawable?, - val icon2: Drawable?, + val description: Text, + val icon1: Icon?, + val icon2: Icon?, ) diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt deleted file mode 100644 index d720b0c4..00000000 --- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt +++ /dev/null @@ -1,63 +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.quickaffordance.ui.viewmodel - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.drawable.Drawable -import com.android.wallpaper.R - -/** Models UI state for a single lock screen quick affordance in a picker experience. */ -data class KeyguardQuickAffordanceViewModel( - /** An icon for the quick affordance. */ - val icon: Drawable, - - /** A content description for the icon. */ - val contentDescription: String, - - /** Whether this quick affordance is selected in its slot. */ - val isSelected: Boolean, - - /** Whether this quick affordance is enabled. */ - val isEnabled: Boolean, - - /** Notifies that the quick affordance has been clicked by the user. */ - val onClicked: (() -> Unit)?, -) { - companion object { - @SuppressLint("UseCompatLoadingForDrawables") - fun none( - context: Context, - isSelected: Boolean, - onSelected: () -> Unit, - ): KeyguardQuickAffordanceViewModel { - return KeyguardQuickAffordanceViewModel( - icon = checkNotNull(context.getDrawable(R.drawable.link_off)), - contentDescription = context.getString(R.string.keyguard_affordance_none), - isSelected = isSelected, - onClicked = - if (isSelected) { - null - } else { - onSelected - }, - isEnabled = true, - ) - } - } -} diff --git a/src/com/android/customization/picker/settings/ui/section/MoreSettingsSectionController.kt b/src/com/android/customization/picker/settings/ui/section/MoreSettingsSectionController.kt new file mode 100644 index 00000000..5e890cdd --- /dev/null +++ b/src/com/android/customization/picker/settings/ui/section/MoreSettingsSectionController.kt @@ -0,0 +1,45 @@ +/* + * 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.settings.ui.section + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.view.LayoutInflater +import com.android.customization.picker.settings.ui.view.MoreSettingsSectionView +import com.android.wallpaper.R +import com.android.wallpaper.model.CustomizationSectionController + +class MoreSettingsSectionController : CustomizationSectionController<MoreSettingsSectionView> { + + override fun isAvailable(context: Context): Boolean { + return true + } + + @SuppressLint("InflateParams") // We're okay not providing a parent view. + override fun createView(context: Context): MoreSettingsSectionView { + return LayoutInflater.from(context) + .inflate(R.layout.more_settings_section_view, null) + .apply { + setOnClickListener { + context.startActivity(Intent(Settings.ACTION_LOCKSCREEN_SETTINGS)) + } + } as MoreSettingsSectionView + } +} diff --git a/src/com/android/customization/picker/settings/ui/view/MoreSettingsSectionView.kt b/src/com/android/customization/picker/settings/ui/view/MoreSettingsSectionView.kt new file mode 100644 index 00000000..5de856e2 --- /dev/null +++ b/src/com/android/customization/picker/settings/ui/view/MoreSettingsSectionView.kt @@ -0,0 +1,31 @@ +/* + * 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.settings.ui.view + +import android.content.Context +import android.util.AttributeSet +import com.android.wallpaper.picker.SectionView + +class MoreSettingsSectionView( + context: Context, + attrs: AttributeSet?, +) : + SectionView( + context, + attrs, + ) |
