Implement Android v2 quick send UX
This commit is contained in:
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user