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