Implement Android v2 quick send UX

This commit is contained in:
Misaka
2026-05-16 20:36:13 +08:00
parent 4d37f264bf
commit fe5cb9af37
15 changed files with 1285 additions and 222 deletions

View File

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