Files
observer/flutter_app/lib/annotate/annotate_tasks_screen.dart
T
2026-09-07 10:11:36 +08:00

198 lines
7.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'annotate_api.dart';
import 'annotator_screen.dart';
String _fmt(DateTime t) {
String p(int n) => n.toString().padLeft(2, '0');
return '${t.month}/${t.day} ${p(t.hour)}:${p(t.minute)}';
}
/// 标注赚时长首页:我的统计(进度/今日已得/冻结态)+ 可领任务列表。
/// 领取后进入标注器逐张画框提交,提交满 [AnnotateStats.rewardPerImages] 张自动到账时长。
class AnnotateTasksScreen extends StatefulWidget {
final AnnotateApi api;
const AnnotateTasksScreen({super.key, required this.api});
@override
State<AnnotateTasksScreen> createState() => _AnnotateTasksScreenState();
}
class _AnnotateTasksScreenState extends State<AnnotateTasksScreen> {
late Future<(List<AnnotateTaskInfo>, AnnotateStats)> _future;
bool _claiming = false;
@override
void initState() {
super.initState();
_future = widget.api.tasks();
}
void _reload() {
setState(() => _future = widget.api.tasks());
}
Future<void> _claim(AnnotateTaskInfo task) async {
if (_claiming) return;
setState(() => _claiming = true);
try {
final images = await widget.api.claim(task.id);
if (!mounted) return;
if (images.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务池暂时没有可领取的图片,稍后再来')));
_reload();
return;
}
final earned = await Navigator.of(context).push<bool>(MaterialPageRoute(
builder: (_) => AnnotatorScreen(api: widget.api, task: task, images: images),
));
if (earned == true) _reload(); // 本批有提交:回列表刷新进度/奖励
} on SessionExpiredException {
if (mounted) _sessionExpired();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e'.replaceFirst('Exception: ', ''))));
}
} finally {
if (mounted) setState(() => _claiming = false);
}
}
void _sessionExpired() {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('登录已失效,请重新登录')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('标注赚时长')),
body: FutureBuilder<(List<AnnotateTaskInfo>, AnnotateStats)>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('加载失败:${snap.error}'.replaceFirst('Exception: ', '')),
const SizedBox(height: 12),
FilledButton(onPressed: _reload, child: const Text('重试')),
],
),
);
}
final (tasks, stats) = snap.data!;
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
_StatsCard(stats: stats),
const SizedBox(height: 16),
Text('可领任务', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (tasks.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('暂无可领任务,敬请期待')),
)
else
...tasks.map((t) => Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
title: Text(t.name,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
'物种:${t.species.isEmpty ? t.datasetName : t.species} · 池余量 ${t.poolRemain} 张'),
trailing: FilledButton(
onPressed: _claiming || stats.frozen || t.poolRemain <= 0
? null
: () => _claim(t),
child: const Text('领取'),
),
),
)),
],
),
);
},
),
);
}
}
class _StatsCard extends StatelessWidget {
final AnnotateStats stats;
const _StatsCard({required this.stats});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.emoji_events,
color: Colors.amber.shade700, size: 28),
const SizedBox(width: 8),
Text('标 ${stats.rewardPerImages} 张得 ${stats.rewardMinutes} 分钟',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: stats.rewardPerImages <= 0
? 0
: (stats.progressDone % stats.rewardPerImages) /
stats.rewardPerImages,
),
const SizedBox(height: 6),
Text(
'本档进度 ${stats.progressDone}/${stats.rewardPerImages} · 今日已得 ${stats.todayEarnedMinutes}/${stats.dailyCapMinutes} 分钟 · 累计 ${stats.totalEarnedMinutes} 分钟',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
Text(
'通过 ${stats.approved} / 拒绝 ${stats.rejected}(通过率 ${(stats.approveRatio * 100).toStringAsFixed(0)}%',
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
),
if (stats.frozen) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(children: [
const Icon(Icons.block, color: Colors.red, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'标注质量未达标,资格冻结中(${_fmt(stats.frozenUntil!)} 解冻)',
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
]),
),
],
],
),
),
);
}
}