You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
466 lines
16 KiB
466 lines
16 KiB
|
5 months ago
|
package com.quillstudios.pokegoalshelper.ui
|
||
|
|
|
||
|
|
import android.animation.ObjectAnimator
|
||
|
|
import android.animation.ValueAnimator
|
||
|
|
import android.animation.AnimatorListenerAdapter
|
||
|
|
import android.animation.Animator
|
||
|
|
import android.content.Context
|
||
|
|
import android.graphics.PixelFormat
|
||
|
|
import android.graphics.drawable.GradientDrawable
|
||
|
|
import android.os.Build
|
||
|
|
import android.util.TypedValue
|
||
|
|
import android.view.*
|
||
|
|
import android.view.animation.AccelerateDecelerateInterpolator
|
||
|
|
import android.widget.*
|
||
|
|
import androidx.core.content.ContextCompat
|
||
|
|
import com.quillstudios.pokegoalshelper.R
|
||
|
|
import com.quillstudios.pokegoalshelper.models.DetectionResult
|
||
|
|
import com.quillstudios.pokegoalshelper.utils.PGHLog
|
||
|
|
import java.time.format.DateTimeFormatter
|
||
|
|
import kotlin.math.abs
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Bottom drawer that slides up to display detection results immediately after capture.
|
||
|
|
*
|
||
|
|
* Features:
|
||
|
|
* - Slides up from bottom with smooth animation
|
||
|
|
* - Shows Pokemon detection results with formatted data
|
||
|
|
* - Auto-dismiss after timeout or manual dismiss
|
||
|
|
* - Expandable for more details
|
||
|
|
* - Gesture handling for swipe dismiss
|
||
|
|
*/
|
||
|
|
class ResultsBottomDrawer(private val context: Context)
|
||
|
|
{
|
||
|
|
companion object
|
||
|
|
{
|
||
|
|
private const val TAG = "ResultsBottomDrawer"
|
||
|
|
private const val DRAWER_HEIGHT_DP = 120
|
||
|
|
private const val SLIDE_ANIMATION_DURATION = 300L
|
||
|
|
private const val AUTO_DISMISS_DELAY = 5000L // 5 seconds
|
||
|
|
private const val SWIPE_THRESHOLD = 100f
|
||
|
|
}
|
||
|
|
|
||
|
|
private var windowManager: WindowManager? = null
|
||
|
|
private var drawerContainer: LinearLayout? = null
|
||
|
|
private var drawerParams: WindowManager.LayoutParams? = null
|
||
|
|
private var isShowing = false
|
||
|
|
private var isDragging = false
|
||
|
|
private var autoDismissRunnable: Runnable? = null
|
||
|
|
|
||
|
|
// Touch handling
|
||
|
|
private var initialTouchY = 0f
|
||
|
|
private var initialTranslationY = 0f
|
||
|
|
|
||
|
|
fun show(result: DetectionResult)
|
||
|
|
{
|
||
|
|
if (isShowing) return
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||
|
|
createDrawerView(result)
|
||
|
|
isShowing = true
|
||
|
|
|
||
|
|
// Schedule auto-dismiss
|
||
|
|
scheduleAutoDismiss()
|
||
|
|
|
||
|
|
PGHLog.d(TAG, "Bottom drawer shown for detection: ${result.id}")
|
||
|
|
}
|
||
|
|
catch (e: Exception)
|
||
|
|
{
|
||
|
|
PGHLog.e(TAG, "Failed to show bottom drawer", e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fun hide()
|
||
|
|
{
|
||
|
|
if (!isShowing) return
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Cancel auto-dismiss
|
||
|
|
autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) }
|
||
|
|
|
||
|
|
// Animate out
|
||
|
|
animateOut {
|
||
|
|
try
|
||
|
|
{
|
||
|
|
drawerContainer?.let { windowManager?.removeView(it) }
|
||
|
|
cleanup()
|
||
|
|
}
|
||
|
|
catch (e: Exception)
|
||
|
|
{
|
||
|
|
PGHLog.e(TAG, "Error removing drawer view", e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
PGHLog.d(TAG, "Bottom drawer hidden")
|
||
|
|
}
|
||
|
|
catch (e: Exception)
|
||
|
|
{
|
||
|
|
PGHLog.e(TAG, "Failed to hide bottom drawer", e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun createDrawerView(result: DetectionResult)
|
||
|
|
{
|
||
|
|
val screenSize = getScreenSize()
|
||
|
|
val drawerHeight = dpToPx(DRAWER_HEIGHT_DP)
|
||
|
|
|
||
|
|
// Create main container
|
||
|
|
drawerContainer = LinearLayout(context).apply {
|
||
|
|
orientation = LinearLayout.VERTICAL
|
||
|
|
background = createDrawerBackground()
|
||
|
|
gravity = Gravity.CENTER_HORIZONTAL
|
||
|
|
setPadding(dpToPx(16), dpToPx(12), dpToPx(16), dpToPx(16))
|
||
|
|
|
||
|
|
// Add drag handle
|
||
|
|
addView(createDragHandle())
|
||
|
|
|
||
|
|
// Add result content
|
||
|
|
addView(createResultContent(result))
|
||
|
|
|
||
|
|
// Set up touch handling for swipe dismiss
|
||
|
|
setOnTouchListener(createSwipeTouchListener())
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create window parameters
|
||
|
|
drawerParams = WindowManager.LayoutParams(
|
||
|
|
WindowManager.LayoutParams.MATCH_PARENT,
|
||
|
|
drawerHeight,
|
||
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||
|
|
{
|
||
|
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
@Suppress("DEPRECATION")
|
||
|
|
WindowManager.LayoutParams.TYPE_PHONE
|
||
|
|
},
|
||
|
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||
|
|
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||
|
|
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||
|
|
PixelFormat.TRANSLUCENT
|
||
|
|
).apply {
|
||
|
|
gravity = Gravity.BOTTOM
|
||
|
|
y = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add to window manager
|
||
|
|
windowManager?.addView(drawerContainer, drawerParams)
|
||
|
|
|
||
|
|
// Animate in
|
||
|
|
animateIn()
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun createDragHandle(): View
|
||
|
|
{
|
||
|
|
return View(context).apply {
|
||
|
|
background = GradientDrawable().apply {
|
||
|
|
setColor(ContextCompat.getColor(context, android.R.color.darker_gray))
|
||
|
|
cornerRadius = dpToPx(2).toFloat()
|
||
|
|
}
|
||
|
|
|
||
|
|
layoutParams = LinearLayout.LayoutParams(
|
||
|
|
dpToPx(40),
|
||
|
|
dpToPx(4)
|
||
|
|
).apply {
|
||
|
|
setMargins(0, 0, 0, dpToPx(8))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun createResultContent(result: DetectionResult): LinearLayout
|
||
|
|
{
|
||
|
|
return LinearLayout(context).apply {
|
||
|
|
orientation = LinearLayout.HORIZONTAL
|
||
|
|
gravity = Gravity.CENTER_VERTICAL
|
||
|
|
|
||
|
|
// Status icon
|
||
|
|
val statusIcon = ImageView(context).apply {
|
||
|
|
setImageResource(
|
||
|
|
if (result.success) android.R.drawable.ic_menu_myplaces
|
||
|
|
else android.R.drawable.ic_dialog_alert
|
||
|
|
)
|
||
|
|
setColorFilter(
|
||
|
|
ContextCompat.getColor(
|
||
|
|
context,
|
||
|
|
if (result.success) android.R.color.holo_green_light
|
||
|
|
else android.R.color.holo_red_light
|
||
|
|
)
|
||
|
|
)
|
||
|
|
layoutParams = LinearLayout.LayoutParams(
|
||
|
|
dpToPx(24),
|
||
|
|
dpToPx(24)
|
||
|
|
).apply {
|
||
|
|
setMargins(0, 0, dpToPx(12), 0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Content container
|
||
|
|
val contentContainer = LinearLayout(context).apply {
|
||
|
|
orientation = LinearLayout.VERTICAL
|
||
|
|
layoutParams = LinearLayout.LayoutParams(
|
||
|
|
0,
|
||
|
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||
|
|
1f
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (result.success && result.pokemonInfo != null)
|
||
|
|
{
|
||
|
|
// Pokemon found - show details
|
||
|
|
val pokemonInfo = result.pokemonInfo
|
||
|
|
|
||
|
|
// Pokemon name and CP
|
||
|
|
val titleText = buildString {
|
||
|
|
append(pokemonInfo.name ?: "Unknown Pokemon")
|
||
|
|
pokemonInfo.cp?.let { append(" (CP $it)") }
|
||
|
|
}
|
||
|
|
|
||
|
|
val titleView = TextView(context).apply {
|
||
|
|
text = titleText
|
||
|
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||
|
|
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
||
|
|
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||
|
|
}
|
||
|
|
|
||
|
|
// Additional details
|
||
|
|
val detailsText = buildString {
|
||
|
|
pokemonInfo.level?.let { append("Level ${String.format("%.1f", it)}") }
|
||
|
|
pokemonInfo.stats?.perfectIV?.let {
|
||
|
|
if (isNotEmpty()) append(" • ")
|
||
|
|
append("${String.format("%.1f", it)}% IV")
|
||
|
|
}
|
||
|
|
pokemonInfo.hp?.let {
|
||
|
|
if (isNotEmpty()) append(" • ")
|
||
|
|
append("${it} HP")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
val detailsView = TextView(context).apply {
|
||
|
|
text = detailsText
|
||
|
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
|
||
|
|
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
|
||
|
|
}
|
||
|
|
|
||
|
|
contentContainer.addView(titleView)
|
||
|
|
if (detailsText.isNotEmpty())
|
||
|
|
{
|
||
|
|
contentContainer.addView(detailsView)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// Detection failed or no Pokemon found
|
||
|
|
val titleView = TextView(context).apply {
|
||
|
|
text = if (result.success) "No Pokemon detected" else "Detection failed"
|
||
|
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||
|
|
setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
||
|
|
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||
|
|
}
|
||
|
|
|
||
|
|
val detailsView = TextView(context).apply {
|
||
|
|
text = result.errorMessage ?: "Try again with a clearer view"
|
||
|
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
|
||
|
|
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
|
||
|
|
}
|
||
|
|
|
||
|
|
contentContainer.addView(titleView)
|
||
|
|
contentContainer.addView(detailsView)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Processing time and timestamp
|
||
|
|
val metaText = buildString {
|
||
|
|
append("${result.processingTimeMs}ms")
|
||
|
|
append(" • ")
|
||
|
|
append(result.timestamp.format(DateTimeFormatter.ofPattern("HH:mm:ss")))
|
||
|
|
}
|
||
|
|
|
||
|
|
val metaView = TextView(context).apply {
|
||
|
|
text = metaText
|
||
|
|
setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
|
||
|
|
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
|
||
|
|
}
|
||
|
|
|
||
|
|
contentContainer.addView(metaView)
|
||
|
|
|
||
|
|
// Dismiss button
|
||
|
|
val dismissButton = ImageButton(context).apply {
|
||
|
|
setImageResource(android.R.drawable.ic_menu_close_clear_cancel)
|
||
|
|
background = createCircularBackground()
|
||
|
|
setColorFilter(ContextCompat.getColor(context, android.R.color.white))
|
||
|
|
setOnClickListener { hide() }
|
||
|
|
|
||
|
|
layoutParams = LinearLayout.LayoutParams(
|
||
|
|
dpToPx(32),
|
||
|
|
dpToPx(32)
|
||
|
|
).apply {
|
||
|
|
setMargins(dpToPx(12), 0, 0, 0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
addView(statusIcon)
|
||
|
|
addView(contentContainer)
|
||
|
|
addView(dismissButton)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun createDrawerBackground(): GradientDrawable
|
||
|
|
{
|
||
|
|
return GradientDrawable().apply {
|
||
|
|
setColor(ContextCompat.getColor(context, android.R.color.black))
|
||
|
|
alpha = (0.9f * 255).toInt()
|
||
|
|
cornerRadii = floatArrayOf(
|
||
|
|
dpToPx(12).toFloat(), dpToPx(12).toFloat(), // top-left
|
||
|
|
dpToPx(12).toFloat(), dpToPx(12).toFloat(), // top-right
|
||
|
|
0f, 0f, // bottom-right
|
||
|
|
0f, 0f // bottom-left
|
||
|
|
)
|
||
|
|
setStroke(2, ContextCompat.getColor(context, android.R.color.darker_gray))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun createCircularBackground(): GradientDrawable
|
||
|
|
{
|
||
|
|
return GradientDrawable().apply {
|
||
|
|
setColor(ContextCompat.getColor(context, android.R.color.transparent))
|
||
|
|
shape = GradientDrawable.OVAL
|
||
|
|
setStroke(1, ContextCompat.getColor(context, android.R.color.darker_gray))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun createSwipeTouchListener(): View.OnTouchListener
|
||
|
|
{
|
||
|
|
return View.OnTouchListener { view, event ->
|
||
|
|
when (event.action)
|
||
|
|
{
|
||
|
|
MotionEvent.ACTION_DOWN ->
|
||
|
|
{
|
||
|
|
isDragging = false
|
||
|
|
initialTouchY = event.rawY
|
||
|
|
initialTranslationY = view.translationY
|
||
|
|
|
||
|
|
// Cancel auto-dismiss while user is interacting
|
||
|
|
autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) }
|
||
|
|
true
|
||
|
|
}
|
||
|
|
|
||
|
|
MotionEvent.ACTION_MOVE ->
|
||
|
|
{
|
||
|
|
val deltaY = event.rawY - initialTouchY
|
||
|
|
|
||
|
|
if (!isDragging && abs(deltaY) > 20)
|
||
|
|
{
|
||
|
|
isDragging = true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isDragging && deltaY > 0)
|
||
|
|
{
|
||
|
|
// Only allow downward drag (dismissing)
|
||
|
|
view.translationY = initialTranslationY + deltaY
|
||
|
|
}
|
||
|
|
true
|
||
|
|
}
|
||
|
|
|
||
|
|
MotionEvent.ACTION_UP ->
|
||
|
|
{
|
||
|
|
if (isDragging)
|
||
|
|
{
|
||
|
|
val deltaY = event.rawY - initialTouchY
|
||
|
|
if (deltaY > SWIPE_THRESHOLD)
|
||
|
|
{
|
||
|
|
// Dismiss if swiped down enough
|
||
|
|
hide()
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// Snap back
|
||
|
|
ObjectAnimator.ofFloat(view, "translationY", view.translationY, initialTranslationY).apply {
|
||
|
|
duration = 200L
|
||
|
|
interpolator = AccelerateDecelerateInterpolator()
|
||
|
|
start()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restart auto-dismiss
|
||
|
|
scheduleAutoDismiss()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
// Restart auto-dismiss on tap
|
||
|
|
scheduleAutoDismiss()
|
||
|
|
}
|
||
|
|
|
||
|
|
isDragging = false
|
||
|
|
true
|
||
|
|
}
|
||
|
|
|
||
|
|
else -> false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun animateIn()
|
||
|
|
{
|
||
|
|
drawerContainer?.let { container ->
|
||
|
|
val screenHeight = getScreenSize().second
|
||
|
|
container.translationY = dpToPx(DRAWER_HEIGHT_DP).toFloat()
|
||
|
|
|
||
|
|
ObjectAnimator.ofFloat(container, "translationY", container.translationY, 0f).apply {
|
||
|
|
duration = SLIDE_ANIMATION_DURATION
|
||
|
|
interpolator = AccelerateDecelerateInterpolator()
|
||
|
|
start()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun animateOut(onComplete: () -> Unit)
|
||
|
|
{
|
||
|
|
drawerContainer?.let { container ->
|
||
|
|
ObjectAnimator.ofFloat(container, "translationY", 0f, dpToPx(DRAWER_HEIGHT_DP).toFloat()).apply {
|
||
|
|
duration = SLIDE_ANIMATION_DURATION
|
||
|
|
interpolator = AccelerateDecelerateInterpolator()
|
||
|
|
addListener(object : AnimatorListenerAdapter() {
|
||
|
|
override fun onAnimationEnd(animation: Animator) {
|
||
|
|
onComplete()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
start()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun scheduleAutoDismiss()
|
||
|
|
{
|
||
|
|
autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) }
|
||
|
|
|
||
|
|
autoDismissRunnable = Runnable { hide() }
|
||
|
|
android.os.Handler().postDelayed(autoDismissRunnable!!, AUTO_DISMISS_DELAY)
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun cleanup()
|
||
|
|
{
|
||
|
|
drawerContainer = null
|
||
|
|
drawerParams = null
|
||
|
|
windowManager = null
|
||
|
|
isShowing = false
|
||
|
|
autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) }
|
||
|
|
autoDismissRunnable = null
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun getScreenSize(): Pair<Int, Int>
|
||
|
|
{
|
||
|
|
val displayMetrics = context.resources.displayMetrics
|
||
|
|
return Pair(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun dpToPx(dp: Int): Int
|
||
|
|
{
|
||
|
|
return TypedValue.applyDimension(
|
||
|
|
TypedValue.COMPLEX_UNIT_DIP,
|
||
|
|
dp.toFloat(),
|
||
|
|
context.resources.displayMetrics
|
||
|
|
).toInt()
|
||
|
|
}
|
||
|
|
}
|