Improve Android connection controls and status display
This commit is contained in:
@@ -40,7 +40,8 @@ class ConnectionService : Service() {
|
||||
ACTION_CONNECT -> {
|
||||
val host = intent.getStringExtra(EXTRA_HOST).orEmpty()
|
||||
val port = intent.getIntExtra(EXTRA_PORT, 8181)
|
||||
connectWebSocket(host, port)
|
||||
val name = intent.getStringExtra(EXTRA_NAME).orEmpty()
|
||||
connectWebSocket(host, port, name)
|
||||
}
|
||||
ACTION_DISCONNECT -> disconnectWebSocket("已断开连接")
|
||||
}
|
||||
@@ -54,8 +55,8 @@ class ConnectionService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun connectWebSocket(host: String, port: Int) {
|
||||
Log.d(tag, "connectWebSocket host=$host port=$port")
|
||||
private fun connectWebSocket(host: String, port: Int, name: String) {
|
||||
Log.d(tag, "connectWebSocket host=$host port=$port name=$name")
|
||||
if (host.isBlank()) {
|
||||
updateState(connected = false, connecting = false, host = host, port = port, lastError = "请先填写 Windows IP")
|
||||
return
|
||||
@@ -65,6 +66,7 @@ class ConnectionService : Service() {
|
||||
.edit()
|
||||
.putString(KEY_HOST, host)
|
||||
.putInt(KEY_PORT, port)
|
||||
.putString(KEY_NAME, name)
|
||||
.apply()
|
||||
|
||||
try {
|
||||
@@ -77,13 +79,14 @@ class ConnectionService : Service() {
|
||||
connecting = false,
|
||||
host = host,
|
||||
port = port,
|
||||
name = name,
|
||||
lastError = exception.message ?: "前台服务启动失败"
|
||||
)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
currentSocket?.close(1000, "reconnect")
|
||||
updateState(connected = false, connecting = true, host = host, port = port, lastError = null)
|
||||
updateState(connected = false, connecting = true, host = host, port = port, name = name, lastError = null)
|
||||
|
||||
val request = Request.Builder().url("ws://$host:$port").build()
|
||||
Log.d(tag, "Creating WebSocket ws://$host:$port")
|
||||
@@ -94,12 +97,22 @@ class ConnectionService : Service() {
|
||||
Log.d(tag, "Ignoring stale WebSocket onOpen")
|
||||
return
|
||||
}
|
||||
updateState(connected = true, connecting = false, host = host, port = port, lastError = null)
|
||||
updateState(connected = true, connecting = false, host = host, port = port, name = name, lastError = null)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(host, port, "已连接"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
Log.d(tag, "WebSocket onMessage $text")
|
||||
if (currentSocket != webSocket) {
|
||||
Log.d(tag, "Ignoring stale WebSocket onMessage")
|
||||
return
|
||||
}
|
||||
|
||||
updateServiceInfo(text, host, port)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(tag, "WebSocket onClosed code=$code reason=$reason")
|
||||
if (currentSocket != webSocket) {
|
||||
@@ -107,7 +120,7 @@ class ConnectionService : Service() {
|
||||
return
|
||||
}
|
||||
currentSocket = null
|
||||
updateState(connected = false, connecting = false, host = host, port = port, lastError = null)
|
||||
updateState(connected = false, connecting = false, host = host, port = port, name = state.name, lastError = null)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
@@ -130,6 +143,7 @@ class ConnectionService : Service() {
|
||||
connecting = false,
|
||||
host = host,
|
||||
port = port,
|
||||
name = state.name.ifBlank { name },
|
||||
lastError = errorMessage
|
||||
)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -148,6 +162,35 @@ class ConnectionService : Service() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateServiceInfo(text: String, fallbackHost: String, fallbackPort: Int) {
|
||||
try {
|
||||
val json = JSONObject(text)
|
||||
if (json.optString("type") != "wirelessTextSyncer.service") {
|
||||
return
|
||||
}
|
||||
|
||||
val serviceHost = json.optString("host", fallbackHost).ifBlank { fallbackHost }
|
||||
val servicePort = json.optInt("port", fallbackPort)
|
||||
val serviceName = json.optString("name").ifBlank { state.name }
|
||||
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_HOST, serviceHost)
|
||||
.putInt(KEY_PORT, servicePort)
|
||||
.putString(KEY_NAME, serviceName)
|
||||
.apply()
|
||||
updateState(
|
||||
connected = true,
|
||||
connecting = false,
|
||||
host = serviceHost,
|
||||
port = servicePort,
|
||||
name = serviceName,
|
||||
lastError = null
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(tag, "Failed to parse service info message", exception)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -179,23 +222,26 @@ class ConnectionService : Service() {
|
||||
private const val ACTION_DISCONNECT = "wireless_text_syncer.DISCONNECT"
|
||||
private const val EXTRA_HOST = "host"
|
||||
private const val EXTRA_PORT = "port"
|
||||
private const val EXTRA_NAME = "name"
|
||||
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 const val KEY_NAME = "server_name"
|
||||
|
||||
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}")
|
||||
fun connect(context: Context, host: String, port: Int, name: String = "") {
|
||||
Log.d("WTS", "ConnectionService.connect requested host=$host port=$port name=$name sdk=${Build.VERSION.SDK_INT}")
|
||||
val intent = Intent(context, ConnectionService::class.java)
|
||||
.setAction(ACTION_CONNECT)
|
||||
.putExtra(EXTRA_HOST, host)
|
||||
.putExtra(EXTRA_PORT, port)
|
||||
.putExtra(EXTRA_NAME, name)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
@@ -209,6 +255,7 @@ class ConnectionService : Service() {
|
||||
connecting = false,
|
||||
host = host,
|
||||
port = port,
|
||||
name = name,
|
||||
lastError = exception.message ?: "连接服务启动失败"
|
||||
)
|
||||
throw exception
|
||||
@@ -273,6 +320,7 @@ class ConnectionService : Service() {
|
||||
"connecting" to snapshot.connecting,
|
||||
"host" to snapshot.host,
|
||||
"port" to snapshot.port,
|
||||
"name" to snapshot.name,
|
||||
"lastError" to snapshot.lastError
|
||||
)
|
||||
}
|
||||
@@ -282,13 +330,14 @@ class ConnectionService : Service() {
|
||||
connecting: Boolean,
|
||||
host: String = state.host,
|
||||
port: Int = state.port,
|
||||
name: String = state.name,
|
||||
lastError: String? = null
|
||||
) {
|
||||
Log.d(
|
||||
"WTS",
|
||||
"updateState connected=$connected connecting=$connecting host=$host port=$port error=$lastError"
|
||||
"updateState connected=$connected connecting=$connecting host=$host port=$port name=$name error=$lastError"
|
||||
)
|
||||
state = ConnectionStateSnapshot(connected, connecting, host, port, lastError)
|
||||
state = ConnectionStateSnapshot(connected, connecting, host, port, name, lastError)
|
||||
mainHandler.post {
|
||||
eventSink?.success(stateMap())
|
||||
QuickSendTileService.requestTileRefresh()
|
||||
@@ -301,6 +350,7 @@ class ConnectionService : Service() {
|
||||
val connecting: Boolean = false,
|
||||
val host: String = "",
|
||||
val port: Int = 8181,
|
||||
val name: String = "",
|
||||
val lastError: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.wirelesstextsyncer.wireless_text_syncer_android
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
|
||||
object DiscoveryClient {
|
||||
private const val tag = "WTS"
|
||||
private const val discoveryPort = 8182
|
||||
private const val defaultServicePort = 8181
|
||||
|
||||
fun discover(context: Context): List<DiscoveredService> {
|
||||
Log.d(tag, "Discovery started")
|
||||
val devices = mutableListOf<DiscoveredService>()
|
||||
val seen = mutableSetOf<String>()
|
||||
var multicastLock: WifiManager.MulticastLock? = null
|
||||
try {
|
||||
val wifi = context.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"),
|
||||
discoveryPort
|
||||
)
|
||||
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", defaultServicePort)
|
||||
val key = "$host:$port"
|
||||
if (seen.add(key)) {
|
||||
Log.d(tag, "Discovery found ${json.optString("name", host)} $key")
|
||||
devices += DiscoveredService(
|
||||
name = json.optString("name", host),
|
||||
host = host,
|
||||
port = port
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(tag, "Discovery failed", exception)
|
||||
} finally {
|
||||
multicastLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Discovery finished count=${devices.size}")
|
||||
return devices
|
||||
}
|
||||
}
|
||||
|
||||
data class DiscoveredService(
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int
|
||||
) {
|
||||
fun toMap(): Map<String, Any> {
|
||||
return mapOf(
|
||||
"name" to name,
|
||||
"host" to host,
|
||||
"port" to port
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
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() {
|
||||
private val tag = "WTS"
|
||||
@@ -26,9 +20,10 @@ class MainActivity : FlutterActivity() {
|
||||
"connect" -> {
|
||||
val host = call.argument<String>("host").orEmpty()
|
||||
val port = call.argument<Int>("port") ?: 8181
|
||||
Log.d(tag, "MethodChannel connect host=$host port=$port")
|
||||
val name = call.argument<String>("name").orEmpty()
|
||||
Log.d(tag, "MethodChannel connect host=$host port=$port name=$name")
|
||||
try {
|
||||
ConnectionService.connect(this, host, port)
|
||||
ConnectionService.connect(this, host, port, name)
|
||||
result.success(null)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(tag, "MethodChannel connect failed", exception)
|
||||
@@ -73,67 +68,8 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
val devices = DiscoveryClient.discover(applicationContext).map { it.toMap() }
|
||||
|
||||
runOnUiThread {
|
||||
Log.d(tag, "Discovery finished count=${devices.size}")
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
@@ -18,6 +19,7 @@ import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
|
||||
class QuickSendActivity : Activity() {
|
||||
@@ -25,11 +27,31 @@ class QuickSendActivity : Activity() {
|
||||
private lateinit var sendButton: LinearLayout
|
||||
private lateinit var sendIcon: ImageView
|
||||
private lateinit var sendLabel: TextView
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var connectionIcon: ImageView
|
||||
private lateinit var endpointText: TextView
|
||||
private lateinit var deviceNameText: TextView
|
||||
private lateinit var connectionPanel: LinearLayout
|
||||
private lateinit var connectionDot: View
|
||||
private lateinit var connectionTitle: TextView
|
||||
private lateinit var connectionSubtitle: TextView
|
||||
private lateinit var retryText: TextView
|
||||
private lateinit var scanProgress: View
|
||||
private lateinit var devicesContainer: LinearLayout
|
||||
private lateinit var appendEnterToggle: LinearLayout
|
||||
private lateinit var appendEnterSwitch: Switch
|
||||
private val connectionInfoTypeface by lazy {
|
||||
runCatching {
|
||||
Typeface.createFromAsset(assets, "flutter_assets/assets/fonts/WDXLLubrifontSC-Regular.ttf")
|
||||
}.getOrDefault(Typeface.DEFAULT)
|
||||
}
|
||||
private var scanning = false
|
||||
private var appendEnter = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d("WTS", "QuickSendActivity onCreate")
|
||||
appendEnter = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_APPEND_ENTER, false)
|
||||
buildUi()
|
||||
input.requestFocus()
|
||||
input.post {
|
||||
@@ -60,10 +82,164 @@ class QuickSendActivity : Activity() {
|
||||
sheet.setBackgroundColor(Color.WHITE)
|
||||
sheet.setOnClickListener { }
|
||||
|
||||
statusText = TextView(this)
|
||||
statusText.textSize = 14f
|
||||
statusText.setTextColor(Color.rgb(71, 85, 105))
|
||||
sheet.addView(statusText)
|
||||
val topRow = LinearLayout(this)
|
||||
topRow.gravity = Gravity.CENTER_VERTICAL
|
||||
topRow.orientation = LinearLayout.HORIZONTAL
|
||||
sheet.addView(topRow, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
val connectionSummary = LinearLayout(this)
|
||||
connectionSummary.gravity = Gravity.CENTER_VERTICAL
|
||||
connectionSummary.orientation = LinearLayout.HORIZONTAL
|
||||
topRow.addView(connectionSummary, LinearLayout.LayoutParams(
|
||||
0,
|
||||
dp(64),
|
||||
1f
|
||||
))
|
||||
|
||||
connectionIcon = ImageView(this)
|
||||
connectionSummary.addView(connectionIcon, LinearLayout.LayoutParams(dp(40), dp(40)))
|
||||
|
||||
val summaryCopy = LinearLayout(this)
|
||||
summaryCopy.orientation = LinearLayout.VERTICAL
|
||||
summaryCopy.gravity = Gravity.CENTER_VERTICAL
|
||||
connectionSummary.addView(summaryCopy, LinearLayout.LayoutParams(
|
||||
0,
|
||||
dp(64),
|
||||
1f
|
||||
).apply {
|
||||
leftMargin = dp(10)
|
||||
})
|
||||
|
||||
endpointText = TextView(this)
|
||||
endpointText.textSize = 21f
|
||||
endpointText.typeface = Typeface.create(connectionInfoTypeface, Typeface.BOLD)
|
||||
endpointText.setTextColor(Color.rgb(15, 23, 42))
|
||||
endpointText.includeFontPadding = false
|
||||
endpointText.maxLines = 1
|
||||
summaryCopy.addView(endpointText)
|
||||
|
||||
deviceNameText = TextView(this)
|
||||
deviceNameText.textSize = 18f
|
||||
deviceNameText.typeface = connectionInfoTypeface
|
||||
deviceNameText.setTextColor(Color.rgb(100, 116, 139))
|
||||
deviceNameText.includeFontPadding = false
|
||||
deviceNameText.maxLines = 1
|
||||
summaryCopy.addView(deviceNameText, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = dp(2)
|
||||
})
|
||||
|
||||
appendEnterToggle = LinearLayout(this)
|
||||
appendEnterToggle.gravity = Gravity.CENTER_VERTICAL
|
||||
appendEnterToggle.orientation = LinearLayout.HORIZONTAL
|
||||
appendEnterToggle.setPadding(dp(10), 0, dp(4), 0)
|
||||
appendEnterToggle.background = strokeDrawable(Color.WHITE, Color.rgb(226, 232, 240), dp(8).toFloat())
|
||||
appendEnterToggle.isClickable = true
|
||||
appendEnterToggle.isFocusable = true
|
||||
appendEnterToggle.setOnClickListener { toggleAppendEnter() }
|
||||
|
||||
val appendEnterLabel = TextView(this)
|
||||
appendEnterLabel.text = "追加回车"
|
||||
appendEnterLabel.textSize = 13f
|
||||
appendEnterLabel.typeface = Typeface.DEFAULT_BOLD
|
||||
appendEnterLabel.setTextColor(Color.rgb(15, 23, 42))
|
||||
appendEnterToggle.addView(appendEnterLabel, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
appendEnterSwitch = Switch(this)
|
||||
appendEnterSwitch.isChecked = appendEnter
|
||||
appendEnterSwitch.setOnCheckedChangeListener { _, isChecked -> setAppendEnter(isChecked) }
|
||||
appendEnterToggle.addView(appendEnterSwitch)
|
||||
topRow.addView(appendEnterToggle, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
dp(44)
|
||||
).apply {
|
||||
leftMargin = dp(10)
|
||||
})
|
||||
|
||||
connectionPanel = LinearLayout(this)
|
||||
connectionPanel.orientation = LinearLayout.VERTICAL
|
||||
connectionPanel.setPadding(dp(14), dp(12), dp(14), dp(12))
|
||||
connectionPanel.background = strokeDrawable(Color.rgb(248, 250, 252), Color.rgb(226, 232, 240), dp(12).toFloat())
|
||||
sheet.addView(connectionPanel, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = dp(10)
|
||||
})
|
||||
|
||||
val connectionHeader = LinearLayout(this)
|
||||
connectionHeader.gravity = Gravity.CENTER_VERTICAL
|
||||
connectionHeader.orientation = LinearLayout.HORIZONTAL
|
||||
connectionPanel.addView(connectionHeader, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
connectionDot = View(this)
|
||||
connectionHeader.addView(connectionDot, LinearLayout.LayoutParams(dp(10), dp(10)))
|
||||
|
||||
val connectionCopy = LinearLayout(this)
|
||||
connectionCopy.orientation = LinearLayout.VERTICAL
|
||||
connectionHeader.addView(connectionCopy, LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
).apply {
|
||||
leftMargin = dp(10)
|
||||
})
|
||||
|
||||
connectionTitle = TextView(this)
|
||||
connectionTitle.textSize = 14f
|
||||
connectionTitle.typeface = Typeface.DEFAULT_BOLD
|
||||
connectionTitle.setTextColor(Color.rgb(15, 23, 42))
|
||||
connectionCopy.addView(connectionTitle)
|
||||
|
||||
connectionSubtitle = TextView(this)
|
||||
connectionSubtitle.textSize = 12f
|
||||
connectionSubtitle.setTextColor(Color.rgb(100, 116, 139))
|
||||
connectionCopy.addView(connectionSubtitle, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = dp(2)
|
||||
})
|
||||
|
||||
retryText = TextView(this)
|
||||
retryText.text = "重试"
|
||||
retryText.textSize = 13f
|
||||
retryText.typeface = Typeface.DEFAULT_BOLD
|
||||
retryText.setTextColor(Color.rgb(37, 99, 235))
|
||||
retryText.setPadding(dp(10), dp(6), dp(10), dp(6))
|
||||
retryText.isClickable = true
|
||||
retryText.isFocusable = true
|
||||
retryText.setOnClickListener { scanAndConnect() }
|
||||
connectionHeader.addView(retryText)
|
||||
|
||||
scanProgress = View(this)
|
||||
scanProgress.background = roundedDrawable(Color.rgb(37, 99, 235), dp(2).toFloat())
|
||||
connectionPanel.addView(scanProgress, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dp(3)
|
||||
).apply {
|
||||
topMargin = dp(10)
|
||||
})
|
||||
|
||||
devicesContainer = LinearLayout(this)
|
||||
devicesContainer.orientation = LinearLayout.VERTICAL
|
||||
connectionPanel.addView(devicesContainer, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
topMargin = dp(10)
|
||||
})
|
||||
|
||||
input = EditText(this)
|
||||
input.minLines = 3
|
||||
@@ -117,19 +293,164 @@ class QuickSendActivity : Activity() {
|
||||
root.addView(sheet, params)
|
||||
setContentView(root)
|
||||
refreshState()
|
||||
autoScanIfDisconnected()
|
||||
}
|
||||
|
||||
private fun refreshState() {
|
||||
val state = ConnectionService.stateMap()
|
||||
val connected = state["connected"] as? Boolean ?: false
|
||||
val connecting = state["connecting"] 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))
|
||||
val name = state["name"] as? String ?: ""
|
||||
val lastError = state["lastError"] as? String
|
||||
updateConnectionSummary(connected, connecting, host, port, name)
|
||||
connectionPanel.visibility = if (connected) View.GONE else View.VISIBLE
|
||||
connectionDot.background = roundedDrawable(when {
|
||||
connecting || scanning -> Color.rgb(37, 99, 235)
|
||||
else -> Color.rgb(185, 28, 28)
|
||||
}, dp(5).toFloat())
|
||||
scanProgress.visibility = if (scanning || connecting) View.VISIBLE else View.GONE
|
||||
retryText.visibility = if (!connecting && !scanning) View.VISIBLE else View.GONE
|
||||
when {
|
||||
connecting -> {
|
||||
connectionTitle.text = "正在连接电脑"
|
||||
connectionSubtitle.text = "$host:$port"
|
||||
}
|
||||
scanning -> {
|
||||
connectionTitle.text = "正在寻找附近的电脑"
|
||||
connectionSubtitle.text = "发现一个会自动连接,多个会显示选择"
|
||||
}
|
||||
else -> {
|
||||
connectionTitle.text = lastError ?: "暂未连接电脑"
|
||||
connectionSubtitle.text = "将自动扫描局域网内的桌面端服务"
|
||||
}
|
||||
}
|
||||
devicesContainer.visibility = if (connected) View.GONE else devicesContainer.visibility
|
||||
sendButton.isEnabled = connected
|
||||
sendButton.alpha = if (connected) 1f else 0.48f
|
||||
}
|
||||
|
||||
private fun updateConnectionSummary(
|
||||
connected: Boolean,
|
||||
connecting: Boolean,
|
||||
host: String,
|
||||
port: Int,
|
||||
name: String
|
||||
) {
|
||||
val iconColor = when {
|
||||
connected -> Color.rgb(22, 163, 74)
|
||||
connecting -> Color.rgb(37, 99, 235)
|
||||
else -> Color.rgb(185, 28, 28)
|
||||
}
|
||||
connectionIcon.setImageResource(when {
|
||||
connected || connecting -> R.drawable.ic_sync_24
|
||||
else -> R.drawable.ic_sync_disabled_24
|
||||
})
|
||||
connectionIcon.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
||||
endpointText.setTextColor(if (connected) Color.rgb(22, 101, 52) else Color.rgb(15, 23, 42))
|
||||
deviceNameText.setTextColor(if (connected) Color.rgb(21, 128, 61) else Color.rgb(100, 116, 139))
|
||||
endpointText.text = if (host.isBlank()) "未设置地址" else "$host:$port"
|
||||
deviceNameText.text = name.ifBlank { "Windows 桌面端" }
|
||||
}
|
||||
|
||||
private fun scanAndConnect() {
|
||||
if (scanning) {
|
||||
return
|
||||
}
|
||||
|
||||
scanning = true
|
||||
updateConnectionSummary(connected = false, connecting = true, host = "", port = 8181, name = "")
|
||||
connectionTitle.text = "正在寻找附近的电脑"
|
||||
connectionSubtitle.text = "发现一个会自动连接,多个会显示选择"
|
||||
connectionDot.background = roundedDrawable(Color.rgb(37, 99, 235), dp(5).toFloat())
|
||||
retryText.visibility = View.GONE
|
||||
scanProgress.visibility = View.VISIBLE
|
||||
devicesContainer.removeAllViews()
|
||||
devicesContainer.visibility = View.GONE
|
||||
|
||||
Thread {
|
||||
val devices = DiscoveryClient.discover(applicationContext)
|
||||
runOnUiThread {
|
||||
scanning = false
|
||||
when (devices.size) {
|
||||
0 -> {
|
||||
updateConnectionSummary(connected = false, connecting = false, host = "", port = 8181, name = "")
|
||||
connectionTitle.text = "没有发现桌面端"
|
||||
connectionSubtitle.text = "确认电脑和手机在同一局域网后重试"
|
||||
connectionDot.background = roundedDrawable(Color.rgb(185, 28, 28), dp(5).toFloat())
|
||||
retryText.visibility = View.VISIBLE
|
||||
scanProgress.visibility = View.GONE
|
||||
}
|
||||
1 -> connectTo(devices.single())
|
||||
else -> showDeviceChoices(devices)
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun autoScanIfDisconnected() {
|
||||
val state = ConnectionService.stateMap()
|
||||
val connected = state["connected"] as? Boolean ?: false
|
||||
val connecting = state["connecting"] as? Boolean ?: false
|
||||
if (!connected && !connecting) {
|
||||
scanAndConnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDeviceChoices(devices: List<DiscoveredService>) {
|
||||
updateConnectionSummary(connected = false, connecting = true, host = "", port = 8181, name = "")
|
||||
connectionTitle.text = "选择要连接的电脑"
|
||||
connectionSubtitle.text = "发现 ${devices.size} 个桌面端服务"
|
||||
connectionDot.background = roundedDrawable(Color.rgb(37, 99, 235), dp(5).toFloat())
|
||||
retryText.visibility = View.VISIBLE
|
||||
scanProgress.visibility = View.GONE
|
||||
devicesContainer.removeAllViews()
|
||||
devicesContainer.visibility = View.VISIBLE
|
||||
|
||||
devices.forEach { device ->
|
||||
devicesContainer.addView(createDeviceChoice(device), LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
bottomMargin = dp(8)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDeviceChoice(device: DiscoveredService): TextView {
|
||||
return TextView(this).apply {
|
||||
text = "${device.name}\n${device.host}:${device.port}"
|
||||
textSize = 14f
|
||||
setTextColor(Color.rgb(15, 23, 42))
|
||||
setPadding(dp(12), dp(10), dp(12), dp(10))
|
||||
background = strokeDrawable(Color.WHITE, Color.rgb(203, 213, 225), dp(10).toFloat())
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
setOnClickListener { connectTo(device) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectTo(device: DiscoveredService) {
|
||||
updateConnectionSummary(
|
||||
connected = false,
|
||||
connecting = true,
|
||||
host = device.host,
|
||||
port = device.port,
|
||||
name = device.name
|
||||
)
|
||||
connectionTitle.text = "正在连接电脑"
|
||||
connectionSubtitle.text = "${device.host}:${device.port}"
|
||||
connectionDot.background = roundedDrawable(Color.rgb(37, 99, 235), dp(5).toFloat())
|
||||
retryText.visibility = View.GONE
|
||||
scanProgress.visibility = View.VISIBLE
|
||||
devicesContainer.removeAllViews()
|
||||
devicesContainer.visibility = View.GONE
|
||||
ConnectionService.connect(this, device.host, device.port, device.name)
|
||||
Handler(Looper.getMainLooper()).postDelayed({ refreshState() }, 700)
|
||||
Handler(Looper.getMainLooper()).postDelayed({ refreshState() }, 1800)
|
||||
}
|
||||
|
||||
private fun sendAndClose() {
|
||||
val text = input.text.toString()
|
||||
Log.d("WTS", "QuickSendActivity send length=${text.length}")
|
||||
@@ -137,7 +458,7 @@ class QuickSendActivity : Activity() {
|
||||
return
|
||||
}
|
||||
|
||||
if (ConnectionService.sendText(text)) {
|
||||
if (ConnectionService.sendText(text) && (!appendEnter || ConnectionService.sendEnter())) {
|
||||
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)
|
||||
@@ -151,6 +472,34 @@ class QuickSendActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleAppendEnter() {
|
||||
setAppendEnter(!appendEnter)
|
||||
}
|
||||
|
||||
private fun setAppendEnter(value: Boolean) {
|
||||
if (appendEnter == value && appendEnterSwitch.isChecked == value) {
|
||||
return
|
||||
}
|
||||
|
||||
appendEnter = value
|
||||
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_APPEND_ENTER, appendEnter)
|
||||
.apply()
|
||||
updateAppendEnterToggle()
|
||||
}
|
||||
|
||||
private fun updateAppendEnterToggle() {
|
||||
if (appendEnterSwitch.isChecked != appendEnter) {
|
||||
appendEnterSwitch.isChecked = appendEnter
|
||||
}
|
||||
appendEnterToggle.background = strokeDrawable(
|
||||
Color.WHITE,
|
||||
if (appendEnter) Color.rgb(147, 197, 253) else Color.rgb(226, 232, 240),
|
||||
dp(16).toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(input.windowToken, 0)
|
||||
@@ -176,4 +525,9 @@ class QuickSendActivity : Activity() {
|
||||
private fun dp(value: Int): Int {
|
||||
return (value * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "FlutterSharedPreferences"
|
||||
private const val KEY_APPEND_ENTER = "flutter.append_enter"
|
||||
}
|
||||
}
|
||||
|
||||
9
android/app/src/main/res/drawable/ic_sync_24.xml
Normal file
9
android/app/src/main/res/drawable/ic_sync_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="M12,4V1L8,5L12,9V6C15.31,6 18,8.69 18,12C18,13.01 17.75,13.96 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12C20,7.58 16.42,4 12,4ZM6,12C6,10.99 6.25,10.04 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12C4,16.42 7.58,20 12,20V23L16,19L12,15V18C8.69,18 6,15.31 6,12Z" />
|
||||
</vector>
|
||||
@@ -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="M10.86,5.14L8,8L10.86,10.86V8.42C12.58,8.83 14.02,9.96 14.83,11.48L16.31,10C15.15,8.15 13.2,6.84 10.86,6.41V5.14ZM2.81,2L1.39,3.41L5.38,7.4C4.51,8.7 4,10.29 4,12C4,16.42 7.58,20 12,20V23L16,19L12,15V18C8.69,18 6,15.31 6,12C6,10.84 6.33,9.76 6.9,8.85L19.59,21.54L21,20.13L2.81,2ZM18.62,16.6C19.49,15.3 20,13.71 20,12C20,7.58 16.42,4 12,4V1L9.38,3.62L11.1,5.34C11.4,5.13 11.69,4.91 12,4.75V6C15.31,6 18,8.69 18,12C18,13.16 17.67,14.24 17.1,15.15L18.62,16.6Z" />
|
||||
</vector>
|
||||
94
assets/fonts/WDXLLubrifontSC-OFL.txt
Normal file
94
assets/fonts/WDXLLubrifontSC-OFL.txt
Normal file
@@ -0,0 +1,94 @@
|
||||
Copyright 2025 The WDXL Lubrifont Project Authors (https://github.com/NightFurySL2001/WD-XL-font)
|
||||
Copyright 2018-2020 The ZCOOL QingKe HuangYou Project Authors (https://www.github.com/googlefonts/zcool-qingke-huangyou)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
assets/fonts/WDXLLubrifontSC-Regular.ttf
Normal file
BIN
assets/fonts/WDXLLubrifontSC-Regular.ttf
Normal file
Binary file not shown.
168
lib/main.dart
168
lib/main.dart
@@ -28,12 +28,15 @@ class WirelessTextSyncerApp extends StatelessWidget {
|
||||
|
||||
enum SendButtonState { idle, sending, success }
|
||||
|
||||
const connectionInfoFontFamily = 'WDXL Lubrifont SC';
|
||||
|
||||
class ConnectionSnapshot {
|
||||
const ConnectionSnapshot({
|
||||
this.connected = false,
|
||||
this.connecting = false,
|
||||
this.host = '',
|
||||
this.port = 8181,
|
||||
this.name = '',
|
||||
this.lastError,
|
||||
});
|
||||
|
||||
@@ -47,6 +50,7 @@ class ConnectionSnapshot {
|
||||
connecting: map['connecting'] == true,
|
||||
host: (map['host'] as String?) ?? '',
|
||||
port: (map['port'] as num?)?.toInt() ?? 8181,
|
||||
name: (map['name'] as String?) ?? '',
|
||||
lastError: map['lastError'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -55,6 +59,7 @@ class ConnectionSnapshot {
|
||||
final bool connecting;
|
||||
final String host;
|
||||
final int port;
|
||||
final String name;
|
||||
final String? lastError;
|
||||
}
|
||||
|
||||
@@ -105,10 +110,11 @@ class NativeConnectionApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(String host, int port) async {
|
||||
Future<void> connect(String host, int port, {String? name}) async {
|
||||
await _methodChannel.invokeMethod<void>('connect', {
|
||||
'host': host,
|
||||
'port': port,
|
||||
if (name != null) 'name': name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,9 +294,15 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
scanning = false;
|
||||
discoveredDevices = devices;
|
||||
});
|
||||
|
||||
if (devices.length == 1 &&
|
||||
!connection.connected &&
|
||||
!connection.connecting) {
|
||||
await _connectDevice(devices.single);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
Future<void> _connect({String? name}) async {
|
||||
final host = hostController.text.trim();
|
||||
final portText = portController.text.trim();
|
||||
final port = int.tryParse(portText);
|
||||
@@ -307,15 +319,24 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
await prefs.setString(_portKey, port.toString());
|
||||
|
||||
setState(() {
|
||||
connection = ConnectionSnapshot(connecting: true, host: host, port: port);
|
||||
connection = ConnectionSnapshot(
|
||||
connecting: true,
|
||||
host: host,
|
||||
port: port,
|
||||
name: name ?? '',
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await api.connect(host, port);
|
||||
await api.connect(host, port, name: name);
|
||||
} on PlatformException catch (exception) {
|
||||
_showToast(exception.message ?? '连接失败,请检查地址');
|
||||
setState(() {
|
||||
connection = ConnectionSnapshot(host: host, port: port);
|
||||
connection = ConnectionSnapshot(
|
||||
host: host,
|
||||
port: port,
|
||||
name: name ?? '',
|
||||
);
|
||||
headerExpanded = true;
|
||||
});
|
||||
}
|
||||
@@ -324,7 +345,7 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
Future<void> _connectDevice(DiscoveredDevice device) async {
|
||||
hostController.text = device.host;
|
||||
portController.text = device.port.toString();
|
||||
await _connect();
|
||||
await _connect(name: device.name);
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
@@ -333,6 +354,7 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
connection = ConnectionSnapshot(
|
||||
host: hostController.text.trim(),
|
||||
port: int.tryParse(portController.text.trim()) ?? 8181,
|
||||
name: connection.name,
|
||||
);
|
||||
headerExpanded = true;
|
||||
sendButtonState = SendButtonState.idle;
|
||||
@@ -542,11 +564,6 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final statusText = connection.connected
|
||||
? '已连接: ${connection.host}:${connection.port}'
|
||||
: connection.connecting
|
||||
? '正在连接: ${connection.host}:${connection.port}'
|
||||
: '未连接 (点击配置)';
|
||||
|
||||
return Material(
|
||||
color: colorScheme.surface,
|
||||
@@ -566,29 +583,23 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
});
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
height: 52,
|
||||
child: Row(
|
||||
children: [
|
||||
_ConnectionIndicator(
|
||||
connected: connection.connected,
|
||||
connecting: connection.connecting,
|
||||
animation: statusPulseController,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: connection.connected
|
||||
? const Color(0xFF166534)
|
||||
: connection.connecting
|
||||
? colorScheme.primary
|
||||
: colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: _ConnectionSummary(
|
||||
connected: connection.connected,
|
||||
connecting: connection.connecting,
|
||||
endpoint: connection.host.isEmpty
|
||||
? '未设置地址'
|
||||
: '${connection.host}:${connection.port}',
|
||||
deviceName: connection.name.isEmpty
|
||||
? 'Windows 桌面端'
|
||||
: connection.name,
|
||||
animation: statusPulseController,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
headerExpanded
|
||||
? Icons.keyboard_arrow_up
|
||||
@@ -979,6 +990,105 @@ class _InputSyncPageState extends State<InputSyncPage>
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionSummary extends StatelessWidget {
|
||||
const _ConnectionSummary({
|
||||
required this.connected,
|
||||
required this.connecting,
|
||||
required this.endpoint,
|
||||
required this.deviceName,
|
||||
required this.animation,
|
||||
});
|
||||
|
||||
final bool connected;
|
||||
final bool connecting;
|
||||
final String endpoint;
|
||||
final String deviceName;
|
||||
final Animation<double> animation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final iconColor = connected
|
||||
? const Color(0xFF16A34A)
|
||||
: connecting
|
||||
? colorScheme.primary
|
||||
: colorScheme.error;
|
||||
final primaryTextColor = connected
|
||||
? const Color(0xFF166534)
|
||||
: const Color(0xFF0F172A);
|
||||
final secondaryTextColor = connected
|
||||
? const Color(0xFF15803D)
|
||||
: colorScheme.onSurfaceVariant;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: connecting ? animation.value : 1,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
connected
|
||||
? Icons.sync
|
||||
: connecting
|
||||
? Icons.sync
|
||||
: Icons.sync_disabled,
|
||||
color: iconColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
endpoint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: primaryTextColor,
|
||||
fontFamily: connectionInfoFontFamily,
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
deviceName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: secondaryTextColor,
|
||||
fontFamily: connectionInfoFontFamily,
|
||||
fontSize: 18,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionIndicator extends StatelessWidget {
|
||||
const _ConnectionIndicator({
|
||||
required this.connected,
|
||||
|
||||
@@ -58,6 +58,11 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
fonts:
|
||||
- family: WDXL Lubrifont SC
|
||||
fonts:
|
||||
- asset: assets/fonts/WDXLLubrifontSC-Regular.ttf
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
||||
Reference in New Issue
Block a user