Browse Source

feat(Android悬浮窗): 添加悬浮窗功能支持实时翻译显示

- 新增悬浮窗服务用于显示实时翻译结果
- 实现悬浮窗权限管理和状态控制
- 添加悬浮窗管理器处理窗口显示、更新和交互
- 在翻译界面添加悬浮窗开关按钮
- 支持悬浮窗最小化、展开和拖拽功能
- 添加Android原生悬浮窗服务和权限插件
weicu
lxm 1 year ago
parent
commit
e6769092c5
  1. 3
      android/app/build.gradle.kts
  2. 6
      android/app/src/main/AndroidManifest.xml
  3. 133
      android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt
  4. 376
      android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt
  5. 4
      android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt
  6. 6
      android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt
  7. 66
      android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt
  8. 5
      lib/core/bindings/initial_binding.dart
  9. 199
      lib/core/services/floating_window_manager.dart
  10. 70
      lib/core/utils/overlay_permission_util.dart
  11. 127
      lib/modules/translation/controllers/translation_controller.dart
  12. 24
      lib/modules/translation/views/translation_view.dart

3
android/app/build.gradle.kts

@ -123,6 +123,9 @@ android {
dependencies {
// 添加核心库反糖化
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
// 添加 CardView 依赖
implementation("androidx.cardview:cardview:1.0.0")
}
flutter {

6
android/app/src/main/AndroidManifest.xml

@ -121,6 +121,12 @@
android:value="78e7946dd602778df82c6ae143a88184"/>
<service android:name="com.amap.api.location.APSService"></service>
<!-- 悬浮窗服务 -->
<service
android:name="com.saitong.voitrans.FloatingWindowService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

133
android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowPlugin.kt

@ -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
}
}

376
android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt

@ -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
}
}

4
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt → android/app/src/main/kotlin/com/saitong/voitrans/MainActivity.kt

@ -94,6 +94,10 @@ class MainActivity: FlutterActivity() {
Log.d(TAG, "📦 注册 Flutter 插件")
super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine)
// 注册悬浮窗相关插件
flutterEngine.plugins.add(FloatingWindowPlugin())
flutterEngine.plugins.add(OverlayPermissionPlugin())
} else {
Log.d(TAG, "⚠️ 插件已注册,跳过重复注册")
}

6
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MyApplication.kt → android/app/src/main/kotlin/com/saitong/voitrans/MyApplication.kt

@ -8,6 +8,8 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugins.GeneratedPluginRegistrant
import com.saitong.voitrans.FloatingWindowPlugin
import com.saitong.voitrans.OverlayPermissionPlugin
// Application.kt
class MyApp : FlutterApplication() {
@ -69,6 +71,10 @@ class MyApp : FlutterApplication() {
Log.d(TAG, "📦 注册 Flutter 插件")
GeneratedPluginRegistrant.registerWith(flutterEngine)
// 注册悬浮窗相关插件
flutterEngine.plugins.add(FloatingWindowPlugin())
flutterEngine.plugins.add(OverlayPermissionPlugin())
try {
Log.d(TAG, "🚀 开始执行 Dart 入口点")
flutterEngine.dartExecutor.executeDartEntrypoint(

66
android/app/src/main/kotlin/com/saitong/voitrans/OverlayPermissionPlugin.kt

@ -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)
}
}

5
lib/core/bindings/initial_binding.dart

@ -22,6 +22,7 @@ import '../../data/services/account_service.dart';
import '../../data/services/version_update_service.dart';
import '../../data/services/ota_check_service.dart';
import '../../core/utils/app_lifecycle_manager.dart';
import '../services/floating_window_manager.dart';
/// 初始绑定,用于管理全局依赖
class InitialBinding extends Bindings {
@ -87,6 +88,10 @@ class InitialBinding extends Bindings {
// OTA检查服务
Get.lazyPut<OtaCheckService>(() => OtaCheckService(), fenix: true);
// 悬浮窗管理器
Get.lazyPut<FloatingWindowManager>(() => FloatingWindowManager(),
fenix: true);
// 杰理蓝牙服务
// // 语言管理器(需要最先初始化)

199
lib/core/services/floating_window_manager.dart

@ -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('悬浮窗被点击');
// 可以发送事件给其他组件处理
}
}

70
lib/core/utils/overlay_permission_util.dart

@ -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;
}
}

127
lib/modules/translation/controllers/translation_controller.dart

