512 lines
16 KiB
Dart
512 lines
16 KiB
Dart
|
|
import 'dart:io';
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
|
||
|
|
import '../services/feedback_store.dart';
|
||
|
|
|
||
|
|
enum _FeedbackFilter { all, accurate, inaccurate }
|
||
|
|
|
||
|
|
/// Browse saved accurate / inaccurate scan feedback.
|
||
|
|
class FeedbackListScreen extends StatefulWidget {
|
||
|
|
const FeedbackListScreen({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<FeedbackListScreen> createState() => _FeedbackListScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _FeedbackListScreenState extends State<FeedbackListScreen> {
|
||
|
|
List<FeedbackEntry>? _items;
|
||
|
|
bool _loading = true;
|
||
|
|
_FeedbackFilter _filter = _FeedbackFilter.all;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_reload();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _reload() async {
|
||
|
|
setState(() => _loading = true);
|
||
|
|
final items = await FeedbackStore.instance.list();
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_items = items;
|
||
|
|
_loading = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _deleteItem(String id) async {
|
||
|
|
final confirmed = await showDialog<bool>(
|
||
|
|
context: context,
|
||
|
|
builder: (ctx) => AlertDialog(
|
||
|
|
title: const Text('删除反馈'),
|
||
|
|
content: const Text('确定要删除这条反馈记录吗?'),
|
||
|
|
actions: [
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||
|
|
child: const Text('取消'),
|
||
|
|
),
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||
|
|
child: const Text('删除', style: TextStyle(color: Colors.red)),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (confirmed != true) return;
|
||
|
|
await FeedbackStore.instance.delete(id);
|
||
|
|
await _reload();
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _confirmClearAll(BuildContext context) async {
|
||
|
|
final confirmed = await showDialog<bool>(
|
||
|
|
context: context,
|
||
|
|
builder: (ctx) => AlertDialog(
|
||
|
|
title: const Text('清空全部反馈'),
|
||
|
|
content: const Text('确定要删除所有反馈记录吗?此操作不可撤销。'),
|
||
|
|
actions: [
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||
|
|
child: const Text('取消'),
|
||
|
|
),
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||
|
|
child: const Text('清空全部', style: TextStyle(color: Colors.red)),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (confirmed != true) return;
|
||
|
|
setState(() => _loading = true);
|
||
|
|
await FeedbackStore.instance.clearAll();
|
||
|
|
await _reload();
|
||
|
|
}
|
||
|
|
|
||
|
|
List<FeedbackEntry> get _filtered {
|
||
|
|
final items = _items ?? const <FeedbackEntry>[];
|
||
|
|
switch (_filter) {
|
||
|
|
case _FeedbackFilter.all:
|
||
|
|
return items;
|
||
|
|
case _FeedbackFilter.accurate:
|
||
|
|
return items.where((e) => e.accurate).toList();
|
||
|
|
case _FeedbackFilter.inaccurate:
|
||
|
|
return items.where((e) => !e.accurate).toList();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final items = _items;
|
||
|
|
final total = items?.length ?? 0;
|
||
|
|
final accurateCount = items?.where((e) => e.accurate).length ?? 0;
|
||
|
|
final inaccurateCount = total - accurateCount;
|
||
|
|
final rate = total == 0 ? null : accurateCount / total;
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(
|
||
|
|
title: const Text('反馈列表'),
|
||
|
|
backgroundColor: Colors.transparent,
|
||
|
|
actions: [
|
||
|
|
if (total > 0)
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.delete_sweep_outlined),
|
||
|
|
tooltip: '清空全部',
|
||
|
|
onPressed: () => _confirmClearAll(context),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
body: _loading
|
||
|
|
? const Center(child: CircularProgressIndicator())
|
||
|
|
: items == null || items.isEmpty
|
||
|
|
? const Center(
|
||
|
|
child: Text(
|
||
|
|
'暂无反馈\n在结果页点「准确」或「不准确」后会出现在这里',
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
style: TextStyle(color: Colors.white54),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
_AccuracyHeader(
|
||
|
|
total: total,
|
||
|
|
accurateCount: accurateCount,
|
||
|
|
inaccurateCount: inaccurateCount,
|
||
|
|
rate: rate!,
|
||
|
|
),
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||
|
|
child: Wrap(
|
||
|
|
spacing: 8,
|
||
|
|
children: [
|
||
|
|
_FilterChip(
|
||
|
|
label: '全部 ($total)',
|
||
|
|
selected: _filter == _FeedbackFilter.all,
|
||
|
|
onTap: () => setState(
|
||
|
|
() => _filter = _FeedbackFilter.all,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
_FilterChip(
|
||
|
|
label: '准确 ($accurateCount)',
|
||
|
|
selected: _filter == _FeedbackFilter.accurate,
|
||
|
|
selectedColor: const Color(0xFF22C55E),
|
||
|
|
onTap: () => setState(
|
||
|
|
() => _filter = _FeedbackFilter.accurate,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
_FilterChip(
|
||
|
|
label: '不准确 ($inaccurateCount)',
|
||
|
|
selected: _filter == _FeedbackFilter.inaccurate,
|
||
|
|
selectedColor: const Color(0xFFEF4444),
|
||
|
|
onTap: () => setState(
|
||
|
|
() => _filter = _FeedbackFilter.inaccurate,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: RefreshIndicator(
|
||
|
|
onRefresh: _reload,
|
||
|
|
child: _filtered.isEmpty
|
||
|
|
? ListView(
|
||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
||
|
|
children: const [
|
||
|
|
SizedBox(height: 120),
|
||
|
|
Center(
|
||
|
|
child: Text(
|
||
|
|
'当前筛选下暂无记录',
|
||
|
|
style: TextStyle(color: Colors.white54),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
: ListView.separated(
|
||
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
||
|
|
padding:
|
||
|
|
const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||
|
|
itemCount: _filtered.length,
|
||
|
|
separatorBuilder: (_, __) =>
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
itemBuilder: (context, index) {
|
||
|
|
final entry = _filtered[index];
|
||
|
|
return _FeedbackTile(
|
||
|
|
entry: entry,
|
||
|
|
onDelete: () => _deleteItem(entry.id),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _AccuracyHeader extends StatelessWidget {
|
||
|
|
const _AccuracyHeader({
|
||
|
|
required this.total,
|
||
|
|
required this.accurateCount,
|
||
|
|
required this.inaccurateCount,
|
||
|
|
required this.rate,
|
||
|
|
});
|
||
|
|
|
||
|
|
final int total;
|
||
|
|
final int accurateCount;
|
||
|
|
final int inaccurateCount;
|
||
|
|
final double rate;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context).textTheme;
|
||
|
|
final pct = (rate * 100).toStringAsFixed(rate == 1 || rate == 0 ? 0 : 1);
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
margin: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||
|
|
padding: const EdgeInsets.all(14),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white10,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
'准确率',
|
||
|
|
style: theme.labelMedium?.copyWith(color: Colors.white54),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text(
|
||
|
|
'$pct%',
|
||
|
|
style: theme.headlineSmall?.copyWith(
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: const Color(0xFF22C55E),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
_Stat(label: '总计', value: '$total'),
|
||
|
|
const SizedBox(width: 16),
|
||
|
|
_Stat(label: '准确', value: '$accurateCount', color: const Color(0xFF22C55E)),
|
||
|
|
const SizedBox(width: 16),
|
||
|
|
_Stat(
|
||
|
|
label: '不准确',
|
||
|
|
value: '$inaccurateCount',
|
||
|
|
color: const Color(0xFFEF4444),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _Stat extends StatelessWidget {
|
||
|
|
const _Stat({
|
||
|
|
required this.label,
|
||
|
|
required this.value,
|
||
|
|
this.color,
|
||
|
|
});
|
||
|
|
|
||
|
|
final String label;
|
||
|
|
final String value;
|
||
|
|
final Color? color;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context).textTheme;
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
label,
|
||
|
|
style: theme.labelSmall?.copyWith(color: Colors.white54),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
value,
|
||
|
|
style: theme.titleMedium?.copyWith(
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: color ?? Colors.white,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _FilterChip extends StatelessWidget {
|
||
|
|
const _FilterChip({
|
||
|
|
required this.label,
|
||
|
|
required this.selected,
|
||
|
|
required this.onTap,
|
||
|
|
this.selectedColor,
|
||
|
|
});
|
||
|
|
|
||
|
|
final String label;
|
||
|
|
final bool selected;
|
||
|
|
final VoidCallback onTap;
|
||
|
|
final Color? selectedColor;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final color = selectedColor ?? Theme.of(context).colorScheme.primary;
|
||
|
|
return FilterChip(
|
||
|
|
label: Text(label),
|
||
|
|
selected: selected,
|
||
|
|
onSelected: (_) => onTap(),
|
||
|
|
selectedColor: color.withOpacity(0.25),
|
||
|
|
checkmarkColor: color,
|
||
|
|
labelStyle: TextStyle(
|
||
|
|
color: selected ? color : Colors.white70,
|
||
|
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
|
||
|
|
),
|
||
|
|
side: BorderSide(color: selected ? color : Colors.white24),
|
||
|
|
backgroundColor: Colors.white10,
|
||
|
|
showCheckmark: false,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _FeedbackTile extends StatelessWidget {
|
||
|
|
const _FeedbackTile({required this.entry, this.onDelete});
|
||
|
|
|
||
|
|
final FeedbackEntry entry;
|
||
|
|
final VoidCallback? onDelete;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context).textTheme;
|
||
|
|
final accurate = entry.accurate;
|
||
|
|
final badgeColor =
|
||
|
|
accurate ? const Color(0xFF22C55E) : const Color(0xFFEF4444);
|
||
|
|
final badgeLabel = accurate ? '准确' : '不准确';
|
||
|
|
final score = entry.matchScore;
|
||
|
|
final status = entry.matchConfident == true
|
||
|
|
? 'confident'
|
||
|
|
: (entry.matchCardId == null ? '—' : 'low');
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Colors.white10,
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
padding:
|
||
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: badgeColor.withOpacity(0.2),
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
border: Border.all(color: badgeColor),
|
||
|
|
),
|
||
|
|
child: Text(
|
||
|
|
badgeLabel,
|
||
|
|
style: theme.labelMedium?.copyWith(
|
||
|
|
color: badgeColor,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const Spacer(),
|
||
|
|
if (onDelete != null)
|
||
|
|
InkWell(
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
onTap: onDelete,
|
||
|
|
child: const Padding(
|
||
|
|
padding: EdgeInsets.all(4),
|
||
|
|
child: Icon(Icons.delete_outline, size: 18, color: Colors.white38),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Text(
|
||
|
|
_formatTime(entry.createdAt),
|
||
|
|
style: theme.labelSmall?.copyWith(color: Colors.white54),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: _LabeledThumb(
|
||
|
|
label: '拍照图',
|
||
|
|
child: _FileImage(path: entry.photoPath),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: _LabeledThumb(
|
||
|
|
label: '裁剪图',
|
||
|
|
child: _FileImage(path: entry.cropPath),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: _LabeledThumb(
|
||
|
|
label: '最佳匹配',
|
||
|
|
child: entry.matchAssetPath == null
|
||
|
|
? const ColoredBox(
|
||
|
|
color: Colors.black26,
|
||
|
|
child: Center(
|
||
|
|
child: Icon(Icons.image_not_supported_outlined),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
: Image.asset(
|
||
|
|
entry.matchAssetPath!,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) => const ColoredBox(
|
||
|
|
color: Colors.black26,
|
||
|
|
child: Center(
|
||
|
|
child: Icon(Icons.broken_image_outlined),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Text(
|
||
|
|
entry.matchCardId == null
|
||
|
|
? '无匹配结果'
|
||
|
|
: '${entry.matchCardId} · ${score?.toStringAsFixed(2) ?? '—'} ($status)',
|
||
|
|
maxLines: 2,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: theme.bodySmall?.copyWith(color: Colors.white70),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static String _formatTime(DateTime t) {
|
||
|
|
final local = t.toLocal();
|
||
|
|
String two(int n) => n.toString().padLeft(2, '0');
|
||
|
|
return '${local.month}/${local.day} ${two(local.hour)}:${two(local.minute)}';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _LabeledThumb extends StatelessWidget {
|
||
|
|
const _LabeledThumb({required this.label, required this.child});
|
||
|
|
|
||
|
|
final String label;
|
||
|
|
final Widget child;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
label,
|
||
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||
|
|
color: Colors.white54,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
AspectRatio(
|
||
|
|
aspectRatio: 0.72,
|
||
|
|
child: ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
child: child,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _FileImage extends StatelessWidget {
|
||
|
|
const _FileImage({required this.path});
|
||
|
|
|
||
|
|
final String path;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final file = File(path);
|
||
|
|
if (!file.existsSync()) {
|
||
|
|
return const ColoredBox(
|
||
|
|
color: Colors.black26,
|
||
|
|
child: Center(child: Icon(Icons.broken_image_outlined)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return Image.file(
|
||
|
|
file,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) => const ColoredBox(
|
||
|
|
color: Colors.black26,
|
||
|
|
child: Center(child: Icon(Icons.broken_image_outlined)),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|