Signal-Android/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ui/custom/CustomChatColorCreatorPageF...

401 wiersze
13 KiB
Kotlin

package org.thoughtcrime.securesms.conversation.colors.ui.custom
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import android.widget.ScrollView
import android.widget.SeekBar
import androidx.annotation.ColorInt
import androidx.annotation.Dimension
import androidx.appcompat.widget.AppCompatSeekBar
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorPreviewView
import org.thoughtcrime.securesms.conversation.colors.ui.ChatColorSelectionViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.customizeOnDraw
private const val MAX_SEEK_DIVISIONS = 1023
private const val MAX_HUE = 360
private const val PAGE_ARG = "page"
private const val SINGLE_PAGE = 0
private const val GRADIENT_PAGE = 1
class CustomChatColorCreatorPageFragment :
Fragment(R.layout.custom_chat_color_creator_fragment_page) {
private lateinit var hueSlider: AppCompatSeekBar
private lateinit var saturationSlider: AppCompatSeekBar
private lateinit var preview: ChatColorPreviewView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args: CustomChatColorCreatorFragmentArgs = CustomChatColorCreatorFragmentArgs.fromBundle(requireArguments())
val chatColorSelectionViewModel: ChatColorSelectionViewModel = ChatColorSelectionViewModel.getOrCreate(requireActivity(), args.recipientId)
val page: Int = requireArguments().getInt(PAGE_ARG)
val factory: CustomChatColorCreatorViewModel.Factory = CustomChatColorCreatorViewModel.Factory(MAX_SEEK_DIVISIONS, ChatColors.Id.forLongValue(args.chatColorId), args.recipientId, createRepository())
val viewModel: CustomChatColorCreatorViewModel = ViewModelProvider(
requireParentFragment(),
factory
)[CustomChatColorCreatorViewModel::class.java]
preview = view.findViewById(R.id.chat_color_preview)
val hueThumb = ThumbDrawable(requireContext())
val saturationThumb = ThumbDrawable(requireContext())
val gradientTool: CustomChatColorGradientToolView = view.findViewById(R.id.gradient_tool)
val save: View = view.findViewById(R.id.save)
val scrollView: ScrollView = view.findViewById(R.id.scroll_view)
if (page == SINGLE_PAGE) {
gradientTool.visibility = View.GONE
} else {
gradientTool.setListener(object : CustomChatColorGradientToolView.Listener {
override fun onGestureStarted() {
scrollView.requestDisallowInterceptTouchEvent(true)
}
override fun onGestureFinished() {
scrollView.requestDisallowInterceptTouchEvent(false)
}
override fun onDegreesChanged(degrees: Float) {
viewModel.setDegrees(degrees)
}
override fun onSelectedEdgeChanged(edge: CustomChatColorEdge) {
viewModel.setSelectedEdge(edge)
}
})
}
hueSlider = view.findViewById(R.id.hue_slider)
saturationSlider = view.findViewById(R.id.saturation_slider)
hueSlider.thumb = hueThumb
saturationSlider.thumb = saturationThumb
hueSlider.max = MAX_SEEK_DIVISIONS
saturationSlider.max = MAX_SEEK_DIVISIONS
val colors: IntArray = (0..MAX_SEEK_DIVISIONS).map { hue ->
ColorUtils.HSLToColor(
floatArrayOf(
hue.toHue(MAX_SEEK_DIVISIONS),
1f,
calculateLightness(hue.toFloat(), valueFor60To80 = 0.4f)
)
)
}.toIntArray()
val hueGradientDrawable = GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colors)
hueSlider.progressDrawable = hueGradientDrawable.forSeekBar()
val saturationProgressDrawable = GradientDrawable().apply {
orientation = GradientDrawable.Orientation.LEFT_RIGHT
}
saturationSlider.progressDrawable = saturationProgressDrawable.forSeekBar()
hueSlider.setOnSeekBarChangeListener(
OnProgressChangedListener {
viewModel.setHueProgress(it)
}
)
saturationSlider.setOnSeekBarChangeListener(
OnProgressChangedListener {
viewModel.setSaturationProgress(it)
}
)
viewModel.events.observe(viewLifecycleOwner) { event ->
when (event) {
is CustomChatColorCreatorViewModel.Event.SaveNow -> {
viewModel.saveNow(event.chatColors) { colors ->
chatColorSelectionViewModel.save(colors)
}
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
}
is CustomChatColorCreatorViewModel.Event.Warn -> MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.CustomChatColorCreatorFragmentPage__edit_color)
.setMessage(resources.getQuantityString(R.plurals.CustomChatColorCreatorFragmentPage__this_color_is_used, event.usageCount, event.usageCount))
.setPositiveButton(R.string.save) { dialog, _ ->
dialog.dismiss()
viewModel.saveNow(event.chatColors) { colors ->
chatColorSelectionViewModel.save(colors)
}
Navigation.findNavController(requireParentFragment().requireView()).popBackStack()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
}
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.loading) {
return@observe
}
val sliderState: ColorSlidersState = requireNotNull(state.sliderStates[state.selectedEdge])
hueSlider.progress = sliderState.huePosition
saturationSlider.progress = sliderState.saturationPosition
val color: Int = sliderState.getColor()
hueThumb.setColor(sliderState.getHueColor())
saturationThumb.setColor(color)
saturationProgressDrawable.colors = sliderState.getSaturationColors()
preview.setWallpaper(state.wallpaper)
if (page == 0) {
val chatColor = ChatColors.forColor(ChatColors.Id.NotSet, color)
preview.setChatColors(chatColor)
save.setOnClickListener {
viewModel.startSave(chatColor)
}
} else {
val topEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.TOP])
val bottomEdgeColor: ColorSlidersState = requireNotNull(state.sliderStates[CustomChatColorEdge.BOTTOM])
val chatColor: ChatColors = ChatColors.forGradient(
ChatColors.Id.NotSet,
ChatColors.LinearGradient(
state.degrees,
intArrayOf(topEdgeColor.getColor(), bottomEdgeColor.getColor()),
floatArrayOf(0f, 1f)
)
)
preview.setChatColors(chatColor)
gradientTool.setSelectedEdge(state.selectedEdge)
gradientTool.setDegrees(state.degrees)
gradientTool.setTopColor(topEdgeColor.getColor())
gradientTool.setBottomColor(bottomEdgeColor.getColor())
save.setOnClickListener {
viewModel.startSave(chatColor)
}
}
}
if (page == 1 && SignalStore.chatColorsValues().shouldShowGradientTooltip) {
view.post {
SignalStore.chatColorsValues().shouldShowGradientTooltip = false
val contentView = layoutInflater.inflate(R.layout.gradient_tool_tooltip, view as ViewGroup, false)
val popupWindow = PopupWindow(contentView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
popupWindow.isOutsideTouchable = false
popupWindow.isFocusable = true
if (Build.VERSION.SDK_INT > 21) {
popupWindow.elevation = ViewUtil.dpToPx(8).toFloat()
}
popupWindow.showAsDropDown(gradientTool, 0, -gradientTool.measuredHeight + ViewUtil.dpToPx(48))
}
}
}
private fun createRepository(): CustomChatColorCreatorRepository {
return CustomChatColorCreatorRepository(requireContext())
}
@ColorInt
private fun ColorSlidersState.getHueColor(): Int {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
return ColorUtils.HSLToColor(
floatArrayOf(
hue,
1f,
calculateLightness(hue, 0.4f)
)
)
}
@ColorInt
private fun ColorSlidersState.getColor(): Int {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
return ColorUtils.HSLToColor(
floatArrayOf(
hue,
saturationPosition.toUnit(MAX_SEEK_DIVISIONS),
calculateLightness(hue)
)
)
}
private fun ColorSlidersState.getSaturationColors(): IntArray {
val hue = huePosition.toHue(MAX_SEEK_DIVISIONS)
val level = calculateLightness(hue)
return listOf(0f, 1f).map {
ColorUtils.HSLToColor(
floatArrayOf(
hue,
it,
level
)
)
}.toIntArray()
}
private fun calculateLightness(hue: Float, valueFor60To80: Float = 0.3f): Float {
val point1 = PointF()
val point2 = PointF()
if (hue >= 0f && hue < 60f) {
point1.set(0f, 0.45f)
point2.set(60f, valueFor60To80)
} else if (hue >= 60f && hue < 180f) {
return valueFor60To80
} else if (hue >= 180f && hue < 240f) {
point1.set(180f, valueFor60To80)
point2.set(240f, 0.5f)
} else if (hue >= 240f && hue < 300f) {
point1.set(240f, 0.5f)
point2.set(300f, 0.4f)
} else if (hue >= 300f && hue < 360f) {
point1.set(300f, 0.4f)
point2.set(360f, 0.45f)
} else {
return 0.45f
}
return interpolate(point1, point2, hue)
}
private fun interpolate(point1: PointF, point2: PointF, x: Float): Float {
return ((point1.y * (point2.x - x)) + (point2.y * (x - point1.x))) / (point2.x - point1.x)
}
private fun Number.toHue(max: Number): Float {
return Util.clamp(toFloat() * (MAX_HUE / max.toFloat()), 0f, MAX_HUE.toFloat())
}
private fun Number.toUnit(max: Number): Float {
return Util.clamp(toFloat() / max.toFloat(), 0f, 1f)
}
private fun Drawable.forSeekBar(): Drawable {
val height: Int = ViewUtil.dpToPx(8)
val radii: FloatArray = (1..8).map { 50f }.toFloatArray()
val bounds = RectF()
val clipPath = Path()
return customizeOnDraw { wrapped, canvas ->
canvas.save()
bounds.set(this.bounds)
bounds.inset(0f, (height / 2f) + 1)
clipPath.rewind()
clipPath.addRoundRect(bounds, radii, Path.Direction.CW)
canvas.clipPath(clipPath)
wrapped.draw(canvas)
canvas.restore()
}
}
private class OnProgressChangedListener(private val updateFn: (Int) -> Unit) :
SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateFn(progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
}
private class ThumbDrawable(context: Context) : Drawable() {
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.signal_background_primary)
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.TRANSPARENT
}
private val borderWidth: Int = ViewUtil.dpToPx(THUMB_MARGIN)
private val thumbInnerSize: Int = ViewUtil.dpToPx(THUMB_INNER_SIZE)
private val innerRadius: Float = thumbInnerSize / 2f
private val thumbSize: Float = (thumbInnerSize + borderWidth).toFloat()
private val thumbRadius: Float = thumbSize / 2f
override fun getIntrinsicHeight(): Int = ViewUtil.dpToPx(48)
override fun getIntrinsicWidth(): Int = ViewUtil.dpToPx(48)
fun setColor(@ColorInt color: Int) {
paint.color = color
invalidateSelf()
}
override fun draw(canvas: Canvas) {
canvas.drawCircle(
(bounds.width() / 2f) + bounds.left,
(bounds.height() / 2f) + bounds.top,
thumbRadius,
borderPaint
)
canvas.drawCircle(
(bounds.width() / 2f) + bounds.left,
(bounds.height() / 2f) + bounds.top,
innerRadius,
paint
)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
companion object {
@Dimension(unit = Dimension.DP)
private val THUMB_INNER_SIZE = 16
@Dimension(unit = Dimension.DP)
private val THUMB_MARGIN = 1
}
}
companion object {
fun forSingle(bundle: Bundle): Fragment = forPage(SINGLE_PAGE, bundle)
fun forGradient(bundle: Bundle): Fragment = forPage(GRADIENT_PAGE, bundle)
private fun forPage(page: Int, bundle: Bundle): Fragment = CustomChatColorCreatorPageFragment().apply {
arguments = Bundle().apply {
putInt(PAGE_ARG, page)
putAll(bundle)
}
}
}
}