Compare commits

..

2 Commits

Author SHA1 Message Date
Misaka
7cd6fbd1f2 Update Android icons and enter action 2026-05-16 22:00:02 +08:00
Misaka
fe5cb9af37 Implement Android v2 quick send UX 2026-05-16 20:36:13 +08:00
22 changed files with 1313 additions and 224 deletions

View File

@@ -42,3 +42,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

View File

@@ -1,10 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application <application
android:label="wireless_text_syncer_android" android:label="文本同步"
android:name="${applicationName}" android:name=".WirelessTextSyncerApplicationHolder"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -27,6 +32,28 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".QuickSendActivity"
android:exported="false"
android:theme="@style/TransparentBottomSheetTheme"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize" />
<service
android:name=".ConnectionService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".QuickSendTileService"
android:exported="true"
android:icon="@drawable/ic_quick_send_tile"
android:label="文本同步"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@@ -0,0 +1,286 @@
package com.wirelesstextsyncer.wireless_text_syncer_android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import io.flutter.plugin.common.EventChannel
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
class ConnectionService : Service() {
private val tag = "WTS"
private val client = OkHttpClient()
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
Log.d(tag, "ConnectionService onCreate")
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(tag, "ConnectionService onStartCommand action=${intent?.action}")
when (intent?.action) {
ACTION_CONNECT -> {
val host = intent.getStringExtra(EXTRA_HOST).orEmpty()
val port = intent.getIntExtra(EXTRA_PORT, 8181)
connectWebSocket(host, port)
}
ACTION_DISCONNECT -> disconnectWebSocket("已断开连接")
}
return START_STICKY
}
override fun onDestroy() {
Log.d(tag, "ConnectionService onDestroy")
currentSocket?.close(1000, "service destroyed")
currentSocket = null
super.onDestroy()
}
private fun connectWebSocket(host: String, port: Int) {
Log.d(tag, "connectWebSocket host=$host port=$port")
if (host.isBlank()) {
updateState(connected = false, connecting = false, host = host, port = port, lastError = "请先填写 Windows IP")
return
}
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_HOST, host)
.putInt(KEY_PORT, port)
.apply()
try {
Log.d(tag, "startForeground connecting notification")
startForeground(NOTIFICATION_ID, buildNotification(host, port, "正在连接"))
} catch (exception: Exception) {
Log.e(tag, "startForeground failed", exception)
updateState(
connected = false,
connecting = false,
host = host,
port = port,
lastError = exception.message ?: "前台服务启动失败"
)
stopSelf()
return
}
currentSocket?.close(1000, "reconnect")
updateState(connected = false, connecting = true, host = host, port = port, lastError = null)
val request = Request.Builder().url("ws://$host:$port").build()
Log.d(tag, "Creating WebSocket ws://$host:$port")
currentSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(tag, "WebSocket onOpen code=${response.code}")
if (currentSocket != webSocket) {
Log.d(tag, "Ignoring stale WebSocket onOpen")
return
}
updateState(connected = true, connecting = false, host = host, port = port, lastError = null)
Handler(Looper.getMainLooper()).post {
startForeground(NOTIFICATION_ID, buildNotification(host, port, "已连接"))
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(tag, "WebSocket onClosed code=$code reason=$reason")
if (currentSocket != webSocket) {
Log.d(tag, "Ignoring stale WebSocket onClosed")
return
}
updateState(connected = false, connecting = false, host = host, port = port, lastError = null)
Handler(Looper.getMainLooper()).post {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(tag, "WebSocket onFailure responseCode=${response?.code}", t)
if (currentSocket != webSocket) {
Log.d(tag, "Ignoring stale WebSocket onFailure")
return
}
updateState(
connected = false,
connecting = false,
host = host,
port = port,
lastError = t.message ?: "连接失败"
)
Handler(Looper.getMainLooper()).post {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
})
}
private fun disconnectWebSocket(message: String?) {
Log.d(tag, "disconnectWebSocket message=$message")
currentSocket?.close(1000, "user disconnected")
currentSocket = null
updateState(connected = false, connecting = false, lastError = message)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun buildNotification(host: String, port: Int, status: String): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
return builder
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
.setContentTitle("文本同步")
.setContentText("$status $host:$port")
.setOngoing(true)
.setShowWhen(false)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(CHANNEL_ID, "文本同步连接", NotificationManager.IMPORTANCE_LOW)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
companion object {
private const val ACTION_CONNECT = "wireless_text_syncer.CONNECT"
private const val ACTION_DISCONNECT = "wireless_text_syncer.DISCONNECT"
private const val EXTRA_HOST = "host"
private const val EXTRA_PORT = "port"
private const val CHANNEL_ID = "wireless_text_syncer_connection"
private const val NOTIFICATION_ID = 1001
private const val PREFS = "wireless_text_syncer_connection"
private const val KEY_HOST = "server_host"
private const val KEY_PORT = "server_port"
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var currentSocket: WebSocket? = null
@Volatile private var state = ConnectionStateSnapshot()
@Volatile var eventSink: EventChannel.EventSink? = null
fun connect(context: Context, host: String, port: Int) {
Log.d("WTS", "ConnectionService.connect requested host=$host port=$port sdk=${Build.VERSION.SDK_INT}")
val intent = Intent(context, ConnectionService::class.java)
.setAction(ACTION_CONNECT)
.putExtra(EXTRA_HOST, host)
.putExtra(EXTRA_PORT, port)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (exception: Exception) {
Log.e("WTS", "Failed to start ConnectionService", exception)
updateState(
connected = false,
connecting = false,
host = host,
port = port,
lastError = exception.message ?: "连接服务启动失败"
)
throw exception
}
}
fun disconnect(context: Context) {
Log.d("WTS", "ConnectionService.disconnect requested")
try {
context.startService(Intent(context, ConnectionService::class.java).setAction(ACTION_DISCONNECT))
} catch (exception: Exception) {
Log.e("WTS", "Failed to stop ConnectionService", exception)
}
}
fun sendText(text: String): Boolean {
Log.d("WTS", "ConnectionService.sendText length=${text.length} connected=${state.connected}")
val socket = currentSocket ?: return false
if (!state.connected || text.isEmpty()) {
return false
}
return sendAction(socket, "replaceAll", text)
}
fun sendEnter(): Boolean {
Log.d("WTS", "ConnectionService.sendEnter connected=${state.connected}")
val socket = currentSocket ?: return false
if (!state.connected) {
return false
}
return sendAction(socket, "enter", null)
}
private fun sendAction(socket: WebSocket, action: String, text: String?): Boolean {
val payload = JSONObject()
.put("action", action)
.apply {
if (text != null) {
put("text", text)
}
}
.toString()
return socket.send(payload)
}
fun stateMap(): Map<String, Any?> {
val snapshot = state
return mapOf(
"connected" to snapshot.connected,
"connecting" to snapshot.connecting,
"host" to snapshot.host,
"port" to snapshot.port,
"lastError" to snapshot.lastError
)
}
private fun updateState(
connected: Boolean,
connecting: Boolean,
host: String = state.host,
port: Int = state.port,
lastError: String? = null
) {
Log.d(
"WTS",
"updateState connected=$connected connecting=$connecting host=$host port=$port error=$lastError"
)
state = ConnectionStateSnapshot(connected, connecting, host, port, lastError)
mainHandler.post {
eventSink?.success(stateMap())
QuickSendTileService.requestTileRefresh()
}
}
}
private data class ConnectionStateSnapshot(
val connected: Boolean = false,
val connecting: Boolean = false,
val host: String = "",
val port: Int = 8181,
val lastError: String? = null
)
}

