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.
 
 
 
 
 
 

829 lines
30 KiB

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_COLLAPSED_DP = 80
private const val DRAWER_HEIGHT_EXPANDED_DP = 240
private const val SLIDE_ANIMATION_DURATION = 300L
private const val SWIPE_THRESHOLD = 100f
private const val EXPAND_THRESHOLD = -50f // Negative because we're pulling up
}
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 isExpanded = false
private var currentDetectionResult: DetectionResult? = 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
currentDetectionResult = result
createDrawerView(result)
isShowing = true
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
{
// 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_COLLAPSED_DP) // Start collapsed
// 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 collapsed content (always visible)
addView(createCollapsedContent(result))
// Add expanded content (initially hidden)
addView(createExpandedContent(result))
// Set up touch handling for swipe and expand
setOnTouchListener(createExpandableSwipeTouchListener())
}
// 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 createCollapsedContent(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(20),
dpToPx(20)
).apply {
setMargins(0, 0, dpToPx(8), 0)
}
}
// Main content (compact)
val mainContent = createCompactDataRow(result)
// 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(24),
dpToPx(24)
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
}
addView(statusIcon)
addView(mainContent)
addView(dismissButton)
}
}
private fun createCompactDataRow(result: DetectionResult): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
if (result.success && result.pokemonInfo != null)
{
val pokemonInfo = result.pokemonInfo
val dataPoints = mutableListOf<String>()
// Collect all available data points
pokemonInfo.name?.let { dataPoints.add(it) }
pokemonInfo.nationalDexNumber?.let { dataPoints.add("#$it") }
pokemonInfo.cp?.let { dataPoints.add("CP $it") }
pokemonInfo.level?.let { dataPoints.add("Lv${String.format("%.1f", it)}") }
pokemonInfo.hp?.let { dataPoints.add("${it}HP") }
pokemonInfo.stats?.perfectIV?.let { dataPoints.add("${String.format("%.1f", it)}%") }
pokemonInfo.gender?.let { dataPoints.add(it) }
// Add processing time
dataPoints.add("${result.processingTimeMs}ms")
// Create compact display
val compactText = if (dataPoints.isNotEmpty()) {
dataPoints.joinToString("")
} else {
"Pokemon detected"
}
val textView = TextView(context).apply {
text = compactText
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
maxLines = 1
setSingleLine(true)
}
addView(textView)
}
else
{
val textView = TextView(context).apply {
text = "${if (result.success) "No Pokemon" else "Failed"}${result.processingTimeMs}ms"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
}
addView(textView)
}
}
}
private fun createExpandedContent(result: DetectionResult): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
visibility = View.GONE // Initially hidden
tag = "expanded_content" // For easy finding
// Add some spacing
setPadding(0, dpToPx(8), 0, 0)
if (result.success && result.pokemonInfo != null)
{
val pokemonInfo = result.pokemonInfo
// Pokemon Basic Info Section
addView(createSectionHeader("Pokemon Info"))
addView(createTwoColumnRow(
leftLabel = "Name", leftValue = pokemonInfo.name ?: "Unknown",
rightLabel = "Dex #", rightValue = pokemonInfo.nationalDexNumber?.let { "#$it" } ?: "N/A"
))
addView(createTwoColumnRow(
leftLabel = "Gender", leftValue = pokemonInfo.gender ?: "Unknown",
rightLabel = "Form", rightValue = pokemonInfo.form ?: "Normal"
))
// Combat Stats Section
addView(createSectionHeader("Combat Stats"))
addView(createTwoColumnRow(
leftLabel = "CP", leftValue = pokemonInfo.cp?.toString() ?: "N/A",
rightLabel = "HP", rightValue = pokemonInfo.hp?.toString() ?: "N/A"
))
addView(createTwoColumnRow(
leftLabel = "Level", leftValue = pokemonInfo.level?.let { String.format("%.1f", it) } ?: "N/A",
rightLabel = "IV %", rightValue = pokemonInfo.stats?.perfectIV?.let { "${String.format("%.1f", it)}%" } ?: "N/A"
))
// Individual Stats Section (if available)
pokemonInfo.stats?.let { stats ->
if (stats.attack != null || stats.defense != null || stats.stamina != null) {
addView(createSectionHeader("Individual Stats"))
addView(createThreeColumnRow(
leftLabel = "ATK", leftValue = stats.attack?.toString() ?: "?",
middleLabel = "DEF", middleValue = stats.defense?.toString() ?: "?",
rightLabel = "STA", rightValue = stats.stamina?.toString() ?: "?"
))
}
}
// Special Properties Section
addView(createSectionHeader("Properties"))
addView(createCheckboxRow(
leftLabel = "Shiny", leftChecked = false, // TODO: get from pokemonInfo when available
rightLabel = "Alpha", rightChecked = false // TODO: get from pokemonInfo when available
))
}
else
{
// Show error details
addView(createSectionHeader("Detection Failed"))
addView(createDetailRow("Status", if (result.success) "No Pokemon detected" else "Detection failed"))
result.errorMessage?.let { error ->
addView(createDetailRow("Error", error))
}
}
// Technical Info Section
addView(createSectionHeader("Technical Info"))
addView(createTwoColumnRow(
leftLabel = "Processing", leftValue = "${result.processingTimeMs}ms",
rightLabel = "Detected", rightValue = "${result.detections.size} items"
))
addView(createDetailRow("Timestamp", result.timestamp.format(DateTimeFormatter.ofPattern("HH:mm:ss"))))
}
}
private fun createDetailRow(label: String, value: String): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val labelView = TextView(context).apply {
text = "$label:"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
layoutParams = LinearLayout.LayoutParams(
dpToPx(80),
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
val valueView = TextView(context).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
typeface = android.graphics.Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
}
addView(labelView)
addView(valueView)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createSectionHeader(title: String): TextView
{
return TextView(context).apply {
text = title
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.holo_blue_light))
typeface = android.graphics.Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(8), 0, dpToPx(4))
}
}
}
private fun createTwoColumnRow(
leftLabel: String, leftValue: String,
rightLabel: String, rightValue: String
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left column
val leftColumn = createColumnItem(leftLabel, leftValue)
leftColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right column
val rightColumn = createColumnItem(rightLabel, rightValue)
rightColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
addView(leftColumn)
addView(rightColumn)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createThreeColumnRow(
leftLabel: String, leftValue: String,
middleLabel: String, middleValue: String,
rightLabel: String, rightValue: String
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left column
val leftColumn = createColumnItem(leftLabel, leftValue)
leftColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Middle column
val middleColumn = createColumnItem(middleLabel, middleValue)
middleColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(4), 0, dpToPx(4), 0)
}
// Right column
val rightColumn = createColumnItem(rightLabel, rightValue)
rightColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
addView(leftColumn)
addView(middleColumn)
addView(rightColumn)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createColumnItem(label: String, value: String): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.START
val labelView = TextView(context).apply {
text = "$label:"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 9f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
}
val valueView = TextView(context).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
addView(labelView)
addView(valueView)
}
}
private fun createCheckboxRow(
leftLabel: String, leftChecked: Boolean,
rightLabel: String, rightChecked: Boolean
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left checkbox
val leftCheckbox = createCheckboxItem(leftLabel, leftChecked)
leftCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right checkbox
val rightCheckbox = createCheckboxItem(rightLabel, rightChecked)
rightCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
addView(leftCheckbox)
addView(rightCheckbox)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createCheckboxItem(label: String, checked: Boolean): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Checkbox symbol
val checkboxView = TextView(context).apply {
text = if (checked) "" else ""
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(
if (checked) ContextCompat.getColor(context, android.R.color.holo_green_light)
else ContextCompat.getColor(context, android.R.color.darker_gray)
)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, dpToPx(6), 0)
}
}
// Label
val labelView = TextView(context).apply {
text = label
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
}
addView(checkboxView)
addView(labelView)
}
}
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 createExpandableSwipeTouchListener(): View.OnTouchListener
{
return View.OnTouchListener { view, event ->
when (event.action)
{
MotionEvent.ACTION_DOWN ->
{
isDragging = false
initialTouchY = event.rawY
initialTranslationY = view.translationY
true
}
MotionEvent.ACTION_MOVE ->
{
val deltaY = event.rawY - initialTouchY
if (!isDragging && abs(deltaY) > 20)
{
isDragging = true
}
if (isDragging)
{
if (deltaY > 0)
{
// Downward drag - dismissing
view.translationY = initialTranslationY + deltaY
}
else if (deltaY < 0 && !isExpanded)
{
// Upward drag - expanding (only if not already expanded)
// Don't move the view, just track the gesture
}
}
true
}
MotionEvent.ACTION_UP ->
{
if (isDragging)
{
val deltaY = event.rawY - initialTouchY
if (deltaY > SWIPE_THRESHOLD)
{
// Dismiss if swiped down enough
hide()
}
else if (deltaY < EXPAND_THRESHOLD && !isExpanded)
{
// Expand if swiped up enough
expandDrawer()
}
else if (deltaY > -EXPAND_THRESHOLD && isExpanded)
{
// Collapse if swiped down a bit while expanded
collapseDrawer()
}
else
{
// Snap back to current state
ObjectAnimator.ofFloat(view, "translationY", view.translationY, initialTranslationY).apply {
duration = 200L
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
}
else
{
// Simple tap - toggle expand/collapse
if (isExpanded)
{
collapseDrawer()
}
else
{
expandDrawer()
}
}
isDragging = false
true
}
else -> false
}
}
}
private fun expandDrawer()
{
if (isExpanded) return
isExpanded = true
// Show expanded content
drawerContainer?.findViewWithTag<LinearLayout>("expanded_content")?.let { expandedContent ->
expandedContent.visibility = View.VISIBLE
expandedContent.alpha = 0f
ObjectAnimator.ofFloat(expandedContent, "alpha", 0f, 1f).apply {
duration = SLIDE_ANIMATION_DURATION
start()
}
}
// Resize drawer window
drawerParams?.let { params ->
params.height = dpToPx(DRAWER_HEIGHT_EXPANDED_DP)
drawerContainer?.let { container ->
windowManager?.updateViewLayout(container, params)
}
}
PGHLog.d(TAG, "Drawer expanded")
}
private fun collapseDrawer()
{
if (!isExpanded) return
isExpanded = false
// Hide expanded content
drawerContainer?.findViewWithTag<LinearLayout>("expanded_content")?.let { expandedContent ->
ObjectAnimator.ofFloat(expandedContent, "alpha", 1f, 0f).apply {
duration = SLIDE_ANIMATION_DURATION
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
expandedContent.visibility = View.GONE
}
})
start()
}
}
// Resize drawer window
drawerParams?.let { params ->
params.height = dpToPx(DRAWER_HEIGHT_COLLAPSED_DP)
drawerContainer?.let { container ->
windowManager?.updateViewLayout(container, params)
}
}
PGHLog.d(TAG, "Drawer collapsed")
}
private fun animateIn()
{
drawerContainer?.let { container ->
val screenHeight = getScreenSize().second
container.translationY = dpToPx(DRAWER_HEIGHT_COLLAPSED_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 ->
val currentHeight = if (isExpanded) DRAWER_HEIGHT_EXPANDED_DP else DRAWER_HEIGHT_COLLAPSED_DP
ObjectAnimator.ofFloat(container, "translationY", 0f, dpToPx(currentHeight).toFloat()).apply {
duration = SLIDE_ANIMATION_DURATION
interpolator = AccelerateDecelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
onComplete()
}
})
start()
}
}
}
private fun cleanup()
{
drawerContainer = null
drawerParams = null
windowManager = null
currentDetectionResult = null
isShowing = false
isExpanded = false
}
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()
}
}