Implement Android v2 draft board experience

This commit is contained in:
Misaka
2026-05-16 08:46:16 +08:00
parent 8c127cbd6e
commit 4d37f264bf
2 changed files with 674 additions and 76 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
@@ -18,6 +19,7 @@ class WirelessTextSyncerApp extends StatelessWidget {
title: 'WirelessTextSyncer',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)),
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
useMaterial3: true,
),
home: const InputSyncPage(),
@@ -25,6 +27,8 @@ class WirelessTextSyncerApp extends StatelessWidget {
}
}
enum SendButtonState { idle, sending, success }
class InputSyncPage extends StatefulWidget {
const InputSyncPage({super.key});
@@ -32,28 +36,50 @@ class InputSyncPage extends StatefulWidget {
State<InputSyncPage> createState() => _InputSyncPageState();
}
class _InputSyncPageState extends State<InputSyncPage> {
class _InputSyncPageState extends State<InputSyncPage>
with SingleTickerProviderStateMixin {
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 hostController = TextEditingController();
final portController = TextEditingController(text: '8181');
final inputController = TextEditingController();
final inputFocusNode = FocusNode();
late final AnimationController statusPulseController;
WebSocketChannel? channel;
String lastSyncedText = '';
StreamSubscription<dynamic>? channelSubscription;
List<String> sendHistory = [];
SendButtonState sendButtonState = SendButtonState.idle;
bool appendEnter = false;
bool clearAfterSend = false;
bool connected = false;
bool headerExpanded = true;
bool inputFocused = false;
@override
void 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
void dispose() {
channelSubscription?.cancel();
channel?.sink.close();
statusPulseController.dispose();
hostController.dispose();
portController.dispose();
inputController.dispose();
@@ -61,16 +87,39 @@ class _InputSyncPageState extends State<InputSyncPage> {
super.dispose();
}
Future<void> _loadSavedConnection() async {
Future<void> _loadSavedState() async {
final prefs = await SharedPreferences.getInstance();
hostController.text = prefs.getString(_hostKey) ?? '';
portController.text = prefs.getString(_portKey) ?? '8181';
if (!mounted) {
return;
}
setState(() {
hostController.text = prefs.getString(_hostKey) ?? '';
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 {
final host = hostController.text.trim();
final port = portController.text.trim();
if (host.isEmpty || port.isEmpty) {
_showToast('请先填写 Windows IP 和端口');
setState(() {
headerExpanded = true;
});
return;
}
@@ -78,99 +127,501 @@ class _InputSyncPageState extends State<InputSyncPage> {
await prefs.setString(_hostKey, host);
await prefs.setString(_portKey, port);
channel?.sink.close();
channel = WebSocketChannel.connect(Uri.parse('ws://$host:$port'));
await channelSubscription?.cancel();
await channel?.sink.close();
setState(() {
connected = true;
});
inputFocusNode.requestFocus();
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(() {
connected = true;
headerExpanded = false;
});
_showToast('成功连接到 Windows 桌面端');
inputFocusNode.requestFocus();
} catch (_) {
_markDisconnected(expandHeader: true, message: '连接失败,请检查地址');
}
}
void _disconnect() {
channel?.sink.close();
channel = null;
setState(() {
connected = false;
});
Future<void> _disconnect() async {
await channelSubscription?.cancel();
await channel?.sink.close();
_markDisconnected(expandHeader: true, message: '已断开连接');
}
void _sendCurrentText() {
if (!connected || channel == null) {
void _markDisconnected({required bool expandHeader, String? message}) {
if (!mounted) {
return;
}
lastSyncedText = inputController.text;
_send('replaceAll', text: inputController.text);
channel = null;
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();
}
void _send(String action, {String? text}) {
channel?.sink.add(jsonEncode({'action': action, 'text': ?text}));
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 connected &&
inputController.text.isNotEmpty &&
sendButtonState != SendButtonState.sending;
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
_buildHeader(context),
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(
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: 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),
),
],
),
),
crossFadeState: headerExpanded
? 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(
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: [
Expanded(
flex: 3,
child: TextField(
controller: hostController,
decoration: const InputDecoration(
labelText: 'Windows IP',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
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,
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: portController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
isDense: true,
Positioned(
right: 14,
bottom: 10,
child: Text(
'${inputController.text.length} 字符',
style: TextStyle(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.72,
),
fontSize: 12,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: connected ? 'Disconnect' : 'Connect',
onPressed: connected ? _disconnect : _connect,
icon: Icon(connected ? Icons.link_off : Icons.link),
),
const SizedBox(width: 8),
IconButton.filled(
tooltip: 'Send',
onPressed: connected ? _sendCurrentText : null,
icon: const Icon(Icons.send),
),
],
),
),
Expanded(
child: TextField(
controller: inputController,
focusNode: inputFocusNode,
autofocus: true,
expands: true,
maxLines: null,
minLines: null,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(16),
hintText: 'Type here',
),
],
),
);
}
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)),
],
),
);
}
}

View File

@@ -6,15 +6,23 @@
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wireless_text_syncer_android/main.dart';
void main() {
testWidgets('shows connection controls', (WidgetTester tester) async {
await tester.pumpWidget(const WirelessTextSyncerApp());
testWidgets('shows v2 draft board controls', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
expect(find.text('Windows IP'), findsOneWidget);
expect(find.text('Port'), findsOneWidget);
expect(find.text('Type here'), findsOneWidget);
await tester.pumpWidget(const WirelessTextSyncerApp());
await tester.pump(const Duration(milliseconds: 100));
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);
});
}