import 'dart:async'; import 'package:flutter/material.dart'; import '../agent.dart'; import '../server/protocol.dart'; /// 饭卡打印机插件页:主状态一眼可懂,技术参数收进"技术详情"。 class CardPrinterPage extends StatefulWidget { const CardPrinterPage({super.key, required this.agent}); final Agent agent; @override State createState() => _CardPrinterPageState(); } class _CardPrinterPageState extends State { PrinterStatus? _status; String? _statusError; bool _loading = false; StreamSubscription? _logSub; final _logs = []; Agent get _agent => widget.agent; @override void initState() { super.initState(); _logSub = _agent.log.stream.listen((line) { if (mounted) setState(() => _logs.add(line)); }); _logs.addAll(_agent.log.lines); _refreshStatus(); } @override void dispose() { _logSub?.cancel(); super.dispose(); } Future _refreshStatus() async { setState(() => _loading = true); try { final status = await _agent.backend.getStatus(); if (!mounted) return; setState(() { _status = status; _statusError = null; }); } catch (e) { if (!mounted) return; setState(() => _statusError = '$e'); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Scaffold( backgroundColor: scheme.surface, body: SelectionArea( child: CustomScrollView( slivers: [ SliverPadding( padding: const EdgeInsets.fromLTRB(28, 24, 28, 24), sliver: SliverList( delegate: SliverChildListDelegate([ _header(context), const SizedBox(height: 20), _statusHeroCard(context), const SizedBox(height: 14), _metricsRow(context), const SizedBox(height: 14), _techDetailsCard(context), ]), ), ), ], ), ), ); } // ---------------- 头部:插件名 + 服务徽章 + 刷新 ---------------- Widget _header(BuildContext context) { final scheme = Theme.of(context).colorScheme; final serviceOnline = _agent.port != null; return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('饭卡打印机', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 2), Text( '管理端开卡页提交的卡面会自动发送到本机打印机', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: scheme.onSurfaceVariant), ), ], ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: serviceOnline ? scheme.primaryContainer : scheme.errorContainer, borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( serviceOnline ? Icons.check_circle : Icons.error_outline, size: 14, color: serviceOnline ? scheme.primary : scheme.error, ), const SizedBox(width: 6), Text( serviceOnline ? '服务运行中' : '服务未运行', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: serviceOnline ? scheme.onPrimaryContainer : scheme.onErrorContainer, ), ), ], ), ), const SizedBox(width: 10), IconButton.filledTonal( onPressed: _loading ? null : _refreshStatus, tooltip: '刷新打印机状态', icon: _loading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), ), ], ); } // ---------------- 状态主卡:一眼看懂 ---------------- /// 主状态归纳:普通用户只需要知道"能不能用、现在干嘛"。 _Headline _headline() { final s = _status; if (s == null) { if (_statusError != null) { return _Headline('状态读取失败', _StateColor.attention, Icons.help_outline_rounded); } return _Headline('正在获取状态…', _StateColor.idle, Icons.hourglass_empty); } if (!s.online) { return _Headline('打印机未连接', _StateColor.error, Icons.print_disabled); } if (s.status == 0) { return _Headline('打印机就绪', _StateColor.ok, Icons.print_rounded); } if (s.status == 0x00000002 || s.status == 0x00000005 || s.status == 0x00000006) { return _Headline('正在打印', _StateColor.ok, Icons.print_rounded); } return _Headline('需要处理', _StateColor.attention, Icons.report_problem_outlined); } Widget _statusHeroCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; final head = _headline(); final s = _status; final color = switch (head.color) { _StateColor.ok => scheme.primary, _StateColor.attention => const Color(0xFFD97706), _StateColor.error => scheme.error, _StateColor.idle => scheme.onSurfaceVariant, }; return Card( margin: EdgeInsets.zero, elevation: 0, color: scheme.surfaceContainerLow, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: scheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.all(20), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 64, height: 64, decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(16), ), child: Icon(head.icon, size: 34, color: color), ), const SizedBox(width: 18), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(head.label, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 4), Text( s == null ? (_statusError ?? '正在读取打印机状态') : (s.statusText.isEmpty ? '—' : s.statusText), style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: scheme.onSurfaceVariant), ), if (s != null && s.model.isNotEmpty) ...[ const SizedBox(height: 4), Text( s.model, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: scheme.onSurfaceVariant, fontWeight: FontWeight.w500, ), ), ], ], ), ), ], ), ), ); } // ---------------- 指标行:色带 / 卡量(生活化表述) ---------------- Widget _metricsRow(BuildContext context) { // SliverList 内高度无界,stretch 需先经 IntrinsicHeight 拿到有界高度, // 否则子项会收到 h=Infinity 的约束直接布局断言失败 return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(child: _ribbonCard(context)), const SizedBox(width: 14), Expanded(child: _cardStockCard(context)), ], ), ); } Widget _ribbonCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; final s = _status; final remain = s?.ribbon.remain ?? -1; final known = remain >= 0; final color = !known ? scheme.onSurfaceVariant : remain <= 10 ? scheme.error : remain <= 30 ? const Color(0xFFD97706) : scheme.primary; return _MetricCard( icon: Icons.style_outlined, title: '色带余量', child: known ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '$remain%', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(color: color, fontWeight: FontWeight.w700), ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: remain / 100, minHeight: 6, backgroundColor: scheme.surfaceContainerHighest, color: color, ), ), if (remain <= 10) Padding( padding: const EdgeInsets.only(top: 6), child: Text( '色带即将用尽,请准备更换', style: Theme.of(context) .textTheme .bodySmall ?.copyWith(color: color), ), ), ], ) : _unknownText(context, '由打印机驱动提供,当前不可读'), ); } Widget _cardStockCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; final s = _status; final left = s?.cardLeft ?? -1; final low = s?.cardLow ?? false; final known = left >= 0; final color = low ? const Color(0xFFD97706) : scheme.onSurfaceVariant; return _MetricCard( icon: Icons.layers_outlined, title: '进卡仓卡量', child: known ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '剩余 $left 张', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), if (low) Padding( padding: const EdgeInsets.only(top: 6), child: Text( '卡量不足,请及时补充空白卡', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color), ), ), ], ) : _unknownText(context, '由打印机驱动提供,当前不可读'), ); } Widget _unknownText(BuildContext context, String hint) => Builder( builder: (context) { final scheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '未知', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(color: scheme.onSurfaceVariant), ), const SizedBox(height: 6), Text( hint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: scheme.onSurfaceVariant, ), ), ], ); }, ); // ---------------- 技术详情(折叠收纳) ---------------- Widget _techDetailsCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; final s = _status; return Card( margin: EdgeInsets.zero, elevation: 0, color: scheme.surfaceContainerLow, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: scheme.outlineVariant), ), clipBehavior: Clip.antiAlias, child: Theme( // 去掉 ExpansionTile 默认分割线密度,保持卡片内干净 data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 16), controlAffinity: ListTileControlAffinity.trailing, title: Text('技术详情', style: Theme.of(context).textTheme.titleSmall), subtitle: Text( '服务地址、后端模式、设备信息与运行日志', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: scheme.onSurfaceVariant, ), ), children: [ Align( alignment: Alignment.centerLeft, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _DetailSection(title: '本机服务', rows: [ ('运行状态', _agent.port != null ? '运行中' : '未运行'), ('服务地址', _agent.port != null ? 'http://127.0.0.1:${_agent.port}' : '—'), ('打印通道', _agent.options.mock ? '模拟(测试用)' : '系统打印通道'), ]), const SizedBox(height: 14), _DetailSection(title: '设备信息', rows: [ ('型号', s == null || s.model.isEmpty ? '—' : s.model), ('固件版本', s != null && s.firmware.isNotEmpty ? s.firmware : '—'), ('连接方式', s?.connection ?? '—'), ('状态码', s == null ? '—' : '0x${s.status.toRadixString(16).padLeft(8, '0').toUpperCase()}'), ('色带类型', s != null && s.ribbon.type.isNotEmpty ? s.ribbon.type : '—'), ('色带剩余张数', s != null && s.ribbon.remainCount >= 0 ? '${s.ribbon.remainCount} 张' : '—'), ]), const SizedBox(height: 14), Text('运行日志(最近 50 条)', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: scheme.onSurfaceVariant, )), const SizedBox(height: 6), Container( width: double.infinity, constraints: const BoxConstraints(maxHeight: 220), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: _logs.isEmpty ? Text('(暂无日志)', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)) : ListView( children: [ for (final line in (_logs.length > 50 ? _logs.sublist(_logs.length - 50) : _logs)) Text( line, style: TextStyle( fontFamily: 'monospace', fontSize: 11.5, height: 1.5, color: scheme.onSurfaceVariant, ), ), ], ), ), ], ), ), ), ], ), ), ); } } /// 指标小卡容器。 class _MetricCard extends StatelessWidget { const _MetricCard({required this.icon, required this.title, required this.child}); final IconData icon; final String title; final Widget child; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Card( margin: EdgeInsets.zero, elevation: 0, color: scheme.surfaceContainerLow, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: scheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 16, color: scheme.onSurfaceVariant), const SizedBox(width: 6), Text(title, style: Theme.of(context).textTheme.labelMedium), ], ), const SizedBox(height: 10), child, ], ), ), ); } } /// 详情分组:标题 + 标签值行。 class _DetailSection extends StatelessWidget { const _DetailSection({required this.title, required this.rows}); final String title; final List<(String, String)> rows; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: scheme.onSurfaceVariant, )), const SizedBox(height: 6), Table( columnWidths: const { 0: FixedColumnWidth(120), 1: FlexColumnWidth(), }, defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ for (final (label, value) in rows) TableRow(children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: scheme.onSurfaceVariant, ), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Text(value, style: Theme.of(context).textTheme.bodySmall), ), ]), ], ), ], ); } } enum _StateColor { ok, attention, error, idle } class _Headline { const _Headline(this.label, this.color, this.icon); final String label; final _StateColor color; final IconData icon; }