card-recog-demo/lib/screens/feedback_list_screen.dart
2026-07-14 22:51:07 -07:00

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)),
),
);
}
}