View File

@@ -1,5 +1,144 @@
package com.wirelesstextsyncer.wireless_text_syncer_android package com.wirelesstextsyncer.wireless_text_syncer_android
import android.content.Context
import android.net.wifi.WifiManager
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
private val tag = "WTS"
private val connectionChannel = "wireless_text_syncer/connection"
private val connectionStateChannel = "wireless_text_syncer/connection_state"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, connectionChannel)
.setMethodCallHandler { call, result ->
when (call.method) {
"connect" -> {
val host = call.argument<String>("host").orEmpty()
val port = call.argument<Int>("port") ?: 8181
Log.d(tag, "MethodChannel connect host=$host port=$port")
try {
ConnectionService.connect(this, host, port)
result.success(null)
} catch (exception: Exception) {
Log.e(tag, "MethodChannel connect failed", exception)
result.error("CONNECT_FAILED", exception.message, null)
}
}
"disconnect" -> {
Log.d(tag, "MethodChannel disconnect")
ConnectionService.disconnect(this)
result.success(null)
}
"sendText" -> {
val text = call.argument<String>("text").orEmpty()
Log.d(tag, "MethodChannel sendText length=${text.length}")
result.success(ConnectionService.sendText(text))
}
"sendEnter" -> {
Log.d(tag, "MethodChannel sendEnter")
result.success(ConnectionService.sendEnter())
}
"getState" -> {
Log.d(tag, "MethodChannel getState")
result.success(ConnectionService.stateMap())
}
"startDiscovery" -> startDiscovery(result)
"stopDiscovery" -> result.success(null)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, connectionStateChannel)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
ConnectionService.eventSink = events
events?.success(ConnectionService.stateMap())
}
override fun onCancel(arguments: Any?) {
ConnectionService.eventSink = null
}
})
}
private fun startDiscovery(result: MethodChannel.Result) {
Log.d(tag, "Discovery started")
Thread {
val devices = mutableListOf<Map<String, Any>>()
val seen = mutableSetOf<String>()
var multicastLock: WifiManager.MulticastLock? = null
try {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
multicastLock = wifi?.createMulticastLock("wireless-text-syncer-discovery")
multicastLock?.setReferenceCounted(false)
multicastLock?.acquire()
DatagramSocket().use { socket ->
socket.broadcast = true
socket.soTimeout = 450
val request = JSONObject()
.put("type", "wirelessTextSyncer.discovery")
.put("version", 1)
.toString()
.toByteArray(Charsets.UTF_8)
val packet = DatagramPacket(
request,
request.size,
InetAddress.getByName("255.255.255.255"),
8182
)
socket.send(packet)
val deadline = System.currentTimeMillis() + 1600
while (System.currentTimeMillis() < deadline) {
try {
val buffer = ByteArray(2048)
val response = DatagramPacket(buffer, buffer.size)
socket.receive(response)
val json = JSONObject(String(response.data, 0, response.length, Charsets.UTF_8))
if (json.optString("type") != "wirelessTextSyncer.service") {
continue
}
val host = json.optString("host", response.address.hostAddress.orEmpty())
val port = json.optInt("port", 8181)
val key = "$host:$port"
if (seen.add(key)) {
Log.d(tag, "Discovery found ${json.optString("name", host)} $key")
devices += mapOf(
"name" to json.optString("name", host),
"host" to host,
"port" to port
)
}
} catch (_: Exception) {
}
}
}
} catch (exception: Exception) {
Log.e(tag, "Discovery failed", exception)
} finally {
multicastLock?.let {
if (it.isHeld) {
it.release()
}
}
}
runOnUiThread {
Log.d(tag, "Discovery finished count=${devices.size}")
result.success(devices)
}
}.start()
}
}

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

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="M9,16.2L4.8,12L3.4,13.4L9,19L21,7L19.6,5.6L9,16.2Z" />
</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="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -15,4 +15,11 @@
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
<style name="TransparentBottomSheetTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:colorAccent">#60A5FA</item>
</style>
</resources> </resources>

