Compare commits

..

6 Commits

Author SHA1 Message Date
Misaka
491513fc5e Add desktop mute toggle button in draft board
Add a setMute WebSocket action and a mute IconButton in the draft
board title bar. The button optimistically flips local state and
rolls back with a toast when the desktop is unreachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 22:12:29 +08:00
Misaka
2798b5a66a Remove toast on history item restore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 21:49:30 +08:00
Misaka
5f6e045e98 Persist quick send history safely 2026-05-17 17:10:01 +08:00
Misaka
19441399d0 Sync append enter setting on resume 2026-05-17 16:47:07 +08:00
Misaka
31d1681947 Improve Android connection controls and status display 2026-05-17 16:42:40 +08:00
Misaka
1286a63507 Detect desktop disconnects on Android 2026-05-17 14:21:09 +08:00
10 changed files with 963 additions and 121 deletions

View File

@@ -18,10 +18,13 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class ConnectionService : Service() {
private val tag = "WTS"
private val client = OkHttpClient()
private val client = OkHttpClient.Builder()
.pingInterval(5, TimeUnit.SECONDS)
.build()
override fun onBind(intent: Intent?): IBinder? = null
@@ -37,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("已断开连接")
}
@@ -51,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
@@ -62,6 +66,7 @@ class ConnectionService : Service() {
.edit()
.putString(KEY_HOST, host)
.putInt(KEY_PORT, port)
.putString(KEY_NAME, name)
.apply()
try {
@@ -74,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")
@@ -91,19 +97,30 @@ 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) {
Log.d(tag, "Ignoring stale WebSocket onClosed")
return
}
updateState(connected = false, connecting = false, host = host, port = port, lastError = null)
currentSocket = null
updateState(connected = false, connecting = false, host = host, port = port, name = state.name, lastError = null)
Handler(Looper.getMainLooper()).post {
stopForeground(STOP_FOREGROUND_REMOVE)
}
@@ -115,12 +132,19 @@ class ConnectionService : Service() {
Log.d(tag, "Ignoring stale WebSocket onFailure")
return
}
currentSocket = null
val errorMessage = if (state.connected) {
"桌面端服务已断开"
} else {
t.message ?: "连接失败"
}
updateState(
connected = false,
connecting = false,
host = host,
port = port,
lastError = t.message ?: "连接失败"
name = state.name.ifBlank { name },
lastError = errorMessage
)
Handler(Looper.getMainLooper()).post {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -138,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)
@@ -169,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)
@@ -199,6 +255,7 @@ class ConnectionService : Service() {
connecting = false,
host = host,
port = port,
name = name,
lastError = exception.message ?: "连接服务启动失败"
)
throw exception
@@ -234,6 +291,34 @@ class ConnectionService : Service() {
return sendAction(socket, "enter", null)
}
fun sendMute(muted: Boolean): Boolean {
Log.d("WTS", "ConnectionService.sendMute muted=$muted connected=${state.connected}")
val socket = currentSocket ?: return false
if (!state.connected) {
return false
}
return sendMuteAction(socket, muted)
}
private fun sendMuteAction(socket: WebSocket, muted: Boolean): Boolean {
val payload = JSONObject()
.put("action", "setMute")
.put("muted", muted)
.toString()
val sent = socket.send(payload)
if (!sent && currentSocket == socket) {
currentSocket = null
updateState(
connected = false,
connecting = false,
lastError = "连接已断开"
)
}
return sent
}
private fun sendAction(socket: WebSocket, action: String, text: String?): Boolean {
val payload = JSONObject()
.put("action", action)
@@ -243,7 +328,17 @@ class ConnectionService : Service() {
}
}
.toString()
return socket.send(payload)
val sent = socket.send(payload)
if (!sent && currentSocket == socket) {
currentSocket = null
updateState(
connected = false,
connecting = false,
lastError = "连接已断开"
)
}
return sent
}
fun stateMap(): Map<String, Any?> {
@@ -253,6 +348,7 @@ class ConnectionService : Service() {
"connecting" to snapshot.connecting,
"host" to snapshot.host,
"port" to snapshot.port,
"name" to snapshot.name,
"lastError" to snapshot.lastError
)
}
@@ -262,13 +358,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()
@@ -281,6 +378,7 @@ class ConnectionService : Service() {
val connecting: Boolean = false,
val host: String = "",
val port: Int = 8181,
val name: String = "",
val lastError: String? = null
)
}

View File

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

View File

@@ -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)
@@ -49,6 +44,11 @@ class MainActivity : FlutterActivity() {
Log.d(tag, "MethodChannel sendEnter")
result.success(ConnectionService.sendEnter())
}
"sendMute" -> {
val muted = call.argument<Boolean>("muted") ?: false
Log.d(tag, "MethodChannel sendMute muted=$muted")
result.success(ConnectionService.sendMute(muted))
}
"getState" -> {
Log.d(tag, "MethodChannel getState")
result.success(ConnectionService.stateMap())
@@ -73,67 +73,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}")

View File

