119 changed files with 7389 additions and 1115 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,245 @@ |
|||||
|
package com.saitong.voitrans |
||||
|
|
||||
|
import android.app.PendingIntent |
||||
|
import android.bluetooth.le.ScanFilter |
||||
|
import android.bluetooth.le.ScanSettings |
||||
|
import android.content.Intent |
||||
|
import android.os.Build |
||||
|
import android.os.Bundle |
||||
|
import io.flutter.embedding.engine.FlutterEngine |
||||
|
import io.flutter.plugins.GeneratedPluginRegistrant |
||||
|
import io.flutter.embedding.android.FlutterActivity |
||||
|
import android.content.Context |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.util.Log |
||||
|
import io.flutter.embedding.engine.FlutterEngineCache |
||||
|
import io.flutter.plugin.common.MethodChannel // 添加这行导入 |
||||
|
|
||||
|
class MainActivity: FlutterActivity() { |
||||
|
private val TAG = "MainActivity" |
||||
|
private var pendingDeepLink: Intent? = null |
||||
|
|
||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||
|
Log.d(TAG, "🚀 MainActivity onCreate 开始") |
||||
|
super.onCreate(savedInstanceState) |
||||
|
|
||||
|
// 检查 Intent 信息 |
||||
|
intent?.let { |
||||
|
Log.d(TAG, "Intent action: ${it.action}") |
||||
|
Log.d(TAG, "Intent data: ${it.data}") |
||||
|
Log.d(TAG, "Intent extras: ${it.extras}") |
||||
|
} |
||||
|
|
||||
|
// 暂存深链接,等Flutter引擎完全准备好后处理 |
||||
|
if (intent?.action == Intent.ACTION_VIEW && intent?.data != null) { |
||||
|
pendingDeepLink = intent |
||||
|
Log.d(TAG, "🔗 暂存深链接,等待Flutter引擎准备完成") |
||||
|
} |
||||
|
|
||||
|
// 设置日志级别,减少系统级日志 |
||||
|
suppressSystemLogs() |
||||
|
Log.d(TAG, "✅ MainActivity onCreate 完成") |
||||
|
} |
||||
|
|
||||
|
// 添加 onNewIntent 方法处理深链接 |
||||
|
override fun onNewIntent(intent: Intent) { |
||||
|
Log.d(TAG, "🔗 onNewIntent 被调用") |
||||
|
super.onNewIntent(intent) |
||||
|
setIntent(intent) // 更新当前 Intent |
||||
|
|
||||
|
// 检查新的 Intent 信息 |
||||
|
Log.d(TAG, "New Intent action: ${intent.action}") |
||||
|
Log.d(TAG, "New Intent data: ${intent.data}") |
||||
|
Log.d(TAG, "New Intent extras: ${intent.extras}") |
||||
|
|
||||
|
// 处理深链接 |
||||
|
handleDeepLink(intent) |
||||
|
} |
||||
|
|
||||
|
override fun provideFlutterEngine(context: Context): FlutterEngine? { |
||||
|
Log.d(TAG, "🔍 provideFlutterEngine 被调用") |
||||
|
val engine = FlutterEngineCache.getInstance().get("main_engine") |
||||
|
if (engine == null) { |
||||
|
Log.w(TAG, "❌ FlutterEngine 缓存为空,这可能导致问题") |
||||
|
// 如果缓存为空,让 Flutter 框架创建默认 Engine |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
// 检查 Engine 状态 - 使用更兼容的方式 |
||||
|
try { |
||||
|
// 检查 Dart 执行器是否正在运行 |
||||
|
val isExecutorRunning = engine.dartExecutor.isExecutingDart |
||||
|
Log.d(TAG, "FlutterEngine Dart 执行状态: $isExecutorRunning") |
||||
|
|
||||
|
// 如果需要,可以检查其他状态 |
||||
|
if (!isExecutorRunning) { |
||||
|
Log.w(TAG, "⚠️ FlutterEngine Dart 执行器未运行,可能需要重新初始化") |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.w(TAG, "❌ 检查 FlutterEngine 状态时出错", e) |
||||
|
} |
||||
|
|
||||
|
Log.d(TAG, "✅ 使用缓存的 FlutterEngine") |
||||
|
return engine |
||||
|
} |
||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { |
||||
|
Log.d(TAG, "🔧 configureFlutterEngine 开始") |
||||
|
|
||||
|
// 检查插件是否已经注册,避免重复注册 |
||||
|
val pluginRegistry = flutterEngine.plugins |
||||
|
|
||||
|
// 只有在插件未注册时才调用 GeneratedPluginRegistrant |
||||
|
if (!pluginRegistry.has(io.flutter.plugins.pathprovider.PathProviderPlugin::class.java)) { |
||||
|
Log.d(TAG, "📦 注册 Flutter 插件") |
||||
|
super.configureFlutterEngine(flutterEngine) |
||||
|
GeneratedPluginRegistrant.registerWith(flutterEngine) |
||||
|
|
||||
|
// 注册悬浮窗相关插件 |
||||
|
flutterEngine.plugins.add(FloatingWindowPlugin()) |
||||
|
flutterEngine.plugins.add(OverlayPermissionPlugin()) |
||||
|
} else { |
||||
|
Log.d(TAG, "⚠️ 插件已注册,跳过重复注册") |
||||
|
} |
||||
|
|
||||
|
// Flutter引擎配置完成后,处理待处理的深链接 |
||||
|
Handler(Looper.getMainLooper()).postDelayed({ |
||||
|
pendingDeepLink?.let { intent -> |
||||
|
Log.d(TAG, "🔗 处理待处理的深链接") |
||||
|
handleDeepLink(intent) |
||||
|
pendingDeepLink = null |
||||
|
} |
||||
|
}, 1000) // 延迟1秒确保Flutter完全准备好 |
||||
|
|
||||
|
Log.d(TAG, "✅ configureFlutterEngine 完成") |
||||
|
} |
||||
|
|
||||
|
// 处理深链接的统一方法 |
||||
|
private fun handleDeepLink(intent: Intent?) { |
||||
|
intent?.let { |
||||
|
if (it.action == Intent.ACTION_VIEW && it.data != null) { |
||||
|
val uri = it.data |
||||
|
Log.d(TAG, "🔗 处理深链接: $uri") |
||||
|
|
||||
|
try { |
||||
|
Log.d(TAG, "🔗 开始发送深链接到 Flutter") |
||||
|
|
||||
|
// 通过 MethodChannel 将深链接信息传递给 Flutter |
||||
|
flutterEngine?.dartExecutor?.binaryMessenger?.let { messenger -> |
||||
|
Log.d(TAG, "🔗 获取到 BinaryMessenger") |
||||
|
|
||||
|
val channel = MethodChannel( |
||||
|
messenger, |
||||
|
"com.saitong.voitrans/deeplink" |
||||
|
) |
||||
|
|
||||
|
val arguments = mapOf( |
||||
|
"url" to uri.toString(), |
||||
|
"scheme" to uri?.scheme, |
||||
|
"host" to uri?.host, |
||||
|
"path" to uri?.path, |
||||
|
"query" to uri?.query, |
||||
|
"queryParameters" to uri?.queryParameterNames?.associateWith { |
||||
|
uri.getQueryParameter(it) |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
Log.d(TAG, "🔗 准备发送参数: $arguments") |
||||
|
|
||||
|
channel.invokeMethod("handleDeepLink", arguments) |
||||
|
Log.d(TAG, "✅ 深链接信息已发送到 Flutter") |
||||
|
} ?: run { |
||||
|
Log.w(TAG, "❌ BinaryMessenger 为空,无法发送深链接") |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "❌ 处理深链接时出错", e) |
||||
|
} |
||||
|
} else { |
||||
|
Log.d(TAG, "🔗 Intent 不是深链接或数据为空") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
super.onDestroy() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 抑制系统级组件的日志输出 |
||||
|
*/ |
||||
|
private fun suppressSystemLogs() { |
||||
|
// 更全面的系统标签列表 |
||||
|
val systemTags = arrayOf( |
||||
|
"MediaCodec", |
||||
|
"CCodec", |
||||
|
"CCodecBufferChannel", |
||||
|
"OpusManager", |
||||
|
"MediaCodecList", |
||||
|
"MediaPlayer", |
||||
|
"AudioTrack", |
||||
|
"AudioManager", |
||||
|
"AudioFlinger", |
||||
|
"AudioService", |
||||
|
"AudioEffect", |
||||
|
"AppOpsManager", |
||||
|
"AudioRecord", |
||||
|
"OpenGLRenderer", |
||||
|
"Choreographer", |
||||
|
"InputMethodManager", |
||||
|
"ViewRootImpl", |
||||
|
"Surface", |
||||
|
"BufferQueueProducer", |
||||
|
"ActivityManager", |
||||
|
"WindowManager", |
||||
|
"SensorManager" |
||||
|
) |
||||
|
|
||||
|
// 尝试多种方式设置日志级别 |
||||
|
systemTags.forEach { tag -> |
||||
|
try { |
||||
|
// 方式1:标准属性设置 |
||||
|
System.setProperty("log.tag.$tag", "ERROR") |
||||
|
|
||||
|
// 方式2:尝试使用反射设置(针对某些特殊标签) |
||||
|
setLogLevelViaReflection(tag, Log.ERROR) |
||||
|
} catch (e: Exception) { |
||||
|
// 忽略设置失败的情况 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 方式3:尝试设置全局日志级别 |
||||
|
try { |
||||
|
// 设置更高的阈值,减少verbose和debug日志 |
||||
|
System.setProperty("log.tag", "WARN") |
||||
|
} catch (e: Exception) { |
||||
|
// 忽略 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 通过反射尝试设置日志级别 |
||||
|
*/ |
||||
|
private fun setLogLevelViaReflection(tag: String, level: Int) { |
||||
|
try { |
||||
|
val logClass = Class.forName("android.util.Log") |
||||
|
val isLoggableMethod = logClass.getDeclaredMethod( |
||||
|
"isLoggable", |
||||
|
String::class.java, |
||||
|
Int::class.javaPrimitiveType |
||||
|
) |
||||
|
isLoggableMethod.isAccessible = true |
||||
|
|
||||
|
// 某些系统可能支持直接设置 |
||||
|
val setLogLevelMethod = logClass.getDeclaredMethod( |
||||
|
"setLogLevel", |
||||
|
String::class.java, |
||||
|
Int::class.javaPrimitiveType |
||||
|
) |
||||
|
setLogLevelMethod.isAccessible = true |
||||
|
setLogLevelMethod.invoke(null, tag, level) |
||||
|
} catch (e: Exception) { |
||||
|
// 反射方法可能不存在,忽略 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,150 @@ |
|||||
|
package com.saitong.voitrans |
||||
|
|
||||
|
import android.app.ActivityManager |
||||
|
import android.content.Context |
||||
|
import android.util.Log |
||||
|
import io.flutter.app.FlutterApplication |
||||
|
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() { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "MyApp" |
||||
|
private const val ENGINE_ID = "main_engine" |
||||
|
|
||||
|
// 标记 Engine 是否已经执行过 Dart 入口点 |
||||
|
@Volatile |
||||
|
private var isDartEntrypointExecuted = false |
||||
|
|
||||
|
// 记录应用启动时间,用于判断是否为新启动 |
||||
|
@Volatile |
||||
|
private var appStartTime = 0L |
||||
|
} |
||||
|
|
||||
|
override fun onCreate() { |
||||
|
super.onCreate() |
||||
|
appStartTime = System.currentTimeMillis() |
||||
|
Log.d(TAG, "MyApp onCreate - 进程启动") |
||||
|
|
||||
|
val cache = FlutterEngineCache.getInstance() |
||||
|
val existingEngine = cache.get(ENGINE_ID) |
||||
|
|
||||
|
if (existingEngine == null) { |
||||
|
Log.d(TAG, "创建新的 FlutterEngine") |
||||
|
createAndCacheEngine(cache) |
||||
|
} else { |
||||
|
Log.d(TAG, "FlutterEngine 已存在,检查状态") |
||||
|
// 检查现有 Engine 的状态 |
||||
|
try { |
||||
|
val isExecuting = existingEngine.dartExecutor.isExecutingDart |
||||
|
Log.d(TAG, "Engine Dart执行状态: $isExecuting") |
||||
|
|
||||
|
if (!isExecuting && isMainProcess()) { |
||||
|
Log.d(TAG, "Engine 存在但Dart未执行,重新执行") |
||||
|
existingEngine.dartExecutor.executeDartEntrypoint( |
||||
|
DartExecutor.DartEntrypoint.createDefault() |
||||
|
) |
||||
|
isDartEntrypointExecuted = true |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "检查或重新执行 Dart 入口点失败", e) |
||||
|
// 如果失败,重新创建 Engine |
||||
|
cache.remove(ENGINE_ID) |
||||
|
createAndCacheEngine(cache) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun createAndCacheEngine(cache: FlutterEngineCache) { |
||||
|
// 在主进程中总是创建并执行Dart入口点 |
||||
|
if (isMainProcess()) { |
||||
|
Log.d(TAG, "✅ 在主进程创建 FlutterEngine 并执行 Dart") |
||||
|
val flutterEngine = FlutterEngine(this) |
||||
|
|
||||
|
// 注册插件(只在这里注册一次) |
||||
|
Log.d(TAG, "📦 注册 Flutter 插件") |
||||
|
GeneratedPluginRegistrant.registerWith(flutterEngine) |
||||
|
|
||||
|
// 注册悬浮窗相关插件 |
||||
|
flutterEngine.plugins.add(FloatingWindowPlugin()) |
||||
|
flutterEngine.plugins.add(OverlayPermissionPlugin()) |
||||
|
|
||||
|
try { |
||||
|
Log.d(TAG, "🚀 开始执行 Dart 入口点") |
||||
|
flutterEngine.dartExecutor.executeDartEntrypoint( |
||||
|
DartExecutor.DartEntrypoint.createDefault() |
||||
|
) |
||||
|
isDartEntrypointExecuted = true |
||||
|
cache.put(ENGINE_ID, flutterEngine) |
||||
|
Log.d(TAG, "✅ FlutterEngine 创建并缓存成功") |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "❌ 执行 Dart 入口点失败", e) |
||||
|
// 如果执行失败,仍然缓存 Engine,但标记为未执行 |
||||
|
isDartEntrypointExecuted = false |
||||
|
cache.put(ENGINE_ID, flutterEngine) |
||||
|
} |
||||
|
} else { |
||||
|
Log.d(TAG, "⚠️ 在非主进程,仅创建 FlutterEngine") |
||||
|
// 非主进程只创建 Engine,不执行 Dart 入口点 |
||||
|
val flutterEngine = FlutterEngine(this) |
||||
|
cache.put(ENGINE_ID, flutterEngine) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun isMainProcess(): Boolean { |
||||
|
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager |
||||
|
val processes = am.runningAppProcesses ?: return true |
||||
|
val mainProcessName = packageName |
||||
|
val myPid = android.os.Process.myPid() |
||||
|
|
||||
|
for (process in processes) { |
||||
|
if (process.pid == myPid) { |
||||
|
return process.processName == mainProcessName |
||||
|
} |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
private fun isAppInForeground(): Boolean { |
||||
|
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager |
||||
|
val processes = am.runningAppProcesses ?: return false |
||||
|
val pkg = packageName |
||||
|
for (p in processes) { |
||||
|
if (p.processName == pkg && |
||||
|
p.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
private fun isAppBeingLaunched(): Boolean { |
||||
|
// 改进的启动检测逻辑 |
||||
|
val currentTime = System.currentTimeMillis() |
||||
|
val isRecentStart = (currentTime - appStartTime) < 3000 // 3秒内认为是新启动 |
||||
|
|
||||
|
if (isRecentStart) { |
||||
|
Log.d(TAG, "检测到应用新启动") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// 备用检测方法(兼容旧版本) |
||||
|
return try { |
||||
|
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager |
||||
|
val tasks = am.getRunningTasks(1) |
||||
|
val result = tasks.isNotEmpty() && tasks[0].topActivity?.packageName == packageName |
||||
|
Log.d(TAG, "通过 RunningTasks 检测启动状态: $result") |
||||
|
result |
||||
|
} catch (e: Exception) { |
||||
|
Log.w(TAG, "无法获取 RunningTasks,可能缺少权限", e) |
||||
|
// 如果无法获取任务信息,默认认为是启动状态 |
||||
|
true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -1,116 +0,0 @@ |
|||||
package com.saitong.voitrans |
|
||||
|
|
||||
import android.app.PendingIntent |
|
||||
import android.bluetooth.le.ScanFilter |
|
||||
import android.bluetooth.le.ScanSettings |
|
||||
import android.content.Intent |
|
||||
import android.os.Build |
|
||||
import android.os.Bundle |
|
||||
import io.flutter.embedding.engine.FlutterEngine |
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant |
|
||||
import io.flutter.embedding.android.FlutterActivity |
|
||||
import android.content.Context |
|
||||
import android.os.Handler |
|
||||
import android.os.Looper |
|
||||
import android.util.Log |
|
||||
|
|
||||
class MainActivity: FlutterActivity() { |
|
||||
private val TAG = "MainActivity" |
|
||||
|
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
|
||||
super.onCreate(savedInstanceState) |
|
||||
|
|
||||
// 设置日志级别,减少系统级日志 |
|
||||
suppressSystemLogs() |
|
||||
} |
|
||||
|
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { |
|
||||
super.configureFlutterEngine(flutterEngine) |
|
||||
|
|
||||
// 注册标准Flutter插件 |
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine) |
|
||||
} |
|
||||
|
|
||||
override fun onDestroy() { |
|
||||
super.onDestroy() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 抑制系统级组件的日志输出 |
|
||||
*/ |
|
||||
private fun suppressSystemLogs() { |
|
||||
// 更全面的系统标签列表 |
|
||||
val systemTags = arrayOf( |
|
||||
"MediaCodec", |
|
||||
"CCodec", |
|
||||
"CCodecBufferChannel", |
|
||||
"OpusManager", |
|
||||
"MediaCodecList", |
|
||||
"MediaPlayer", |
|
||||
"AudioTrack", |
|
||||
"AudioManager", |
|
||||
"AudioFlinger", |
|
||||
"AudioService", |
|
||||
"AudioEffect", |
|
||||
"AppOpsManager", |
|
||||
"AudioRecord", |
|
||||
"OpenGLRenderer", |
|
||||
"Choreographer", |
|
||||
"InputMethodManager", |
|
||||
"ViewRootImpl", |
|
||||
"Surface", |
|
||||
"BufferQueueProducer", |
|
||||
"ActivityManager", |
|
||||
"WindowManager", |
|
||||
"SensorManager" |
|
||||
) |
|
||||
|
|
||||
// 尝试多种方式设置日志级别 |
|
||||
systemTags.forEach { tag -> |
|
||||
try { |
|
||||
// 方式1:标准属性设置 |
|
||||
System.setProperty("log.tag.$tag", "ERROR") |
|
||||
|
|
||||
// 方式2:尝试使用反射设置(针对某些特殊标签) |
|
||||
setLogLevelViaReflection(tag, Log.ERROR) |
|
||||
} catch (e: Exception) { |
|
||||
// 忽略设置失败的情况 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 方式3:尝试设置全局日志级别 |
|
||||
try { |
|
||||
// 设置更高的阈值,减少verbose和debug日志 |
|
||||
System.setProperty("log.tag", "WARN") |
|
||||
} catch (e: Exception) { |
|
||||
// 忽略 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 通过反射尝试设置日志级别 |
|
||||
*/ |
|
||||
private fun setLogLevelViaReflection(tag: String, level: Int) { |
|
||||
try { |
|
||||
val logClass = Class.forName("android.util.Log") |
|
||||
val isLoggableMethod = logClass.getDeclaredMethod( |
|
||||
"isLoggable", |
|
||||
String::class.java, |
|
||||
Int::class.javaPrimitiveType |
|
||||
) |
|
||||
isLoggableMethod.isAccessible = true |
|
||||
|
|
||||
// 某些系统可能支持直接设置 |
|
||||
val setLogLevelMethod = logClass.getDeclaredMethod( |
|
||||
"setLogLevel", |
|
||||
String::class.java, |
|
||||
Int::class.javaPrimitiveType |
|
||||
) |
|
||||
setLogLevelMethod.isAccessible = true |
|
||||
setLogLevelMethod.invoke(null, tag, level) |
|
||||
} catch (e: Exception) { |
|
||||
// 反射方法可能不存在,忽略 |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@ -0,0 +1,10 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>com.apple.security.application-groups</key> |
||||
|
<array> |
||||
|
<string>group.com.saitong.voitrans.shared</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,38 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>CFBundleDevelopmentRegion</key> |
||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string> |
||||
|
<key>CFBundleDisplayName</key> |
||||
|
<string>Voitrans 字幕悬浮窗</string> |
||||
|
<key>CFBundleExecutable</key> |
||||
|
<string>$(EXECUTABLE_NAME)</string> |
||||
|
<key>CFBundleIdentifier</key> |
||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> |
||||
|
<key>CFBundleInfoDictionaryVersion</key> |
||||
|
<string>6.0</string> |
||||
|
<key>CFBundleName</key> |
||||
|
<string>$(PRODUCT_NAME)</string> |
||||
|
<key>CFBundlePackageType</key> |
||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> |
||||
|
<key>CFBundleShortVersionString</key> |
||||
|
<string>$(FLUTTER_BUILD_NAME)</string> |
||||
|
<key>CFBundleVersion</key> |
||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string> |
||||
|
<key>NSExtension</key> |
||||
|
<dict> |
||||
|
<key>NSExtensionPointIdentifier</key> |
||||
|
<string>com.apple.broadcast-services-upload</string> |
||||
|
<key>NSExtensionPrincipalClass</key> |
||||
|
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string> |
||||
|
<key>RPBroadcastProcessMode</key> |
||||
|
<string>RPBroadcastProcessModeSampleBuffer</string> |
||||
|
</dict> |
||||
|
<key>NSAppTransportSecurity</key> |
||||
|
<dict> |
||||
|
<key>NSAllowsArbitraryLoads</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,216 @@ |
|||||
|
import ReplayKit |
||||
|
import UIKit |
||||
|
import AVFoundation |
||||
|
|
||||
|
/// Broadcast Upload Extension 的核心处理类 |
||||
|
/// 负责接收屏幕内容并叠加字幕悬浮窗 |
||||
|
class SampleHandler: RPBroadcastSampleHandler { |
||||
|
|
||||
|
// MARK: - 属性 |
||||
|
private var overlayWindow: UIWindow? |
||||
|
private var subtitleLabel: UILabel? |
||||
|
private var containerView: UIView? |
||||
|
private let appGroupIdentifier = "group.com.saitong.voitrans.shared" |
||||
|
|
||||
|
// MARK: - 生命周期方法 |
||||
|
|
||||
|
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) { |
||||
|
print("🎬 ReplayKit 广播开始") |
||||
|
setupOverlayWindow() |
||||
|
startMonitoringSubtitleUpdates() |
||||
|
} |
||||
|
|
||||
|
override func broadcastPaused() { |
||||
|
print("⏸️ ReplayKit 广播暂停") |
||||
|
hideOverlay() |
||||
|
} |
||||
|
|
||||
|
override func broadcastResumed() { |
||||
|
print("▶️ ReplayKit 广播恢复") |
||||
|
showOverlay() |
||||
|
} |
||||
|
|
||||
|
override func broadcastFinished() { |
||||
|
print("🛑 ReplayKit 广播结束") |
||||
|
cleanupOverlay() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 样本处理方法 |
||||
|
|
||||
|
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { |
||||
|
switch sampleBufferType { |
||||
|
case RPSampleBufferType.video: |
||||
|
// 处理视频帧 - 这里我们主要用于触发悬浮窗显示 |
||||
|
break |
||||
|
case RPSampleBufferType.audioApp: |
||||
|
// 处理应用音频 |
||||
|
break |
||||
|
case RPSampleBufferType.audioMic: |
||||
|
// 处理麦克风音频 |
||||
|
break |
||||
|
@unknown default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 悬浮窗设置 |
||||
|
|
||||
|
/// 设置悬浮窗口 |
||||
|
private func setupOverlayWindow() { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
// 创建悬浮窗口 |
||||
|
self.overlayWindow = UIWindow(frame: UIScreen.main.bounds) |
||||
|
self.overlayWindow?.windowLevel = UIWindow.Level.alert + 1000 |
||||
|
self.overlayWindow?.backgroundColor = UIColor.clear |
||||
|
self.overlayWindow?.isHidden = false |
||||
|
|
||||
|
// 创建容器视图 |
||||
|
self.containerView = UIView() |
||||
|
self.containerView?.backgroundColor = UIColor.black.withAlphaComponent(0.8) |
||||
|
self.containerView?.layer.cornerRadius = 12 |
||||
|
self.containerView?.layer.masksToBounds = true |
||||
|
self.containerView?.translatesAutoresizingMaskIntoConstraints = false |
||||
|
|
||||
|
// 创建字幕标签 |
||||
|
self.subtitleLabel = UILabel() |
||||
|
self.subtitleLabel?.textColor = .white |
||||
|
self.subtitleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) |
||||
|
self.subtitleLabel?.numberOfLines = 0 |
||||
|
self.subtitleLabel?.textAlignment = .center |
||||
|
self.subtitleLabel?.translatesAutoresizingMaskIntoConstraints = false |
||||
|
self.subtitleLabel?.text = "等待翻译内容..." |
||||
|
|
||||
|
// 添加视图层次 |
||||
|
guard let containerView = self.containerView, |
||||
|
let subtitleLabel = self.subtitleLabel, |
||||
|
let overlayWindow = self.overlayWindow else { return } |
||||
|
|
||||
|
containerView.addSubview(subtitleLabel) |
||||
|
overlayWindow.addSubview(containerView) |
||||
|
|
||||
|
// 设置约束 |
||||
|
NSLayoutConstraint.activate([ |
||||
|
// 容器视图约束 - 位于屏幕顶部 |
||||
|
containerView.topAnchor.constraint(equalTo: overlayWindow.safeAreaLayoutGuide.topAnchor, constant: 20), |
||||
|
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: overlayWindow.leadingAnchor, constant: 20), |
||||
|
containerView.trailingAnchor.constraint(lessThanOrEqualTo: overlayWindow.trailingAnchor, constant: -20), |
||||
|
containerView.centerXAnchor.constraint(equalTo: overlayWindow.centerXAnchor), |
||||
|
containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 60), |
||||
|
|
||||
|
// 字幕标签约束 |
||||
|
subtitleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 12), |
||||
|
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), |
||||
|
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), |
||||
|
subtitleLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -12) |
||||
|
]) |
||||
|
|
||||
|
print("✅ 悬浮窗口设置完成") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 显示悬浮窗 |
||||
|
private func showOverlay() { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.overlayWindow?.isHidden = false |
||||
|
self?.overlayWindow?.alpha = 1.0 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 隐藏悬浮窗 |
||||
|
private func hideOverlay() { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
UIView.animate(withDuration: 0.3) { |
||||
|
self?.overlayWindow?.alpha = 0.0 |
||||
|
} completion: { _ in |
||||
|
self?.overlayWindow?.isHidden = true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清理悬浮窗 |
||||
|
private func cleanupOverlay() { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.overlayWindow?.isHidden = true |
||||
|
self?.overlayWindow = nil |
||||
|
self?.subtitleLabel = nil |
||||
|
self?.containerView = nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 字幕更新监听 |
||||
|
|
||||
|
/// 开始监听字幕更新 |
||||
|
private func startMonitoringSubtitleUpdates() { |
||||
|
// 使用 UserDefaults 监听字幕更新 |
||||
|
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) { |
||||
|
// 设置初始值监听 |
||||
|
userDefaults.addObserver(self, forKeyPath: "subtitle_text", options: [.new], context: nil) |
||||
|
userDefaults.addObserver(self, forKeyPath: "subtitle_source_language", options: [.new], context: nil) |
||||
|
userDefaults.addObserver(self, forKeyPath: "subtitle_target_language", options: [.new], context: nil) |
||||
|
|
||||
|
print("✅ 开始监听字幕更新") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// KVO 监听回调 |
||||
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { |
||||
|
if keyPath == "subtitle_text" || keyPath == "subtitle_source_language" || keyPath == "subtitle_target_language" { |
||||
|
updateSubtitleDisplay() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新字幕显示 |
||||
|
private func updateSubtitleDisplay() { |
||||
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return } |
||||
|
|
||||
|
let subtitleText = userDefaults.string(forKey: "subtitle_text") ?? "" |
||||
|
let sourceLanguage = userDefaults.string(forKey: "subtitle_source_language") ?? "" |
||||
|
let targetLanguage = userDefaults.string(forKey: "subtitle_target_language") ?? "" |
||||
|
let isIntermediate = userDefaults.bool(forKey: "subtitle_is_intermediate") |
||||
|
|
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
guard let self = self, let subtitleLabel = self.subtitleLabel else { return } |
||||
|
|
||||
|
if subtitleText.isEmpty { |
||||
|
subtitleLabel.text = "等待翻译内容..." |
||||
|
subtitleLabel.alpha = 0.6 |
||||
|
} else { |
||||
|
// 构建显示文本 |
||||
|
var displayText = subtitleText |
||||
|
if !sourceLanguage.isEmpty && !targetLanguage.isEmpty { |
||||
|
displayText = "\(sourceLanguage) → \(targetLanguage)\n\(subtitleText)" |
||||
|
} |
||||
|
|
||||
|
subtitleLabel.text = displayText |
||||
|
subtitleLabel.alpha = isIntermediate ? 0.8 : 1.0 |
||||
|
|
||||
|
// 添加淡入动画 |
||||
|
UIView.animate(withDuration: 0.2) { |
||||
|
self.containerView?.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) |
||||
|
} completion: { _ in |
||||
|
UIView.animate(withDuration: 0.2) { |
||||
|
self.containerView?.transform = .identity |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
print("📝 字幕更新: \(subtitleText)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 清理 |
||||
|
|
||||
|
deinit { |
||||
|
// 移除 KVO 监听 |
||||
|
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) { |
||||
|
userDefaults.removeObserver(self, forKeyPath: "subtitle_text") |
||||
|
userDefaults.removeObserver(self, forKeyPath: "subtitle_source_language") |
||||
|
userDefaults.removeObserver(self, forKeyPath: "subtitle_target_language") |
||||
|
} |
||||
|
|
||||
|
cleanupOverlay() |
||||
|
print("🗑️ SampleHandler 已清理") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,180 @@ |
|||||
|
# iOS ReplayKit 悬浮窗实现总结 |
||||
|
|
||||
|
## 🎯 实现目标 |
||||
|
|
||||
|
✅ **已完成**:使用 ReplayKit + Broadcast Upload Extension 实现 iOS 全局字幕悬浮窗功能,与 Android 版本保持功能一致。 |
||||
|
|
||||
|
## 📁 文件结构 |
||||
|
|
||||
|
### 主应用文件 |
||||
|
``` |
||||
|
ios/Runner/ |
||||
|
├── FloatingWindowPlugin.swift # Flutter 平台通道插件 |
||||
|
├── ReplayKitManager.swift # ReplayKit 管理器 |
||||
|
├── OverlayPermissionPlugin.swift # 权限管理插件(兼容性) |
||||
|
├── AppDelegate.swift # 应用委托(已更新) |
||||
|
└── Runner.entitlements # 主应用权限配置 |
||||
|
``` |
||||
|
|
||||
|
### Extension 文件 |
||||
|
``` |
||||
|
ios/BroadcastExtension/ |
||||
|
├── SampleHandler.swift # 广播处理器(核心实现) |
||||
|
├── Info.plist # Extension 配置 |
||||
|
└── BroadcastExtension.entitlements # Extension 权限配置 |
||||
|
``` |
||||
|
|
||||
|
### 配置和文档 |
||||
|
``` |
||||
|
ios/ |
||||
|
├── ReplayKit_Integration_Guide.md # Xcode 集成指南 |
||||
|
└── ReplayKit_Implementation_Summary.md # 实现总结(本文件) |
||||
|
``` |
||||
|
|
||||
|
## 🔧 核心技术实现 |
||||
|
|
||||
|
### 1. ReplayKit 广播控制 (`ReplayKitManager.swift`) |
||||
|
- **功能**:管理屏幕广播的启动、停止和状态监控 |
||||
|
- **关键方法**: |
||||
|
- `startBroadcast()`: 启动 ReplayKit 广播 |
||||
|
- `stopBroadcast()`: 停止广播 |
||||
|
- `updateSubtitle()`: 更新字幕内容 |
||||
|
- **数据通信**:使用 App Groups 与 Extension 共享数据 |
||||
|
|
||||
|
### 2. 广播处理器 (`SampleHandler.swift`) |
||||
|
- **功能**:接收屏幕内容并显示悬浮窗字幕 |
||||
|
- **关键特性**: |
||||
|
- 创建全局悬浮窗口 (`UIWindow`) |
||||
|
- 实时监听字幕更新 (KVO + UserDefaults) |
||||
|
- 自适应字幕显示和动画效果 |
||||
|
- **显示位置**:屏幕顶部中央,半透明黑色背景 |
||||
|
|
||||
|
### 3. Flutter 接口 (`FloatingWindowPlugin.swift`) |
||||
|
- **功能**:提供与 Android 一致的 Flutter 调用接口 |
||||
|
- **方法映射**: |
||||
|
- `enableFloatingWindow` → 启动 ReplayKit 广播 |
||||
|
- `disableFloatingWindow` → 停止广播 |
||||
|
- `updateContent` → 更新字幕内容 |
||||
|
- `setPosition` → 兼容性方法(位置固定) |
||||
|
|
||||
|
## 📱 用户体验流程 |
||||
|
|
||||
|
### 启动流程 |
||||
|
1. 用户在翻译界面点击"悬浮窗"按钮 |
||||
|
2. 系统弹出"开始直播屏幕"选择界面 |
||||
|
3. 用户选择 "Voitrans 字幕悬浮窗" 选项 |
||||
|
4. 系统开始屏幕录制,状态栏显示红色录制指示器 |
||||
|
5. 悬浮窗出现在屏幕顶部,显示"等待翻译内容..." |
||||
|
|
||||
|
### 使用流程 |
||||
|
1. 应用进行语音识别和翻译 |
||||
|
2. 实时更新悬浮窗中的字幕内容 |
||||
|
3. 支持源语言和目标语言显示 |
||||
|
4. 中间结果和最终结果有不同的透明度显示 |
||||
|
5. 用户可以在任何应用中看到翻译字幕 |
||||
|
|
||||
|
### 停止流程 |
||||
|
1. 用户再次点击"悬浮窗"按钮或停止翻译 |
||||
|
2. 应用调用停止广播方法 |
||||
|
3. 悬浮窗消失,录制指示器消失 |
||||
|
|
||||
|
## 🔐 权限和配置 |
||||
|
|
||||
|
### App Groups 配置 |
||||
|
- **标识符**:`group.com.saitong.voitrans.shared` |
||||
|
- **用途**:主应用与 Extension 之间的数据共享 |
||||
|
- **数据键**: |
||||
|
- `subtitle_text`: 字幕文本 |
||||
|
- `subtitle_source_language`: 源语言 |
||||
|
- `subtitle_target_language`: 目标语言 |
||||
|
- `subtitle_is_intermediate`: 是否为中间结果 |
||||
|
|
||||
|
### 系统权限 |
||||
|
- **屏幕录制权限**:用户首次使用时系统自动请求 |
||||
|
- **麦克风权限**:如果需要录制音频(可选) |
||||
|
- **摄像头权限**:ReplayKit 框架要求(实际不使用) |
||||
|
|
||||
|
## 🎨 UI 设计特点 |
||||
|
|
||||
|
### 悬浮窗样式 |
||||
|
- **位置**:屏幕顶部中央,距离安全区域 20pt |
||||
|
- **背景**:黑色半透明 (alpha: 0.8) |
||||
|
- **圆角**:12pt 圆角矩形 |
||||
|
- **字体**:系统字体 16pt,白色文字 |
||||
|
- **布局**:垂直布局,支持多行文本 |
||||
|
|
||||
|
### 动画效果 |
||||
|
- **出现动画**:淡入效果 (0.3s) |
||||
|
- **更新动画**:轻微缩放效果 (1.05x → 1.0x) |
||||
|
- **状态指示**:中间结果透明度 0.8,最终结果透明度 1.0 |
||||
|
|
||||
|
## 🔄 与 Android 版本对比 |
||||
|
|
||||
|
### 功能一致性 |
||||
|
| 功能 | Android | iOS (ReplayKit) | 状态 | |
||||
|
|------|---------|-----------------|------| |
||||
|
| 全局悬浮窗 | ✅ 系统悬浮窗 | ✅ ReplayKit 悬浮窗 | 一致 | |
||||
|
| 实时字幕更新 | ✅ | ✅ | 一致 | |
||||
|
| 拖拽移动 | ✅ | ❌ 位置固定 | 差异 | |
||||
|
| 最小化/展开 | ✅ | ❌ 固定样式 | 差异 | |
||||
|
| 权限管理 | ✅ 悬浮窗权限 | ✅ 录制权限 | 一致 | |
||||
|
|
||||
|
### Flutter 接口一致性 |
||||
|
- ✅ 所有方法签名保持一致 |
||||
|
- ✅ 回调事件保持一致 |
||||
|
- ✅ 数据模型保持一致 |
||||
|
- ✅ 错误处理保持一致 |
||||
|
|
||||
|
## 🚀 部署和发布 |
||||
|
|
||||
|
### 开发环境要求 |
||||
|
- Xcode 14.0+ |
||||
|
- iOS 16.0+ (ReplayKit 2 要求) |
||||
|
- 有效的 Apple Developer 账号 |
||||
|
- App Groups 权限配置 |
||||
|
|
||||
|
### App Store 审核要点 |
||||
|
- ✅ 使用官方 ReplayKit API,符合审核规范 |
||||
|
- ✅ 明确的用户权限请求和说明 |
||||
|
- ✅ 合理的功能用途(翻译字幕显示) |
||||
|
- ✅ 不涉及隐私数据收集 |
||||
|
|
||||
|
### 发布注意事项 |
||||
|
1. 确保 Extension 的 Bundle ID 正确配置 |
||||
|
2. App Groups 在 Developer Portal 中正确设置 |
||||
|
3. 两个 target 使用相同的签名证书 |
||||
|
4. 测试在不同 iOS 版本上的兼容性 |
||||
|
|
||||
|
## 📊 技术优势 |
||||
|
|
||||
|
### 相比应用内悬浮窗 |
||||
|
- ✅ 真正的全局显示能力 |
||||
|
- ✅ 符合 iOS 平台规范 |
||||
|
- ✅ 通过 App Store 审核 |
||||
|
- ✅ 系统级权限管理 |
||||
|
|
||||
|
### 相比其他方案 |
||||
|
- ✅ 无需越狱或私有 API |
||||
|
- ✅ 稳定可靠的系统支持 |
||||
|
- ✅ 良好的用户体验 |
||||
|
- ✅ 与现有架构无缝集成 |
||||
|
|
||||
|
## 🔮 未来扩展 |
||||
|
|
||||
|
### 可能的改进 |
||||
|
1. **自定义样式**:支持更多字幕样式选项 |
||||
|
2. **位置调整**:探索在录制流中实现位置调整 |
||||
|
3. **多语言支持**:优化多语言字幕显示 |
||||
|
4. **性能优化**:减少内存占用和电池消耗 |
||||
|
|
||||
|
### 技术演进 |
||||
|
- 关注 iOS 新版本的 ReplayKit 功能更新 |
||||
|
- 考虑集成 Live Activities (iOS 16+) 作为补充方案 |
||||
|
- 探索 WidgetKit 在锁屏界面的字幕显示 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**实现状态**: ✅ 完成 |
||||
|
**测试状态**: ✅ 基础功能测试通过 |
||||
|
**文档状态**: ✅ 完整的集成和使用文档 |
||||
|
**发布准备**: ⏳ 需要 Xcode 项目配置 |
||||
@ -0,0 +1,142 @@ |
|||||
|
# iOS ReplayKit 悬浮窗集成指南 |
||||
|
|
||||
|
## 📋 概述 |
||||
|
|
||||
|
本指南说明如何在 Xcode 中配置 ReplayKit Broadcast Upload Extension 以实现全局字幕悬浮窗功能。 |
||||
|
|
||||
|
## 🔧 Xcode 项目配置步骤 |
||||
|
|
||||
|
### 1. 添加 Broadcast Upload Extension Target |
||||
|
|
||||
|
1. 在 Xcode 中打开 `ios/Runner.xcworkspace` |
||||
|
2. 选择项目根节点 → 点击 "+" 添加新 Target |
||||
|
3. 选择 "Broadcast Upload Extension" |
||||
|
4. 配置信息: |
||||
|
- Product Name: `BroadcastExtension` |
||||
|
- Bundle Identifier: `com.saitong.voitrans.BroadcastExtension` |
||||
|
- Language: Swift |
||||
|
- 确保 "Include UI Extension" 未选中 |
||||
|
|
||||
|
### 2. 配置 Extension Target |
||||
|
|
||||
|
#### 2.1 替换默认文件 |
||||
|
- 删除自动生成的 `SampleHandler.swift` |
||||
|
- 将项目中的 `ios/BroadcastExtension/SampleHandler.swift` 复制到 Extension target |
||||
|
- 将项目中的 `ios/BroadcastExtension/Info.plist` 替换 Extension 的 Info.plist |
||||
|
|
||||
|
#### 2.2 添加 Entitlements |
||||
|
- 在 Extension target 的 Build Settings 中设置 Code Signing Entitlements |
||||
|
- 指向 `ios/BroadcastExtension/BroadcastExtension.entitlements` |
||||
|
|
||||
|
#### 2.3 配置 Build Settings |
||||
|
- iOS Deployment Target: 16.0 (与主应用保持一致) |
||||
|
- Swift Language Version: Swift 5 |
||||
|
- Enable Bitcode: No |
||||
|
|
||||
|
### 3. 配置 App Groups |
||||
|
|
||||
|
#### 3.1 在 Apple Developer Portal 中: |
||||
|
1. 创建 App Group: `group.com.saitong.voitrans.shared` |
||||
|
2. 将主应用和 Extension 都添加到此 App Group |
||||
|
|
||||
|
#### 3.2 在 Xcode 中: |
||||
|
1. 主应用 Target → Signing & Capabilities → 添加 "App Groups" capability |
||||
|
2. Extension Target → Signing & Capabilities → 添加 "App Groups" capability |
||||
|
3. 两个 target 都勾选 `group.com.saitong.voitrans.shared` |
||||
|
|
||||
|
### 4. 更新主应用配置 |
||||
|
|
||||
|
#### 4.1 添加 ReplayKit 框架 |
||||
|
- 主应用 Target → Build Phases → Link Binary With Libraries |
||||
|
- 添加 `ReplayKit.framework` |
||||
|
|
||||
|
#### 4.2 更新 Info.plist |
||||
|
在主应用的 Info.plist 中添加: |
||||
|
```xml |
||||
|
<key>NSCameraUsageDescription</key> |
||||
|
<string>需要摄像头权限以支持屏幕录制功能</string> |
||||
|
<key>NSMicrophoneUsageDescription</key> |
||||
|
<string>需要麦克风权限以支持音频录制功能</string> |
||||
|
``` |
||||
|
|
||||
|
## 📱 使用说明 |
||||
|
|
||||
|
### Flutter 调用方式 |
||||
|
|
||||
|
```dart |
||||
|
// 启动悬浮窗(会弹出系统录屏选择界面) |
||||
|
await floatingWindowManager.enableFloatingWindow(); |
||||
|
|
||||
|
// 更新字幕内容 |
||||
|
await floatingWindowManager.updateFloatingWindowContent( |
||||
|
FloatingWindowData( |
||||
|
sourceText: '你好', |
||||
|
translatedText: 'Hello', |
||||
|
sourceLanguage: '中文', |
||||
|
targetLanguage: '英语', |
||||
|
timestamp: DateTime.now(), |
||||
|
isIntermediate: false, |
||||
|
), |
||||
|
); |
||||
|
|
||||
|
// 停止悬浮窗 |
||||
|
await floatingWindowManager.disableFloatingWindow(); |
||||
|
``` |
||||
|
|
||||
|
### 用户操作流程 |
||||
|
|
||||
|
1. 用户点击"启用悬浮窗"按钮 |
||||
|
2. 系统弹出"开始直播屏幕"选择界面 |
||||
|
3. 用户选择 "Voitrans 字幕悬浮窗" 选项 |
||||
|
4. 系统开始屏幕录制,悬浮窗显示在屏幕顶部 |
||||
|
5. 应用实时更新字幕内容 |
||||
|
6. 用户可以在任何应用中看到翻译字幕 |
||||
|
|
||||
|
## ⚠️ 注意事项 |
||||
|
|
||||
|
### 开发阶段 |
||||
|
- 确保两个 target 使用相同的开发者账号签名 |
||||
|
- App Groups 必须在 Apple Developer Portal 中正确配置 |
||||
|
- Extension 的 Bundle ID 必须是主应用的子域名 |
||||
|
|
||||
|
### 发布阶段 |
||||
|
- Extension 会随主应用一起打包发布 |
||||
|
- 用户首次使用时需要授权屏幕录制权限 |
||||
|
- 符合 App Store 审核规范,因为使用的是官方 ReplayKit API |
||||
|
|
||||
|
### 用户体验 |
||||
|
- 录制期间状态栏会显示红色录制指示器 |
||||
|
- 悬浮窗位置固定在屏幕顶部中央 |
||||
|
- 支持实时字幕更新和语言切换 |
||||
|
- 可以通过停止录制来关闭悬浮窗 |
||||
|
|
||||
|
## 🔍 故障排除 |
||||
|
|
||||
|
### 常见问题 |
||||
|
|
||||
|
1. **Extension 无法启动** |
||||
|
- 检查 Bundle ID 配置 |
||||
|
- 确认 App Groups 权限配置正确 |
||||
|
- 验证代码签名设置 |
||||
|
|
||||
|
2. **字幕不更新** |
||||
|
- 检查 App Groups 数据同步 |
||||
|
- 确认 UserDefaults 键名一致 |
||||
|
- 查看 Extension 日志输出 |
||||
|
|
||||
|
3. **编译错误** |
||||
|
- 确保 iOS Deployment Target 一致 |
||||
|
- 检查 Swift 版本设置 |
||||
|
- 验证框架依赖配置 |
||||
|
|
||||
|
### 调试方法 |
||||
|
|
||||
|
1. 使用 Xcode 的 Device Console 查看 Extension 日志 |
||||
|
2. 在 SampleHandler 中添加 print 语句进行调试 |
||||
|
3. 检查 App Groups 中的数据是否正确写入 |
||||
|
|
||||
|
## 📚 相关文档 |
||||
|
|
||||
|
- [Apple ReplayKit Documentation](https://developer.apple.com/documentation/replaykit) |
||||
|
- [Broadcast Upload Extension Guide](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler) |
||||
|
- [App Groups Documentation](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups) |
||||
@ -0,0 +1,341 @@ |
|||||
|
import UIKit |
||||
|
|
||||
|
/// 悬浮窗实现类 |
||||
|
/// 使用 UIWindow 创建应用内悬浮窗口 |
||||
|
class FloatingWindow: UIWindow { |
||||
|
|
||||
|
// MARK: - 属性 |
||||
|
private weak var delegate: FloatingWindowDelegate? |
||||
|
private var floatingViewController: FloatingViewController? |
||||
|
private var isMinimized = true |
||||
|
private var lastPosition = CGPoint(x: 100, y: 100) |
||||
|
|
||||
|
// MARK: - 初始化 |
||||
|
init(delegate: FloatingWindowDelegate) { |
||||
|
self.delegate = delegate |
||||
|
|
||||
|
// 创建窗口 |
||||
|
super.init(frame: CGRect(x: 100, y: 100, width: 300, height: 120)) |
||||
|
|
||||
|
setupWindow() |
||||
|
setupViewController() |
||||
|
} |
||||
|
|
||||
|
required init?(coder: NSCoder) { |
||||
|
fatalError("init(coder:) has not been implemented") |
||||
|
} |
||||
|
|
||||
|
// MARK: - 私有方法 |
||||
|
|
||||
|
/// 设置窗口属性 |
||||
|
private func setupWindow() { |
||||
|
// 设置窗口层级,确保在最顶层显示 |
||||
|
windowLevel = UIWindow.Level.alert + 1 |
||||
|
backgroundColor = UIColor.clear |
||||
|
|
||||
|
// 设置窗口为可见但不成为主窗口 |
||||
|
isHidden = true |
||||
|
|
||||
|
// 设置圆角 |
||||
|
layer.cornerRadius = 12 |
||||
|
layer.masksToBounds = true |
||||
|
} |
||||
|
|
||||
|
/// 设置视图控制器 |
||||
|
private func setupViewController() { |
||||
|
floatingViewController = FloatingViewController() |
||||
|
floatingViewController?.delegate = self |
||||
|
rootViewController = floatingViewController |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共方法 |
||||
|
|
||||
|
/// 显示悬浮窗 |
||||
|
func show() { |
||||
|
isHidden = false |
||||
|
makeKeyAndVisible() |
||||
|
|
||||
|
// 添加显示动画 |
||||
|
alpha = 0 |
||||
|
UIView.animate(withDuration: 0.3) { |
||||
|
self.alpha = 1 |
||||
|
} |
||||
|
|
||||
|
delegate?.floatingWindowDidChangeState(isMinimized ? "minimized" : "expanded") |
||||
|
} |
||||
|
|
||||
|
/// 隐藏悬浮窗 |
||||
|
func hide() { |
||||
|
UIView.animate(withDuration: 0.3, animations: { |
||||
|
self.alpha = 0 |
||||
|
}) { _ in |
||||
|
self.isHidden = true |
||||
|
self.resignKey() |
||||
|
} |
||||
|
|
||||
|
delegate?.floatingWindowDidChangeState("hidden") |
||||
|
} |
||||
|
|
||||
|
/// 更新内容 |
||||
|
func updateContent(_ data: FloatingWindowContentData) { |
||||
|
floatingViewController?.updateContent(data) |
||||
|
} |
||||
|
|
||||
|
/// 设置位置 |
||||
|
func setPosition(x: Double, y: Double) { |
||||
|
let newPosition = CGPoint(x: x, y: y) |
||||
|
lastPosition = newPosition |
||||
|
|
||||
|
UIView.animate(withDuration: 0.3) { |
||||
|
self.frame.origin = newPosition |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 切换最小化/展开状态 |
||||
|
func toggleMinimized() { |
||||
|
isMinimized.toggle() |
||||
|
|
||||
|
let newSize: CGSize |
||||
|
if isMinimized { |
||||
|
newSize = CGSize(width: 120, height: 60) |
||||
|
} else { |
||||
|
newSize = CGSize(width: 300, height: 120) |
||||
|
} |
||||
|
|
||||
|
UIView.animate(withDuration: 0.3) { |
||||
|
self.frame.size = newSize |
||||
|
} |
||||
|
|
||||
|
floatingViewController?.setMinimized(isMinimized) |
||||
|
delegate?.floatingWindowDidChangeState(isMinimized ? "minimized" : "expanded") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - FloatingViewControllerDelegate |
||||
|
extension FloatingWindow: FloatingViewControllerDelegate { |
||||
|
|
||||
|
func floatingViewControllerDidTap() { |
||||
|
toggleMinimized() |
||||
|
delegate?.floatingWindowDidClick() |
||||
|
} |
||||
|
|
||||
|
func floatingViewControllerDidPan(translation: CGPoint) { |
||||
|
let newOrigin = CGPoint( |
||||
|
x: lastPosition.x + translation.x, |
||||
|
y: lastPosition.y + translation.y |
||||
|
) |
||||
|
|
||||
|
// 限制在屏幕范围内 |
||||
|
let screenBounds = UIScreen.main.bounds |
||||
|
let constrainedX = max(0, min(newOrigin.x, screenBounds.width - frame.width)) |
||||
|
let constrainedY = max(0, min(newOrigin.y, screenBounds.height - frame.height)) |
||||
|
|
||||
|
frame.origin = CGPoint(x: constrainedX, y: constrainedY) |
||||
|
} |
||||
|
|
||||
|
func floatingViewControllerDidEndPan() { |
||||
|
lastPosition = frame.origin |
||||
|
} |
||||
|
|
||||
|
func floatingViewControllerDidRequestClose() { |
||||
|
hide() |
||||
|
delegate?.floatingWindowDidClose() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 悬浮窗视图控制器 |
||||
|
private class FloatingViewController: UIViewController { |
||||
|
|
||||
|
// MARK: - 属性 |
||||
|
weak var delegate: FloatingViewControllerDelegate? |
||||
|
|
||||
|
// UI 组件 |
||||
|
private let containerView = UIView() |
||||
|
private let sourceTextLabel = UILabel() |
||||
|
private let translatedTextLabel = UILabel() |
||||
|
private let languageInfoLabel = UILabel() |
||||
|
private let timestampLabel = UILabel() |
||||
|
private let closeButton = UIButton(type: .system) |
||||
|
|
||||
|
// 手势识别器 |
||||
|
private var panGestureRecognizer: UIPanGestureRecognizer! |
||||
|
private var tapGestureRecognizer: UITapGestureRecognizer! |
||||
|
|
||||
|
private var isMinimized = true |
||||
|
|
||||
|
// MARK: - 生命周期 |
||||
|
override func viewDidLoad() { |
||||
|
super.viewDidLoad() |
||||
|
setupUI() |
||||
|
setupGestures() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 私有方法 |
||||
|
|
||||
|
/// 设置 UI |
||||
|
private func setupUI() { |
||||
|
view.backgroundColor = UIColor.clear |
||||
|
|
||||
|
// 容器视图 |
||||
|
containerView.backgroundColor = UIColor.black.withAlphaComponent(0.8) |
||||
|
containerView.layer.cornerRadius = 12 |
||||
|
containerView.translatesAutoresizingMaskIntoConstraints = false |
||||
|
view.addSubview(containerView) |
||||
|
|
||||
|
// 源文本标签 |
||||
|
sourceTextLabel.textColor = .white |
||||
|
sourceTextLabel.font = UIFont.systemFont(ofSize: 14) |
||||
|
sourceTextLabel.numberOfLines = 2 |
||||
|
sourceTextLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
|
containerView.addSubview(sourceTextLabel) |
||||
|
|
||||
|
// 翻译文本标签 |
||||
|
translatedTextLabel.textColor = .systemBlue |
||||
|
translatedTextLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) |
||||
|
translatedTextLabel.numberOfLines = 2 |
||||
|
translatedTextLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
|
containerView.addSubview(translatedTextLabel) |
||||
|
|
||||
|
// 语言信息标签 |
||||
|
languageInfoLabel.textColor = .lightGray |
||||
|
languageInfoLabel.font = UIFont.systemFont(ofSize: 10) |
||||
|
languageInfoLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
|
containerView.addSubview(languageInfoLabel) |
||||
|
|
||||
|
// 时间戳标签 |
||||
|
timestampLabel.textColor = .lightGray |
||||
|
timestampLabel.font = UIFont.systemFont(ofSize: 10) |
||||
|
timestampLabel.textAlignment = .right |
||||
|
timestampLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
|
containerView.addSubview(timestampLabel) |
||||
|
|
||||
|
// 关闭按钮 |
||||
|
closeButton.setTitle("×", for: .normal) |
||||
|
closeButton.setTitleColor(.white, for: .normal) |
||||
|
closeButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .bold) |
||||
|
closeButton.translatesAutoresizingMaskIntoConstraints = false |
||||
|
closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) |
||||
|
containerView.addSubview(closeButton) |
||||
|
|
||||
|
setupConstraints() |
||||
|
} |
||||
|
|
||||
|
/// 设置约束 |
||||
|
private func setupConstraints() { |
||||
|
NSLayoutConstraint.activate([ |
||||
|
// 容器视图 |
||||
|
containerView.topAnchor.constraint(equalTo: view.topAnchor), |
||||
|
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||
|
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||
|
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
|
|
||||
|
// 关闭按钮 |
||||
|
closeButton.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), |
||||
|
closeButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -4), |
||||
|
closeButton.widthAnchor.constraint(equalToConstant: 20), |
||||
|
closeButton.heightAnchor.constraint(equalToConstant: 20), |
||||
|
|
||||
|
// 源文本标签 |
||||
|
sourceTextLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8), |
||||
|
sourceTextLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), |
||||
|
sourceTextLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -4), |
||||
|
|
||||
|
// 翻译文本标签 |
||||
|
translatedTextLabel.topAnchor.constraint(equalTo: sourceTextLabel.bottomAnchor, constant: 4), |
||||
|
translatedTextLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), |
||||
|
translatedTextLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), |
||||
|
|
||||
|
// 语言信息标签 |
||||
|
languageInfoLabel.topAnchor.constraint(equalTo: translatedTextLabel.bottomAnchor, constant: 4), |
||||
|
languageInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), |
||||
|
languageInfoLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4), |
||||
|
|
||||
|
// 时间戳标签 |
||||
|
timestampLabel.topAnchor.constraint(equalTo: translatedTextLabel.bottomAnchor, constant: 4), |
||||
|
timestampLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), |
||||
|
timestampLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4) |
||||
|
]) |
||||
|
} |
||||
|
|
||||
|
/// 设置手势识别器 |
||||
|
private func setupGestures() { |
||||
|
// 拖拽手势 |
||||
|
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) |
||||
|
view.addGestureRecognizer(panGestureRecognizer) |
||||
|
|
||||
|
// 点击手势 |
||||
|
tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) |
||||
|
view.addGestureRecognizer(tapGestureRecognizer) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 手势处理 |
||||
|
|
||||
|
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) { |
||||
|
let translation = gesture.translation(in: view.superview) |
||||
|
|
||||
|
switch gesture.state { |
||||
|
case .changed: |
||||
|
delegate?.floatingViewControllerDidPan(translation: translation) |
||||
|
case .ended, .cancelled: |
||||
|
delegate?.floatingViewControllerDidEndPan() |
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc private func handleTap(_ gesture: UITapGestureRecognizer) { |
||||
|
delegate?.floatingViewControllerDidTap() |
||||
|
} |
||||
|
|
||||
|
@objc private func closeButtonTapped() { |
||||
|
delegate?.floatingViewControllerDidRequestClose() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共方法 |
||||
|
|
||||
|
/// 更新内容 |
||||
|
func updateContent(_ data: FloatingWindowContentData) { |
||||
|
sourceTextLabel.text = data.sourceText.isEmpty ? "正在识别..." : data.sourceText |
||||
|
translatedTextLabel.text = data.translatedText.isEmpty ? "翻译中..." : data.translatedText |
||||
|
languageInfoLabel.text = "\(data.sourceLanguage) → \(data.targetLanguage)" |
||||
|
|
||||
|
// 格式化时间戳 |
||||
|
if data.timestamp > 0 { |
||||
|
let date = Date(timeIntervalSince1970: TimeInterval(data.timestamp / 1000)) |
||||
|
let formatter = DateFormatter() |
||||
|
formatter.dateFormat = "HH:mm:ss" |
||||
|
timestampLabel.text = formatter.string(from: date) |
||||
|
} |
||||
|
|
||||
|
// 中间结果的视觉提示 |
||||
|
translatedTextLabel.alpha = data.isIntermediate ? 0.7 : 1.0 |
||||
|
} |
||||
|
|
||||
|
/// 设置最小化状态 |
||||
|
func setMinimized(_ minimized: Bool) { |
||||
|
isMinimized = minimized |
||||
|
|
||||
|
// 在最小化状态下隐藏部分元素 |
||||
|
sourceTextLabel.isHidden = minimized |
||||
|
languageInfoLabel.isHidden = minimized |
||||
|
timestampLabel.isHidden = minimized |
||||
|
closeButton.isHidden = minimized |
||||
|
|
||||
|
// 调整翻译文本标签的约束 |
||||
|
if minimized { |
||||
|
translatedTextLabel.numberOfLines = 1 |
||||
|
translatedTextLabel.font = UIFont.systemFont(ofSize: 12, weight: .medium) |
||||
|
} else { |
||||
|
translatedTextLabel.numberOfLines = 2 |
||||
|
translatedTextLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 悬浮窗视图控制器代理协议 |
||||
|
private protocol FloatingViewControllerDelegate: AnyObject { |
||||
|
func floatingViewControllerDidTap() |
||||
|
func floatingViewControllerDidPan(translation: CGPoint) |
||||
|
func floatingViewControllerDidEndPan() |
||||
|
func floatingViewControllerDidRequestClose() |
||||
|
} |
||||
@ -0,0 +1,154 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
|
||||
|
/// iOS 悬浮窗插件 - ReplayKit 方案 |
||||
|
/// 使用 ReplayKit + Broadcast Upload Extension 实现全局字幕悬浮窗 |
||||
|
public class FloatingWindowPlugin: NSObject, FlutterPlugin { |
||||
|
|
||||
|
// MARK: - 常量 |
||||
|
private static let channelName = "floating_window" |
||||
|
|
||||
|
// MARK: - 属性 |
||||
|
private var methodChannel: FlutterMethodChannel? |
||||
|
private let replayKitManager = ReplayKitManager.shared |
||||
|
|
||||
|
// MARK: - 插件注册 |
||||
|
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
let instance = FloatingWindowPlugin() |
||||
|
|
||||
|
// 创建方法通道 |
||||
|
let methodChannel = FlutterMethodChannel( |
||||
|
name: channelName, |
||||
|
binaryMessenger: registrar.messenger() |
||||
|
) |
||||
|
instance.methodChannel = methodChannel |
||||
|
registrar.addMethodCallDelegate(instance, channel: methodChannel) |
||||
|
|
||||
|
// 设置 ReplayKit 回调 |
||||
|
instance.setupReplayKitCallbacks() |
||||
|
|
||||
|
print("FloatingWindowPlugin (ReplayKit) 注册成功") |
||||
|
} |
||||
|
|
||||
|
// MARK: - 方法调用处理 |
||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
switch call.method { |
||||
|
case "enableFloatingWindow": |
||||
|
enableFloatingWindow(result: result) |
||||
|
|
||||
|
case "disableFloatingWindow": |
||||
|
disableFloatingWindow(result: result) |
||||
|
|
||||
|
case "showFloatingWindow": |
||||
|
showFloatingWindow(result: result) |
||||
|
|
||||
|
case "hideFloatingWindow": |
||||
|
hideFloatingWindow(result: result) |
||||
|
|
||||
|
case "updateContent": |
||||
|
if let arguments = call.arguments as? [String: Any] { |
||||
|
updateContent(arguments: arguments, result: result) |
||||
|
} else { |
||||
|
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的参数", details: nil)) |
||||
|
} |
||||
|
|
||||
|
case "setPosition": |
||||
|
// ReplayKit 方案中位置固定,但保持接口兼容性 |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - ReplayKit 回调设置 |
||||
|
|
||||
|
/// 设置 ReplayKit 回调 |
||||
|
private func setupReplayKitCallbacks() { |
||||
|
replayKitManager.onBroadcastStarted = { [weak self] in |
||||
|
self?.methodChannel?.invokeMethod("onFloatingWindowStateChanged", arguments: ["state": "minimized"]) |
||||
|
} |
||||
|
|
||||
|
replayKitManager.onBroadcastFinished = { [weak self] in |
||||
|
self?.methodChannel?.invokeMethod("onFloatingWindowClosed", arguments: nil) |
||||
|
} |
||||
|
|
||||
|
replayKitManager.onBroadcastError = { [weak self] error in |
||||
|
self?.methodChannel?.invokeMethod("onFloatingWindowError", arguments: ["error": error.localizedDescription]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 悬浮窗控制方法 |
||||
|
|
||||
|
/// 启用悬浮窗功能(启动 ReplayKit 广播) |
||||
|
private func enableFloatingWindow(result: @escaping FlutterResult) { |
||||
|
replayKitManager.startBroadcast { success, error in |
||||
|
if success { |
||||
|
result(true) |
||||
|
} else { |
||||
|
result(FlutterError( |
||||
|
code: "ENABLE_ERROR", |
||||
|
message: "启用悬浮窗失败: \(error?.localizedDescription ?? "未知错误")", |
||||
|
details: nil |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 禁用悬浮窗功能(停止 ReplayKit 广播) |
||||
|
private func disableFloatingWindow(result: @escaping FlutterResult) { |
||||
|
replayKitManager.stopBroadcast { success, error in |
||||
|
if success { |
||||
|
result(true) |
||||
|
} else { |
||||
|
result(FlutterError( |
||||
|
code: "DISABLE_ERROR", |
||||
|
message: "禁用悬浮窗失败: \(error?.localizedDescription ?? "未知错误")", |
||||
|
details: nil |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 显示悬浮窗(ReplayKit 方案中自动显示) |
||||
|
private func showFloatingWindow(result: @escaping FlutterResult) { |
||||
|
if replayKitManager.isBroadcasting { |
||||
|
result(true) |
||||
|
} else { |
||||
|
result(FlutterError(code: "NOT_BROADCASTING", message: "广播未启动", details: nil)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 隐藏悬浮窗(ReplayKit 方案中通过停止广播实现) |
||||
|
private func hideFloatingWindow(result: @escaping FlutterResult) { |
||||
|
disableFloatingWindow(result: result) |
||||
|
} |
||||
|
|
||||
|
/// 更新悬浮窗内容 |
||||
|
private func updateContent(arguments: [String: Any], result: @escaping FlutterResult) { |
||||
|
let sourceText = arguments["sourceText"] as? String ?? "" |
||||
|
let translatedText = arguments["translatedText"] as? String ?? "" |
||||
|
let sourceLanguage = arguments["sourceLanguage"] as? String ?? "" |
||||
|
let targetLanguage = arguments["targetLanguage"] as? String ?? "" |
||||
|
let isIntermediate = arguments["isIntermediate"] as? Bool ?? false |
||||
|
|
||||
|
// 构建显示文本 |
||||
|
var displayText = "" |
||||
|
if !sourceText.isEmpty && !translatedText.isEmpty { |
||||
|
displayText = "\(sourceText)\n\(translatedText)" |
||||
|
} else if !translatedText.isEmpty { |
||||
|
displayText = translatedText |
||||
|
} else if !sourceText.isEmpty { |
||||
|
displayText = sourceText |
||||
|
} |
||||
|
|
||||
|
replayKitManager.updateSubtitle( |
||||
|
text: displayText, |
||||
|
sourceLanguage: sourceLanguage, |
||||
|
targetLanguage: targetLanguage, |
||||
|
isIntermediate: isIntermediate |
||||
|
) |
||||
|
|
||||
|
result(true) |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,40 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
|
||||
|
/// iOS 悬浮窗权限插件 |
||||
|
/// 为了与 Android 版本保持接口一致性而创建 |
||||
|
public class OverlayPermissionPlugin: NSObject, FlutterPlugin { |
||||
|
|
||||
|
// MARK: - 常量 |
||||
|
private static let channelName = "overlay_permission" |
||||
|
|
||||
|
// MARK: - 插件注册 |
||||
|
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
let instance = OverlayPermissionPlugin() |
||||
|
|
||||
|
// 创建方法通道 |
||||
|
let methodChannel = FlutterMethodChannel( |
||||
|
name: channelName, |
||||
|
binaryMessenger: registrar.messenger() |
||||
|
) |
||||
|
registrar.addMethodCallDelegate(instance, channel: methodChannel) |
||||
|
|
||||
|
print("OverlayPermissionPlugin 注册成功") |
||||
|
} |
||||
|
|
||||
|
// MARK: - 方法调用处理 |
||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
switch call.method { |
||||
|
case "hasOverlayPermission": |
||||
|
// iOS 使用应用内悬浮窗,不需要特殊权限 |
||||
|
result(true) |
||||
|
|
||||
|
case "requestOverlayPermission": |
||||
|
// iOS 使用应用内悬浮窗,不需要特殊权限 |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,247 @@ |
|||||
|
import ReplayKit |
||||
|
import UIKit |
||||
|
|
||||
|
/// ReplayKit 管理器 |
||||
|
/// 负责管理屏幕广播的启动、停止和状态监控 |
||||
|
class ReplayKitManager: NSObject { |
||||
|
|
||||
|
// MARK: - 单例 |
||||
|
static let shared = ReplayKitManager() |
||||
|
|
||||
|
// MARK: - 属性 |
||||
|
private var broadcastController: RPBroadcastController? |
||||
|
private let appGroupIdentifier = "group.com.saitong.voitrans.shared" |
||||
|
private var isRecording = false |
||||
|
|
||||
|
// 回调闭包 |
||||
|
var onBroadcastStarted: (() -> Void)? |
||||
|
var onBroadcastFinished: (() -> Void)? |
||||
|
var onBroadcastError: ((Error) -> Void)? |
||||
|
|
||||
|
// MARK: - 初始化 |
||||
|
private override init() { |
||||
|
super.init() |
||||
|
setupNotifications() |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共方法 |
||||
|
|
||||
|
/// 启动屏幕广播 |
||||
|
/// - Parameter completion: 完成回调 |
||||
|
func startBroadcast(completion: @escaping (Bool, Error?) -> Void) { |
||||
|
print("🎬 准备启动 ReplayKit 广播") |
||||
|
|
||||
|
// 检查是否已经在录制 |
||||
|
if isRecording { |
||||
|
print("⚠️ 广播已在进行中") |
||||
|
completion(false, NSError(domain: "ReplayKitManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "广播已在进行中"])) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 获取广播选择器 |
||||
|
RPBroadcastActivityViewController.load { [weak self] (broadcastActivityViewController, error) in |
||||
|
DispatchQueue.main.async { |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
if let error = error { |
||||
|
print("❌ 加载广播选择器失败: \(error.localizedDescription)") |
||||
|
completion(false, error) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let broadcastActivityViewController = broadcastActivityViewController else { |
||||
|
let error = NSError(domain: "ReplayKitManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "无法加载广播选择器"]) |
||||
|
completion(false, error) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 设置代理 |
||||
|
broadcastActivityViewController.delegate = self |
||||
|
|
||||
|
// 显示广播选择器 |
||||
|
if let topViewController = self.getTopViewController() { |
||||
|
broadcastActivityViewController.modalPresentationStyle = .formSheet |
||||
|
topViewController.present(broadcastActivityViewController, animated: true) { |
||||
|
print("✅ 广播选择器已显示") |
||||
|
completion(true, nil) |
||||
|
} |
||||
|
} else { |
||||
|
let error = NSError(domain: "ReplayKitManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "无法获取顶层视图控制器"]) |
||||
|
completion(false, error) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止屏幕广播 |
||||
|
/// - Parameter completion: 完成回调 |
||||
|
func stopBroadcast(completion: @escaping (Bool, Error?) -> Void) { |
||||
|
print("🛑 准备停止 ReplayKit 广播") |
||||
|
|
||||
|
guard isRecording else { |
||||
|
print("⚠️ 当前没有进行中的广播") |
||||
|
completion(false, NSError(domain: "ReplayKitManager", code: -4, userInfo: [NSLocalizedDescriptionKey: "当前没有进行中的广播"])) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
broadcastController?.finishBroadcast { [weak self] error in |
||||
|
DispatchQueue.main.async { |
||||
|
if let error = error { |
||||
|
print("❌ 停止广播失败: \(error.localizedDescription)") |
||||
|
completion(false, error) |
||||
|
} else { |
||||
|
print("✅ 广播已停止") |
||||
|
self?.isRecording = false |
||||
|
self?.broadcastController = nil |
||||
|
completion(true, nil) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新字幕内容 |
||||
|
/// - Parameters: |
||||
|
/// - text: 字幕文本 |
||||
|
/// - sourceLanguage: 源语言 |
||||
|
/// - targetLanguage: 目标语言 |
||||
|
/// - isIntermediate: 是否为中间结果 |
||||
|
func updateSubtitle(text: String, sourceLanguage: String = "", targetLanguage: String = "", isIntermediate: Bool = false) { |
||||
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { |
||||
|
print("❌ 无法访问 App Group UserDefaults") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 更新字幕数据 |
||||
|
userDefaults.set(text, forKey: "subtitle_text") |
||||
|
userDefaults.set(sourceLanguage, forKey: "subtitle_source_language") |
||||
|
userDefaults.set(targetLanguage, forKey: "subtitle_target_language") |
||||
|
userDefaults.set(isIntermediate, forKey: "subtitle_is_intermediate") |
||||
|
userDefaults.set(Date().timeIntervalSince1970, forKey: "subtitle_timestamp") |
||||
|
|
||||
|
// 强制同步 |
||||
|
userDefaults.synchronize() |
||||
|
|
||||
|
print("📝 字幕已更新: \(text)") |
||||
|
} |
||||
|
|
||||
|
/// 检查广播状态 |
||||
|
var isBroadcasting: Bool { |
||||
|
return isRecording |
||||
|
} |
||||
|
|
||||
|
// MARK: - 私有方法 |
||||
|
|
||||
|
/// 设置通知监听 |
||||
|
private func setupNotifications() { |
||||
|
NotificationCenter.default.addObserver( |
||||
|
self, |
||||
|
selector: #selector(applicationDidEnterBackground), |
||||
|
name: UIApplication.didEnterBackgroundNotification, |
||||
|
object: nil |
||||
|
) |
||||
|
|
||||
|
NotificationCenter.default.addObserver( |
||||
|
self, |
||||
|
selector: #selector(applicationWillEnterForeground), |
||||
|
name: UIApplication.willEnterForegroundNotification, |
||||
|
object: nil |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/// 应用进入后台 |
||||
|
@objc private func applicationDidEnterBackground() { |
||||
|
print("📱 应用进入后台,广播状态: \(isRecording)") |
||||
|
} |
||||
|
|
||||
|
/// 应用即将进入前台 |
||||
|
@objc private func applicationWillEnterForeground() { |
||||
|
print("📱 应用即将进入前台,广播状态: \(isRecording)") |
||||
|
} |
||||
|
|
||||
|
/// 获取顶层视图控制器 |
||||
|
private func getTopViewController() -> UIViewController? { |
||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, |
||||
|
let window = windowScene.windows.first else { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
var topViewController = window.rootViewController |
||||
|
while let presentedViewController = topViewController?.presentedViewController { |
||||
|
topViewController = presentedViewController |
||||
|
} |
||||
|
|
||||
|
return topViewController |
||||
|
} |
||||
|
|
||||
|
// MARK: - 清理 |
||||
|
deinit { |
||||
|
NotificationCenter.default.removeObserver(self) |
||||
|
print("🗑️ ReplayKitManager 已清理") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - RPBroadcastActivityViewControllerDelegate |
||||
|
extension ReplayKitManager: RPBroadcastActivityViewControllerDelegate { |
||||
|
|
||||
|
func broadcastActivityViewController(_ broadcastActivityViewController: RPBroadcastActivityViewController, didFinishWith broadcastController: RPBroadcastController?, error: Error?) { |
||||
|
|
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
// 关闭选择器 |
||||
|
broadcastActivityViewController.dismiss(animated: true) |
||||
|
|
||||
|
if let error = error { |
||||
|
print("❌ 广播设置失败: \(error.localizedDescription)") |
||||
|
self?.onBroadcastError?(error) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let broadcastController = broadcastController else { |
||||
|
print("❌ 无法获取广播控制器") |
||||
|
let error = NSError(domain: "ReplayKitManager", code: -5, userInfo: [NSLocalizedDescriptionKey: "无法获取广播控制器"]) |
||||
|
self?.onBroadcastError?(error) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 保存广播控制器 |
||||
|
self?.broadcastController = broadcastController |
||||
|
broadcastController.delegate = self |
||||
|
|
||||
|
// 开始广播 |
||||
|
broadcastController.startBroadcast { error in |
||||
|
DispatchQueue.main.async { |
||||
|
if let error = error { |
||||
|
print("❌ 开始广播失败: \(error.localizedDescription)") |
||||
|
self?.onBroadcastError?(error) |
||||
|
} else { |
||||
|
print("✅ 广播已开始") |
||||
|
self?.isRecording = true |
||||
|
self?.onBroadcastStarted?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - RPBroadcastControllerDelegate |
||||
|
extension ReplayKitManager: RPBroadcastControllerDelegate { |
||||
|
|
||||
|
func broadcastController(_ broadcastController: RPBroadcastController, didFinishWithError error: Error?) { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.isRecording = false |
||||
|
self?.broadcastController = nil |
||||
|
|
||||
|
if let error = error { |
||||
|
print("❌ 广播结束时发生错误: \(error.localizedDescription)") |
||||
|
self?.onBroadcastError?(error) |
||||
|
} else { |
||||
|
print("✅ 广播正常结束") |
||||
|
self?.onBroadcastFinished?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func broadcastController(_ broadcastController: RPBroadcastController, didUpdateServiceInfo serviceInfo: [String : NSCoding & NSObjectProtocol]) { |
||||
|
print("📊 广播服务信息更新: \(serviceInfo)") |
||||
|
} |
||||
|
} |
||||
@ -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,79 @@ |
|||||
|
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; |
||||
|
} |
||||
|
} else if (Platform.isIOS) { |
||||
|
// iOS 使用应用内悬浮窗,不需要特殊权限 |
||||
|
Logger.info('iOS 平台:使用应用内悬浮窗,无需特殊权限'); |
||||
|
return true; |
||||
|
} |
||||
|
// 其他平台不支持 |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// 请求悬浮窗权限 |
||||
|
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; |
||||
|
} else if (Platform.isIOS) { |
||||
|
// iOS 使用应用内悬浮窗,不需要特殊权限,但需要检查应用状态 |
||||
|
Logger.info('iOS 平台:使用应用内悬浮窗,无需特殊权限'); |
||||
|
return true; |
||||
|
} |
||||
|
// 其他平台不支持 |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,362 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:voitrans/data/services/network/api.dart'; |
||||
|
import 'package:tobias/tobias.dart'; |
||||
|
|
||||
|
import '../../modules/login/controllers/weixinLogin.dart'; |
||||
|
|
||||
|
typedef PaymentResultCallback = void Function(bool success, String message); |
||||
|
|
||||
|
/// 支付服务 |
||||
|
class PayService extends GetxService { |
||||
|
late FlWX _flwx; |
||||
|
late Tobias _tobias; // 添加 Tobias 实例 |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
//微信 |
||||
|
FlWX().register( |
||||
|
appSecret: '9ceb39df312b3ba95a229b2aa0d15496', |
||||
|
appId: 'wx4b5ec8957ad5e425', |
||||
|
partnerId: '1719983673', |
||||
|
universalLink: 'https://voitrans.net/app/', |
||||
|
params: FlWXCallbackParams(onHttp: (url) async { |
||||
|
return ''; |
||||
|
})); |
||||
|
_flwx = FlWX(); |
||||
|
|
||||
|
// 初始化支付宝SDK |
||||
|
_tobias = Tobias(); // 初始化 Tobias 实例 |
||||
|
_initAlipay(); |
||||
|
} |
||||
|
|
||||
|
/// 初始化支付宝SDK |
||||
|
Future<void> _initAlipay() async { |
||||
|
try { |
||||
|
// 检查支付宝是否安装 - 修复用法 |
||||
|
final isInstalled = await _tobias.isAliPayInstalled; |
||||
|
print('支付宝是否安装: $isInstalled'); |
||||
|
} catch (e) { |
||||
|
print('初始化支付宝SDK失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 查询支付结果 |
||||
|
/// |
||||
|
/// [orderId] 订单ID |
||||
|
/// 返回支付状态:success, failed, pending |
||||
|
Future<String?> queryPaymentStatus(String orderId) async { |
||||
|
try { |
||||
|
// TODO: 调用后端API查询支付状态 |
||||
|
// final response = await Api.queryPaymentStatus({'orderId': orderId}); |
||||
|
// return response['status'] as String?; |
||||
|
|
||||
|
// 临时返回,实际使用时需要删除 |
||||
|
return 'pending'; |
||||
|
} catch (e) { |
||||
|
print('查询支付状态失败: $e'); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 微信支付 |
||||
|
/// |
||||
|
/// [paymentData] 支付参数,包含以下字段: |
||||
|
/// - appId: 应用ID |
||||
|
/// - partnerId: 商户号 |
||||
|
/// - prepayId: 预支付交易会话ID |
||||
|
/// - packageValue: 扩展字段,固定值"Sign=WXPay" |
||||
|
/// - nonceStr: 随机字符串 |
||||
|
/// - timestamp: 时间戳 |
||||
|
/// - sign: 签名 |
||||
|
/// |
||||
|
/// [onResult] 支付结果回调 |
||||
|
Future<bool> wechatPay({ |
||||
|
required Map<String, dynamic> paymentData, |
||||
|
PaymentResultCallback? onResult, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 检查微信是否安装 |
||||
|
if (!await _flwx.isInstalled) { |
||||
|
onResult?.call(false, '微信未安装'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 将支付数据转换为Payment对象 |
||||
|
final payment = paymentData.toPayment(); |
||||
|
if (payment == null) { |
||||
|
onResult?.call(false, '支付参数格式错误'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 调用微信支付 |
||||
|
final result = await _flwx.pay( |
||||
|
payment, |
||||
|
onSuccess: () { |
||||
|
onResult?.call(true, '支付成功'); |
||||
|
}, |
||||
|
onResponse: (response) { |
||||
|
if (response.errCode == 0) { |
||||
|
onResult?.call(true, '支付成功'); |
||||
|
} else if (response.errCode == -2) { |
||||
|
onResult?.call(false, '用户取消支付'); |
||||
|
} else { |
||||
|
onResult?.call(false, '支付失败: ${response.errStr}'); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
onResult?.call(false, '支付异常: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 创建微信支付订单 |
||||
|
/// |
||||
|
/// [productId] 商品ID |
||||
|
/// [productName] 商品名称 |
||||
|
/// [productDesc] 商品描述 |
||||
|
/// [amount] 支付金额(分) |
||||
|
/// [paymentMethod] 支付方式(1=微信支付) |
||||
|
/// [clientIp] 客户端IP(可选) |
||||
|
/// [notifyUrl] 通知URL(可选) |
||||
|
/// [extraData] 扩展数据(可选) |
||||
|
/// |
||||
|
/// 返回支付参数,用于调用 [wechatPay] 方法 |
||||
|
Future<Map<String, dynamic>?> createWechatPayOrder({ |
||||
|
required String productId, |
||||
|
required String productName, |
||||
|
String? productDesc, |
||||
|
required int amount, |
||||
|
int paymentMethod = 4, // 1表示微信支付 |
||||
|
String? clientIp, |
||||
|
String? notifyUrl, |
||||
|
String? extraData, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 调用后端API创建支付订单 |
||||
|
final response = await Api.paycreateorder({ |
||||
|
'product_id': productId, |
||||
|
'product_name': productName, |
||||
|
'product_desc': productDesc ?? '', |
||||
|
'amount': amount, |
||||
|
'payment_method': paymentMethod, |
||||
|
'client_ip': clientIp ?? '', |
||||
|
'notify_url': notifyUrl ?? '', |
||||
|
'extra_data': extraData ?? '', |
||||
|
}); |
||||
|
print('创建支付订单: $response'); |
||||
|
// 检查响应是否成功 |
||||
|
if (response != null) { |
||||
|
final pay_params = response["pay_params"] as Map<String, dynamic>; |
||||
|
// 返回微信支付所需的参数 |
||||
|
return { |
||||
|
'appId': pay_params['appid'], |
||||
|
'partnerId': pay_params['partnerid'], |
||||
|
'prepayId': pay_params['prepayid'], |
||||
|
'packageValue': pay_params['package'] ?? 'Sign=WXPay', |
||||
|
'nonceStr': pay_params['noncestr'], |
||||
|
'timestamp': pay_params['timestamp'], |
||||
|
'sign': pay_params['sign'], |
||||
|
}; |
||||
|
} else { |
||||
|
print('创建支付订单失败: ${response?['message'] ?? '未知错误'}'); |
||||
|
return null; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('创建支付订单异常: $e'); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<bool> processWechatPayment({ |
||||
|
required String productId, |
||||
|
required int amount, |
||||
|
required String description, |
||||
|
String? userId, |
||||
|
PaymentResultCallback? onResult, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 1. 创建支付订单 |
||||
|
final paymentData = await createWechatPayOrder( |
||||
|
productId: productId, |
||||
|
productName: '会员充值', |
||||
|
productDesc: '1个月会员服务', |
||||
|
amount: 1, // 29.99元,单位:分 |
||||
|
paymentMethod: 4, // 微信支付 |
||||
|
notifyUrl: 'https://api.ideapsound.com/api/home/pay_wechatnotify', |
||||
|
); |
||||
|
|
||||
|
if (paymentData == null) { |
||||
|
onResult?.call(false, '创建支付订单失败'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 2. 调用微信支付 |
||||
|
return await wechatPay( |
||||
|
paymentData: paymentData, |
||||
|
onResult: onResult, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
onResult?.call(false, '支付流程异常: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 完整的支付宝支付流程 |
||||
|
/// |
||||
|
/// [productId] 产品ID |
||||
|
/// [amount] 支付金额(分) |
||||
|
/// [description] 商品描述 |
||||
|
/// [onResult] 支付结果回调 |
||||
|
Future<bool> processAlipayPayment({ |
||||
|
required String productId, |
||||
|
required int amount, |
||||
|
required String description, |
||||
|
PaymentResultCallback? onResult, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 检查支付宝是否安装 |
||||
|
final isInstalled = await _tobias.isAliPayInstalled; |
||||
|
if (!isInstalled) { |
||||
|
onResult?.call(false, '支付宝未安装'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 调用现有的 createAlipayOrder 方法创建订单 |
||||
|
final orderInfo = await createAlipayOrder( |
||||
|
productId: productId, |
||||
|
productName: description, |
||||
|
productDesc: description, |
||||
|
amount: amount, |
||||
|
paymentMethod: 3, // 支付宝支付 |
||||
|
notifyUrl: 'https://api.ideapsound.com/api/home/pay_alipaynotify', |
||||
|
); |
||||
|
|
||||
|
if (orderInfo == null) { |
||||
|
onResult?.call(false, '创建支付订单失败'); |
||||
|
return false; |
||||
|
} |
||||
|
print('支付宝支付订单: $orderInfo'); |
||||
|
// 调用支付宝支付 |
||||
|
final result = await _tobias.pay(orderInfo); |
||||
|
|
||||
|
print('支付宝支付结果: $result'); |
||||
|
// 处理支付结果 |
||||
|
if (result != null) { |
||||
|
final resultStatus = result['resultStatus'] as String?; |
||||
|
final memo = result['memo'] as String?; |
||||
|
|
||||
|
if (resultStatus == '9000') { |
||||
|
onResult?.call(true, '支付成功'); |
||||
|
return true; |
||||
|
} else { |
||||
|
onResult?.call(false, memo ?? '支付失败'); |
||||
|
return false; |
||||
|
} |
||||
|
} else { |
||||
|
onResult?.call(false, '支付结果为空'); |
||||
|
return false; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
onResult?.call(false, '支付异常: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 支付宝支付 |
||||
|
/// |
||||
|
/// [orderInfo] 支付宝订单信息字符串 |
||||
|
/// [onResult] 支付结果回调 |
||||
|
Future<bool> alipay({ |
||||
|
required String orderInfo, |
||||
|
PaymentResultCallback? onResult, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 检查支付宝是否安装 - 使用实例方法 |
||||
|
final isInstalled = await _tobias.isAliPayInstalled; |
||||
|
if (!isInstalled) { |
||||
|
onResult?.call(false, '支付宝未安装'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 调用支付宝支付 - 使用实例方法 |
||||
|
final result = await _tobias.pay(orderInfo); |
||||
|
|
||||
|
if (result != null) { |
||||
|
// 解析支付结果 |
||||
|
final resultStatus = result['resultStatus'] as String?; |
||||
|
final memo = result['memo'] as String?; |
||||
|
|
||||
|
if (resultStatus == '9000') { |
||||
|
// 支付成功 |
||||
|
onResult?.call(true, '支付成功'); |
||||
|
return true; |
||||
|
} else { |
||||
|
// 支付失败或取消 |
||||
|
onResult?.call(false, memo ?? '支付失败'); |
||||
|
return false; |
||||
|
} |
||||
|
} else { |
||||
|
onResult?.call(false, '支付结果为空'); |
||||
|
return false; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
onResult?.call(false, '支付异常: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 创建支付宝支付订单 |
||||
|
/// |
||||
|
/// [productId] 商品ID |
||||
|
/// [productName] 商品名称 |
||||
|
/// [productDesc] 商品描述 |
||||
|
/// [amount] 支付金额(分) |
||||
|
/// [paymentMethod] 支付方式(2=支付宝) |
||||
|
/// [clientIp] 客户端IP(可选) |
||||
|
/// [notifyUrl] 通知URL(可选) |
||||
|
/// [extraData] 扩展数据(可选) |
||||
|
/// |
||||
|
/// 返回支付宝订单信息字符串,用于调用 [alipay] 方法 |
||||
|
Future<String?> createAlipayOrder({ |
||||
|
required String productId, |
||||
|
required String productName, |
||||
|
String? productDesc, |
||||
|
required int amount, |
||||
|
int paymentMethod = 2, // 2表示支付宝 |
||||
|
String? clientIp, |
||||
|
String? notifyUrl, |
||||
|
String? extraData, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 调用后端API创建支付订单 |
||||
|
final response = await Api.paycreateorder({ |
||||
|
'product_id': productId, |
||||
|
'product_name': productName, |
||||
|
'product_desc': productDesc ?? '', |
||||
|
'amount': amount, |
||||
|
'payment_method': paymentMethod, |
||||
|
'client_ip': clientIp ?? '', |
||||
|
'notify_url': notifyUrl ?? '', |
||||
|
'extra_data': extraData ?? '', |
||||
|
}); |
||||
|
|
||||
|
print('创建支付宝订单: $response'); |
||||
|
|
||||
|
// 检查响应是否成功 |
||||
|
if (response != null && response['alipay_pay_params'] != null) { |
||||
|
// 返回支付宝订单信息字符串 |
||||
|
return response['alipay_pay_params'] as String?; |
||||
|
} else { |
||||
|
print('创建支付宝订单失败: ${response?['message'] ?? '未知错误'}'); |
||||
|
return null; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('创建支付宝订单异常: $e'); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/payment_controller.dart'; |
||||
|
|
||||
|
class PaymentBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<PaymentController>(() => PaymentController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,143 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/pay_service.dart'; |
||||
|
|
||||
|
class PaymentController extends GetxController { |
||||
|
final PayService _payService = Get.find<PayService>(); |
||||
|
|
||||
|
// 支付状态 |
||||
|
final RxBool isLoading = false.obs; |
||||
|
final RxString paymentStatus = ''.obs; |
||||
|
final RxString errorMessage = ''.obs; |
||||
|
|
||||
|
// 测试订单信息 |
||||
|
final RxString testProductId = 'vip_101'.obs; |
||||
|
final RxDouble testAmount = 0.01.obs; // 测试金额 1分钱 |
||||
|
|
||||
|
// 支付方式选择 |
||||
|
final RxInt selectedPaymentMethod = 1.obs; // 1=微信支付, 2=支付宝支付 |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
// 生成测试订单ID |
||||
|
// testProductId.value = 'test_${DateTime.now().millisecondsSinceEpoch}'; |
||||
|
} |
||||
|
|
||||
|
/// 测试微信支付 |
||||
|
Future<void> testWechatPayment() async { |
||||
|
try { |
||||
|
isLoading.value = true; |
||||
|
paymentStatus.value = '正在发起微信支付...'; |
||||
|
errorMessage.value = ''; |
||||
|
|
||||
|
// 调用支付服务,使用回调处理结果 |
||||
|
final launched = await _payService.processWechatPayment( |
||||
|
productId: testProductId.value, |
||||
|
amount: (testAmount.value * 100).toInt(), // 转换为分 |
||||
|
description: '微信支付测试', |
||||
|
onResult: (success, message) { |
||||
|
// 支付结果回调 |
||||
|
isLoading.value = false; |
||||
|
|
||||
|
if (success) { |
||||
|
paymentStatus.value = '微信支付成功!'; |
||||
|
errorMessage.value = ''; |
||||
|
Get.snackbar( |
||||
|
'支付成功', |
||||
|
message ?? '微信支付测试完成', |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
); |
||||
|
} else { |
||||
|
paymentStatus.value = '微信支付失败'; |
||||
|
errorMessage.value = message ?? '未知错误'; |
||||
|
Get.snackbar( |
||||
|
'支付失败', |
||||
|
message ?? '未知错误', |
||||
|
snackPosition: SnackPosition.TOP, |
||||
|
); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (!launched) { |
||||
|
// 如果支付没有成功启动 |
||||
|
isLoading.value = false; |
||||
|
paymentStatus.value = '微信支付启动失败'; |
||||
|
errorMessage.value = '无法启动微信支付'; |
||||
|
Get.snackbar('支付启动失败', '无法启动微信支付'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
isLoading.value = false; |
||||
|
paymentStatus.value = '微信支付异常'; |
||||
|
errorMessage.value = e.toString(); |
||||
|
Get.snackbar('支付异常', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 测试支付宝支付 |
||||
|
Future<void> testAlipayPayment() async { |
||||
|
try { |
||||
|
isLoading.value = true; |
||||
|
paymentStatus.value = '正在发起支付宝支付...'; |
||||
|
errorMessage.value = ''; |
||||
|
|
||||
|
// 调用支付宝支付服务 |
||||
|
final launched = await _payService.processAlipayPayment( |
||||
|
productId: testProductId.value, // 这里传递的是产品ID |
||||
|
amount: (testAmount.value * 100).toInt(), // 转换为分 |
||||
|
description: '支付宝支付测试', |
||||
|
onResult: (success, message) { |
||||
|
// 支付结果回调 |
||||
|
isLoading.value = false; |
||||
|
|
||||
|
if (success) { |
||||
|
paymentStatus.value = '支付宝支付成功!'; |
||||
|
errorMessage.value = ''; |
||||
|
} else { |
||||
|
paymentStatus.value = '支付宝支付失败'; |
||||
|
errorMessage.value = message; |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (!launched) { |
||||
|
// 如果支付没有成功启动 |
||||
|
isLoading.value = false; |
||||
|
paymentStatus.value = '支付宝支付启动失败'; |
||||
|
errorMessage.value = '无法启动支付宝支付'; |
||||
|
Get.snackbar('支付启动失败', '无法启动支付宝支付'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
isLoading.value = false; |
||||
|
paymentStatus.value = '支付宝支付异常'; |
||||
|
errorMessage.value = e.toString(); |
||||
|
Get.snackbar('支付异常', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 根据选择的支付方式进行支付 |
||||
|
Future<void> processPayment() async { |
||||
|
if (selectedPaymentMethod.value == 1) { |
||||
|
await testWechatPayment(); |
||||
|
} else if (selectedPaymentMethod.value == 2) { |
||||
|
await testAlipayPayment(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 选择支付方式 |
||||
|
void selectPaymentMethod(int method) { |
||||
|
selectedPaymentMethod.value = method; |
||||
|
} |
||||
|
|
||||
|
/// 重置状态 |
||||
|
void resetStatus() { |
||||
|
paymentStatus.value = ''; |
||||
|
errorMessage.value = ''; |
||||
|
// testProductId.value = 'test_${DateTime.now().millisecondsSinceEpoch}'; |
||||
|
} |
||||
|
|
||||
|
/// 更新测试金额 |
||||
|
void updateTestAmount(double amount) { |
||||
|
testAmount.value = amount; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,506 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../controllers/payment_controller.dart'; |
||||
|
|
||||
|
class PaymentView extends GetView<PaymentController> { |
||||
|
const PaymentView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return Scaffold( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
||||
|
elevation: 0.5, |
||||
|
title: Text( |
||||
|
'支付测试', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
leading: IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.arrow_back_ios, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
), |
||||
|
body: SingleChildScrollView( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
_buildTestInfoCard(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
_buildPaymentMethodSelector(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
_buildAmountSelector(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
_buildPaymentButton(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
_buildStatusCard(isDarkMode), |
||||
|
SizedBox(height: 20.h), |
||||
|
_buildInstructions(isDarkMode), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 测试信息卡片 |
||||
|
Widget _buildTestInfoCard(bool isDarkMode) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'测试订单信息', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Obx(() => _buildInfoRow( |
||||
|
'商品ID', controller.testProductId.value, isDarkMode)), |
||||
|
SizedBox(height: 8.h), |
||||
|
_buildInfoRow('商品描述', '微信支付测试', isDarkMode), |
||||
|
SizedBox(height: 8.h), |
||||
|
Obx(() => _buildInfoRow( |
||||
|
'支付金额', |
||||
|
'¥${controller.testAmount.value.toStringAsFixed(2)}', |
||||
|
isDarkMode)), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 信息行 |
||||
|
Widget _buildInfoRow(String label, String value, bool isDarkMode) { |
||||
|
return Row( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 80.w, |
||||
|
child: Text( |
||||
|
'$label:', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 金额选择器 |
||||
|
Widget _buildAmountSelector(bool isDarkMode) { |
||||
|
final amounts = [0.01, 0.10, 1.00, 10.00]; |
||||
|
|
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'选择测试金额', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Wrap( |
||||
|
spacing: 12.w, |
||||
|
runSpacing: 8.h, |
||||
|
children: amounts.map((amount) { |
||||
|
return Obx(() => GestureDetector( |
||||
|
onTap: () => controller.updateTestAmount(amount), |
||||
|
child: Container( |
||||
|
padding: |
||||
|
EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: controller.testAmount.value == amount |
||||
|
? Colors.blue[600] |
||||
|
: (isDarkMode |
||||
|
? Colors.grey[800] |
||||
|
: Colors.grey[200]), |
||||
|
borderRadius: BorderRadius.circular(20.r), |
||||
|
), |
||||
|
child: Text( |
||||
|
'¥${amount.toStringAsFixed(2)}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: controller.testAmount.value == amount |
||||
|
? Colors.white |
||||
|
: (isDarkMode ? Colors.white : Colors.black), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
)); |
||||
|
}).toList(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 支付方式选择器 |
||||
|
Widget _buildPaymentMethodSelector(bool isDarkMode) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'选择支付方式', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
// 移除外层的 Obx,因为内部的 _buildPaymentMethodOption 已经有 Obx 了 |
||||
|
Column( |
||||
|
children: [ |
||||
|
_buildPaymentMethodOption( |
||||
|
method: 1, |
||||
|
title: '微信支付', |
||||
|
icon: Icons.wechat, |
||||
|
iconColor: Colors.green, |
||||
|
isDarkMode: isDarkMode, |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
_buildPaymentMethodOption( |
||||
|
method: 2, |
||||
|
title: '支付宝支付', |
||||
|
icon: Icons.account_balance_wallet, |
||||
|
iconColor: Colors.blue, |
||||
|
isDarkMode: isDarkMode, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 支付方式选项 |
||||
|
Widget _buildPaymentMethodOption({ |
||||
|
required int method, |
||||
|
required String title, |
||||
|
required IconData icon, |
||||
|
required Color iconColor, |
||||
|
required bool isDarkMode, |
||||
|
}) { |
||||
|
return Obx(() => GestureDetector( |
||||
|
onTap: () => controller.selectPaymentMethod(method), |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: controller.selectedPaymentMethod.value == method |
||||
|
? (isDarkMode |
||||
|
? Colors.blue.withOpacity(0.2) |
||||
|
: Colors.blue.withOpacity(0.1)) |
||||
|
: (isDarkMode ? Colors.grey[800] : Colors.grey[100]), |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all( |
||||
|
color: controller.selectedPaymentMethod.value == method |
||||
|
? Colors.blue |
||||
|
: Colors.transparent, |
||||
|
width: 1.5, |
||||
|
), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
icon, |
||||
|
color: iconColor, |
||||
|
size: 24.sp, |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
if (controller.selectedPaymentMethod.value == method) |
||||
|
Icon( |
||||
|
Icons.check_circle, |
||||
|
color: Colors.blue, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
/// 支付按钮 |
||||
|
Widget _buildPaymentButton(bool isDarkMode) { |
||||
|
return Obx(() => Container( |
||||
|
width: double.infinity, |
||||
|
height: 50.h, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: controller.isLoading.value |
||||
|
? null |
||||
|
: () { |
||||
|
controller.processPayment(); |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: controller.isLoading.value |
||||
|
? Colors.grey |
||||
|
: (controller.selectedPaymentMethod.value == 1 |
||||
|
? Colors.green |
||||
|
: Colors.blue), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
elevation: 2, |
||||
|
), |
||||
|
child: controller.isLoading.value |
||||
|
? Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 20.w, |
||||
|
height: 20.w, |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
valueColor: |
||||
|
AlwaysStoppedAnimation<Color>(Colors.white), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 12.w), |
||||
|
Text( |
||||
|
'支付中...', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
controller.selectedPaymentMethod.value == 1 |
||||
|
? Icons.wechat |
||||
|
: Icons.account_balance_wallet, |
||||
|
color: Colors.white, |
||||
|
size: 20.sp, |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
controller.selectedPaymentMethod.value == 1 |
||||
|
? '微信支付 ¥${controller.testAmount.value.toStringAsFixed(2)}' |
||||
|
: '支付宝支付 ¥${controller.testAmount.value.toStringAsFixed(2)}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
/// 状态卡片 |
||||
|
Widget _buildStatusCard(bool isDarkMode) { |
||||
|
return Obx(() { |
||||
|
if (controller.paymentStatus.value.isEmpty) { |
||||
|
return const SizedBox.shrink(); |
||||
|
} |
||||
|
|
||||
|
final isError = controller.errorMessage.value.isNotEmpty; |
||||
|
|
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isError |
||||
|
? (isDarkMode ? Colors.red[900] : Colors.red[50]) |
||||
|
: (isDarkMode ? Colors.green[900] : Colors.green[50]), |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
border: Border.all( |
||||
|
color: isError ? Colors.red[300]! : Colors.green[300]!, |
||||
|
width: 1, |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
isError ? Icons.error : Icons.check_circle, |
||||
|
color: isError ? Colors.red[600] : Colors.green[600], |
||||
|
size: 20.w, |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
'支付状态', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isError ? Colors.red[600] : Colors.green[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
controller.paymentStatus.value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
if (controller.errorMessage.value.isNotEmpty) ...[ |
||||
|
SizedBox(height: 8.h), |
||||
|
Text( |
||||
|
'错误信息: ${controller.errorMessage.value}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12.sp, |
||||
|
color: Colors.red[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
SizedBox(height: 12.h), |
||||
|
SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: OutlinedButton( |
||||
|
onPressed: controller.resetStatus, |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
side: BorderSide( |
||||
|
color: isError ? Colors.red[600]! : Colors.green[600]!, |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
'重新测试', |
||||
|
style: TextStyle( |
||||
|
color: isError ? Colors.red[600] : Colors.green[600], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 使用说明 |
||||
|
Widget _buildInstructions(bool isDarkMode) { |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.grey[900] : Colors.blue[50], |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
border: Border.all( |
||||
|
color: Colors.blue[200]!, |
||||
|
width: 1, |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.info_outline, |
||||
|
color: Colors.blue[600], |
||||
|
size: 20.w, |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Text( |
||||
|
'使用说明', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.blue[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
Text( |
||||
|
'1. 选择测试金额(建议使用0.01元进行测试)\n' |
||||
|
'2. 点击"发起微信支付"按钮\n' |
||||
|
'3. 系统将调用微信支付SDK\n' |
||||
|
'4. 在微信中完成支付流程\n' |
||||
|
'5. 返回应用查看支付结果\n\n' |
||||
|
'注意:这是测试环境,实际不会产生真实扣费', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.grey[700], |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import '../../../../core/theme/app_colors.dart'; |
||||
|
import '../../models/translation_models.dart'; |
||||
|
|
||||
|
/// 删除确认对话框 |
||||
|
/// 提供翻译记录的删除确认功能 |
||||
|
class DeleteConfirmDialog { |
||||
|
/// 显示删除单条翻译记录确认对话框 |
||||
|
static void showDeleteItemDialog({ |
||||
|
required TranslationItem item, |
||||
|
required Function() onConfirm, |
||||
|
}) { |
||||
|
final isDarkMode = Theme.of(Get.context!).brightness == Brightness.dark; |
||||
|
|
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, |
||||
|
title: Text( |
||||
|
'deleteTranslationRecord'.tr, // 删除翻译记录 |
||||
|
style: TextStyle( |
||||
|
fontSize: 18.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
content: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'deleteTranslationConfirm'.tr, // 确定要删除这条翻译记录吗?此操作无法撤销。 |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12.h), |
||||
|
// 显示要删除的记录预览 |
||||
|
Container( |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode |
||||
|
? Colors.grey[800]?.withOpacity(0.5) |
||||
|
: Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
border: Border.all( |
||||
|
color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!, |
||||
|
width: 1, |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
item.sourceText, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
maxLines: 2, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
if (item.translatedText.isNotEmpty) ...[ |
||||
|
SizedBox(height: 4.h), |
||||
|
Text( |
||||
|
item.translatedText, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.grey[700], |
||||
|
), |
||||
|
maxLines: 2, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: Text( |
||||
|
'cancel'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Get.back(); |
||||
|
onConfirm(); |
||||
|
}, |
||||
|
child: Text( |
||||
|
'confirm'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.red, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 显示清除所有历史记录确认对话框 |
||||
|
static void showClearHistoryDialog({ |
||||
|
required Function() onConfirm, |
||||
|
}) { |
||||
|
final isDarkMode = Get.isDarkMode; |
||||
|
|
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, |
||||
|
title: Text( |
||||
|
'clearHistory'.tr, // 清除历史记录 |
||||
|
style: TextStyle( |
||||
|
fontSize: 18.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
content: Text( |
||||
|
'clearHistoryConfirm'.tr, // 确定要清除所有翻译历史记录吗?此操作无法撤销。 |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.grey[300] : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: Text( |
||||
|
'cancel'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: isDarkMode ? Colors.grey[400] : Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Get.back(); |
||||
|
onConfirm(); |
||||
|
}, |
||||
|
child: Text( |
||||
|
'confirm'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.red, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,187 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:haptic_feedback/haptic_feedback.dart'; |
||||
|
import '../../../../core/theme/app_colors.dart'; |
||||
|
import '../../models/translation_models.dart'; |
||||
|
|
||||
|
/// 翻译长按菜单对话框 |
||||
|
/// 负责处理翻译条目的长按菜单显示和交互 |
||||
|
class TranslationLongPressMenu { |
||||
|
/// 显示翻译长按菜单 |
||||
|
static void show({ |
||||
|
required BuildContext context, |
||||
|
required bool isDarkMode, |
||||
|
required LongPressStartDetails details, |
||||
|
required TranslationItem item, |
||||
|
required Function(TranslationItem) onCopy, |
||||
|
required Function(TranslationItem) onDeleteItem, |
||||
|
required Function() onClearHistory, |
||||
|
}) { |
||||
|
// 只对完成态记录显示长按菜单 |
||||
|
if (item.isIntermediate) return; |
||||
|
|
||||
|
// 触发触觉反馈 |
||||
|
Haptics.vibrate(HapticsType.success); |
||||
|
|
||||
|
// 计算弹出菜单位置 |
||||
|
RenderBox? renderBox = |
||||
|
Overlay.of(context).context.findRenderObject() as RenderBox; |
||||
|
final RelativeRect position = RelativeRect.fromRect( |
||||
|
Rect.fromLTRB( |
||||
|
details.globalPosition.dx, |
||||
|
details.globalPosition.dy, |
||||
|
details.globalPosition.dx + 110, // 菜单显示位置X轴坐标 |
||||
|
details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标 |
||||
|
), |
||||
|
Offset.zero & renderBox.size, |
||||
|
); |
||||
|
|
||||
|
// 显示弹出菜单 |
||||
|
showMenu( |
||||
|
context: context, |
||||
|
position: position, |
||||
|
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
items: _buildMenuItems( |
||||
|
context: context, |
||||
|
isDarkMode: isDarkMode, |
||||
|
item: item, |
||||
|
onCopy: onCopy, |
||||
|
onDeleteItem: onDeleteItem, |
||||
|
onClearHistory: onClearHistory, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建菜单项 |
||||
|
static List<PopupMenuEntry> _buildMenuItems({ |
||||
|
required BuildContext context, |
||||
|
required bool isDarkMode, |
||||
|
required TranslationItem item, |
||||
|
required Function(TranslationItem) onCopy, |
||||
|
required Function(TranslationItem) onDeleteItem, |
||||
|
required Function() onClearHistory, |
||||
|
}) { |
||||
|
return [ |
||||
|
// 复制选项 |
||||
|
_buildPopupMenuItem( |
||||
|
Icon( |
||||
|
Icons.copy, |
||||
|
size: 20.w, |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
'copy'.tr, // 复制 |
||||
|
isDarkMode ? Colors.white : Colors.black87, |
||||
|
() => onCopy(item), |
||||
|
), |
||||
|
|
||||
|
// 分隔线 |
||||
|
PopupMenuItem( |
||||
|
height: 1, |
||||
|
enabled: false, |
||||
|
child: Divider( |
||||
|
height: 1, |
||||
|
thickness: 0.8, |
||||
|
indent: 0, |
||||
|
endIndent: 0, |
||||
|
color: isDarkMode ? Colors.grey[700] : Colors.grey[300], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 删除该条记录选项 |
||||
|
_buildPopupMenuItem( |
||||
|
Icon( |
||||
|
Icons.delete_outline, |
||||
|
size: 20.w, |
||||
|
color: isDarkMode ? Colors.orange[300] : Colors.orange, |
||||
|
), |
||||
|
'delete'.tr, // 删除 |
||||
|
isDarkMode ? Colors.orange[300]! : Colors.orange, |
||||
|
() => onDeleteItem(item), |
||||
|
), |
||||
|
|
||||
|
// 分隔线 |
||||
|
PopupMenuItem( |
||||
|
height: 1, |
||||
|
enabled: false, |
||||
|
child: Divider( |
||||
|
height: 1, |
||||
|
thickness: 0.8, |
||||
|
indent: 0, |
||||
|
endIndent: 0, |
||||
|
color: isDarkMode ? Colors.grey[700] : Colors.grey[300], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 删除所有记录选项 |
||||
|
_buildPopupMenuItem( |
||||
|
Icon( |
||||
|
Icons.delete_sweep, |
||||
|
size: 20.w, |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
'clearHistory'.tr, // 清除历史记录 |
||||
|
Colors.red, |
||||
|
onClearHistory, |
||||
|
), |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
/// 构建弹出菜单项 |
||||
|
static PopupMenuEntry _buildPopupMenuItem( |
||||
|
Icon icon, |
||||
|
String title, |
||||
|
Color color, |
||||
|
Function() onTap, |
||||
|
) { |
||||
|
return PopupMenuItem( |
||||
|
onTap: onTap, |
||||
|
child: SizedBox( |
||||
|
width: 150.w, |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
icon, |
||||
|
8.horizontalSpace, |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
color: color, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 复制翻译条目内容 |
||||
|
/// 将原文和翻译文本复制到剪贴板 |
||||
|
static void copyTranslationItem(TranslationItem item) { |
||||
|
// 构建复制文本:原文 + 翻译 |
||||
|
String copyText = item.sourceText; |
||||
|
if (item.translatedText.isNotEmpty) { |
||||
|
copyText += '\n${item.translatedText}'; |
||||
|
} |
||||
|
|
||||
|
Clipboard.setData(ClipboardData(text: copyText)).then((_) { |
||||
|
Get.snackbar( |
||||
|
'success'.tr, |
||||
|
'copySuccess'.tr, // 复制成功 |
||||
|
backgroundColor: Colors.blue.withOpacity(0.8), |
||||
|
colorText: Colors.white, |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: const Duration(milliseconds: 1200), |
||||
|
icon: Icon( |
||||
|
Icons.check_circle_outline, |
||||
|
color: Colors.white, |
||||
|
size: 24.sp, |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue