1100 lines
34 KiB
Dart
1100 lines
34 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
runApp(const WirelessTextSyncerApp());
|
|
}
|
|
|
|
class WirelessTextSyncerApp extends StatelessWidget {
|
|
const WirelessTextSyncerApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
title: 'WirelessTextSyncer',
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)),
|
|
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
|
|
useMaterial3: true,
|
|
),
|
|
home: const InputSyncPage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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});
|
|
|
|
@override
|
|
State<InputSyncPage> createState() => _InputSyncPageState();
|
|
}
|
|
|
|
class _InputSyncPageState extends State<InputSyncPage>
|
|
with TickerProviderStateMixin {
|
|
static const _hostKey = 'server_host';
|
|
static const _portKey = 'server_port';
|
|
static const _historyKey = 'send_history';
|
|
static const _clearAfterSendKey = 'clear_after_send';
|
|
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;
|
|
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 headerExpanded = true;
|
|
bool inputFocused = false;
|
|
bool scanning = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
statusPulseController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1300),
|
|
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() {
|
|
stateSubscription?.cancel();
|
|
statusPulseController.dispose();
|
|
radarPulseController.dispose();
|
|
hostController.dispose();
|
|
portController.dispose();
|
|
inputController.dispose();
|
|
inputFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadSavedState() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final nativeState = await api.getState();
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
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() {
|
|
setState(() {});
|
|
}
|
|
|
|
void _handleFocusChanged() {
|
|
setState(() {
|
|
inputFocused = inputFocusNode.hasFocus;
|
|
});
|
|
}
|
|
|
|
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 portText = portController.text.trim();
|
|
final port = int.tryParse(portText);
|
|
if (host.isEmpty || port == null) {
|
|
_showToast('请先填写 Windows IP 和端口');
|
|
setState(() {
|
|
headerExpanded = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_hostKey, host);
|
|
await prefs.setString(_portKey, port.toString());
|
|
|
|
setState(() {
|
|
connection = ConnectionSnapshot(connecting: true, host: host, port: port);
|
|
});
|
|
|
|
try {
|
|
await api.connect(host, port);
|
|
} on PlatformException catch (exception) {
|
|
_showToast(exception.message ?? '连接失败,请检查地址');
|
|
setState(() {
|
|
connection = ConnectionSnapshot(host: host, port: port);
|
|
headerExpanded = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _connectDevice(DiscoveredDevice device) async {
|
|
hostController.text = device.host;
|
|
portController.text = device.port.toString();
|
|
await _connect();
|
|
}
|
|
|
|
Future<void> _disconnect() async {
|
|
await api.disconnect();
|
|
setState(() {
|
|
connection = ConnectionSnapshot(
|
|
host: hostController.text.trim(),
|
|
port: int.tryParse(portController.text.trim()) ?? 8181,
|
|
);
|
|
headerExpanded = true;
|
|
sendButtonState = SendButtonState.idle;
|
|
});
|
|
_showToast('已断开连接');
|
|
}
|
|
|
|
Future<void> _sendCurrentText() async {
|
|
if (!_canSend) {
|
|
return;
|
|
}
|
|
|
|
final draft = inputController.text;
|
|
final textToSend = appendEnter ? '$draft\n' : draft;
|
|
|
|
setState(() {
|
|
sendButtonState = SendButtonState.sending;
|
|
});
|
|
|
|
try {
|
|
final sent = await api.sendText(textToSend);
|
|
if (!sent) {
|
|
_showToast('发送失败,连接已断开');
|
|
setState(() {
|
|
sendButtonState = SendButtonState.idle;
|
|
headerExpanded = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
await _saveHistory(draft);
|
|
|
|
if (clearAfterSend) {
|
|
inputController.clear();
|
|
}
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
sendButtonState = SendButtonState.success;
|
|
});
|
|
await Future<void>.delayed(const Duration(milliseconds: 1500));
|
|
if (mounted) {
|
|
setState(() {
|
|
sendButtonState = SendButtonState.idle;
|
|
});
|
|
inputFocusNode.requestFocus();
|
|
}
|
|
} on PlatformException {
|
|
_showToast('发送失败,连接已断开');
|
|
setState(() {
|
|
sendButtonState = SendButtonState.idle;
|
|
headerExpanded = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _saveHistory(String text) async {
|
|
final trimmed = text.trim();
|
|
if (trimmed.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final nextHistory = [
|
|
text,
|
|
...sendHistory.where((item) => item != text),
|
|
].take(_maxHistoryItems).toList();
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList(_historyKey, nextHistory);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
sendHistory = nextHistory;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _setClearAfterSend(bool value) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(_clearAfterSendKey, value);
|
|
setState(() {
|
|
clearAfterSend = value;
|
|
});
|
|
}
|
|
|
|
Future<void> _setAppendEnter(bool value) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(_appendEnterKey, value);
|
|
setState(() {
|
|
appendEnter = value;
|
|
});
|
|
}
|
|
|
|
void _restoreHistory(String text) {
|
|
inputController.text = text;
|
|
inputController.selection = TextSelection.collapsed(offset: text.length);
|
|
Navigator.of(context).pop();
|
|
_showToast('已从历史记录恢复');
|
|
inputFocusNode.requestFocus();
|
|
}
|
|
|
|
void _showHistorySheet() {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
isScrollControlled: true,
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
builder: (context) {
|
|
return SafeArea(
|
|
child: SizedBox(
|
|
height: MediaQuery.of(context).size.height * 0.58,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 12, 10),
|
|
child: Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'发送历史',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: '关闭',
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: sendHistory.isEmpty
|
|
? const _HistoryEmptyState()
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 20),
|
|
itemBuilder: (context, index) {
|
|
final item = sendHistory[index];
|
|
return ListTile(
|
|
minVerticalPadding: 12,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
tileColor: const Color(0xFFF8FAFC),
|
|
title: Text(
|
|
item,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Text('${item.length} 字符'),
|
|
onTap: () => _restoreHistory(item),
|
|
);
|
|
},
|
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
|
itemCount: sendHistory.length,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showToast(String message) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
content: Text(message),
|
|
duration: const Duration(milliseconds: 1600),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool get _canSend {
|
|
return connection.connected &&
|
|
inputController.text.isNotEmpty &&
|
|
sendButtonState != SendButtonState.sending;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
resizeToAvoidBottomInset: true,
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(context),
|
|
Expanded(child: _buildDraftBoard(context)),
|
|
_buildFooter(context),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final statusText = connection.connected
|
|
? '已连接: ${connection.host}:${connection.port}'
|
|
: connection.connecting
|
|
? '正在连接: ${connection.host}:${connection.port}'
|
|
: '未连接 (点击配置)';
|
|
|
|
return Material(
|
|
color: colorScheme.surface,
|
|
elevation: 1,
|
|
child: AnimatedSize(
|
|
duration: const Duration(milliseconds: 260),
|
|
curve: Curves.easeOutCubic,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
|
child: Column(
|
|
children: [
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () {
|
|
setState(() {
|
|
headerExpanded = !headerExpanded;
|
|
});
|
|
},
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: Row(
|
|
children: [
|
|
_ConnectionIndicator(
|
|
connected: connection.connected,
|
|
connecting: connection.connecting,
|
|
animation: statusPulseController,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
statusText,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: connection.connected
|
|
? const Color(0xFF166534)
|
|
: connection.connecting
|
|
? colorScheme.primary
|
|
: colorScheme.error,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
headerExpanded
|
|
? Icons.keyboard_arrow_up
|
|
: Icons.keyboard_arrow_down,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
AnimatedCrossFade(
|
|
firstChild: const SizedBox.shrink(),
|
|
secondChild: Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 7,
|
|
child: TextField(
|
|
controller: hostController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'IP 地址',
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
keyboardType: TextInputType.url,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 3,
|
|
child: TextField(
|
|
controller: portController,
|
|
decoration: const InputDecoration(
|
|
labelText: '端口',
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
crossFadeState: headerExpanded
|
|
? CrossFadeState.showSecond
|
|
: CrossFadeState.showFirst,
|
|
duration: const Duration(milliseconds: 260),
|
|
sizeCurve: Curves.easeOutCubic,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 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,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildFooter(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Material(
|
|
color: colorScheme.surface,
|
|
elevation: 4,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _FooterToggle(
|
|
label: '发送后清空',
|
|
value: clearAfterSend,
|
|
onChanged: _setClearAfterSend,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _FooterToggle(
|
|
label: '追加回车',
|
|
value: appendEnter,
|
|
onChanged: _setAppendEnter,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
height: 52,
|
|
child: FilledButton.icon(
|
|
onPressed: _canSend ? _sendCurrentText : null,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: sendButtonState == SendButtonState.success
|
|
? const Color(0xFF16A34A)
|
|
: null,
|
|
disabledBackgroundColor: const Color(0xFFE2E8F0),
|
|
disabledForegroundColor: const Color(0xFF64748B),
|
|
),
|
|
icon: _buildSendButtonIcon(),
|
|
label: Text(
|
|
_sendButtonText,
|
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSendButtonIcon() {
|
|
return switch (sendButtonState) {
|
|
SendButtonState.sending => const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
SendButtonState.success => const Icon(Icons.check),
|
|
SendButtonState.idle => const Icon(Icons.send),
|
|
};
|
|
}
|
|
|
|
String get _sendButtonText {
|
|
return switch (sendButtonState) {
|
|
SendButtonState.sending => '发送中...',
|
|
SendButtonState.success => '发送成功',
|
|
SendButtonState.idle => '发送到电脑',
|
|
};
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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: color,
|
|
scale: connected || connecting ? animation.value : 1,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusDot extends StatelessWidget {
|
|
const _StatusDot({required this.color, required this.scale});
|
|
|
|
final Color color;
|
|
final double scale;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Transform.scale(
|
|
scale: scale,
|
|
child: Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withValues(alpha: 0.24),
|
|
blurRadius: 8,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FooterToggle extends StatelessWidget {
|
|
const _FooterToggle({
|
|
required this.label,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final String label;
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () => onChanged(!value),
|
|
child: Container(
|
|
constraints: const BoxConstraints(minHeight: 44),
|
|
padding: const EdgeInsets.only(left: 10),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
Switch.adaptive(value: value, onChanged: onChanged),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryEmptyState extends StatelessWidget {
|
|
const _HistoryEmptyState();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.history_toggle_off,
|
|
size: 56,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.45),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text('暂无发送记录', style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|