import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/web_socket_channel.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 InputSyncPage extends StatefulWidget { const InputSyncPage({super.key}); @override State createState() => _InputSyncPageState(); } 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; 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(); 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(); inputFocusNode.dispose(); super.dispose(); } Future _loadSavedState() async { final prefs = await SharedPreferences.getInstance(); 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; } final prefs = await SharedPreferences.getInstance(); await prefs.setString(_hostKey, host); await prefs.setString(_portKey, port); await channelSubscription?.cancel(); 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(() { connected = true; headerExpanded = false; }); _showToast('成功连接到 Windows 桌面端'); inputFocusNode.requestFocus(); } catch (_) { _markDisconnected(expandHeader: true, message: '连接失败,请检查地址'); } } Future _disconnect() async { await channelSubscription?.cancel(); await channel?.sink.close(); _markDisconnected(expandHeader: true, message: '已断开连接'); } void _markDisconnected({required bool expandHeader, String? message}) { if (!mounted) { return; } channel = null; channelSubscription = null; 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 _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: [ _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: [ 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.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)), ], ), ); } }