Files
android/lib/main.dart

1214 lines
37 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 }
const connectionInfoFontFamily = 'WDXL Lubrifont SC';
class ConnectionSnapshot {
const ConnectionSnapshot({
this.connected = false,
this.connecting = false,
this.host = '',
this.port = 8181,
this.name = '',
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,
name: (map['name'] as String?) ?? '',
lastError: map['lastError'] as String?,
);
}
final bool connected;
final bool connecting;
final String host;
final int port;
final String name;
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, {String? name}) async {
await _methodChannel.invokeMethod<void>('connect', {
'host': host,
'port': port,
if (name != null) 'name': name,
});
}
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<bool> sendEnter() async {
final result = await _methodChannel.invokeMethod<bool>('sendEnter');
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;
});
if (devices.length == 1 &&
!connection.connected &&
!connection.connecting) {
await _connectDevice(devices.single);
}
}
Future<void> _connect({String? name}) 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,
name: name ?? '',
);
});
try {
await api.connect(host, port, name: name);
} on PlatformException catch (exception) {
_showToast(exception.message ?? '连接失败,请检查地址');
setState(() {
connection = ConnectionSnapshot(
host: host,
port: port,
name: name ?? '',
);
headerExpanded = true;
});
}
}
Future<void> _connectDevice(DiscoveredDevice device) async {
hostController.text = device.host;
portController.text = device.port.toString();
await _connect(name: device.name);
}
Future<void> _disconnect() async {
await api.disconnect();
setState(() {
connection = ConnectionSnapshot(
host: hostController.text.trim(),
port: int.tryParse(portController.text.trim()) ?? 8181,
name: connection.name,
);
headerExpanded = true;
sendButtonState = SendButtonState.idle;
});
_showToast('已断开连接');
}
Future<void> _sendCurrentText() async {
if (!_canSend) {
return;
}
final draft = inputController.text;
setState(() {
sendButtonState = SendButtonState.sending;
});
try {
final sent = await api.sendText(draft);
final enterSent = !sent || !appendEnter ? true : await api.sendEnter();
if (!sent || !enterSent) {
_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;
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: 52,
child: Row(
children: [
Expanded(
child: _ConnectionSummary(
connected: connection.connected,
connecting: connection.connecting,
endpoint: connection.host.isEmpty
? '未设置地址'
: '${connection.host}:${connection.port}',
deviceName: connection.name.isEmpty
? 'Windows 桌面端'
: connection.name,
animation: statusPulseController,
),
),
const SizedBox(width: 8),
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 _ConnectionSummary extends StatelessWidget {
const _ConnectionSummary({
required this.connected,
required this.connecting,
required this.endpoint,
required this.deviceName,
required this.animation,
});
final bool connected;
final bool connecting;
final String endpoint;
final String deviceName;
final Animation<double> animation;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final iconColor = connected
? const Color(0xFF16A34A)
: connecting
? colorScheme.primary
: colorScheme.error;
final primaryTextColor = connected
? const Color(0xFF166534)
: const Color(0xFF0F172A);
final secondaryTextColor = connected
? const Color(0xFF15803D)
: colorScheme.onSurfaceVariant;
return Row(
children: [
AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.scale(
scale: connecting ? animation.value : 1,
child: Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
connected
? Icons.sync
: connecting
? Icons.sync
: Icons.sync_disabled,
color: iconColor,
size: 24,
),
),
);
},
),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 64,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
endpoint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: primaryTextColor,
fontFamily: connectionInfoFontFamily,
fontSize: 21,
fontWeight: FontWeight.w700,
height: 1.15,
),
),
const SizedBox(height: 3),
Text(
deviceName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: secondaryTextColor,
fontFamily: connectionInfoFontFamily,
fontSize: 18,
height: 1.15,
),
),
],
),
),
),
],
);
}
}
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)),
],
),
);
}
}