diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 883fb2b..4fc5b4a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,3 +42,7 @@ android { flutter { source = "../.." } + +dependencies { + implementation("com.squareup.okhttp3:okhttp:4.12.0") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cab2d71..10bc276 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,15 @@ + + + + + android:label="文本同步" + android:name=".WirelessTextSyncerApplicationHolder" + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> + + + + + + + { + val host = intent.getStringExtra(EXTRA_HOST).orEmpty() + val port = intent.getIntExtra(EXTRA_PORT, 8181) + connectWebSocket(host, port) + } + ACTION_DISCONNECT -> disconnectWebSocket("已断开连接") + } + return START_STICKY + } + + override fun onDestroy() { + Log.d(tag, "ConnectionService onDestroy") + currentSocket?.close(1000, "service destroyed") + currentSocket = null + super.onDestroy() + } + + private fun connectWebSocket(host: String, port: Int) { + Log.d(tag, "connectWebSocket host=$host port=$port") + if (host.isBlank()) { + updateState(connected = false, connecting = false, host = host, port = port, lastError = "请先填写 Windows IP") + return + } + + getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_HOST, host) + .putInt(KEY_PORT, port) + .apply() + + try { + Log.d(tag, "startForeground connecting notification") + startForeground(NOTIFICATION_ID, buildNotification(host, port, "正在连接")) + } catch (exception: Exception) { + Log.e(tag, "startForeground failed", exception) + updateState( + connected = false, + connecting = false, + host = host, + port = port, + lastError = exception.message ?: "前台服务启动失败" + ) + stopSelf() + return + } + currentSocket?.close(1000, "reconnect") + updateState(connected = false, connecting = true, host = host, port = port, lastError = null) + + val request = Request.Builder().url("ws://$host:$port").build() + Log.d(tag, "Creating WebSocket ws://$host:$port") + currentSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(tag, "WebSocket onOpen code=${response.code}") + if (currentSocket != webSocket) { + Log.d(tag, "Ignoring stale WebSocket onOpen") + return + } + updateState(connected = true, connecting = false, host = host, port = port, lastError = null) + Handler(Looper.getMainLooper()).post { + startForeground(NOTIFICATION_ID, buildNotification(host, port, "已连接")) + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(tag, "WebSocket onClosed code=$code reason=$reason") + if (currentSocket != webSocket) { + Log.d(tag, "Ignoring stale WebSocket onClosed") + return + } + updateState(connected = false, connecting = false, host = host, port = port, lastError = null) + Handler(Looper.getMainLooper()).post { + stopForeground(STOP_FOREGROUND_REMOVE) + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(tag, "WebSocket onFailure responseCode=${response?.code}", t) + if (currentSocket != webSocket) { + Log.d(tag, "Ignoring stale WebSocket onFailure") + return + } + updateState( + connected = false, + connecting = false, + host = host, + port = port, + lastError = t.message ?: "连接失败" + ) + Handler(Looper.getMainLooper()).post { + stopForeground(STOP_FOREGROUND_REMOVE) + } + } + }) + } + + private fun disconnectWebSocket(message: String?) { + Log.d(tag, "disconnectWebSocket message=$message") + currentSocket?.close(1000, "user disconnected") + currentSocket = null + updateState(connected = false, connecting = false, lastError = message) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun buildNotification(host: String, port: Int, status: String): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + Notification.Builder(this) + } + + return builder + .setSmallIcon(android.R.drawable.stat_sys_upload_done) + .setContentTitle("文本同步") + .setContentText("$status $host:$port") + .setOngoing(true) + .setShowWhen(false) + .build() + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val channel = NotificationChannel(CHANNEL_ID, "文本同步连接", NotificationManager.IMPORTANCE_LOW) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + + companion object { + private const val ACTION_CONNECT = "wireless_text_syncer.CONNECT" + private const val ACTION_DISCONNECT = "wireless_text_syncer.DISCONNECT" + private const val EXTRA_HOST = "host" + private const val EXTRA_PORT = "port" + private const val CHANNEL_ID = "wireless_text_syncer_connection" + private const val NOTIFICATION_ID = 1001 + private const val PREFS = "wireless_text_syncer_connection" + private const val KEY_HOST = "server_host" + private const val KEY_PORT = "server_port" + + private val mainHandler = Handler(Looper.getMainLooper()) + @Volatile private var currentSocket: WebSocket? = null + @Volatile private var state = ConnectionStateSnapshot() + @Volatile var eventSink: EventChannel.EventSink? = null + + fun connect(context: Context, host: String, port: Int) { + Log.d("WTS", "ConnectionService.connect requested host=$host port=$port sdk=${Build.VERSION.SDK_INT}") + val intent = Intent(context, ConnectionService::class.java) + .setAction(ACTION_CONNECT) + .putExtra(EXTRA_HOST, host) + .putExtra(EXTRA_PORT, port) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (exception: Exception) { + Log.e("WTS", "Failed to start ConnectionService", exception) + updateState( + connected = false, + connecting = false, + host = host, + port = port, + lastError = exception.message ?: "连接服务启动失败" + ) + throw exception + } + } + + fun disconnect(context: Context) { + Log.d("WTS", "ConnectionService.disconnect requested") + try { + context.startService(Intent(context, ConnectionService::class.java).setAction(ACTION_DISCONNECT)) + } catch (exception: Exception) { + Log.e("WTS", "Failed to stop ConnectionService", exception) + } + } + + fun sendText(text: String): Boolean { + Log.d("WTS", "ConnectionService.sendText length=${text.length} connected=${state.connected}") + val socket = currentSocket ?: return false + if (!state.connected || text.isEmpty()) { + return false + } + + val payload = JSONObject() + .put("action", "replaceAll") + .put("text", text) + .toString() + return socket.send(payload) + } + + fun stateMap(): Map { + val snapshot = state + return mapOf( + "connected" to snapshot.connected, + "connecting" to snapshot.connecting, + "host" to snapshot.host, + "port" to snapshot.port, + "lastError" to snapshot.lastError + ) + } + + private fun updateState( + connected: Boolean, + connecting: Boolean, + host: String = state.host, + port: Int = state.port, + lastError: String? = null + ) { + Log.d( + "WTS", + "updateState connected=$connected connecting=$connecting host=$host port=$port error=$lastError" + ) + state = ConnectionStateSnapshot(connected, connecting, host, port, lastError) + mainHandler.post { + eventSink?.success(stateMap()) + QuickSendTileService.requestTileRefresh() + } + } + } + + private data class ConnectionStateSnapshot( + val connected: Boolean = false, + val connecting: Boolean = false, + val host: String = "", + val port: Int = 8181, + val lastError: String? = null + ) +} diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt index d32b584..2578932 100644 --- a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/MainActivity.kt @@ -1,5 +1,140 @@ package com.wirelesstextsyncer.wireless_text_syncer_android +import android.content.Context +import android.net.wifi.WifiManager +import android.util.Log +import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.android.FlutterActivity +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import org.json.JSONObject +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + private val tag = "WTS" + private val connectionChannel = "wireless_text_syncer/connection" + private val connectionStateChannel = "wireless_text_syncer/connection_state" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, connectionChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "connect" -> { + val host = call.argument("host").orEmpty() + val port = call.argument("port") ?: 8181 + Log.d(tag, "MethodChannel connect host=$host port=$port") + try { + ConnectionService.connect(this, host, port) + result.success(null) + } catch (exception: Exception) { + Log.e(tag, "MethodChannel connect failed", exception) + result.error("CONNECT_FAILED", exception.message, null) + } + } + "disconnect" -> { + Log.d(tag, "MethodChannel disconnect") + ConnectionService.disconnect(this) + result.success(null) + } + "sendText" -> { + val text = call.argument("text").orEmpty() + Log.d(tag, "MethodChannel sendText length=${text.length}") + result.success(ConnectionService.sendText(text)) + } + "getState" -> { + Log.d(tag, "MethodChannel getState") + result.success(ConnectionService.stateMap()) + } + "startDiscovery" -> startDiscovery(result) + "stopDiscovery" -> result.success(null) + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, connectionStateChannel) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ConnectionService.eventSink = events + events?.success(ConnectionService.stateMap()) + } + + override fun onCancel(arguments: Any?) { + ConnectionService.eventSink = null + } + }) + } + + private fun startDiscovery(result: MethodChannel.Result) { + Log.d(tag, "Discovery started") + Thread { + val devices = mutableListOf>() + val seen = mutableSetOf() + var multicastLock: WifiManager.MulticastLock? = null + try { + val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + multicastLock = wifi?.createMulticastLock("wireless-text-syncer-discovery") + multicastLock?.setReferenceCounted(false) + multicastLock?.acquire() + + DatagramSocket().use { socket -> + socket.broadcast = true + socket.soTimeout = 450 + val request = JSONObject() + .put("type", "wirelessTextSyncer.discovery") + .put("version", 1) + .toString() + .toByteArray(Charsets.UTF_8) + val packet = DatagramPacket( + request, + request.size, + InetAddress.getByName("255.255.255.255"), + 8182 + ) + socket.send(packet) + + val deadline = System.currentTimeMillis() + 1600 + while (System.currentTimeMillis() < deadline) { + try { + val buffer = ByteArray(2048) + val response = DatagramPacket(buffer, buffer.size) + socket.receive(response) + val json = JSONObject(String(response.data, 0, response.length, Charsets.UTF_8)) + if (json.optString("type") != "wirelessTextSyncer.service") { + continue + } + val host = json.optString("host", response.address.hostAddress.orEmpty()) + val port = json.optInt("port", 8181) + val key = "$host:$port" + if (seen.add(key)) { + Log.d(tag, "Discovery found ${json.optString("name", host)} $key") + devices += mapOf( + "name" to json.optString("name", host), + "host" to host, + "port" to port + ) + } + } catch (_: Exception) { + } + } + } + } catch (exception: Exception) { + Log.e(tag, "Discovery failed", exception) + } finally { + multicastLock?.let { + if (it.isHeld) { + it.release() + } + } + } + + runOnUiThread { + Log.d(tag, "Discovery finished count=${devices.size}") + result.success(devices) + } + }.start() + } +} diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt new file mode 100644 index 0000000..09e1cdc --- /dev/null +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendActivity.kt @@ -0,0 +1,179 @@ +package com.wirelesstextsyncer.wireless_text_syncer_android + +import android.app.Activity +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Gravity +import android.view.WindowInsets +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.content.Context +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView + +class QuickSendActivity : Activity() { + private lateinit var input: EditText + private lateinit var sendButton: LinearLayout + private lateinit var sendIcon: ImageView + private lateinit var sendLabel: TextView + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d("WTS", "QuickSendActivity onCreate") + buildUi() + input.requestFocus() + input.post { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun buildUi() { + val root = FrameLayout(this) + root.setBackgroundColor(0x66000000) + root.setOnClickListener { finish() } + root.setOnApplyWindowInsetsListener { view, insets -> + val imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom + val navBottom = insets.getInsets(WindowInsets.Type.navigationBars()).bottom + val keyboardLift = (imeBottom - navBottom).coerceAtLeast(0) + view.setPadding(0, 0, 0, keyboardLift) + insets + } + window.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or + WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE + ) + + val sheet = LinearLayout(this) + sheet.orientation = LinearLayout.VERTICAL + sheet.setPadding(dp(20), dp(18), dp(20), dp(18)) + sheet.setBackgroundColor(Color.WHITE) + sheet.setOnClickListener { } + + statusText = TextView(this) + statusText.textSize = 14f + statusText.setTextColor(Color.rgb(71, 85, 105)) + sheet.addView(statusText) + + input = EditText(this) + input.minLines = 3 + input.maxLines = 6 + input.hint = "输入要发送到电脑的文本" + input.textSize = 16f + input.setPadding(dp(14), dp(12), dp(14), dp(12)) + input.background = strokeDrawable(Color.WHITE, Color.rgb(226, 232, 240), dp(12).toFloat()) + sheet.addView(input, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = dp(12) + }) + + sendButton = LinearLayout(this) + sendButton.gravity = Gravity.CENTER + sendButton.orientation = LinearLayout.HORIZONTAL + sendButton.setPadding(dp(16), 0, dp(16), 0) + sendButton.background = roundedDrawable(Color.rgb(37, 99, 235), dp(26).toFloat()) + sendButton.isClickable = true + sendButton.isFocusable = true + sendButton.setOnClickListener { sendAndClose() } + sendIcon = ImageView(this) + sendIcon.setImageResource(R.drawable.ic_send_24) + sendIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) + sendLabel = TextView(this) + sendLabel.text = "发送到电脑" + sendLabel.textSize = 16f + sendLabel.typeface = Typeface.DEFAULT_BOLD + sendLabel.setTextColor(Color.WHITE) + sendButton.addView(sendIcon, LinearLayout.LayoutParams(dp(20), dp(20))) + sendButton.addView(sendLabel, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + leftMargin = dp(8) + }) + sheet.addView(sendButton, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(52) + ).apply { + topMargin = dp(12) + }) + + val params = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ) + root.addView(sheet, params) + setContentView(root) + refreshState() + } + + private fun refreshState() { + val state = ConnectionService.stateMap() + val connected = state["connected"] as? Boolean ?: false + val host = state["host"] as? String ?: "" + val port = state["port"] as? Int ?: 8181 + statusText.text = if (connected) "● 已连接 $host:$port" else "● 未连接,请先打开主应用连接电脑" + statusText.setTextColor(if (connected) Color.rgb(22, 101, 52) else Color.rgb(185, 28, 28)) + sendButton.isEnabled = connected + sendButton.alpha = if (connected) 1f else 0.48f + } + + private fun sendAndClose() { + val text = input.text.toString() + Log.d("WTS", "QuickSendActivity send length=${text.length}") + if (text.isBlank()) { + return + } + + if (ConnectionService.sendText(text)) { + sendButton.background = roundedDrawable(Color.rgb(22, 163, 74), dp(26).toFloat()) + sendIcon.setImageResource(R.drawable.ic_check_24) + sendIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) + sendLabel.text = "发送成功" + Handler(Looper.getMainLooper()).postDelayed({ + hideKeyboard() + finish() + }, 520) + } else { + refreshState() + } + } + + private fun hideKeyboard() { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(input.windowToken, 0) + } + + private fun roundedDrawable(color: Int, radius: Float): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setColor(color) + cornerRadius = radius + } + } + + private fun strokeDrawable(fill: Int, stroke: Int, radius: Float): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setColor(fill) + setStroke(dp(1), stroke) + cornerRadius = radius + } + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } +} diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendTileService.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendTileService.kt new file mode 100644 index 0000000..807f95b --- /dev/null +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/QuickSendTileService.kt @@ -0,0 +1,62 @@ +package com.wirelesstextsyncer.wireless_text_syncer_android + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log + +class QuickSendTileService : TileService() { + override fun onStartListening() { + super.onStartListening() + Log.d("WTS", "QuickSendTileService onStartListening") + refreshTile() + } + + override fun onClick() { + super.onClick() + Log.d("WTS", "QuickSendTileService onClick") + val intent = Intent(this, QuickSendActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } + + private fun refreshTile() { + val tile = qsTile ?: return + val state = ConnectionService.stateMap() + val connected = state["connected"] as? Boolean ?: false + tile.label = "文本同步" + tile.subtitle = if (connected) "已连接" else "未连接" + tile.state = if (connected) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + tile.updateTile() + } + + companion object { + fun requestTileRefresh() { + Log.d("WTS", "QuickSendTileService requestTileRefresh") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + try { + requestListeningState( + WirelessTextSyncerApplicationHolder.context, + ComponentName(WirelessTextSyncerApplicationHolder.context, QuickSendTileService::class.java) + ) + } catch (exception: Exception) { + Log.e("WTS", "requestListeningState failed", exception) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/WirelessTextSyncerApplicationHolder.kt b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/WirelessTextSyncerApplicationHolder.kt new file mode 100644 index 0000000..0abdca9 --- /dev/null +++ b/android/app/src/main/kotlin/com/wirelesstextsyncer/wireless_text_syncer_android/WirelessTextSyncerApplicationHolder.kt @@ -0,0 +1,17 @@ +package com.wirelesstextsyncer.wireless_text_syncer_android + +import android.app.Application +import android.content.Context +import android.util.Log + +class WirelessTextSyncerApplicationHolder : Application() { + override fun onCreate() { + super.onCreate() + Log.d("WTS", "Application onCreate") + context = applicationContext + } + + companion object { + lateinit var context: Context + } +} diff --git a/android/app/src/main/res/drawable/ic_check_24.xml b/android/app/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 0000000..c0b31cc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_send_24.xml b/android/app/src/main/res/drawable/ic_send_24.xml new file mode 100644 index 0000000..9ad208d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_send_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..5beb891 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -15,4 +15,11 @@ + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..40b76cb 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,11 @@ + diff --git a/lib/main.dart b/lib/main.dart index c6b717f..99ee0d6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,8 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; void main() { runApp(const WirelessTextSyncerApp()); @@ -29,6 +28,117 @@ class WirelessTextSyncerApp extends StatelessWidget { enum SendButtonState { idle, sending, success } +class ConnectionSnapshot { + const ConnectionSnapshot({ + this.connected = false, + this.connecting = false, + this.host = '', + this.port = 8181, + this.lastError, + }); + + factory ConnectionSnapshot.fromMap(Map? map) { + if (map == null) { + return const ConnectionSnapshot(); + } + + return ConnectionSnapshot( + connected: map['connected'] == true, + connecting: map['connecting'] == true, + host: (map['host'] as String?) ?? '', + port: (map['port'] as num?)?.toInt() ?? 8181, + lastError: map['lastError'] as String?, + ); + } + + final bool connected; + final bool connecting; + final String host; + final int port; + final String? lastError; +} + +class DiscoveredDevice { + const DiscoveredDevice({ + required this.name, + required this.host, + required this.port, + }); + + factory DiscoveredDevice.fromMap(Map map) { + return DiscoveredDevice( + name: (map['name'] as String?) ?? 'Windows 桌面端', + host: (map['host'] as String?) ?? '', + port: (map['port'] as num?)?.toInt() ?? 8181, + ); + } + + final String name; + final String host; + final int port; + + String get endpoint => '$host:$port'; +} + +class NativeConnectionApi { + static const MethodChannel _methodChannel = MethodChannel( + 'wireless_text_syncer/connection', + ); + static const EventChannel _eventChannel = EventChannel( + 'wireless_text_syncer/connection_state', + ); + + Stream watchState() { + return _eventChannel.receiveBroadcastStream().map((event) { + return ConnectionSnapshot.fromMap(event as Map?); + }); + } + + Future getState() async { + try { + final state = await _methodChannel.invokeMapMethod( + 'getState', + ); + return ConnectionSnapshot.fromMap(state); + } on MissingPluginException { + return const ConnectionSnapshot(); + } + } + + Future connect(String host, int port) async { + await _methodChannel.invokeMethod('connect', { + 'host': host, + 'port': port, + }); + } + + Future disconnect() async { + await _methodChannel.invokeMethod('disconnect'); + } + + Future sendText(String text) async { + final result = await _methodChannel.invokeMethod('sendText', { + 'text': text, + }); + return result ?? false; + } + + Future> startDiscovery() async { + try { + final result = await _methodChannel.invokeListMethod( + 'startDiscovery', + ); + return (result ?? const []) + .whereType>() + .map(DiscoveredDevice.fromMap) + .where((device) => device.host.isNotEmpty) + .toList(); + } on MissingPluginException { + return const []; + } + } +} + class InputSyncPage extends StatefulWidget { const InputSyncPage({super.key}); @@ -37,7 +147,7 @@ class InputSyncPage extends StatefulWidget { } class _InputSyncPageState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { static const _hostKey = 'server_host'; static const _portKey = 'server_port'; static const _historyKey = 'send_history'; @@ -45,21 +155,24 @@ class _InputSyncPageState extends State static const _appendEnterKey = 'append_enter'; static const _maxHistoryItems = 10; + final api = NativeConnectionApi(); final hostController = TextEditingController(); final portController = TextEditingController(text: '8181'); final inputController = TextEditingController(); final inputFocusNode = FocusNode(); late final AnimationController statusPulseController; - WebSocketChannel? channel; - StreamSubscription? channelSubscription; + late final AnimationController radarPulseController; + StreamSubscription? stateSubscription; List sendHistory = []; + List discoveredDevices = []; SendButtonState sendButtonState = SendButtonState.idle; + ConnectionSnapshot connection = const ConnectionSnapshot(); bool appendEnter = false; bool clearAfterSend = false; - bool connected = false; bool headerExpanded = true; bool inputFocused = false; + bool scanning = false; @override void initState() { @@ -70,16 +183,21 @@ class _InputSyncPageState extends State lowerBound: 0.55, upperBound: 1, )..repeat(reverse: true); + radarPulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); inputController.addListener(_handleDraftChanged); inputFocusNode.addListener(_handleFocusChanged); _loadSavedState(); + _subscribeConnectionState(); } @override void dispose() { - channelSubscription?.cancel(); - channel?.sink.close(); + stateSubscription?.cancel(); statusPulseController.dispose(); + radarPulseController.dispose(); hostController.dispose(); portController.dispose(); inputController.dispose(); @@ -89,17 +207,52 @@ class _InputSyncPageState extends State Future _loadSavedState() async { final prefs = await SharedPreferences.getInstance(); + final nativeState = await api.getState(); if (!mounted) { return; } setState(() { - hostController.text = prefs.getString(_hostKey) ?? ''; - portController.text = prefs.getString(_portKey) ?? '8181'; + hostController.text = prefs.getString(_hostKey) ?? nativeState.host; + portController.text = + prefs.getString(_portKey) ?? nativeState.port.toString(); clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false; appendEnter = prefs.getBool(_appendEnterKey) ?? false; sendHistory = prefs.getStringList(_historyKey) ?? []; + connection = nativeState; + headerExpanded = !nativeState.connected; }); + + if (!nativeState.connected) { + unawaited(_scanForDevices()); + } + } + + void _subscribeConnectionState() { + stateSubscription = api.watchState().listen((nextState) { + if (!mounted) { + return; + } + setState(() { + connection = nextState; + if (nextState.connected) { + headerExpanded = false; + hostController.text = nextState.host; + portController.text = nextState.port.toString(); + } else if (!nextState.connecting) { + headerExpanded = true; + } + if (!nextState.connected) { + sendButtonState = SendButtonState.idle; + } + }); + + if (nextState.connected) { + inputFocusNode.requestFocus(); + } else if (nextState.lastError != null) { + _showToast(nextState.lastError!); + } + }, onError: (_) {}); } void _handleDraftChanged() { @@ -112,10 +265,31 @@ class _InputSyncPageState extends State }); } + Future _scanForDevices() async { + if (scanning) { + return; + } + + setState(() { + scanning = true; + }); + + final devices = await api.startDiscovery(); + if (!mounted) { + return; + } + + setState(() { + scanning = false; + discoveredDevices = devices; + }); + } + Future _connect() async { final host = hostController.text.trim(); - final port = portController.text.trim(); - if (host.isEmpty || port.isEmpty) { + final portText = portController.text.trim(); + final port = int.tryParse(portText); + if (host.isEmpty || port == null) { _showToast('请先填写 Windows IP 和端口'); setState(() { headerExpanded = true; @@ -125,55 +299,40 @@ class _InputSyncPageState extends State final prefs = await SharedPreferences.getInstance(); await prefs.setString(_hostKey, host); - await prefs.setString(_portKey, port); + await prefs.setString(_portKey, port.toString()); - await channelSubscription?.cancel(); - await channel?.sink.close(); + setState(() { + connection = ConnectionSnapshot(connecting: true, host: host, port: port); + }); try { - final nextChannel = WebSocketChannel.connect( - Uri.parse('ws://$host:$port'), - ); - channel = nextChannel; - channelSubscription = nextChannel.stream.listen( - (_) {}, - onError: (_) => _markDisconnected(expandHeader: true, message: '连接已断开'), - onDone: () => _markDisconnected(expandHeader: true, message: '连接已断开'), - ); - + await api.connect(host, port); + } on PlatformException catch (exception) { + _showToast(exception.message ?? '连接失败,请检查地址'); setState(() { - connected = true; - headerExpanded = false; + connection = ConnectionSnapshot(host: host, port: port); + headerExpanded = true; }); - _showToast('成功连接到 Windows 桌面端'); - inputFocusNode.requestFocus(); - } catch (_) { - _markDisconnected(expandHeader: true, message: '连接失败,请检查地址'); } } + Future _connectDevice(DiscoveredDevice device) async { + hostController.text = device.host; + portController.text = device.port.toString(); + await _connect(); + } + Future _disconnect() async { - await channelSubscription?.cancel(); - await channel?.sink.close(); - _markDisconnected(expandHeader: true, message: '已断开连接'); - } - - void _markDisconnected({required bool expandHeader, String? message}) { - if (!mounted) { - return; - } - - channel = null; - channelSubscription = null; + await api.disconnect(); setState(() { - connected = false; - headerExpanded = expandHeader; + connection = ConnectionSnapshot( + host: hostController.text.trim(), + port: int.tryParse(portController.text.trim()) ?? 8181, + ); + headerExpanded = true; sendButtonState = SendButtonState.idle; }); - - if (message != null) { - _showToast(message); - } + _showToast('已断开连接'); } Future _sendCurrentText() async { @@ -181,23 +340,24 @@ class _InputSyncPageState extends State return; } - final channel = this.channel; final draft = inputController.text; final textToSend = appendEnter ? '$draft\n' : draft; - if (channel == null) { - _markDisconnected(expandHeader: true, message: '未连接到桌面端'); - return; - } - setState(() { sendButtonState = SendButtonState.sending; }); try { - channel.sink.add( - jsonEncode({'action': 'replaceAll', 'text': textToSend}), - ); + final sent = await api.sendText(textToSend); + if (!sent) { + _showToast('发送失败,连接已断开'); + setState(() { + sendButtonState = SendButtonState.idle; + headerExpanded = true; + }); + return; + } + await _saveHistory(draft); if (clearAfterSend) { @@ -218,8 +378,12 @@ class _InputSyncPageState extends State }); inputFocusNode.requestFocus(); } - } catch (_) { - _markDisconnected(expandHeader: true, message: '发送失败,连接已断开'); + } on PlatformException { + _showToast('发送失败,连接已断开'); + setState(() { + sendButtonState = SendButtonState.idle; + headerExpanded = true; + }); } } @@ -351,7 +515,7 @@ class _InputSyncPageState extends State } bool get _canSend { - return connected && + return connection.connected && inputController.text.isNotEmpty && sendButtonState != SendButtonState.sending; } @@ -374,8 +538,10 @@ class _InputSyncPageState extends State Widget _buildHeader(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final statusText = connected - ? '已连接: ${hostController.text.trim()}:${portController.text.trim()}' + final statusText = connection.connected + ? '已连接: ${connection.host}:${connection.port}' + : connection.connecting + ? '正在连接: ${connection.host}:${connection.port}' : '未连接 (点击配置)'; return Material( @@ -400,7 +566,8 @@ class _InputSyncPageState extends State child: Row( children: [ _ConnectionIndicator( - connected: connected, + connected: connection.connected, + connecting: connection.connecting, animation: statusPulseController, ), const SizedBox(width: 10), @@ -409,8 +576,10 @@ class _InputSyncPageState extends State statusText, overflow: TextOverflow.ellipsis, style: TextStyle( - color: connected + color: connection.connected ? const Color(0xFF166534) + : connection.connecting + ? colorScheme.primary : colorScheme.error, fontWeight: FontWeight.w600, ), @@ -429,45 +598,59 @@ class _InputSyncPageState extends State firstChild: const SizedBox.shrink(), secondChild: Padding( padding: const EdgeInsets.only(top: 8), - child: Row( + child: Column( children: [ - Expanded( - flex: 7, - child: TextField( - controller: hostController, - decoration: const InputDecoration( - labelText: 'IP 地址', - border: OutlineInputBorder(), - isDense: true, + Row( + children: [ + Expanded( + flex: 7, + child: TextField( + controller: hostController, + decoration: const InputDecoration( + labelText: 'IP 地址', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.url, + ), ), - keyboardType: TextInputType.url, - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 3, - child: TextField( - controller: portController, - decoration: const InputDecoration( - labelText: '端口', - border: OutlineInputBorder(), - isDense: true, + const SizedBox(width: 8), + Expanded( + flex: 3, + child: TextField( + controller: portController, + decoration: const InputDecoration( + labelText: '端口', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + ), ), - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 8), - IconButton.filledTonal( - tooltip: connected ? '断开连接' : '连接', - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - foregroundColor: connected - ? colorScheme.error - : colorScheme.primary, - ), - onPressed: connected ? _disconnect : _connect, - icon: Icon(connected ? Icons.link_off : Icons.link), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: connection.connected ? '断开连接' : '连接', + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + foregroundColor: connection.connected + ? colorScheme.error + : colorScheme.primary, + ), + onPressed: connection.connecting + ? null + : connection.connected + ? _disconnect + : _connect, + icon: Icon( + connection.connected + ? Icons.link_off + : Icons.link, + ), + ), + ], ), + const SizedBox(height: 12), + _buildRadar(context), ], ), ), @@ -484,94 +667,234 @@ class _InputSyncPageState extends State ); } + Widget _buildRadar(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AnimatedBuilder( + animation: radarPulseController, + builder: (context, child) { + final scale = scanning + ? 0.85 + radarPulseController.value * 0.3 + : 1.0; + return Transform.scale( + scale: scale, + child: Icon( + Icons.radar, + color: scanning + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '局域网设备雷达', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton.icon( + onPressed: scanning ? null : _scanForDevices, + icon: scanning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: Text(scanning ? '扫描中' : '刷新'), + ), + ], + ), + const SizedBox(height: 8), + if (discoveredDevices.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Text( + scanning ? '正在寻找附近的 Windows 桌面端...' : '暂无发现,可刷新或使用上方 IP 手动连接', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ) + else + ...discoveredDevices.map((device) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: connection.connecting + ? null + : () => _connectDevice(device), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon(Icons.desktop_windows_outlined), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + Text( + device.endpoint, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '可连接', + style: TextStyle( + color: Color(0xFF166534), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ); + }), + ], + ); + } + Widget _buildDraftBoard(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: Column( - children: [ - SizedBox( - height: 44, - child: Row( - children: [ - Text( - '草稿板', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - IconButton( - tooltip: '历史记录', - onPressed: _showHistorySheet, - icon: const Icon(Icons.history), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - child: inputController.text.isEmpty - ? const SizedBox(width: 48, height: 48) - : IconButton( - key: const ValueKey('clear-draft'), - tooltip: '清空', - onPressed: inputController.clear, - icon: const Icon(Icons.delete_outline), - ), - ), - ], - ), - ), - Expanded( - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: inputFocused - ? colorScheme.primary - : const Color(0xFFE2E8F0), - width: inputFocused ? 2 : 1, - ), - ), - child: Stack( - children: [ - TextField( - controller: inputController, - focusNode: inputFocusNode, - autofocus: true, - expands: true, - maxLines: null, - minLines: null, - textAlignVertical: TextAlignVertical.top, - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.fromLTRB(16, 16, 16, 42), - hintText: '点击输入或语音录入... 发送后草稿会保留,方便随时修改', - ), - keyboardType: TextInputType.multiline, - ), - Positioned( - right: 14, - bottom: 10, - child: Text( - '${inputController.text.length} 字符', + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxHeight < 72) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Column( + children: [ + SizedBox( + height: 44, + child: Row( + children: [ + Text( + '草稿板', style: TextStyle( - color: colorScheme.onSurfaceVariant.withValues( - alpha: 0.72, - ), - fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, ), ), - ), - ], + const Spacer(), + IconButton( + tooltip: '历史记录', + onPressed: _showHistorySheet, + icon: const Icon(Icons.history), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: inputController.text.isEmpty + ? const SizedBox(width: 48, height: 48) + : IconButton( + key: const ValueKey('clear-draft'), + tooltip: '清空', + onPressed: inputController.clear, + icon: const Icon(Icons.delete_outline), + ), + ), + ], + ), ), - ), + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: inputFocused + ? colorScheme.primary + : const Color(0xFFE2E8F0), + width: inputFocused ? 2 : 1, + ), + ), + child: Stack( + children: [ + TextField( + controller: inputController, + focusNode: inputFocusNode, + autofocus: true, + expands: true, + maxLines: null, + minLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.fromLTRB(16, 16, 16, 42), + hintText: '点击输入或语音录入... 发送后草稿会保留,方便随时修改', + ), + keyboardType: TextInputType.multiline, + ), + Positioned( + right: 14, + bottom: 10, + child: Text( + '${inputController.text.length} 字符', + style: TextStyle( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.72, + ), + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ], ), - ], - ), + ); + }, ); } @@ -655,24 +978,28 @@ class _InputSyncPageState extends State class _ConnectionIndicator extends StatelessWidget { const _ConnectionIndicator({ required this.connected, + required this.connecting, required this.animation, }); final bool connected; + final bool connecting; final Animation animation; @override Widget build(BuildContext context) { - if (!connected) { - return const _StatusDot(color: Color(0xFFDC2626), scale: 1); - } + final color = connected + ? const Color(0xFF16A34A) + : connecting + ? Theme.of(context).colorScheme.primary + : const Color(0xFFDC2626); return AnimatedBuilder( animation: animation, builder: (context, child) { return _StatusDot( - color: const Color(0xFF16A34A), - scale: animation.value, + color: color, + scale: connected || connecting ? animation.value : 1, ); }, ); diff --git a/pubspec.lock b/pubspec.lock index 51b022d..56d6a7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -317,14 +309,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" vector_math: dependency: transitive description: @@ -349,22 +333,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0e2ca44..3dd1a59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,6 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - web_socket_channel: ^3.0.3 shared_preferences: ^2.5.5 dev_dependencies: diff --git a/test/widget_test.dart b/test/widget_test.dart index 1e4d0ae..3d69866 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,20 +6,65 @@ // tree, read text, and verify that the values of widget properties are correct. import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wireless_text_syncer_android/main.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const connectionChannel = MethodChannel('wireless_text_syncer/connection'); + const stateChannel = MethodChannel('wireless_text_syncer/connection_state'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectionChannel, (call) async { + switch (call.method) { + case 'getState': + return { + 'connected': false, + 'connecting': false, + 'host': '', + 'port': 8181, + 'lastError': null, + }; + case 'startDiscovery': + return [ + {'name': 'Desktop-WIN11', 'host': '192.168.1.10', 'port': 8181}, + ]; + case 'connect': + case 'disconnect': + return null; + case 'sendText': + return true; + } + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(stateChannel, (call) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectionChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(stateChannel, null); + }); + testWidgets('shows v2 draft board controls', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget(const WirelessTextSyncerApp()); await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(seconds: 2)); expect(find.text('未连接 (点击配置)'), findsOneWidget); expect(find.text('IP 地址'), findsOneWidget); expect(find.text('端口'), findsOneWidget); + expect(find.text('局域网设备雷达'), findsOneWidget); + expect(find.text('Desktop-WIN11'), findsOneWidget); + expect(find.text('可连接'), findsOneWidget); expect(find.text('草稿板'), findsOneWidget); expect(find.text('发送后清空'), findsOneWidget); expect(find.text('追加回车'), findsOneWidget);