Compare commits
1 Commits
8c127cbd6e
...
4d37f264bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d37f264bf |
656
lib/main.dart
656
lib/main.dart
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -18,6 +19,7 @@ class WirelessTextSyncerApp extends StatelessWidget {
|
|||||||
title: 'WirelessTextSyncer',
|
title: 'WirelessTextSyncer',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)),
|
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const InputSyncPage(),
|
home: const InputSyncPage(),
|
||||||
@@ -25,6 +27,8 @@ class WirelessTextSyncerApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SendButtonState { idle, sending, success }
|
||||||
|
|
||||||
class InputSyncPage extends StatefulWidget {
|
class InputSyncPage extends StatefulWidget {
|
||||||
const InputSyncPage({super.key});
|
const InputSyncPage({super.key});
|
||||||
|
|
||||||
@@ -32,28 +36,50 @@ class InputSyncPage extends StatefulWidget {
|
|||||||
State<InputSyncPage> createState() => _InputSyncPageState();
|
State<InputSyncPage> createState() => _InputSyncPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InputSyncPageState extends State<InputSyncPage> {
|
class _InputSyncPageState extends State<InputSyncPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
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 _clearAfterSendKey = 'clear_after_send';
|
||||||
|
static const _appendEnterKey = 'append_enter';
|
||||||
|
static const _maxHistoryItems = 10;
|
||||||
|
|
||||||
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;
|
||||||
WebSocketChannel? channel;
|
WebSocketChannel? channel;
|
||||||
String lastSyncedText = '';
|
StreamSubscription<dynamic>? channelSubscription;
|
||||||
|
List<String> sendHistory = [];
|
||||||
|
SendButtonState sendButtonState = SendButtonState.idle;
|
||||||
|
bool appendEnter = false;
|
||||||
|
bool clearAfterSend = false;
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
|
bool headerExpanded = true;
|
||||||
|
bool inputFocused = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadSavedConnection();
|
statusPulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1300),
|
||||||
|
lowerBound: 0.55,
|
||||||
|
upperBound: 1,
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
inputController.addListener(_handleDraftChanged);
|
||||||
|
inputFocusNode.addListener(_handleFocusChanged);
|
||||||
|
_loadSavedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
channelSubscription?.cancel();
|
||||||
channel?.sink.close();
|
channel?.sink.close();
|
||||||
|
statusPulseController.dispose();
|
||||||
hostController.dispose();
|
hostController.dispose();
|
||||||
portController.dispose();
|
portController.dispose();
|
||||||
inputController.dispose();
|
inputController.dispose();
|
||||||
@@ -61,16 +87,39 @@ class _InputSyncPageState extends State<InputSyncPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSavedConnection() async {
|
Future<void> _loadSavedState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
hostController.text = prefs.getString(_hostKey) ?? '';
|
hostController.text = prefs.getString(_hostKey) ?? '';
|
||||||
portController.text = prefs.getString(_portKey) ?? '8181';
|
portController.text = prefs.getString(_portKey) ?? '8181';
|
||||||
|
clearAfterSend = prefs.getBool(_clearAfterSendKey) ?? false;
|
||||||
|
appendEnter = prefs.getBool(_appendEnterKey) ?? false;
|
||||||
|
sendHistory = prefs.getStringList(_historyKey) ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDraftChanged() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChanged() {
|
||||||
|
setState(() {
|
||||||
|
inputFocused = inputFocusNode.hasFocus;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connect() async {
|
Future<void> _connect() async {
|
||||||
final host = hostController.text.trim();
|
final host = hostController.text.trim();
|
||||||
final port = portController.text.trim();
|
final port = portController.text.trim();
|
||||||
if (host.isEmpty || port.isEmpty) {
|
if (host.isEmpty || port.isEmpty) {
|
||||||
|
_showToast('请先填写 Windows IP 和端口');
|
||||||
|
setState(() {
|
||||||
|
headerExpanded = true;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,65 +127,329 @@ class _InputSyncPageState extends State<InputSyncPage> {
|
|||||||
await prefs.setString(_hostKey, host);
|
await prefs.setString(_hostKey, host);
|
||||||
await prefs.setString(_portKey, port);
|
await prefs.setString(_portKey, port);
|
||||||
|
|
||||||
channel?.sink.close();
|
await channelSubscription?.cancel();
|
||||||
channel = WebSocketChannel.connect(Uri.parse('ws://$host:$port'));
|
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;
|
connected = true;
|
||||||
|
headerExpanded = false;
|
||||||
});
|
});
|
||||||
|
_showToast('成功连接到 Windows 桌面端');
|
||||||
inputFocusNode.requestFocus();
|
inputFocusNode.requestFocus();
|
||||||
|
} catch (_) {
|
||||||
|
_markDisconnected(expandHeader: true, message: '连接失败,请检查地址');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _disconnect() {
|
Future<void> _disconnect() async {
|
||||||
channel?.sink.close();
|
await channelSubscription?.cancel();
|
||||||
channel = null;
|
await channel?.sink.close();
|
||||||
setState(() {
|
_markDisconnected(expandHeader: true, message: '已断开连接');
|
||||||
connected = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendCurrentText() {
|
void _markDisconnected({required bool expandHeader, String? message}) {
|
||||||
if (!connected || channel == null) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSyncedText = inputController.text;
|
channel = null;
|
||||||
_send('replaceAll', text: inputController.text);
|
channelSubscription = null;
|
||||||
|
setState(() {
|
||||||
|
connected = false;
|
||||||
|
headerExpanded = expandHeader;
|
||||||
|
sendButtonState = SendButtonState.idle;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
|
_showToast(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendCurrentText() async {
|
||||||
|
if (!_canSend) {
|
||||||
|
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}),
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_markDisconnected(expandHeader: true, message: '发送失败,连接已断开');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
inputFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _send(String action, {String? text}) {
|
void _showHistorySheet() {
|
||||||
channel?.sink.add(jsonEncode({'action': action, 'text': ?text}));
|
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 connected &&
|
||||||
|
inputController.text.isNotEmpty &&
|
||||||
|
sendButtonState != SendButtonState.sending;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: true,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
_buildHeader(context),
|
||||||
padding: const EdgeInsets.all(12),
|
Expanded(child: _buildDraftBoard(context)),
|
||||||
|
_buildFooter(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final statusText = connected
|
||||||
|
? '已连接: ${hostController.text.trim()}:${portController.text.trim()}'
|
||||||
|
: '未连接 (点击配置)';
|
||||||
|
|
||||||
|
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: connected,
|
||||||
|
animation: statusPulseController,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
statusText,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: connected
|
||||||
|
? const Color(0xFF166534)
|
||||||
|
: 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: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 7,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: hostController,
|
controller: hostController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Windows IP',
|
labelText: 'IP 地址',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.url,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: portController,
|
controller: portController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Port',
|
labelText: '端口',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
@@ -145,21 +458,87 @@ class _InputSyncPageState extends State<InputSyncPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
tooltip: connected ? 'Disconnect' : 'Connect',
|
tooltip: connected ? '断开连接' : '连接',
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
minimumSize: const Size(48, 48),
|
||||||
|
foregroundColor: connected
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.primary,
|
||||||
|
),
|
||||||
onPressed: connected ? _disconnect : _connect,
|
onPressed: connected ? _disconnect : _connect,
|
||||||
icon: Icon(connected ? Icons.link_off : Icons.link),
|
icon: Icon(connected ? Icons.link_off : Icons.link),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
IconButton.filled(
|
),
|
||||||
tooltip: 'Send',
|
),
|
||||||
onPressed: connected ? _sendCurrentText : null,
|
crossFadeState: headerExpanded
|
||||||
icon: const Icon(Icons.send),
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
sizeCurve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
Expanded(
|
||||||
child: TextField(
|
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,
|
controller: inputController,
|
||||||
focusNode: inputFocusNode,
|
focusNode: inputFocusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
@@ -169,8 +548,80 @@ class _InputSyncPageState extends State<InputSyncPage> {
|
|||||||
textAlignVertical: TextAlignVertical.top,
|
textAlignVertical: TextAlignVertical.top,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.all(16),
|
contentPadding: EdgeInsets.fromLTRB(16, 16, 16, 42),
|
||||||
hintText: 'Type here',
|
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -179,4 +630,143 @@ class _InputSyncPageState extends State<InputSyncPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.animation,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool connected;
|
||||||
|
final Animation<double> animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!connected) {
|
||||||
|
return const _StatusDot(color: Color(0xFFDC2626), scale: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return _StatusDot(
|
||||||
|
color: const Color(0xFF16A34A),
|
||||||
|
scale: animation.value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,23 @@
|
|||||||
// 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: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() {
|
||||||
testWidgets('shows connection controls', (WidgetTester tester) async {
|
testWidgets('shows v2 draft board controls', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const WirelessTextSyncerApp());
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
expect(find.text('Windows IP'), findsOneWidget);
|
await tester.pumpWidget(const WirelessTextSyncerApp());
|
||||||
expect(find.text('Port'), findsOneWidget);
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
expect(find.text('Type here'), findsOneWidget);
|
|
||||||
|
expect(find.text('未连接 (点击配置)'), findsOneWidget);
|
||||||
|
expect(find.text('IP 地址'), findsOneWidget);
|
||||||
|
expect(find.text('端口'), findsOneWidget);
|
||||||
|
expect(find.text('草稿板'), findsOneWidget);
|
||||||
|
expect(find.text('发送后清空'), findsOneWidget);
|
||||||
|
expect(find.text('追加回车'), findsOneWidget);
|
||||||
|
expect(find.text('发送到电脑'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user