diff --git a/lib/main.dart b/lib/main.dart index c1fb664..c6b717f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 createState() => _InputSyncPageState(); } -class _InputSyncPageState extends State { +class _InputSyncPageState extends State + 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? channelSubscription; + List 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 { super.dispose(); } - Future _loadSavedConnection() async { + Future _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 _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 { 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 _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 _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.delayed(const Duration(milliseconds: 1500)); + if (mounted) { + setState(() { + sendButtonState = SendButtonState.idle; + }); + inputFocusNode.requestFocus(); + } + } catch (_) { + _markDisconnected(expandHeader: true, message: '发送失败,连接已断开'); + } + } + + Future _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 _setClearAfterSend(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_clearAfterSendKey, value); + setState(() { + clearAfterSend = value; + }); + } + + Future _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( + 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 { ), ); } + + 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 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 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)), + ], + ), + ); + } } diff --git a/test/widget_test.dart b/test/widget_test.dart index bf8a482..1e4d0ae 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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); }); }