diff --git a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt index 8eb46ad33..504e8f8d5 100644 --- a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt +++ b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt @@ -49,6 +49,7 @@ import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpType import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.timeout.ClipboardHelper +import com.kunzisoft.keepass.view.TagsListView import com.kunzisoft.keepass.view.setTextSize import com.kunzisoft.keepass.view.strikeOut import java.util.LinkedList @@ -195,6 +196,7 @@ class NodesAdapter ( && oldItem.foregroundColor == newItem.foregroundColor && oldItem.getOtpElement() == newItem.getOtpElement() && oldItem.containsAttachment() == newItem.containsAttachment() + && oldItem.tags == newItem.tags } else if (oldItem is Group && newItem is Group) { typeContentTheSame = oldItem.numberOfChildEntries == newItem.numberOfChildEntries && oldItem.notes == newItem.notes @@ -447,6 +449,8 @@ class NodesAdapter ( holder.attachmentIcon?.setColorFilter(foregroundColor) holder.meta.setTextColor(foregroundColor) iconColor = foregroundColor + holder.tagsContainer?.textColor = foregroundColor + holder.tagsContainer?.bgColor = foregroundColor } else { holder.text.setTextColor(mTextColor) holder.subText?.setTextColor(mTextColorSecondary) @@ -454,6 +458,8 @@ class NodesAdapter ( holder.otpProgress?.setIndicatorColor(mTextColorSecondary) holder.attachmentIcon?.setColorFilter(mTextColorSecondary) holder.meta.setTextColor(mTextColor) + holder.tagsContainer?.textColor = mTextColorSecondary + holder.tagsContainer?.bgColor = mTextColorSecondary } } else { holder.text.setTextColor(mColorOnSecondary) @@ -462,6 +468,12 @@ class NodesAdapter ( holder.otpProgress?.setIndicatorColor(mColorOnSecondary) holder.attachmentIcon?.setColorFilter(mColorOnSecondary) holder.meta.setTextColor(mColorOnSecondary) + holder.tagsContainer?.textColor = mColorOnSecondary + holder.tagsContainer?.bgColor = mColorOnSecondary + } + + holder.tagsContainer?.apply { + currentTags = subNode.tags.toList() } database.stopManageEntry(entry) @@ -600,6 +612,7 @@ class NodesAdapter ( var otpRunnable: OtpRunnable = OtpRunnable(otpContainer) var numberChildren: TextView? = itemView.findViewById(R.id.node_child_numbers) var attachmentIcon: ImageView? = itemView.findViewById(R.id.node_attachment_icon) + var tagsContainer: TagsListView? = itemView.findViewById(R.id.node_tags_container) } companion object { diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/Conversions.kt b/app/src/main/java/com/kunzisoft/keepass/utils/Conversions.kt new file mode 100644 index 000000000..fd9c58f0b --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/utils/Conversions.kt @@ -0,0 +1,67 @@ +package com.kunzisoft.keepass.utils + +import android.content.res.Resources + +sealed class Dimension( + protected val value: Float +) : Comparable { + + + abstract fun toDp(): Float + abstract fun toPx(): Float + abstract fun toSp(): Float + + val intPx: Int get() = toPx().toInt() + + override fun compareTo(other: Dimension): Int { + return toPx().compareTo(other.toPx()) + } + + class Dp(value: Float): Dimension(value) { + override fun toDp(): Float { + return value + } + + override fun toPx(): Float { + return value * Resources.getSystem().displayMetrics.density + } + + override fun toSp(): Float { + return Px(toPx()).toSp() + } + } + + class Px(value: Float): Dimension(value) { + override fun toDp(): Float { + return value / Resources.getSystem().displayMetrics.density + } + + override fun toPx(): Float { + return value + } + + override fun toSp(): Float { + return value / Resources.getSystem().displayMetrics.scaledDensity + } + } + + class Sp(value: Float): Dimension(value) { + override fun toDp(): Float { + return Px(toPx()).toDp() + } + + override fun toPx(): Float { + return value * Resources.getSystem().displayMetrics.scaledDensity + } + + override fun toSp(): Float { + return value + } + } +} + +val Float.dp get() = Dimension.Dp(this) +val Int.dp get() = toFloat().dp + +val Float.sp get() = Dimension.Sp(this) +val Int.sp get() = toFloat().sp diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/StubAnimatorListener.kt b/app/src/main/java/com/kunzisoft/keepass/utils/StubAnimatorListener.kt new file mode 100644 index 000000000..3d9689124 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/utils/StubAnimatorListener.kt @@ -0,0 +1,21 @@ +package com.kunzisoft.keepass.utils + +import android.animation.Animator + +abstract class StubAnimatorListener : Animator.AnimatorListener{ + override fun onAnimationStart(p0: Animator) { + // no-op + } + + override fun onAnimationEnd(p0: Animator) { + // no-op + } + + override fun onAnimationCancel(p0: Animator) { + // no-op + } + + override fun onAnimationRepeat(p0: Animator) { + // no-op + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TagsListView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TagsListView.kt new file mode 100644 index 000000000..d38afd2f5 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/view/TagsListView.kt @@ -0,0 +1,274 @@ +package com.kunzisoft.keepass.view + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.view.ViewTreeObserver +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.constraintlayout.helper.widget.Flow +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat.generateViewId +import androidx.core.view.children +import androidx.core.view.isGone +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.utils.StubAnimatorListener +import com.kunzisoft.keepass.utils.dp + +class TagsListView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { + + var textColor: Int? = null + set(value) { + if (field == value) { + return + } + field = value + expandBtn?.setColorFilter(value ?: Color.TRANSPARENT) + } + var bgColor: Int? = null + set(value) { + if (field == value) { + return + } + field = value + } + var currentTags: List = emptyList() + set(value) { + field = value + drawAllTagsAndMeasure() + } + + private var flow: Flow? = null + private var expandBtn: AppCompatImageView? = null + private var hiddenViews: MutableList = mutableListOf() + private var currentState: State = State.IDLE + private var animationHelper: AnimationHelper? = null + + init { + inflate(context, R.layout.tags_list_view, this) + initialize() + } + + private fun initialize() { + viewTreeObserver.addOnGlobalLayoutListener(InitialMeasuringObserver()) + flow = findViewById(R.id.flow) + expandBtn = findViewById(R.id.button) + expandBtn?.setOnClickListener { + animationHelper?.startAnimation() + val sign = if (currentState == State.EXPANDED) -1 else 1 + it.animate().rotationBy(180f * sign).start() + } + } + + private fun drawAllTagsAndMeasure() { + clear() + post { + layoutParams.height = WRAP_CONTENT + currentState = State.MEASURING_EXPANDED + makeTagsList() + } + } + + private fun clear() { + for (child in children.toList()) { + if (child.id == R.id.flow || child.id == R.id.button) continue + removeView(child) + flow?.removeView(child) + } + hiddenViews.clear() + } + + private fun makeTagsList() { + for (i in currentTags.indices) { + val view = createTagView(currentTags[i]) + addView(view) + if (i >= MAX_TAGS_IN_COLLAPSED) { + hiddenViews.add(view) + } + flow?.addView(view) + } + } + + private fun toggleHiddenViews(animate: Boolean) { + for (ind in hiddenViews.indices) { + toggleHiddenView(ind, animate) + } + } + + private fun toggleHiddenView(ind: Int, animate: Boolean) { + val isGone = hiddenViews[ind].isGone + val alpha = if (isGone) 1f else 0f + if (!animate) { + hiddenViews[ind].isGone = !isGone + hiddenViews[ind].alpha = alpha + return + } + + if (isGone) { + hiddenViews[ind].isGone = !isGone + } + hiddenViews[ind].animate().setListener(object : StubAnimatorListener() { + override fun onAnimationEnd(p0: Animator) { + if (!isGone) { + hiddenViews[ind].isGone = !isGone + } + requestLayout() + } + }).alpha(alpha).start() + } + + private inner class AnimationHelper( + expandedHeight: Int, + collapsedHeight: Int, + ) : StubAnimatorListener() { + + private val collapsingAnimator = setupAnimator(expandedHeight, collapsedHeight) + private val expandingAnimator = setupAnimator(collapsedHeight, expandedHeight) + + fun startAnimation() { + when (currentState) { + State.EXPANDED -> animateInternal(collapsingAnimator) + State.COLLAPSED -> animateInternal(expandingAnimator) + else -> { /* np-op */ } + } + } + + private fun animateInternal(animator: Animator) { + AnimatorSet().apply { + play(animator) + interpolator = AccelerateDecelerateInterpolator() + }.start() + } + + override fun onAnimationStart(p0: Animator) { + if (currentState == State.COLLAPSED) return + toggleHiddenViews(false) + } + + override fun onAnimationEnd(p0: Animator) { + currentState = currentState.next() + if (currentState == State.EXPANDED) { + toggleHiddenViews(true) + } + } + + private fun setupAnimator(from: Int, to: Int): Animator { + val animator = ValueAnimator.ofInt(from, to) + animator.duration = ANIMATION_DURATION + animator.addUpdateListener { animation -> + post { + layoutParams.height = animation.animatedValue as Int + requestLayout() + } + } + animator.addListener(this) + return animator + } + } + + private inner class InitialMeasuringObserver : ViewTreeObserver.OnGlobalLayoutListener { + private var expandedHeight = 0 + + override fun onGlobalLayout() { + when (currentState) { + State.MEASURING_EXPANDED -> { + expandedHeight = measuredHeight + currentState = currentState.next() + toggleHiddenViews(false) + } + State.MEASURING_COLLAPSED -> { + currentState = currentState.next() + animationHelper = AnimationHelper(expandedHeight, measuredHeight) + } + else -> { /* no-op */ } + } + } + } + + private enum class State { + MEASURING_EXPANDED { + override fun next() = MEASURING_COLLAPSED + }, + MEASURING_COLLAPSED { + override fun next() = COLLAPSED + }, + EXPANDED { + override fun next() = COLLAPSED + }, + COLLAPSED { + override fun next() = EXPANDED + }, + IDLE { + override fun next() = MEASURING_EXPANDED + }; + + abstract fun next(): State + } + + private companion object { + const val MAX_TAGS_IN_COLLAPSED = 4 + const val ANIMATION_DURATION = 300L + } +} + +private val VERTICAL_PADDING = 2.dp.intPx +private val HORIZONTAL_PADDING = 5.dp.intPx +private const val TAG_TEXT_SIZE = 13f +private val TAG_STROKE = 1.2f.dp.intPx + +private fun TagsListView.createTagView(tag: String): View { + val view = AppCompatTextView(context) + view.text = tag + view.id = generateViewId() + return styleTagView(view) +} + +private fun TagsListView.styleTagView(view: AppCompatTextView): View { + val bg = createTagBg() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + view.background = bg + } else { + view.setBackgroundDrawable(bg) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + view.setTextAppearance(R.style.KeepassDXStyle_Meta_Entry) + } else { + view.setTextAppearance(context, R.style.KeepassDXStyle_Meta_Entry) + } + + textColor?.let { + view.setTextColor(it) + } + + view.setPadding(HORIZONTAL_PADDING, VERTICAL_PADDING, HORIZONTAL_PADDING, VERTICAL_PADDING) + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, TAG_TEXT_SIZE) + + return view +} + +private fun TagsListView.createTagBg(): Drawable? { + val bg = ContextCompat.getDrawable( + context, + R.drawable.background_rounded_hollow_square, + ) as? GradientDrawable + + bgColor?.let { + bg?.setStroke(TAG_STROKE, it) + } + + return bg +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt index f5ad2e75d..2b1858d21 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt @@ -166,6 +166,38 @@ fun View.expand(animate: Boolean = true, }.start() } +fun View.expand( + from: Int, + to: Int, + onExpandFinished: (() -> Unit)? = null, +) { + layoutParams.height = 0 + val slideAnimator = ValueAnimator + .ofInt(from, to) + slideAnimator.duration = 300L + var alreadyVisible = false + slideAnimator.addUpdateListener { animation -> + layoutParams.height = animation.animatedValue as Int + if (!alreadyVisible && layoutParams.height > 0) { + visibility = View.VISIBLE + alreadyVisible = true + } + requestLayout() + } + AnimatorSet().apply { + play(slideAnimator) + interpolator = AccelerateDecelerateInterpolator() + addListener(object: Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) {} + override fun onAnimationRepeat(animation: Animator) {} + override fun onAnimationEnd(animation: Animator) { + onExpandFinished?.invoke() + } + override fun onAnimationCancel(animation: Animator) {} + }) + }.start() +} + /*** * This function returns the actual height the layout. * The getHeight() function returns the current height which might be zero if diff --git a/app/src/main/res/drawable/background_rounded_hollow_square.xml b/app/src/main/res/drawable/background_rounded_hollow_square.xml new file mode 100644 index 000000000..cdcb83aab --- /dev/null +++ b/app/src/main/res/drawable/background_rounded_hollow_square.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_list_nodes_entry.xml b/app/src/main/res/layout/item_list_nodes_entry.xml index 78ef4b236..9773898e7 100644 --- a/app/src/main/res/layout/item_list_nodes_entry.xml +++ b/app/src/main/res/layout/item_list_nodes_entry.xml @@ -111,6 +111,13 @@ android:maxLines="2" android:visibility="gone" tools:text="Database / Group A / Group B" /> + + + + + + + + + + + \ No newline at end of file diff --git a/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt b/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt index 608a2f34b..de01af188 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt @@ -73,6 +73,17 @@ class Tags: Parcelable { return mTags.joinToString(DELIMITER.toString()) } + override fun equals(other: Any?): Boolean { + return when (other) { + !is Tags -> false + else -> mTags == other.toList() + } + } + + override fun hashCode(): Int { + return mTags.hashCode() + } + companion object CREATOR : Parcelable.Creator { const val DELIMITER= ',' const val DELIMITER1= ';'