@@ -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,18 +19,40 @@ import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Switch
import android.widget.TextView
import org.json.JSONArray
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
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 +83,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 +294,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 +459,8 @@ class QuickSendActivity : Activity() {
return
}
if (ConnectionService.sendText(text)) {
if (ConnectionService.sendText(text) && (!appendEnter || ConnectionService.sendEnter())) {
saveHistory(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)
@@ -151,6 +474,73 @@ class QuickSendActivity : Activity() {
}
}
private fun saveHistory(text: String) {
if (text.trim().isEmpty()) {
return
}
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val currentHistory = readHistory()
val nextHistory = linkedSetOf(text)
currentHistory
.filter { it != text }
.take(MAX_HISTORY_ITEMS - 1)
.forEach { nextHistory.add(it) }
prefs.edit()
.putString(KEY_HISTORY, JSON_LIST_PREFIX + JSONArray(nextHistory.toList()).toString())
.apply()
}
private fun readHistory(): List<String> {
val rawValue = getSharedPreferences(PREFS, Context.MODE_PRIVATE).all[KEY_HISTORY]
return when (rawValue) {
is String -> decodeHistoryString(rawValue)
is Set<*> -> rawValue.filterIsInstance<String>()
else -> emptyList()
}
}
private fun decodeHistoryString(value: String): List<String> {
if (!value.startsWith(JSON_LIST_PREFIX)) {
return emptyList()
}
return runCatching {
val array = JSONArray(value.substring(JSON_LIST_PREFIX.length))
List(array.length()) { index -> array.optString(index) }
.filter { it.isNotEmpty() }
}.getOrDefault(emptyList())
}
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 +566,12 @@ 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"
private const val KEY_HISTORY = "flutter.send_history"
private const val JSON_LIST_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu!"
private const val MAX_HISTORY_ITEMS = 10
}
}

View 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>

View 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="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>

View 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.

Binary file not shown.

View File

@@ -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,
});
}
@@ -128,6 +134,13 @@ class NativeConnectionApi {
return result ?? false;
}
Future<bool> sendMute(bool muted) async {
final result = await _methodChannel.invokeMethod<bool>('sendMute', {
'muted': muted,
});
return result ?? false;
}
Future<List<DiscoveredDevice>> startDiscovery() async {
try {
final result = await _methodChannel.invokeListMethod<dynamic>(
@@ -152,7 +165,7 @@ class InputSyncPage extends StatefulWidget {
}
class _InputSyncPageState extends State<InputSyncPage>
with TickerProviderStateMixin {
with TickerProviderStateMixin, WidgetsBindingObserver {
static const _hostKey = 'server_host';
static const _portKey = 'server_port';
static const _historyKey = 'send_history';
@@ -178,10 +191,12 @@ class _InputSyncPageState extends State<InputSyncPage>
bool headerExpanded = true;
bool inputFocused = false;
bool scanning = false;
bool desktopMuted = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
statusPulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1300),
@@ -200,6 +215,7 @@ class _InputSyncPageState extends State<InputSyncPage>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
stateSubscription?.cancel();
statusPulseController.dispose();
radarPulseController.dispose();
@@ -210,8 +226,16 @@ class _InputSyncPageState extends State<InputSyncPage>
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_syncSharedPrefsFromDisk());
}
}
Future<void> _loadSavedState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.reload();
final nativeState = await api.getState();
if (!mounted) {
return;
@@ -223,7 +247,7 @@ class _InputSyncPageState extends State<InputSyncPage>
prefs.getString(_portKey) ?? nativeState.port.toString();
clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false;
appendEnter = prefs.getBool(_appendEnterKey) ?? false;
sendHistory = prefs.getStringList(_historyKey) ?? [];
sendHistory = _readHistory(prefs);
connection = nativeState;
headerExpanded = !nativeState.connected;
});
@@ -249,6 +273,7 @@ class _InputSyncPageState extends State<InputSyncPage>
}
if (!nextState.connected) {
sendButtonState = SendButtonState.idle;
desktopMuted = false;
}
});
@@ -288,9 +313,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 +338,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 +364,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 +373,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;
@@ -391,6 +432,23 @@ class _InputSyncPageState extends State<InputSyncPage>
}
}
Future<void> _toggleMute() async {
if (!connection.connected) {
return;
}
final nextMuted = !desktopMuted;
setState(() {
desktopMuted = nextMuted;
});
final sent = await api.sendMute(nextMuted);
if (!sent && mounted) {
setState(() {
desktopMuted = !nextMuted;
});
_showToast('发送失败,连接已断开');
}
}
Future<void> _saveHistory(String text) async {
final trimmed = text.trim();
if (trimmed.isEmpty) {
@@ -427,11 +485,50 @@ class _InputSyncPageState extends State<InputSyncPage>
});
}
Future<void> _syncSharedPrefsFromDisk() async {
final prefs = await SharedPreferences.getInstance();
await prefs.reload();
final nextAppendEnter = prefs.getBool(_appendEnterKey) ?? false;
final nextHistory = _readHistory(prefs);
if (!mounted) {
return;
}
if (nextAppendEnter != appendEnter ||
!_stringListsEqual(nextHistory, sendHistory)) {
setState(() {
appendEnter = nextAppendEnter;
sendHistory = nextHistory;
});
}
}
bool _stringListsEqual(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
for (var index = 0; index < left.length; index += 1) {
if (left[index] != right[index]) {
return false;
}
}
return true;
}
List<String> _readHistory(SharedPreferences prefs) {
try {
return prefs.getStringList(_historyKey) ?? [];
} catch (_) {
return [];
}
}
void _restoreHistory(String text) {
inputController.text = text;
inputController.selection = TextSelection.collapsed(offset: text.length);
Navigator.of(context).pop();
_showToast('已从历史记录恢复');
inputFocusNode.requestFocus();
}
@@ -542,11 +639,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 +658,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
@@ -844,6 +930,13 @@ class _InputSyncPageState extends State<InputSyncPage>
icon: const Icon(Icons.delete_outline),
),
),
IconButton(
tooltip: desktopMuted ? '取消静音' : '静音桌面',
onPressed: connection.connected ? _toggleMute : null,
icon: Icon(
desktopMuted ? Icons.volume_off : Icons.volume_up,
),
),
],
),
),
@@ -979,6 +1072,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,

View File

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