View File

@@ -15,4 +15,11 @@
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
<style name="TransparentBottomSheetTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources> </resources>

View File

@@ -1,9 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
void main() { void main() {
runApp(const WirelessTextSyncerApp()); runApp(const WirelessTextSyncerApp());
@@ -29,6 +28,122 @@ class WirelessTextSyncerApp extends StatelessWidget {
enum SendButtonState { idle, sending, success } enum SendButtonState { idle, sending, success }
class ConnectionSnapshot {
const ConnectionSnapshot({
this.connected = false,
this.connecting = false,
this.host = '',
this.port = 8181,
this.lastError,
});
factory ConnectionSnapshot.fromMap(Map<dynamic, dynamic>? map) {
if (map == null) {
return const ConnectionSnapshot();
}
return ConnectionSnapshot(
connected: map['connected'] == true,
connecting: map['connecting'] == true,
host: (map['host'] as String?) ?? '',
port: (map['port'] as num?)?.toInt() ?? 8181,
lastError: map['lastError'] as String?,
);
}
final bool connected;
final bool connecting;
final String host;
final int port;
final String? lastError;
}
class DiscoveredDevice {
const DiscoveredDevice({
required this.name,
required this.host,
required this.port,
});
factory DiscoveredDevice.fromMap(Map<dynamic, dynamic> map) {
return DiscoveredDevice(
name: (map['name'] as String?) ?? 'Windows 桌面端',
host: (map['host'] as String?) ?? '',
port: (map['port'] as num?)?.toInt() ?? 8181,
);
}
final String name;
final String host;
final int port;
String get endpoint => '$host:$port';
}
class NativeConnectionApi {
static const MethodChannel _methodChannel = MethodChannel(
'wireless_text_syncer/connection',
);
static const EventChannel _eventChannel = EventChannel(
'wireless_text_syncer/connection_state',
);
Stream<ConnectionSnapshot> watchState() {
return _eventChannel.receiveBroadcastStream().map((event) {
return ConnectionSnapshot.fromMap(event as Map<dynamic, dynamic>?);
});
}
Future<ConnectionSnapshot> getState() async {
try {
final state = await _methodChannel.invokeMapMethod<dynamic, dynamic>(
'getState',
);
return ConnectionSnapshot.fromMap(state);
} on MissingPluginException {
return const ConnectionSnapshot();
}
}
Future<void> connect(String host, int port) async {
await _methodChannel.invokeMethod<void>('connect', {
'host': host,
'port': port,
});
}
Future<void> disconnect() async {
await _methodChannel.invokeMethod<void>('disconnect');
}
Future<bool> sendText(String text) async {
final result = await _methodChannel.invokeMethod<bool>('sendText', {
'text': text,
});
return result ?? false;
}
Future<bool> sendEnter() async {
final result = await _methodChannel.invokeMethod<bool>('sendEnter');
return result ?? false;
}
Future<List<DiscoveredDevice>> startDiscovery() async {
try {
final result = await _methodChannel.invokeListMethod<dynamic>(
'startDiscovery',
);
return (result ?? const [])
.whereType<Map<dynamic, dynamic>>()
.map(DiscoveredDevice.fromMap)
.where((device) => device.host.isNotEmpty)
.toList();
} on MissingPluginException {
return const [];
}
}
}
class InputSyncPage extends StatefulWidget { class InputSyncPage extends StatefulWidget {
const InputSyncPage({super.key}); const InputSyncPage({super.key});
@@ -37,7 +152,7 @@ class InputSyncPage extends StatefulWidget {
} }
class _InputSyncPageState extends State<InputSyncPage> class _InputSyncPageState extends State<InputSyncPage>
with SingleTickerProviderStateMixin { with TickerProviderStateMixin {
static const _hostKey = 'server_host'; static const _hostKey = 'server_host';
static const _portKey = 'server_port'; static const _portKey = 'server_port';
static const _historyKey = 'send_history'; static const _historyKey = 'send_history';
@@ -45,21 +160,24 @@ class _InputSyncPageState extends State<InputSyncPage>
static const _appendEnterKey = 'append_enter'; static const _appendEnterKey = 'append_enter';
static const _maxHistoryItems = 10; static const _maxHistoryItems = 10;
final api = NativeConnectionApi();
final hostController = TextEditingController(); final hostController = TextEditingController();
final portController = TextEditingController(text: '8181'); final portController = TextEditingController(text: '8181');
final inputController = TextEditingController(); final inputController = TextEditingController();
final inputFocusNode = FocusNode(); final inputFocusNode = FocusNode();
late final AnimationController statusPulseController; late final AnimationController statusPulseController;
WebSocketChannel? channel; late final AnimationController radarPulseController;
StreamSubscription<dynamic>? channelSubscription; StreamSubscription<ConnectionSnapshot>? stateSubscription;
List<String> sendHistory = []; List<String> sendHistory = [];
List<DiscoveredDevice> discoveredDevices = [];
SendButtonState sendButtonState = SendButtonState.idle; SendButtonState sendButtonState = SendButtonState.idle;
ConnectionSnapshot connection = const ConnectionSnapshot();
bool appendEnter = false; bool appendEnter = false;
bool clearAfterSend = false; bool clearAfterSend = false;
bool connected = false;
bool headerExpanded = true; bool headerExpanded = true;
bool inputFocused = false; bool inputFocused = false;
bool scanning = false;
@override @override
void initState() { void initState() {
@@ -70,16 +188,21 @@ class _InputSyncPageState extends State<InputSyncPage>
lowerBound: 0.55, lowerBound: 0.55,
upperBound: 1, upperBound: 1,
)..repeat(reverse: true); )..repeat(reverse: true);
radarPulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
inputController.addListener(_handleDraftChanged); inputController.addListener(_handleDraftChanged);
inputFocusNode.addListener(_handleFocusChanged); inputFocusNode.addListener(_handleFocusChanged);
_loadSavedState(); _loadSavedState();
_subscribeConnectionState();
} }
@override @override
void dispose() { void dispose() {
channelSubscription?.cancel(); stateSubscription?.cancel();
channel?.sink.close();
statusPulseController.dispose(); statusPulseController.dispose();
radarPulseController.dispose();
hostController.dispose(); hostController.dispose();
portController.dispose(); portController.dispose();
inputController.dispose(); inputController.dispose();
@@ -89,17 +212,52 @@ class _InputSyncPageState extends State<InputSyncPage>
Future<void> _loadSavedState() async { Future<void> _loadSavedState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final nativeState = await api.getState();
if (!mounted) { if (!mounted) {
return; return;
} }
setState(() { setState(() {
hostController.text = prefs.getString(_hostKey) ?? ''; hostController.text = prefs.getString(_hostKey) ?? nativeState.host;
portController.text = prefs.getString(_portKey) ?? '8181'; portController.text =
prefs.getString(_portKey) ?? nativeState.port.toString();
clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false; clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false;
appendEnter = prefs.getBool(_appendEnterKey) ?? false; appendEnter = prefs.getBool(_appendEnterKey) ?? false;
sendHistory = prefs.getStringList(_historyKey) ?? []; sendHistory = prefs.getStringList(_historyKey) ?? [];
connection = nativeState;
headerExpanded = !nativeState.connected;
}); });
if (!nativeState.connected) {
unawaited(_scanForDevices());
}
}
void _subscribeConnectionState() {
stateSubscription = api.watchState().listen((nextState) {
if (!mounted) {
return;
}
setState(() {
connection = nextState;
if (nextState.connected) {
headerExpanded = false;
hostController.text = nextState.host;
portController.text = nextState.port.toString();
} else if (!nextState.connecting) {
headerExpanded = true;
}
if (!nextState.connected) {
sendButtonState = SendButtonState.idle;
}
});
if (nextState.connected) {
inputFocusNode.requestFocus();
} else if (nextState.lastError != null) {
_showToast(nextState.lastError!);
}
}, onError: (_) {});
} }
void _handleDraftChanged() { void _handleDraftChanged() {
@@ -112,10 +270,31 @@ class _InputSyncPageState extends State<InputSyncPage>
}); });
} }
Future<void> _scanForDevices() async {
if (scanning) {
return;
}
setState(() {
scanning = true;
});
final devices = await api.startDiscovery();
if (!mounted) {
return;
}
setState(() {
scanning = false;
discoveredDevices = devices;
});
}
Future<void> _connect() async { Future<void> _connect() async {
final host = hostController.text.trim(); final host = hostController.text.trim();
final port = portController.text.trim(); final portText = portController.text.trim();
if (host.isEmpty || port.isEmpty) { final port = int.tryParse(portText);
if (host.isEmpty || port == null) {
_showToast('请先填写 Windows IP 和端口'); _showToast('请先填写 Windows IP 和端口');
setState(() { setState(() {
headerExpanded = true; headerExpanded = true;
@@ -125,55 +304,40 @@ class _InputSyncPageState extends State<InputSyncPage>
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_hostKey, host); await prefs.setString(_hostKey, host);
await prefs.setString(_portKey, port); await prefs.setString(_portKey, port.toString());
await channelSubscription?.cancel();
await channel?.sink.close();
try {
final nextChannel = WebSocketChannel.connect(
Uri.parse('ws://$host:$port'),
);
channel = nextChannel;
channelSubscription = nextChannel.stream.listen(
(_) {},
onError: (_) => _markDisconnected(expandHeader: true, message: '连接已断开'),
onDone: () => _markDisconnected(expandHeader: true, message: '连接已断开'),
);
setState(() { setState(() {
connected = true; connection = ConnectionSnapshot(connecting: true, host: host, port: port);
headerExpanded = false; });
try {
await api.connect(host, port);
} on PlatformException catch (exception) {
_showToast(exception.message ?? '连接失败,请检查地址');
setState(() {
connection = ConnectionSnapshot(host: host, port: port);
headerExpanded = true;
}); });
_showToast('成功连接到 Windows 桌面端');
inputFocusNode.requestFocus();
} catch (_) {
_markDisconnected(expandHeader: true, message: '连接失败,请检查地址');
} }
} }
Future<void> _connectDevice(DiscoveredDevice device) async {
hostController.text = device.host;
portController.text = device.port.toString();
await _connect();
}
Future<void> _disconnect() async { Future<void> _disconnect() async {
await channelSubscription?.cancel(); await api.disconnect();
await channel?.sink.close();
_markDisconnected(expandHeader: true, message: '已断开连接');
}
void _markDisconnected({required bool expandHeader, String? message}) {
if (!mounted) {
return;
}
channel = null;
channelSubscription = null;
setState(() { setState(() {
connected = false; connection = ConnectionSnapshot(
headerExpanded = expandHeader; host: hostController.text.trim(),
port: int.tryParse(portController.text.trim()) ?? 8181,
);
headerExpanded = true;
sendButtonState = SendButtonState.idle; sendButtonState = SendButtonState.idle;
}); });
_showToast('已断开连接');
if (message != null) {
_showToast(message);
}
} }
Future<void> _sendCurrentText() async { Future<void> _sendCurrentText() async {
@@ -181,23 +345,23 @@ class _InputSyncPageState extends State<InputSyncPage>
return; return;
} }
final channel = this.channel;
final draft = inputController.text; final draft = inputController.text;
final textToSend = appendEnter ? '$draft\n' : draft;
if (channel == null) {
_markDisconnected(expandHeader: true, message: '未连接到桌面端');
return;
}
setState(() { setState(() {
sendButtonState = SendButtonState.sending; sendButtonState = SendButtonState.sending;
}); });
try { try {
channel.sink.add( final sent = await api.sendText(draft);
jsonEncode({'action': 'replaceAll', 'text': textToSend}), final enterSent = !sent || !appendEnter ? true : await api.sendEnter();
); if (!sent || !enterSent) {
_showToast('发送失败,连接已断开');
setState(() {
sendButtonState = SendButtonState.idle;
headerExpanded = true;
});
return;
}
await _saveHistory(draft); await _saveHistory(draft);
if (clearAfterSend) { if (clearAfterSend) {
@@ -218,8 +382,12 @@ class _InputSyncPageState extends State<InputSyncPage>
}); });
inputFocusNode.requestFocus(); inputFocusNode.requestFocus();
} }
} catch (_) { } on PlatformException {
_markDisconnected(expandHeader: true, message: '发送失败,连接已断开'); _showToast('发送失败,连接已断开');
setState(() {
sendButtonState = SendButtonState.idle;
headerExpanded = true;
});
} }
} }
@@ -351,7 +519,7 @@ class _InputSyncPageState extends State<InputSyncPage>
} }
bool get _canSend { bool get _canSend {
return connected && return connection.connected &&
inputController.text.isNotEmpty && inputController.text.isNotEmpty &&
sendButtonState != SendButtonState.sending; sendButtonState != SendButtonState.sending;
} }
@@ -374,8 +542,10 @@ class _InputSyncPageState extends State<InputSyncPage>
Widget _buildHeader(BuildContext context) { Widget _buildHeader(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final statusText = connected final statusText = connection.connected
? '已连接: ${hostController.text.trim()}:${portController.text.trim()}' ? '已连接: ${connection.host}:${connection.port}'
: connection.connecting
? '正在连接: ${connection.host}:${connection.port}'
: '未连接 (点击配置)'; : '未连接 (点击配置)';
return Material( return Material(
@@ -400,7 +570,8 @@ class _InputSyncPageState extends State<InputSyncPage>
child: Row( child: Row(
children: [ children: [
_ConnectionIndicator( _ConnectionIndicator(
connected: connected, connected: connection.connected,
connecting: connection.connecting,
animation: statusPulseController, animation: statusPulseController,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -409,8 +580,10 @@ class _InputSyncPageState extends State<InputSyncPage>
statusText, statusText,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: connected color: connection.connected
? const Color(0xFF166534) ? const Color(0xFF166534)
: connection.connecting
? colorScheme.primary
: colorScheme.error, : colorScheme.error,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -429,7 +602,9 @@ class _InputSyncPageState extends State<InputSyncPage>
firstChild: const SizedBox.shrink(), firstChild: const SizedBox.shrink(),
secondChild: Padding( secondChild: Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Row( child: Column(
children: [
Row(
children: [ children: [
Expanded( Expanded(
flex: 7, flex: 7,
@@ -458,16 +633,28 @@ class _InputSyncPageState extends State<InputSyncPage>
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.filledTonal( IconButton.filledTonal(
tooltip: connected ? '断开连接' : '连接', tooltip: connection.connected ? '断开连接' : '连接',
style: IconButton.styleFrom( style: IconButton.styleFrom(
minimumSize: const Size(48, 48), minimumSize: const Size(48, 48),
foregroundColor: connected foregroundColor: connection.connected
? colorScheme.error ? colorScheme.error
: colorScheme.primary, : colorScheme.primary,
), ),
onPressed: connected ? _disconnect : _connect, onPressed: connection.connecting
icon: Icon(connected ? Icons.link_off : Icons.link), ? null
: connection.connected
? _disconnect
: _connect,
icon: Icon(
connection.connected
? Icons.link_off
: Icons.link,
), ),
),
],
),
const SizedBox(height: 12),
_buildRadar(context),
], ],
), ),
), ),
@@ -484,9 +671,147 @@ class _InputSyncPageState extends State<InputSyncPage>
); );
} }
Widget _buildRadar(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AnimatedBuilder(
animation: radarPulseController,
builder: (context, child) {
final scale = scanning
? 0.85 + radarPulseController.value * 0.3
: 1.0;
return Transform.scale(
scale: scale,
child: Icon(
Icons.radar,
color: scanning
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
);
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
'局域网设备雷达',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
),
TextButton.icon(
onPressed: scanning ? null : _scanForDevices,
icon: scanning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: Text(scanning ? '扫描中' : '刷新'),
),
],
),
const SizedBox(height: 8),
if (discoveredDevices.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Text(
scanning ? '正在寻找附近的 Windows 桌面端...' : '暂无发现,可刷新或使用上方 IP 手动连接',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
)
else
...discoveredDevices.map((device) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: connection.connecting
? null
: () => _connectDevice(device),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
const Icon(Icons.desktop_windows_outlined),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
),
),
Text(
device.endpoint,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'可连接',
style: TextStyle(
color: Color(0xFF166534),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
);
}),
],
);
}
Widget _buildDraftBoard(BuildContext context) { Widget _buildDraftBoard(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxHeight < 72) {
return const SizedBox.shrink();
}
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Column( child: Column(
@@ -573,6 +898,8 @@ class _InputSyncPageState extends State<InputSyncPage>
], ],
), ),
); );
},
);
} }
Widget _buildFooter(BuildContext context) { Widget _buildFooter(BuildContext context) {
@@ -655,24 +982,28 @@ class _InputSyncPageState extends State<InputSyncPage>
class _ConnectionIndicator extends StatelessWidget { class _ConnectionIndicator extends StatelessWidget {
const _ConnectionIndicator({ const _ConnectionIndicator({
required this.connected, required this.connected,
required this.connecting,
required this.animation, required this.animation,
}); });
final bool connected; final bool connected;
final bool connecting;
final Animation<double> animation; final Animation<double> animation;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!connected) { final color = connected
return const _StatusDot(color: Color(0xFFDC2626), scale: 1); ? const Color(0xFF16A34A)
} : connecting
? Theme.of(context).colorScheme.primary
: const Color(0xFFDC2626);
return AnimatedBuilder( return AnimatedBuilder(
animation: animation, animation: animation,
builder: (context, child) { builder: (context, child) {
return _StatusDot( return _StatusDot(
color: const Color(0xFF16A34A), color: color,
scale: animation.value, scale: connected || connecting ? animation.value : 1,
); );
}, },
); );

View File

@@ -41,14 +41,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -317,14 +309,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.10"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -349,22 +333,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@@ -34,7 +34,6 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
web_socket_channel: ^3.0.3
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
dev_dependencies: dev_dependencies:

View File

@@ -6,20 +6,65 @@
// tree, read text, and verify that the values of widget properties are correct. // tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:wireless_text_syncer_android/main.dart'; import 'package:wireless_text_syncer_android/main.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const connectionChannel = MethodChannel('wireless_text_syncer/connection');
const stateChannel = MethodChannel('wireless_text_syncer/connection_state');
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(connectionChannel, (call) async {
switch (call.method) {
case 'getState':
return {
'connected': false,
'connecting': false,
'host': '',
'port': 8181,
'lastError': null,
};
case 'startDiscovery':
return [
{'name': 'Desktop-WIN11', 'host': '192.168.1.10', 'port': 8181},
];
case 'connect':
case 'disconnect':
return null;
case 'sendText':
return true;
}
return null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(stateChannel, (call) async => null);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(connectionChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(stateChannel, null);
});
testWidgets('shows v2 draft board controls', (WidgetTester tester) async { testWidgets('shows v2 draft board controls', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const WirelessTextSyncerApp()); await tester.pumpWidget(const WirelessTextSyncerApp());
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(seconds: 2));
expect(find.text('未连接 (点击配置)'), findsOneWidget); expect(find.text('未连接 (点击配置)'), findsOneWidget);
expect(find.text('IP 地址'), findsOneWidget); expect(find.text('IP 地址'), findsOneWidget);
expect(find.text('端口'), findsOneWidget); expect(find.text('端口'), findsOneWidget);
expect(find.text('局域网设备雷达'), findsOneWidget);
expect(find.text('Desktop-WIN11'), findsOneWidget);
expect(find.text('可连接'), findsOneWidget);
expect(find.text('草稿板'), findsOneWidget); expect(find.text('草稿板'), findsOneWidget);
expect(find.text('发送后清空'), findsOneWidget); expect(find.text('发送后清空'), findsOneWidget);
expect(find.text('追加回车'), findsOneWidget); expect(find.text('追加回车'), findsOneWidget);