@ -16,6 +16,7 @@ import '../../../data/services/tts_service.dart';
import '../../../data/services/language_manager.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/permission_util.dart';
import '../../../core/services/floating_window_manager.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/translation_usage_service.dart';
@ -48,6 +49,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
final BleManager bleManager = Get.find<BleManager>();
final _musiceManager = Get.find<MusicManager>();
late final TranslationUsageService _usageService;
late final FloatingWindowManager _floatingWindowManager;
// ==================== 悬浮窗相关 ====================
final isFloatingWindowEnabled = false.obs;
// ==================== ASR统计相关 ====================
DateTime? _asrSessionStartTime; // ASR会话开始时间
@ -130,6 +135,100 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 底部控制栏高度
double _bottomBarHeight = 0.0;
/// 初始化悬浮窗管理器
void _initFloatingWindowManager() {
try {
if (!Get.isRegistered<FloatingWindowManager>()) {
Get.put(FloatingWindowManager(), permanent: true);
}
_floatingWindowManager = Get.find<FloatingWindowManager>();
Logger.info('悬浮窗管理器初始化完成');
} catch (e) {
Logger.error('悬浮窗管理器初始化失败: $e');
}
}
/// 切换悬浮窗功能
Future<void> toggleFloatingWindow() async {
if (isFloatingWindowEnabled.value) {
await _disableFloatingWindow();
} else {
await _enableFloatingWindow();
}
}
/// 启用悬浮窗功能
Future<void> _enableFloatingWindow() async {
// 检查是否在支持悬浮窗的模式
if (!_isFloatingWindowSupportedMode()) {
// Get.snackbar(
// 'unsupportedMode'.tr,
// '悬浮窗仅支持通话和音视频翻译模式',
// snackPosition: SnackPosition.TOP,
// );
return;
}
final bool success = await _floatingWindowManager.enableFloatingWindow();
if (success) {
isFloatingWindowEnabled.value = true;
await _floatingWindowManager.showFloatingWindow();
// 如果当前有翻译内容,立即更新到悬浮窗
if (translationHistory.isNotEmpty) {
final latestItem = translationHistory.last;
await _updateFloatingWindowWithTranslation(latestItem);
}
// Get.snackbar(
// '悬浮窗已启用',
// '翻译结果将在悬浮窗中显示',
// snackPosition: SnackPosition.TOP,
// );
}
}
/// 禁用悬浮窗功能
Future<void> _disableFloatingWindow() async {
await _floatingWindowManager.disableFloatingWindow();
isFloatingWindowEnabled.value = false;
// Get.snackbar(
// '悬浮窗已禁用',
// '翻译结果将在应用内显示',
// snackPosition: SnackPosition.TOP,
// );
}
/// 检查当前模式是否支持悬浮窗
bool _isFloatingWindowSupportedMode() {
return currentMode.value == 'call' || currentMode.value == 'audioVideo';
}
/// 更新悬浮窗翻译内容
Future<void> _updateFloatingWindowWithTranslation(
TranslationItem item) async {
if (!isFloatingWindowEnabled.value) return;
final sourceLanguageName =
_languageManager.getChineseNameByAsrCode(item.sourceLanguageCode) ??
'未知';
final targetLanguageName =
_languageManager.getChineseNameByAsrCode(item.targetLanguageCode) ??
'未知';
final data = FloatingWindowData(
sourceText: item.sourceText,
translatedText: item.translatedText,
sourceLanguage: sourceLanguageName,
targetLanguage: targetLanguageName,
timestamp: item.timestamp,
isIntermediate: item.isIntermediate,
);
await _floatingWindowManager.updateFloatingWindowContent(data);
}
/// 更改翻译模式
/// [mode] 翻译模式:simultaneous(同声传译)、audioVideo(音视频)、call(通话)、faceToFace(面对面)
Future<void> changeTranslationMode(String mode) async {
@ -137,6 +236,26 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
currentModeTitle.value = _getModeTitle(mode);
stopAll();
// 检查悬浮窗支持情况
if (isFloatingWindowEnabled.value && !_isFloatingWindowSupportedMode()) {
// 当前模式不支持悬浮窗,自动禁用
await _disableFloatingWindow();
// Get.snackbar(
// '模式已切换',
// '悬浮窗仅支持通话和音视频翻译模式',
// snackPosition: SnackPosition.TOP,
// );
} else if (!isFloatingWindowEnabled.value &&
_isFloatingWindowSupportedMode()) {
// 切换到支持悬浮窗的模式,提示用户可以启用
// Get.snackbar(
// '模式已切换',
// '可以启用悬浮窗显示翻译结果',
// snackPosition: SnackPosition.TOP,
// duration: const Duration(seconds: 4),
// );
}
if (mode == 'call') {
await _initializeCallModeTranslationService();
} else {
@ -253,6 +372,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_timerManager = RecordTimerController();
_historyManager = TranslationHistoryManager();
// 初始化悬浮窗管理器
_initFloatingWindowManager();
// 预先检查权限,避免在用户交互时阻塞
_preCheckPermissions();
@ -1007,6 +1129,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
translationHistory[index].targetLanguageCode = languageCodes['target']!;
translationHistory.refresh();
// 更新悬浮窗内容
if (isFloatingWindowEnabled.value) {
await _updateFloatingWindowWithTranslation(translationHistory[index]);
}
// 添加TranslationItem日志打印
if (isFinal) {
final item = translationHistory[index];

24
lib/modules/translation/views/translation_view.dart

@ -203,9 +203,31 @@ class TranslationView extends GetView<TranslationController> {
),
);
} else {
// 未录音:显示红色录音按钮 + 文件夹按钮
// 未录音:显示红色录音按钮 + 文件夹按钮 + 悬浮窗按钮
return Row(
children: [
// 悬浮窗切换按钮(仅在支持的模式下显示)
Obx(() {
if (controller.currentMode.value == 'call' ||
controller.currentMode.value == 'audioVideo') {
return IconButton(
icon: Icon(
controller.isFloatingWindowEnabled.value
? Icons.picture_in_picture
: Icons.picture_in_picture_alt_outlined,
color: controller.isFloatingWindowEnabled.value
? Colors.blue
: (isDarkMode ? Colors.white : Colors.black),
size: 24.sp,
),
onPressed: controller.toggleFloatingWindow,
tooltip: controller.isFloatingWindowEnabled.value
? '禁用悬浮窗'
: '启用悬浮窗',
);
}
return const SizedBox.shrink();
}),
IconButton(
icon: Icon(
Icons.radio_button_checked,

Loading…
Cancel
Save