Compare commits
2 Commits
4d37f264bf
...
7cd6fbd1f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cd6fbd1f2 | ||
|
|
fe5cb9af37 |
@@ -42,3 +42,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="wireless_text_syncer_android"
|
android:label="文本同步"
|
||||||
android:name="${applicationName}"
|
android:name=".WirelessTextSyncerApplicationHolder"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -27,6 +32,28 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".QuickSendActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/TransparentBottomSheetTheme"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:finishOnTaskLaunch="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
<service
|
||||||
|
android:name=".ConnectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<service
|
||||||
|
android:name=".QuickSendTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_quick_send_tile"
|
||||||
|
android:label="文本同步"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class ConnectionService : Service() {
|
||||||
|
private val tag = "WTS"
|
||||||
|
private val client = OkHttpClient()
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(tag, "ConnectionService onCreate")
|
||||||
|
ensureNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.d(tag, "ConnectionService onStartCommand action=${intent?.action}")
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_CONNECT -> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendAction(socket, "replaceAll", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendEnter(): Boolean {
|
||||||
|
Log.d("WTS", "ConnectionService.sendEnter connected=${state.connected}")
|
||||||
|
val socket = currentSocket ?: return false
|
||||||
|
if (!state.connected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendAction(socket, "enter", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendAction(socket: WebSocket, action: String, text: String?): Boolean {
|
||||||
|
val payload = JSONObject()
|
||||||
|
.put("action", action)
|
||||||
|
.apply {
|
||||||
|
if (text != null) {
|
||||||
|
put("text", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
return socket.send(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stateMap(): Map<String, Any?> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,144 @@
|
|||||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
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.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<String>("host").orEmpty()
|
||||||
|
val port = call.argument<Int>("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<String>("text").orEmpty()
|
||||||
|
Log.d(tag, "MethodChannel sendText length=${text.length}")
|
||||||
|
result.success(ConnectionService.sendText(text))
|
||||||
|
}
|
||||||
|
"sendEnter" -> {
|
||||||
|
Log.d(tag, "MethodChannel sendEnter")
|
||||||
|
result.success(ConnectionService.sendEnter())
|
||||||
|
}
|
||||||
|
"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<Map<String, Any>>()
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/drawable-nodpi/app_icon_source.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
android/app/src/main/res/drawable-nodpi/ic_quick_send_tile.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
9
android/app/src/main/res/drawable/ic_check_24.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,16.2L4.8,12L3.4,13.4L9,19L21,7L19.6,5.6L9,16.2Z" />
|
||||||
|
</vector>
|
||||||
9
android/app/src/main/res/drawable/ic_send_24.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
|
||||||
|
</vector>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 34 KiB |
@@ -15,4 +15,11 @@
|
|||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
<style name="TransparentBottomSheetTheme" parent="@android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:backgroundDimEnabled">false</item>
|
||||||
|
<item name="android:colorAccent">#60A5FA</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -15,4 +15,11 @@
|
|||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
<style name="TransparentBottomSheetTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:backgroundDimEnabled">false</item>
|
||||||
|
<item name="android:colorAccent">#2563EB</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
489
lib/main.dart
@@ -1,9 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const WirelessTextSyncerApp());
|
runApp(const WirelessTextSyncerApp());
|
||||||
@@ -29,6 +28,122 @@ class WirelessTextSyncerApp extends StatelessWidget {
|
|||||||
|
|
||||||
enum SendButtonState { idle, sending, success }
|
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<dynamic, dynamic>? 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<dynamic, dynamic> 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<ConnectionSnapshot> watchState() {
|
||||||
|
return _eventChannel.receiveBroadcastStream().map((event) {
|
||||||
|
return ConnectionSnapshot.fromMap(event as Map<dynamic, dynamic>?);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ConnectionSnapshot> getState() async {
|
||||||
|
try {
|
||||||
|
final state = await _methodChannel.invokeMapMethod<dynamic, dynamic>(
|
||||||
|
'getState',
|
||||||
|
);
|
||||||
|
return ConnectionSnapshot.fromMap(state);
|
||||||
|
} on MissingPluginException {
|
||||||
|
return const ConnectionSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect(String host, int port) async {
|
||||||
|
await _methodChannel.invokeMethod<void>('connect', {
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _methodChannel.invokeMethod<void>('disconnect');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> sendText(String text) async {
|
||||||
|
final result = await _methodChannel.invokeMethod<bool>('sendText', {
|
||||||
|
'text': text,
|
||||||
|
});
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> sendEnter() async {
|
||||||
|
final result = await _methodChannel.invokeMethod<bool>('sendEnter');
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DiscoveredDevice>> startDiscovery() async {
|
||||||
|
try {
|
||||||
|
final result = await _methodChannel.invokeListMethod<dynamic>(
|
||||||
|
'startDiscovery',
|
||||||
|
);
|
||||||
|
return (result ?? const [])
|
||||||
|
.whereType<Map<dynamic, dynamic>>()
|
||||||
|
.map(DiscoveredDevice.fromMap)
|
||||||
|
.where((device) => device.host.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
} on MissingPluginException {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class InputSyncPage extends StatefulWidget {
|
class InputSyncPage extends StatefulWidget {
|
||||||
const InputSyncPage({super.key});
|
const InputSyncPage({super.key});
|
||||||
|
|
||||||
@@ -37,7 +152,7 @@ class InputSyncPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _InputSyncPageState extends State<InputSyncPage>
|
class _InputSyncPageState extends State<InputSyncPage>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
static const _hostKey = 'server_host';
|
static const _hostKey = 'server_host';
|
||||||
static const _portKey = 'server_port';
|
static const _portKey = 'server_port';
|
||||||
static const _historyKey = 'send_history';
|
static const _historyKey = 'send_history';
|
||||||
@@ -45,21 +160,24 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
static const _appendEnterKey = 'append_enter';
|
static const _appendEnterKey = 'append_enter';
|
||||||
static const _maxHistoryItems = 10;
|
static const _maxHistoryItems = 10;
|
||||||
|
|
||||||
|
final api = NativeConnectionApi();
|
||||||
final hostController = TextEditingController();
|
final hostController = TextEditingController();
|
||||||
final portController = TextEditingController(text: '8181');
|
final portController = TextEditingController(text: '8181');
|
||||||
final inputController = TextEditingController();
|
final inputController = TextEditingController();
|
||||||
final inputFocusNode = FocusNode();
|
final inputFocusNode = FocusNode();
|
||||||
|
|
||||||
late final AnimationController statusPulseController;
|
late final AnimationController statusPulseController;
|
||||||
WebSocketChannel? channel;
|
late final AnimationController radarPulseController;
|
||||||
StreamSubscription<dynamic>? channelSubscription;
|
StreamSubscription<ConnectionSnapshot>? stateSubscription;
|
||||||
List<String> sendHistory = [];
|
List<String> sendHistory = [];
|
||||||
|
List<DiscoveredDevice> discoveredDevices = [];
|
||||||
SendButtonState sendButtonState = SendButtonState.idle;
|
SendButtonState sendButtonState = SendButtonState.idle;
|
||||||
|
ConnectionSnapshot connection = const ConnectionSnapshot();
|
||||||
bool appendEnter = false;
|
bool appendEnter = false;
|
||||||
bool clearAfterSend = false;
|
bool clearAfterSend = false;
|
||||||
bool connected = false;
|
|
||||||
bool headerExpanded = true;
|
bool headerExpanded = true;
|
||||||
bool inputFocused = false;
|
bool inputFocused = false;
|
||||||
|
bool scanning = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -70,16 +188,21 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
lowerBound: 0.55,
|
lowerBound: 0.55,
|
||||||
upperBound: 1,
|
upperBound: 1,
|
||||||
)..repeat(reverse: true);
|
)..repeat(reverse: true);
|
||||||
|
radarPulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
)..repeat();
|
||||||
inputController.addListener(_handleDraftChanged);
|
inputController.addListener(_handleDraftChanged);
|
||||||
inputFocusNode.addListener(_handleFocusChanged);
|
inputFocusNode.addListener(_handleFocusChanged);
|
||||||
_loadSavedState();
|
_loadSavedState();
|
||||||
|
_subscribeConnectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
channelSubscription?.cancel();
|
stateSubscription?.cancel();
|
||||||
channel?.sink.close();
|
|
||||||
statusPulseController.dispose();
|
statusPulseController.dispose();
|
||||||
|
radarPulseController.dispose();
|
||||||
hostController.dispose();
|
hostController.dispose();
|
||||||
portController.dispose();
|
portController.dispose();
|
||||||
inputController.dispose();
|
inputController.dispose();
|
||||||
@@ -89,17 +212,52 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
|
|
||||||
Future<void> _loadSavedState() async {
|
Future<void> _loadSavedState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final nativeState = await api.getState();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
hostController.text = prefs.getString(_hostKey) ?? '';
|
hostController.text = prefs.getString(_hostKey) ?? nativeState.host;
|
||||||
portController.text = prefs.getString(_portKey) ?? '8181';
|
portController.text =
|
||||||
|
prefs.getString(_portKey) ?? nativeState.port.toString();
|
||||||
clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false;
|
clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false;
|
||||||
appendEnter = prefs.getBool(_appendEnterKey) ?? false;
|
appendEnter = prefs.getBool(_appendEnterKey) ?? false;
|
||||||
sendHistory = prefs.getStringList(_historyKey) ?? [];
|
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() {
|
void _handleDraftChanged() {
|
||||||
@@ -112,10 +270,31 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _scanForDevices() async {
|
||||||
|
if (scanning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
scanning = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final devices = await api.startDiscovery();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
scanning = false;
|
||||||
|
discoveredDevices = devices;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _connect() async {
|
Future<void> _connect() async {
|
||||||
final host = hostController.text.trim();
|
final host = hostController.text.trim();
|
||||||
final port = portController.text.trim();
|
final portText = portController.text.trim();
|
||||||
if (host.isEmpty || port.isEmpty) {
|
final port = int.tryParse(portText);
|
||||||
|
if (host.isEmpty || port == null) {
|
||||||
_showToast('请先填写 Windows IP 和端口');
|
_showToast('请先填写 Windows IP 和端口');
|
||||||
setState(() {
|
setState(() {
|
||||||
headerExpanded = true;
|
headerExpanded = true;
|
||||||
@@ -125,55 +304,40 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_hostKey, host);
|
await prefs.setString(_hostKey, host);
|
||||||
await prefs.setString(_portKey, port);
|
await prefs.setString(_portKey, port.toString());
|
||||||
|
|
||||||
await channelSubscription?.cancel();
|
|
||||||
await channel?.sink.close();
|
|
||||||
|
|
||||||
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: '连接已断开'),
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
connected = true;
|
connection = ConnectionSnapshot(connecting: true, host: host, port: port);
|
||||||
headerExpanded = false;
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.connect(host, port);
|
||||||
|
} on PlatformException catch (exception) {
|
||||||
|
_showToast(exception.message ?? '连接失败,请检查地址');
|
||||||
|
setState(() {
|
||||||
|
connection = ConnectionSnapshot(host: host, port: port);
|
||||||
|
headerExpanded = true;
|
||||||
});
|
});
|
||||||
_showToast('成功连接到 Windows 桌面端');
|
|
||||||
inputFocusNode.requestFocus();
|
|
||||||
} catch (_) {
|
|
||||||
_markDisconnected(expandHeader: true, message: '连接失败,请检查地址');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _connectDevice(DiscoveredDevice device) async {
|
||||||
|
hostController.text = device.host;
|
||||||
|
portController.text = device.port.toString();
|
||||||
|
await _connect();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _disconnect() async {
|
Future<void> _disconnect() async {
|
||||||
await channelSubscription?.cancel();
|
await api.disconnect();
|
||||||
await channel?.sink.close();
|
|
||||||
_markDisconnected(expandHeader: true, message: '已断开连接');
|
|
||||||
}
|
|
||||||
|
|
||||||
void _markDisconnected({required bool expandHeader, String? message}) {
|
|
||||||
if (!mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
channel = null;
|
|
||||||
channelSubscription = null;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
connected = false;
|
connection = ConnectionSnapshot(
|
||||||
headerExpanded = expandHeader;
|
host: hostController.text.trim(),
|
||||||
|
port: int.tryParse(portController.text.trim()) ?? 8181,
|
||||||
|
);
|
||||||
|
headerExpanded = true;
|
||||||
sendButtonState = SendButtonState.idle;
|
sendButtonState = SendButtonState.idle;
|
||||||
});
|
});
|
||||||
|
_showToast('已断开连接');
|
||||||
if (message != null) {
|
|
||||||
_showToast(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendCurrentText() async {
|
Future<void> _sendCurrentText() async {
|
||||||
@@ -181,23 +345,23 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final channel = this.channel;
|
|
||||||
final draft = inputController.text;
|
final draft = inputController.text;
|
||||||
final textToSend = appendEnter ? '$draft\n' : draft;
|
|
||||||
|
|
||||||
if (channel == null) {
|
|
||||||
_markDisconnected(expandHeader: true, message: '未连接到桌面端');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
sendButtonState = SendButtonState.sending;
|
sendButtonState = SendButtonState.sending;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
channel.sink.add(
|
final sent = await api.sendText(draft);
|
||||||
jsonEncode({'action': 'replaceAll', 'text': textToSend}),
|
final enterSent = !sent || !appendEnter ? true : await api.sendEnter();
|
||||||
);
|
if (!sent || !enterSent) {
|
||||||
|
_showToast('发送失败,连接已断开');
|
||||||
|
setState(() {
|
||||||
|
sendButtonState = SendButtonState.idle;
|
||||||
|
headerExpanded = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _saveHistory(draft);
|
await _saveHistory(draft);
|
||||||
|
|
||||||
if (clearAfterSend) {
|
if (clearAfterSend) {
|
||||||
@@ -218,8 +382,12 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
});
|
});
|
||||||
inputFocusNode.requestFocus();
|
inputFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} on PlatformException {
|
||||||
_markDisconnected(expandHeader: true, message: '发送失败,连接已断开');
|
_showToast('发送失败,连接已断开');
|
||||||
|
setState(() {
|
||||||
|
sendButtonState = SendButtonState.idle;
|
||||||
|
headerExpanded = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +519,7 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get _canSend {
|
bool get _canSend {
|
||||||
return connected &&
|
return connection.connected &&
|
||||||
inputController.text.isNotEmpty &&
|
inputController.text.isNotEmpty &&
|
||||||
sendButtonState != SendButtonState.sending;
|
sendButtonState != SendButtonState.sending;
|
||||||
}
|
}
|
||||||
@@ -374,8 +542,10 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
Widget _buildHeader(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final statusText = connected
|
final statusText = connection.connected
|
||||||
? '已连接: ${hostController.text.trim()}:${portController.text.trim()}'
|
? '已连接: ${connection.host}:${connection.port}'
|
||||||
|
: connection.connecting
|
||||||
|
? '正在连接: ${connection.host}:${connection.port}'
|
||||||
: '未连接 (点击配置)';
|
: '未连接 (点击配置)';
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
@@ -400,7 +570,8 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_ConnectionIndicator(
|
_ConnectionIndicator(
|
||||||
connected: connected,
|
connected: connection.connected,
|
||||||
|
connecting: connection.connecting,
|
||||||
animation: statusPulseController,
|
animation: statusPulseController,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
@@ -409,8 +580,10 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
statusText,
|
statusText,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: connected
|
color: connection.connected
|
||||||
? const Color(0xFF166534)
|
? const Color(0xFF166534)
|
||||||
|
: connection.connecting
|
||||||
|
? colorScheme.primary
|
||||||
: colorScheme.error,
|
: colorScheme.error,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -429,7 +602,9 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
firstChild: const SizedBox.shrink(),
|
firstChild: const SizedBox.shrink(),
|
||||||
secondChild: Padding(
|
secondChild: Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 7,
|
flex: 7,
|
||||||
@@ -458,16 +633,28 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: connected ? '断开连接' : '连接',
|
tooltip: connection.connected ? '断开连接' : '连接',
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
minimumSize: const Size(48, 48),
|
minimumSize: const Size(48, 48),
|
||||||
foregroundColor: connected
|
foregroundColor: connection.connected
|
||||||
? colorScheme.error
|
? colorScheme.error
|
||||||
: colorScheme.primary,
|
: colorScheme.primary,
|
||||||
),
|
),
|
||||||
onPressed: connected ? _disconnect : _connect,
|
onPressed: connection.connecting
|
||||||
icon: Icon(connected ? Icons.link_off : Icons.link),
|
? null
|
||||||
|
: connection.connected
|
||||||
|
? _disconnect
|
||||||
|
: _connect,
|
||||||
|
icon: Icon(
|
||||||
|
connection.connected
|
||||||
|
? Icons.link_off
|
||||||
|
: Icons.link,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildRadar(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -484,9 +671,147 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
Widget _buildDraftBoard(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxHeight < 72) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -573,6 +898,8 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFooter(BuildContext context) {
|
Widget _buildFooter(BuildContext context) {
|
||||||
@@ -655,24 +982,28 @@ class _InputSyncPageState extends State<InputSyncPage>
|
|||||||
class _ConnectionIndicator extends StatelessWidget {
|
class _ConnectionIndicator extends StatelessWidget {
|
||||||
const _ConnectionIndicator({
|
const _ConnectionIndicator({
|
||||||
required this.connected,
|
required this.connected,
|
||||||
|
required this.connecting,
|
||||||
required this.animation,
|
required this.animation,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool connected;
|
final bool connected;
|
||||||
|
final bool connecting;
|
||||||
final Animation<double> animation;
|
final Animation<double> animation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!connected) {
|
final color = connected
|
||||||
return const _StatusDot(color: Color(0xFFDC2626), scale: 1);
|
? const Color(0xFF16A34A)
|
||||||
}
|
: connecting
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: const Color(0xFFDC2626);
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: animation,
|
animation: animation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return _StatusDot(
|
return _StatusDot(
|
||||||
color: const Color(0xFF16A34A),
|
color: color,
|
||||||
scale: animation.value,
|
scale: connected || connecting ? animation.value : 1,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
32
pubspec.lock
@@ -41,14 +41,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: crypto
|
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.7"
|
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -317,14 +309,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -349,22 +333,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ dependencies:
|
|||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
web_socket_channel: ^3.0.3
|
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
@@ -6,20 +6,65 @@
|
|||||||
// tree, read text, and verify that the values of widget properties are correct.
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:wireless_text_syncer_android/main.dart';
|
import 'package:wireless_text_syncer_android/main.dart';
|
||||||
|
|
||||||
void main() {
|
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 {
|
testWidgets('shows v2 draft board controls', (WidgetTester tester) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
await tester.pumpWidget(const WirelessTextSyncerApp());
|
await tester.pumpWidget(const WirelessTextSyncerApp());
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
|
||||||
expect(find.text('未连接 (点击配置)'), findsOneWidget);
|
expect(find.text('未连接 (点击配置)'), findsOneWidget);
|
||||||
expect(find.text('IP 地址'), findsOneWidget);
|
expect(find.text('IP 地址'), findsOneWidget);
|
||||||
expect(find.text('端口'), 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);
|
expect(find.text('发送后清空'), findsOneWidget);
|
||||||
expect(find.text('追加回车'), findsOneWidget);
|
expect(find.text('追加回车'), findsOneWidget);
|
||||||
|
|||||||