diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt index 7e8a4b0da..e69e41144 100644 --- a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt @@ -4,10 +4,13 @@ import android.app.Service import android.content.Intent import android.graphics.Color import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.text.TextUtils import android.util.TypedValue import android.view.Gravity @@ -19,10 +22,8 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import io.flutter.plugin.common.MethodChannel -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +/// 悬浮窗口服务 class FloatingWindowService : Service() { companion object { const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT" @@ -51,11 +52,23 @@ class FloatingWindowService : Service() { private var sourceTextView: TextView? = null private var translatedTextView: TextView? = null + private var sourceLoadingView: TextView? = null + private var translatedLoadingView: TextView? = null private var languageInfoView: TextView? = null - private var timestampView: TextView? = null private var minimizedView: View? = null private var expandedView: View? = null + private var minimizedTextView: TextView? = null private var closeButton: TextView? = null + private var expandedContainer: LinearLayout? = null + + private var baseExpandedWidth: Int = 0 + private var uiScale: Float = 1.0f + private val backgroundBaseColor: Int = Color.argb(150, 40, 40, 40) + private val uiHandler: Handler = Handler(Looper.getMainLooper()) + private var sourceDotsStep: Int = 0 + private var translatedDotsStep: Int = 0 + private var sourceDotsRunnable: Runnable? = null + private var translatedDotsRunnable: Runnable? = null override fun onCreate() { super.onCreate() @@ -76,6 +89,7 @@ class FloatingWindowService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { + stopAllLoading() removeFloatingWindow() serviceInstance = null super.onDestroy() @@ -129,39 +143,73 @@ class FloatingWindowService : Service() { } private fun createMinimizedView(): View { - val container = FrameLayout(this) - val lp = FrameLayout.LayoutParams(dp(60), dp(60)) + val container = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + val lp = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(48)) val margin = dp(8) lp.setMargins(margin, margin, margin, margin) container.layoutParams = lp + container.setPadding(dp(10), dp(8), dp(12), dp(8)) + container.minimumWidth = dp(160) val bg = GradientDrawable().apply { - setColor(0xFFE3F2FD.toInt()) - cornerRadius = dp(30).toFloat() + setColor(backgroundBaseColor) + cornerRadius = dp(24).toFloat() + setStroke(dp(1), Color.argb(46, 255, 255, 255)) } container.background = bg - container.elevation = dp(8).toFloat() + container.elevation = dp(10).toFloat() + val iconBg = GradientDrawable().apply { + setColor(Color.argb(42, 255, 255, 255)) + cornerRadius = dp(14).toFloat() + } val icon = TextView(this).apply { - text = "译" - setTextColor(0xFF1976D2.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + text = "V" + setTextColor(0xFFFFFFFF.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) gravity = Gravity.CENTER typeface = Typeface.DEFAULT_BOLD + background = iconBg } - container.addView( - icon, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) + + val iconLp = LinearLayout.LayoutParams(dp(28), dp(28)) + container.addView(icon, iconLp) + + minimizedTextView = TextView(this).apply { + text = "Voitrans" + setTextColor(Color.argb(235, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + val textLp = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(10) + } + container.addView(minimizedTextView, textLp) + + val chevron = TextView(this).apply { + text = "›" + setTextColor(Color.argb(150, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + gravity = Gravity.CENTER + setPadding(dp(6), 0, 0, 0) + } + container.addView(chevron) return container } private fun createExpandedView(): View { val container = LinearLayout(this) - val lp = FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT) + val maxWidth = dp(360) + val minWidth = dp(280) + val horizontalMargins = dp(8) * 2 + val screenWidth = resources.displayMetrics.widthPixels + val targetWidth = (screenWidth - horizontalMargins).coerceIn(minWidth, maxWidth) + baseExpandedWidth = targetWidth + val lp = FrameLayout.LayoutParams(targetWidth, ViewGroup.LayoutParams.WRAP_CONTENT) val margin = dp(8) lp.setMargins(margin, margin, margin, margin) container.layoutParams = lp @@ -169,10 +217,12 @@ class FloatingWindowService : Service() { container.setPadding(dp(16), dp(16), dp(16), dp(16)) container.visibility = View.GONE container.elevation = dp(12).toFloat() + expandedContainer = container val bg = GradientDrawable().apply { - setColor(Color.WHITE) + setColor(backgroundBaseColor) cornerRadius = dp(16).toFloat() + setStroke(dp(1), Color.argb(46, 255, 255, 255)) } container.background = bg @@ -185,62 +235,80 @@ class FloatingWindowService : Service() { ) } - val title = TextView(this).apply { - text = "实时翻译" - setTextColor(0xFF333333.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) - typeface = Typeface.DEFAULT_BOLD - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) - } - closeButton = TextView(this).apply { text = "✕" - setTextColor(0xFF666666.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + setTextColor(Color.argb(210, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) gravity = Gravity.CENTER setPadding(dp(8), dp(8), dp(8), dp(8)) } - titleRow.addView(title) - titleRow.addView(closeButton) - languageInfoView = TextView(this).apply { - text = "中文 → 英文" - setTextColor(0xFF666666.toInt()) + text = "src → tgt" + setTextColor(Color.argb(235, 255, 255, 255)) setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - setPadding(0, dp(8), 0, dp(8)) + setPadding(dp(10), dp(6), dp(10), dp(6)) + } + val langBg = GradientDrawable().apply { + setColor(Color.argb(36, 255, 255, 255)) + cornerRadius = dp(10).toFloat() } + languageInfoView?.background = langBg + val languageInRowLp = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) sourceTextView = TextView(this).apply { - text = "正在识别..." - setTextColor(0xFF333333.toInt()) + text = "" + setTextColor(Color.argb(235, 255, 255, 255)) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setPadding(0, dp(8), 0, dp(4)) maxLines = 3 ellipsize = TextUtils.TruncateAt.END + setLineSpacing(dpF(2f), 1.1f) + visibility = View.GONE } translatedTextView = TextView(this).apply { - text = "翻译中..." - setTextColor(0xFF1976D2.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + text = "" + setTextColor(0xFF90CAF9.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) setPadding(0, dp(4), 0, dp(8)) maxLines = 3 ellipsize = TextUtils.TruncateAt.END + setLineSpacing(dpF(2f), 1.1f) + visibility = View.GONE } - timestampView = TextView(this).apply { + sourceLoadingView = TextView(this).apply { text = "" - setTextColor(0xFF999999.toInt()) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f) - gravity = Gravity.END + setTextColor(Color.argb(235, 255, 255, 255)) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + setPadding(0, dp(8), 0, dp(4)) + visibility = View.GONE } + translatedLoadingView = TextView(this).apply { + text = "" + setTextColor(0xFF90CAF9.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + setPadding(0, dp(4), 0, dp(8)) + visibility = View.GONE + } + + val spacer = View(this) + val spacerLp = LinearLayout.LayoutParams(0, 0, 1f) + titleRow.addView(languageInfoView, languageInRowLp) + titleRow.addView(spacer, spacerLp) + titleRow.addView(closeButton) + container.addView(titleRow) - container.addView(languageInfoView) container.addView(sourceTextView) + container.addView(sourceLoadingView) container.addView(translatedTextView) - container.addView(timestampView) + container.addView(translatedLoadingView) + applyUiScale(uiScale) return container } @@ -270,21 +338,50 @@ class FloatingWindowService : Service() { private var initialTouchX: Float = 0f private var initialTouchY: Float = 0f private var dragging: Boolean = false + private var scaling: Boolean = false + private var initialSpan: Float = 0f + private var initialScale: Float = 1.0f override fun onTouch(v: View, event: MotionEvent): Boolean { val lp = layoutParams ?: return false val wm = windowManager ?: return false - when (event.action) { + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { initialX = lp.x initialY = lp.y initialTouchX = event.rawX initialTouchY = event.rawY + + if (!isMinimized && isTouchingView(closeButton, event.rawX, event.rawY) + ) { + return false + } + dragging = false + scaling = false + return true + } + + MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount >= 2) { + scaling = true + dragging = false + initialSpan = pointerSpan(event) + initialScale = uiScale + return true + } return true } MotionEvent.ACTION_MOVE -> { + if (scaling && event.pointerCount >= 2) { + val span = pointerSpan(event) + if (initialSpan > 0f && span > 0f) { + val next = (initialScale * (span / initialSpan)).coerceIn(0.85f, 1.4f) + setUiScale(next) + } + return true + } val dx = (event.rawX - initialTouchX).toInt() val dy = (event.rawY - initialTouchY).toInt() if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) { @@ -304,12 +401,21 @@ class FloatingWindowService : Service() { return true } + MotionEvent.ACTION_POINTER_UP -> { + val remainingPointers = event.pointerCount - 1 + if (scaling && remainingPointers < 2) { + scaling = false + return true + } + return false + } + MotionEvent.ACTION_UP -> { - if (!dragging) updateViewState(!isMinimized) + if (!dragging && !scaling) updateViewState(!isMinimized) dragging = false + scaling = false return true } - else -> return false } } @@ -320,6 +426,13 @@ class FloatingWindowService : Service() { isMinimized = minimized minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE + + if (minimized) { + stopAllLoading() + minimizedView?.scaleX = 1.0f + minimizedView?.scaleY = 1.0f + } + notifyStateChanged(if (minimized) "minimized" else "expanded") } @@ -328,26 +441,58 @@ class FloatingWindowService : Service() { val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) - val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L) val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) + val sourceValue = sourceText ?: "" + val translatedValue = translatedText ?: "" + val hasSource = !TextUtils.isEmpty(sourceValue) + val hasTranslated = !TextUtils.isEmpty(translatedValue) + languageInfoView?.let { view -> val langText = (sourceLang ?: "") + " → " + (targetLang ?: "") view.text = langText } sourceTextView?.let { view -> - val value = sourceText ?: "" - view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value + if (hasSource) { + view.text = sourceValue + view.visibility = View.VISIBLE + stopSourceLoading() + } else { + view.text = "" + view.visibility = View.GONE + if (isIntermediate) { + startSourceLoading() + } else { + stopSourceLoading() + } + } + view.alpha = if (isIntermediate) 0.85f else 1.0f } translatedTextView?.let { view -> - val value = translatedText ?: "" - view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value + if (hasTranslated) { + view.text = translatedValue + view.visibility = View.VISIBLE + stopTranslatedLoading() + } else { + view.text = "" + view.visibility = View.GONE + if (hasSource || isIntermediate) { + startTranslatedLoading() + } else { + stopTranslatedLoading() + } + } view.alpha = if (isIntermediate) 0.7f else 1.0f } - timestampView?.text = if (timestamp > 0) formatTimestamp(timestamp) else "" + minimizedTextView?.let { view -> + val primary = translatedText?.takeIf { !TextUtils.isEmpty(it) } + val secondary = sourceText?.takeIf { !TextUtils.isEmpty(it) } + view.text = primary ?: secondary ?: "Voitrans" + view.alpha = if (isIntermediate) 0.85f else 1.0f + } } private fun setPosition(intent: Intent) { @@ -386,18 +531,108 @@ class FloatingWindowService : Service() { channel.invokeMethod("onFloatingWindowClicked", null) } - private fun formatTimestamp(ts: Long): String { - if (ts <= 0) return "" - return try { - val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - sdf.format(Date(ts)) - } catch (_: Exception) { - "" + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private fun dpF(value: Float): Float { + return value * resources.displayMetrics.density + } + + private fun setUiScale(next: Float) { + uiScale = next + applyUiScale(uiScale) + } + + private fun applyUiScale(scale: Float) { + val container = expandedContainer ?: return + if (baseExpandedWidth <= 0) return + + languageInfoView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f * scale) + sourceTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale) + translatedTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale) + sourceLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale) + translatedLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale) + + val buttonTextSizeSp = (18f * scale).coerceIn(14f, 22f) + closeButton?.setTextSize(TypedValue.COMPLEX_UNIT_SP, buttonTextSizeSp) + + val closeHPad = (dp(8) * scale).toInt().coerceIn(dp(6), dp(16)) + val closeVPad = (dp(8) * scale).toInt().coerceIn(dp(4), dp(12)) + closeButton?.setPadding(closeHPad, closeVPad, closeHPad, closeVPad) + + val padding = (dp(16) * scale).toInt().coerceIn(dp(12), dp(24)) + container.setPadding(padding, padding, padding, padding) + + val maxLines = if (scale >= 1.15f) 4 else 3 + sourceTextView?.maxLines = maxLines + translatedTextView?.maxLines = maxLines + } + + private fun startSourceLoading() { + val view = sourceLoadingView ?: return + if (view.visibility == View.VISIBLE && sourceDotsRunnable != null) return + sourceDotsStep = 0 + view.visibility = View.VISIBLE + val runnable = object : Runnable { + override fun run() { + val count = (sourceDotsStep % 3) + 1 + view.text = "•".repeat(count) + sourceDotsStep++ + uiHandler.postDelayed(this, 350L) + } } + sourceDotsRunnable = runnable + uiHandler.post(runnable) } - private fun dp(value: Int): Int { - return (value * resources.displayMetrics.density).toInt() + private fun stopSourceLoading() { + sourceDotsRunnable?.let { uiHandler.removeCallbacks(it) } + sourceDotsRunnable = null + sourceLoadingView?.visibility = View.GONE + sourceLoadingView?.text = "" } -} + private fun startTranslatedLoading() { + val view = translatedLoadingView ?: return + if (view.visibility == View.VISIBLE && translatedDotsRunnable != null) return + translatedDotsStep = 0 + view.visibility = View.VISIBLE + val runnable = object : Runnable { + override fun run() { + val count = (translatedDotsStep % 3) + 1 + view.text = "•".repeat(count) + translatedDotsStep++ + uiHandler.postDelayed(this, 350L) + } + } + translatedDotsRunnable = runnable + uiHandler.post(runnable) + } + + private fun stopTranslatedLoading() { + translatedDotsRunnable?.let { uiHandler.removeCallbacks(it) } + translatedDotsRunnable = null + translatedLoadingView?.visibility = View.GONE + translatedLoadingView?.text = "" + } + + private fun stopAllLoading() { + stopSourceLoading() + stopTranslatedLoading() + } + + private fun isTouchingView(view: View?, x: Float, y: Float): Boolean { + if (view == null || view.visibility != View.VISIBLE) return false + val rect = Rect() + if (!view.getGlobalVisibleRect(rect)) return false + return rect.contains(x.toInt(), y.toInt()) + } + + private fun pointerSpan(event: MotionEvent): Float { + if (event.pointerCount < 2) return 0f + val dx = event.getX(0) - event.getX(1) + val dy = event.getY(0) - event.getY(1) + return kotlin.math.sqrt(dx * dx + dy * dy) + } +} diff --git a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt index 269dcea3a..b35eebf81 100644 --- a/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt +++ b/local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt @@ -16,6 +16,7 @@ import android.view.WindowManager import android.widget.LinearLayout import android.widget.TextView +/// 录音悬浮条服务 class RecordingFloatingService : Service() { companion object { const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR"