Browse Source
- 新增悬浮窗服务用于显示实时翻译结果 - 实现悬浮窗权限管理和状态控制 - 添加悬浮窗管理器处理窗口显示、更新和交互 - 在翻译界面添加悬浮窗开关按钮 - 支持悬浮窗最小化、展开和拖拽功能 - 添加Android原生悬浮窗服务和权限插件weicu
12 changed files with 1018 additions and 1 deletions
@ -0,0 +1,133 @@ |
|||||
|
package com.saitong.voitrans |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.content.Intent |
||||
|
import android.net.Uri |
||||
|
import android.os.Build |
||||
|
import android.provider.Settings |
||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin |
||||
|
import io.flutter.plugin.common.MethodCall |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
||||
|
import io.flutter.plugin.common.MethodChannel.Result |
||||
|
|
||||
|
class FloatingWindowPlugin: FlutterPlugin, MethodCallHandler { |
||||
|
private lateinit var channel: MethodChannel |
||||
|
private lateinit var context: Context |
||||
|
|
||||
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
context = flutterPluginBinding.applicationContext |
||||
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "floating_window") |
||||
|
channel.setMethodCallHandler(this) |
||||
|
|
||||
|
// 设置回调通道 |
||||
|
FloatingWindowService.methodChannel = channel |
||||
|
} |
||||
|
|
||||
|
override fun onMethodCall(call: MethodCall, result: Result) { |
||||
|
when (call.method) { |
||||
|
"enableFloatingWindow" -> { |
||||
|
enableFloatingWindow(result) |
||||
|
} |
||||
|
"disableFloatingWindow" -> { |
||||
|
disableFloatingWindow(result) |
||||
|
} |
||||
|
"showFloatingWindow" -> { |
||||
|
showFloatingWindow(result) |
||||
|
} |
||||
|
"hideFloatingWindow" -> { |
||||
|
hideFloatingWindow(result) |
||||
|
} |
||||
|
"updateContent" -> { |
||||
|
updateContent(call, result) |
||||
|
} |
||||
|
"setPosition" -> { |
||||
|
setPosition(call, result) |
||||
|
} |
||||
|
else -> { |
||||
|
result.notImplemented() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun enableFloatingWindow(result: Result) { |
||||
|
try { |
||||
|
val intent = Intent(context, FloatingWindowService::class.java) |
||||
|
context.startService(intent) |
||||
|
result.success(true) |
||||
|
} catch (e: Exception) { |
||||
|
result.error("ENABLE_ERROR", "Failed to enable floating window: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun disableFloatingWindow(result: Result) { |
||||
|
try { |
||||
|
val intent = Intent(context, FloatingWindowService::class.java) |
||||
|
context.stopService(intent) |
||||
|
result.success(true) |
||||
|
} catch (e: Exception) { |
||||
|
result.error("DISABLE_ERROR", "Failed to disable floating window: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun showFloatingWindow(result: Result) { |
||||
|
try { |
||||
|
// 服务已经在运行,不需要额外操作 |
||||
|
result.success(true) |
||||
|
} catch (e: Exception) { |
||||
|
result.error("SHOW_ERROR", "Failed to show floating window: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun hideFloatingWindow(result: Result) { |
||||
|
try { |
||||
|
FloatingWindowService.serviceInstance?.stopSelf() |
||||
|
result.success(true) |
||||
|
} catch (e: Exception) { |
||||
|
result.error("HIDE_ERROR", "Failed to hide floating window: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun updateContent(call: MethodCall, result: Result) { |
||||
|
try { |
||||
|
val args = call.arguments as? Map<String, Any> |
||||
|
if (args != null) { |
||||
|
val intent = Intent(context, FloatingWindowService::class.java).apply { |
||||
|
action = FloatingWindowService.ACTION_UPDATE_CONTENT |
||||
|
putExtra(FloatingWindowService.EXTRA_SOURCE_TEXT, args["sourceText"] as? String ?: "") |
||||
|
putExtra(FloatingWindowService.EXTRA_TRANSLATED_TEXT, args["translatedText"] as? String ?: "") |
||||
|
putExtra(FloatingWindowService.EXTRA_SOURCE_LANGUAGE, args["sourceLanguage"] as? String ?: "") |
||||
|
putExtra(FloatingWindowService.EXTRA_TARGET_LANGUAGE, args["targetLanguage"] as? String ?: "") |
||||
|
putExtra(FloatingWindowService.EXTRA_TIMESTAMP, (args["timestamp"] as? Number)?.toLong() ?: 0L) |
||||
|
putExtra(FloatingWindowService.EXTRA_IS_INTERMEDIATE, args["isIntermediate"] as? Boolean ?: false) |
||||
|
} |
||||
|
context.startService(intent) |
||||
|
} |
||||
|
result.success(true) |
||||
|
} catch (e: Exception) { |
||||
|
result.error("UPDATE_ERROR", "Failed to update content: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun setPosition(call: MethodCall, result: Result) { |
||||
|
try { |
||||
|
val args = call.arguments as? Map<String, Any> |
||||
|
if (args != null) { |
||||
|
val intent = Intent(context, FloatingWindowService::class.java).apply { |
||||
|
action = FloatingWindowService.ACTION_SET_POSITION |
||||
|
putExtra(FloatingWindowService.EXTRA_X, (args["x"] as? Number)?.toInt() ?: 0) |
||||
|
putExtra(FloatingWindowService.EXTRA_Y, (args["y"] as? Number)?.toInt() ?: 0) |
||||
|
} |
||||
|
context.startService(intent) |
||||
|
} |
||||
|
result.success(true) |
||||
|
} catch (e: Exception) { |
||||
|
result.error("POSITION_ERROR", "Failed to set position: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
channel.setMethodCallHandler(null) |
||||
|
FloatingWindowService.methodChannel = null |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,376 @@ |
|||||
|
package com.saitong.voitrans |
||||
|
|
||||
|
import android.app.Service |
||||
|
import android.content.Intent |
||||
|
import android.graphics.PixelFormat |
||||
|
import android.os.Build |
||||
|
import android.os.IBinder |
||||
|
import android.view.* |
||||
|
import android.widget.TextView |
||||
|
import android.widget.ImageView |
||||
|
import android.widget.LinearLayout |
||||
|
import android.widget.FrameLayout |
||||
|
import androidx.cardview.widget.CardView |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
import java.text.SimpleDateFormat |
||||
|
import java.util.* |
||||
|
|
||||
|
class FloatingWindowService : Service() { |
||||
|
private var windowManager: WindowManager? = null |
||||
|
private var floatingView: View? = null |
||||
|
private var isMinimized = true |
||||
|
|
||||
|
private lateinit var sourceTextView: TextView |
||||
|
private lateinit var translatedTextView: TextView |
||||
|
private lateinit var languageInfoView: TextView |
||||
|
private lateinit var timestampView: TextView |
||||
|
private lateinit var minimizedView: CardView |
||||
|
private lateinit var expandedView: CardView |
||||
|
|
||||
|
companion object { |
||||
|
var methodChannel: MethodChannel? = null |
||||
|
var serviceInstance: FloatingWindowService? = null |
||||
|
|
||||
|
const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT" |
||||
|
const val ACTION_SET_POSITION = "SET_POSITION" |
||||
|
|
||||
|
const val EXTRA_SOURCE_TEXT = "source_text" |
||||
|
const val EXTRA_TRANSLATED_TEXT = "translated_text" |
||||
|
const val EXTRA_SOURCE_LANGUAGE = "source_language" |
||||
|
const val EXTRA_TARGET_LANGUAGE = "target_language" |
||||
|
const val EXTRA_TIMESTAMP = "timestamp" |
||||
|
const val EXTRA_IS_INTERMEDIATE = "is_intermediate" |
||||
|
const val EXTRA_X = "x" |
||||
|
const val EXTRA_Y = "y" |
||||
|
} |
||||
|
|
||||
|
override fun onCreate() { |
||||
|
super.onCreate() |
||||
|
serviceInstance = this |
||||
|
createFloatingWindow() |
||||
|
} |
||||
|
|
||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
||||
|
when (intent?.action) { |
||||
|
ACTION_UPDATE_CONTENT -> updateContent(intent) |
||||
|
ACTION_SET_POSITION -> setPosition(intent) |
||||
|
} |
||||
|
return START_STICKY |
||||
|
} |
||||
|
|
||||
|
override fun onBind(intent: Intent?): IBinder? = null |
||||
|
|
||||
|
private fun createFloatingWindow() { |
||||
|
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager |
||||
|
|
||||
|
// 创建布局 |
||||
|
floatingView = createFloatingLayout() |
||||
|
|
||||
|
// 初始化视图组件 |
||||
|
initViews() |
||||
|
|
||||
|
// 设置点击事件 |
||||
|
setupClickListeners() |
||||
|
|
||||
|
// 设置窗口参数 |
||||
|
val layoutParams = WindowManager.LayoutParams().apply { |
||||
|
width = WindowManager.LayoutParams.WRAP_CONTENT |
||||
|
height = WindowManager.LayoutParams.WRAP_CONTENT |
||||
|
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY |
||||
|
} else { |
||||
|
WindowManager.LayoutParams.TYPE_PHONE |
||||
|
} |
||||
|
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or |
||||
|
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or |
||||
|
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
||||
|
format = PixelFormat.TRANSLUCENT |
||||
|
gravity = Gravity.TOP or Gravity.START |
||||
|
x = 100 |
||||
|
y = 100 |
||||
|
} |
||||
|
|
||||
|
windowManager?.addView(floatingView, layoutParams) |
||||
|
|
||||
|
// 默认显示最小化状态 |
||||
|
updateViewState(true) |
||||
|
} |
||||
|
|
||||
|
private fun createFloatingLayout(): View { |
||||
|
val context = this |
||||
|
val density = resources.displayMetrics.density |
||||
|
|
||||
|
// 创建根布局 |
||||
|
val rootLayout = FrameLayout(context).apply { |
||||
|
layoutParams = ViewGroup.LayoutParams( |
||||
|
ViewGroup.LayoutParams.WRAP_CONTENT, |
||||
|
ViewGroup.LayoutParams.WRAP_CONTENT |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 创建最小化视图 |
||||
|
val minimizedCard = CardView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
layoutParams = FrameLayout.LayoutParams( |
||||
|
(60 * density).toInt(), |
||||
|
(60 * density).toInt() |
||||
|
).apply { |
||||
|
setMargins((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt()) |
||||
|
} |
||||
|
radius = 30 * density |
||||
|
cardElevation = 8 * density |
||||
|
setCardBackgroundColor(0xFFE3F2FD.toInt()) |
||||
|
} |
||||
|
|
||||
|
val minimizedContent = LinearLayout(context).apply { |
||||
|
orientation = LinearLayout.VERTICAL |
||||
|
gravity = Gravity.CENTER |
||||
|
layoutParams = ViewGroup.LayoutParams( |
||||
|
ViewGroup.LayoutParams.MATCH_PARENT, |
||||
|
ViewGroup.LayoutParams.MATCH_PARENT |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
val icon = TextView(context).apply { |
||||
|
text = "译" |
||||
|
textSize = 14f |
||||
|
setTextColor(0xFF1976D2.toInt()) |
||||
|
gravity = Gravity.CENTER |
||||
|
} |
||||
|
|
||||
|
minimizedContent.addView(icon) |
||||
|
minimizedCard.addView(minimizedContent) |
||||
|
|
||||
|
// 创建展开视图 |
||||
|
val expandedCard = CardView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
layoutParams = FrameLayout.LayoutParams( |
||||
|
(320 * density).toInt(), |
||||
|
ViewGroup.LayoutParams.WRAP_CONTENT |
||||
|
).apply { |
||||
|
setMargins((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt()) |
||||
|
} |
||||
|
radius = 16 * density |
||||
|
cardElevation = 12 * density |
||||
|
setCardBackgroundColor(0xFFFFFFFF.toInt()) |
||||
|
visibility = View.GONE |
||||
|
} |
||||
|
|
||||
|
val expandedContent = LinearLayout(context).apply { |
||||
|
orientation = LinearLayout.VERTICAL |
||||
|
setPadding( |
||||
|
(16 * density).toInt(), |
||||
|
(16 * density).toInt(), |
||||
|
(16 * density).toInt(), |
||||
|
(16 * density).toInt() |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 标题栏 |
||||
|
val titleLayout = LinearLayout(context).apply { |
||||
|
orientation = LinearLayout.HORIZONTAL |
||||
|
gravity = Gravity.CENTER_VERTICAL |
||||
|
} |
||||
|
|
||||
|
val titleText = TextView(context).apply { |
||||
|
text = "实时翻译" |
||||
|
textSize = 16f |
||||
|
setTextColor(0xFF333333.toInt()) |
||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) |
||||
|
} |
||||
|
|
||||
|
val closeButton = TextView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
text = "✕" |
||||
|
textSize = 18f |
||||
|
setTextColor(0xFF666666.toInt()) |
||||
|
gravity = Gravity.CENTER |
||||
|
setPadding((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt()) |
||||
|
} |
||||
|
|
||||
|
titleLayout.addView(titleText) |
||||
|
titleLayout.addView(closeButton) |
||||
|
|
||||
|
// 语言信息 |
||||
|
val languageInfo = TextView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
text = "中文 → 英文" |
||||
|
textSize = 12f |
||||
|
setTextColor(0xFF666666.toInt()) |
||||
|
setPadding(0, (8 * density).toInt(), 0, (8 * density).toInt()) |
||||
|
} |
||||
|
|
||||
|
// 源文本 |
||||
|
val sourceText = TextView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
text = "正在识别..." |
||||
|
textSize = 14f |
||||
|
setTextColor(0xFF333333.toInt()) |
||||
|
setPadding(0, (8 * density).toInt(), 0, (4 * density).toInt()) |
||||
|
maxLines = 3 |
||||
|
} |
||||
|
|
||||
|
// 翻译文本 |
||||
|
val translatedText = TextView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
text = "翻译中..." |
||||
|
textSize = 14f |
||||
|
setTextColor(0xFF1976D2.toInt()) |
||||
|
setPadding(0, (4 * density).toInt(), 0, (8 * density).toInt()) |
||||
|
maxLines = 3 |
||||
|
} |
||||
|
|
||||
|
// 时间戳 |
||||
|
val timestamp = TextView(context).apply { |
||||
|
id = View.generateViewId() |
||||
|
text = "" |
||||
|
textSize = 10f |
||||
|
setTextColor(0xFF999999.toInt()) |
||||
|
gravity = Gravity.END |
||||
|
} |
||||
|
|
||||
|
expandedContent.addView(titleLayout) |
||||
|
expandedContent.addView(languageInfo) |
||||
|
expandedContent.addView(sourceText) |
||||
|
expandedContent.addView(translatedText) |
||||
|
expandedContent.addView(timestamp) |
||||
|
expandedCard.addView(expandedContent) |
||||
|
|
||||
|
rootLayout.addView(minimizedCard) |
||||
|
rootLayout.addView(expandedCard) |
||||
|
|
||||
|
// 保存引用 |
||||
|
minimizedView = minimizedCard |
||||
|
expandedView = expandedCard |
||||
|
sourceTextView = sourceText |
||||
|
translatedTextView = translatedText |
||||
|
languageInfoView = languageInfo |
||||
|
timestampView = timestamp |
||||
|
|
||||
|
return rootLayout |
||||
|
} |
||||
|
|
||||
|
private fun initViews() { |
||||
|
// Views are already initialized in createFloatingLayout |
||||
|
} |
||||
|
|
||||
|
private fun setupClickListeners() { |
||||
|
// 最小化状态点击展开 |
||||
|
minimizedView.setOnClickListener { |
||||
|
isMinimized = false |
||||
|
updateViewState(false) |
||||
|
notifyStateChange("expanded") |
||||
|
} |
||||
|
|
||||
|
// 展开状态点击最小化 |
||||
|
expandedView.setOnClickListener { |
||||
|
isMinimized = true |
||||
|
updateViewState(true) |
||||
|
notifyStateChange("minimized") |
||||
|
} |
||||
|
|
||||
|
// 长按关闭 |
||||
|
floatingView!!.setOnLongClickListener { |
||||
|
closeFloatingWindow() |
||||
|
true |
||||
|
} |
||||
|
|
||||
|
// 拖拽功能 |
||||
|
setupDragListener() |
||||
|
} |
||||
|
|
||||
|
private fun setupDragListener() { |
||||
|
var initialX = 0 |
||||
|
var initialY = 0 |
||||
|
var initialTouchX = 0f |
||||
|
var initialTouchY = 0f |
||||
|
|
||||
|
floatingView!!.setOnTouchListener { _, event -> |
||||
|
when (event.action) { |
||||
|
MotionEvent.ACTION_DOWN -> { |
||||
|
val layoutParams = floatingView!!.layoutParams as WindowManager.LayoutParams |
||||
|
initialX = layoutParams.x |
||||
|
initialY = layoutParams.y |
||||
|
initialTouchX = event.rawX |
||||
|
initialTouchY = event.rawY |
||||
|
true |
||||
|
} |
||||
|
MotionEvent.ACTION_MOVE -> { |
||||
|
val layoutParams = floatingView!!.layoutParams as WindowManager.LayoutParams |
||||
|
layoutParams.x = initialX + (event.rawX - initialTouchX).toInt() |
||||
|
layoutParams.y = initialY + (event.rawY - initialTouchY).toInt() |
||||
|
windowManager?.updateViewLayout(floatingView, layoutParams) |
||||
|
true |
||||
|
} |
||||
|
else -> false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun updateViewState(minimized: Boolean) { |
||||
|
if (minimized) { |
||||
|
minimizedView.visibility = View.VISIBLE |
||||
|
expandedView.visibility = View.GONE |
||||
|
} else { |
||||
|
minimizedView.visibility = View.GONE |
||||
|
expandedView.visibility = View.VISIBLE |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun updateContent(intent: Intent) { |
||||
|
val sourceText = intent.getStringExtra(EXTRA_SOURCE_TEXT) ?: "" |
||||
|
val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) ?: "" |
||||
|
val sourceLanguage = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) ?: "" |
||||
|
val targetLanguage = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) ?: "" |
||||
|
val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0) |
||||
|
val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) |
||||
|
|
||||
|
// 更新UI |
||||
|
sourceTextView.text = if (sourceText.isNotEmpty()) sourceText else "正在识别..." |
||||
|
translatedTextView.text = if (translatedText.isNotEmpty()) translatedText else "翻译中..." |
||||
|
languageInfoView.text = "$sourceLanguage → $targetLanguage" |
||||
|
|
||||
|
if (timestamp > 0) { |
||||
|
val dateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) |
||||
|
timestampView.text = dateFormat.format(Date(timestamp)) |
||||
|
} |
||||
|
|
||||
|
// 如果是中间结果,可以添加一些视觉提示 |
||||
|
if (isIntermediate) { |
||||
|
translatedTextView.alpha = 0.7f |
||||
|
} else { |
||||
|
translatedTextView.alpha = 1.0f |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun setPosition(intent: Intent) { |
||||
|
val x = intent.getIntExtra(EXTRA_X, 0) |
||||
|
val y = intent.getIntExtra(EXTRA_Y, 0) |
||||
|
|
||||
|
floatingView?.let { view -> |
||||
|
val layoutParams = view.layoutParams as WindowManager.LayoutParams |
||||
|
layoutParams.x = x |
||||
|
layoutParams.y = y |
||||
|
windowManager?.updateViewLayout(view, layoutParams) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun closeFloatingWindow() { |
||||
|
stopSelf() |
||||
|
notifyFloatingWindowClosed() |
||||
|
} |
||||
|
|
||||
|
private fun notifyStateChange(state: String) { |
||||
|
methodChannel?.invokeMethod("onFloatingWindowStateChanged", mapOf("state" to state)) |
||||
|
} |
||||
|
|
||||
|
private fun notifyFloatingWindowClosed() { |
||||
|
methodChannel?.invokeMethod("onFloatingWindowClosed", null) |
||||
|
} |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
super.onDestroy() |
||||
|
floatingView?.let { windowManager?.removeView(it) } |
||||
|
serviceInstance = null |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
package com.saitong.voitrans |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.content.Intent |
||||
|
import android.net.Uri |
||||
|
import android.os.Build |
||||
|
import android.provider.Settings |
||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin |
||||
|
import io.flutter.plugin.common.MethodCall |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
||||
|
import io.flutter.plugin.common.MethodChannel.Result |
||||
|
|
||||
|
class OverlayPermissionPlugin: FlutterPlugin, MethodCallHandler { |
||||
|
private lateinit var channel: MethodChannel |
||||
|
private lateinit var context: Context |
||||
|
|
||||
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
context = flutterPluginBinding.applicationContext |
||||
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "overlay_permission") |
||||
|
channel.setMethodCallHandler(this) |
||||
|
} |
||||
|
|
||||
|
override fun onMethodCall(call: MethodCall, result: Result) { |
||||
|
when (call.method) { |
||||
|
"hasOverlayPermission" -> { |
||||
|
result.success(hasOverlayPermission()) |
||||
|
} |
||||
|
"requestOverlayPermission" -> { |
||||
|
requestOverlayPermission(result) |
||||
|
} |
||||
|
else -> { |
||||
|
result.notImplemented() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun hasOverlayPermission(): Boolean { |
||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
||||
|
Settings.canDrawOverlays(context) |
||||
|
} else { |
||||
|
true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun requestOverlayPermission(result: Result) { |
||||
|
try { |
||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
||||
|
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply { |
||||
|
data = Uri.parse("package:${context.packageName}") |
||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK |
||||
|
} |
||||
|
context.startActivity(intent) |
||||
|
result.success(true) |
||||
|
} else { |
||||
|
result.success(true) |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
result.error("REQUEST_ERROR", "Failed to request overlay permission: ${e.message}", null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
channel.setMethodCallHandler(null) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,199 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:convert'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../utils/logger.dart'; |
||||
|
import '../utils/overlay_permission_util.dart'; |
||||
|
|
||||
|
/// 悬浮窗状态 |
||||
|
enum FloatingWindowState { |
||||
|
hidden, // 隐藏 |
||||
|
minimized, // 最小化 |
||||
|
expanded, // 展开 |
||||
|
} |
||||
|
|
||||
|
/// 悬浮窗数据模型 |
||||
|
class FloatingWindowData { |
||||
|
final String sourceText; |
||||
|
final String translatedText; |
||||
|
final String sourceLanguage; |
||||
|
final String targetLanguage; |
||||
|
final DateTime timestamp; |
||||
|
final bool isIntermediate; |
||||
|
|
||||
|
FloatingWindowData({ |
||||
|
required this.sourceText, |
||||
|
required this.translatedText, |
||||
|
required this.sourceLanguage, |
||||
|
required this.targetLanguage, |
||||
|
required this.timestamp, |
||||
|
this.isIntermediate = false, |
||||
|
}); |
||||
|
|
||||
|
Map<String, dynamic> toMap() { |
||||
|
return { |
||||
|
'sourceText': sourceText, |
||||
|
'translatedText': translatedText, |
||||
|
'sourceLanguage': sourceLanguage, |
||||
|
'targetLanguage': targetLanguage, |
||||
|
'timestamp': timestamp.millisecondsSinceEpoch, |
||||
|
'isIntermediate': isIntermediate, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 悬浮窗管理器 |
||||
|
class FloatingWindowManager extends GetxController { |
||||
|
static const String TAG = 'FloatingWindowManager'; |
||||
|
static const MethodChannel _channel = MethodChannel('floating_window'); |
||||
|
|
||||
|
final _isEnabled = false.obs; |
||||
|
final _currentState = FloatingWindowState.hidden.obs; |
||||
|
final _hasPermission = false.obs; |
||||
|
|
||||
|
// Getters |
||||
|
bool get isEnabled => _isEnabled.value; |
||||
|
FloatingWindowState get currentState => _currentState.value; |
||||
|
bool get hasPermission => _hasPermission.value; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_setupMethodCallHandler(); |
||||
|
_checkPermissions(); |
||||
|
} |
||||
|
|
||||
|
/// 设置方法调用处理器 |
||||
|
void _setupMethodCallHandler() { |
||||
|
_channel.setMethodCallHandler((call) async { |
||||
|
switch (call.method) { |
||||
|
case 'onFloatingWindowStateChanged': |
||||
|
final String state = call.arguments['state']; |
||||
|
_updateState(_parseState(state)); |
||||
|
break; |
||||
|
case 'onFloatingWindowClosed': |
||||
|
_currentState.value = FloatingWindowState.hidden; |
||||
|
_isEnabled.value = false; |
||||
|
break; |
||||
|
case 'onFloatingWindowClicked': |
||||
|
// 悬浮窗被点击,可以执行相应操作(如返回主应用) |
||||
|
_handleFloatingWindowClick(); |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 检查权限 |
||||
|
Future<void> _checkPermissions() async { |
||||
|
_hasPermission.value = await OverlayPermissionUtil.hasOverlayPermission(); |
||||
|
} |
||||
|
|
||||
|
/// 启用悬浮窗功能 |
||||
|
Future<bool> enableFloatingWindow() async { |
||||
|
if (!_hasPermission.value) { |
||||
|
final bool granted = |
||||
|
await OverlayPermissionUtil.requestOverlayPermission(); |
||||
|
if (!granted) { |
||||
|
Get.snackbar('权限不足'.tr, '需要悬浮窗权限才能启用此功能'.tr); |
||||
|
return false; |
||||
|
} |
||||
|
_hasPermission.value = true; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await _channel.invokeMethod('enableFloatingWindow'); |
||||
|
_isEnabled.value = true; |
||||
|
Logger.info('悬浮窗功能已启用'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('启用悬浮窗失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 禁用悬浮窗功能 |
||||
|
Future<void> disableFloatingWindow() async { |
||||
|
try { |
||||
|
await _channel.invokeMethod('disableFloatingWindow'); |
||||
|
_isEnabled.value = false; |
||||
|
_currentState.value = FloatingWindowState.hidden; |
||||
|
Logger.info('悬浮窗功能已禁用'); |
||||
|
} catch (e) { |
||||
|
Logger.error('禁用悬浮窗失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 显示悬浮窗 |
||||
|
Future<void> showFloatingWindow() async { |
||||
|
if (!_isEnabled.value) return; |
||||
|
|
||||
|
try { |
||||
|
await _channel.invokeMethod('showFloatingWindow'); |
||||
|
_currentState.value = FloatingWindowState.minimized; |
||||
|
Logger.info('悬浮窗已显示'); |
||||
|
} catch (e) { |
||||
|
Logger.error('显示悬浮窗失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 隐藏悬浮窗 |
||||
|
Future<void> hideFloatingWindow() async { |
||||
|
try { |
||||
|
await _channel.invokeMethod('hideFloatingWindow'); |
||||
|
_currentState.value = FloatingWindowState.hidden; |
||||
|
Logger.info('悬浮窗已隐藏'); |
||||
|
} catch (e) { |
||||
|
Logger.error('隐藏悬浮窗失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新悬浮窗内容 |
||||
|
Future<void> updateFloatingWindowContent(FloatingWindowData data) async { |
||||
|
if (!_isEnabled.value || |
||||
|
_currentState.value == FloatingWindowState.hidden) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await _channel.invokeMethod('updateContent', data.toMap()); |
||||
|
Logger.info('悬浮窗内容已更新'); |
||||
|
} catch (e) { |
||||
|
Logger.error('更新悬浮窗内容失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置悬浮窗位置 |
||||
|
Future<void> setFloatingWindowPosition(double x, double y) async { |
||||
|
try { |
||||
|
await _channel.invokeMethod('setPosition', {'x': x, 'y': y}); |
||||
|
} catch (e) { |
||||
|
Logger.error('设置悬浮窗位置失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 解析状态字符串 |
||||
|
FloatingWindowState _parseState(String state) { |
||||
|
switch (state) { |
||||
|
case 'minimized': |
||||
|
return FloatingWindowState.minimized; |
||||
|
case 'expanded': |
||||
|
return FloatingWindowState.expanded; |
||||
|
case 'hidden': |
||||
|
default: |
||||
|
return FloatingWindowState.hidden; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新状态 |
||||
|
void _updateState(FloatingWindowState state) { |
||||
|
_currentState.value = state; |
||||
|
Logger.info('悬浮窗状态更新为: $state'); |
||||
|
} |
||||
|
|
||||
|
/// 处理悬浮窗点击事件 |
||||
|
void _handleFloatingWindowClick() { |
||||
|
// 这里可以实现点击悬浮窗后的操作,比如返回主应用 |
||||
|
Logger.info('悬浮窗被点击'); |
||||
|
// 可以发送事件给其他组件处理 |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,70 @@ |
|||||
|
import 'dart:io'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'logger.dart'; |
||||
|
|
||||
|
/// 悬浮窗权限管理工具类 |
||||
|
class OverlayPermissionUtil { |
||||
|
static const MethodChannel _channel = MethodChannel('overlay_permission'); |
||||
|
|
||||
|
/// 检查是否有悬浮窗权限 |
||||
|
static Future<bool> hasOverlayPermission() async { |
||||
|
if (Platform.isAndroid) { |
||||
|
try { |
||||
|
final bool hasPermission = await _channel.invokeMethod('hasOverlayPermission'); |
||||
|
Logger.info('检查悬浮窗权限: $hasPermission'); |
||||
|
return hasPermission; |
||||
|
} catch (e) { |
||||
|
Logger.error('检查悬浮窗权限失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
// iOS不需要特殊权限 |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/// 请求悬浮窗权限 |
||||
|
static Future<bool> requestOverlayPermission() async { |
||||
|
if (Platform.isAndroid) { |
||||
|
// 首先检查是否已有权限 |
||||
|
if (await hasOverlayPermission()) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// 显示说明对话框 |
||||
|
bool? userAgreed = await Get.dialog<bool>( |
||||
|
AlertDialog( |
||||
|
title: Text('悬浮窗权限'.tr), |
||||
|
content: Text('为了在通话或音视频翻译时显示翻译结果,需要悬浮窗权限。这样您就可以在使用其他应用时看到实时翻译。'.tr), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(result: false), |
||||
|
child: Text('取消'.tr), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () => Get.back(result: true), |
||||
|
child: Text('去设置'.tr), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
barrierDismissible: false, |
||||
|
); |
||||
|
|
||||
|
if (userAgreed == true) { |
||||
|
try { |
||||
|
// 跳转系统设置页 |
||||
|
await _channel.invokeMethod('requestOverlayPermission'); |
||||
|
// 等待用户操作完成后重新检查权限 |
||||
|
await Future.delayed(const Duration(seconds: 1)); |
||||
|
return await hasOverlayPermission(); |
||||
|
} catch (e) { |
||||
|
Logger.error('请求悬浮窗权限失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue