import 'package:flutter/material.dart'; import 'annotate_api.dart'; /// 标注器:逐张画框提交。交互:空白处拖拽画框、点框选中(高亮)、 /// 撤销/删除选中;每张提交进「待审核」,满 rewardPerImages 张自动到账时长。 class AnnotatorScreen extends StatefulWidget { final AnnotateApi api; final AnnotateTaskInfo task; final List images; const AnnotatorScreen({ super.key, required this.api, required this.task, required this.images, }); @override State createState() => _AnnotatorScreenState(); } /// 归一化框(cx,cy 中心 + w,h,0~1,同服务端 AdminLabelBox) class _NormBox { double cx; double cy; double w; double h; _NormBox(this.cx, this.cy, this.w, this.h); bool hit(Offset p) => p.dx >= cx - w / 2 && p.dx <= cx + w / 2 && p.dy >= cy - h / 2 && p.dy <= cy + h / 2; } class _AnnotatorScreenState extends State { int _index = 0; final List<_NormBox> _boxes = []; int? _selected; Offset? _dragStart; // 拖拽画框进行中(归一化坐标) Offset? _dragCur; bool _submitting = false; bool _earnedThisBatch = false; AnnotateClaimImage get _current => widget.images[_index]; void _clamp(_NormBox b) { // 边界钳制 + 防越界(归一化 0~1,同服务端校验) b.cx = b.cx.clamp(0.0, 1.0); b.cy = b.cy.clamp(0.0, 1.0); b.w = b.w.clamp(0.001, 1.0); b.h = b.h.clamp(0.001, 1.0); } void _onPanStart(DragStartDetails d) { _dragStart = d.localPosition; _dragCur = d.localPosition; setState(() {}); } void _onPanUpdate(DragUpdateDetails d) { _dragCur = d.localPosition; setState(() {}); } void _onPanEnd(DragEndDetails d) { final s = _dragStart; final e = _dragCur; _dragStart = null; _dragCur = null; if (s == null || e == null) { setState(() {}); return; } final dx = (e.dx - s.dx).abs(); final dy = (e.dy - s.dy).abs(); if (dx < 8 || dy < 8) { setState(() {}); // 过小视为误触 return; } final box = _NormBox( (s.dx + e.dx) / 2, (s.dy + e.dy) / 2, dx, dy, ); final size = _canvasSize; if (size == null || size.width <= 0 || size.height <= 0) { setState(() {}); return; } box.cx /= size.width; box.cy /= size.height; box.w /= size.width; box.h /= size.height; _clamp(box); setState(() { _boxes.add(box); _selected = _boxes.length - 1; }); } Size? _canvasSize; void _onTapUp(TapUpDetails d) { final p = d.localPosition; final size = _canvasSize; if (size == null) return; final n = Offset(p.dx / size.width, p.dy / size.height); int? hit; for (var i = _boxes.length - 1; i >= 0; i--) { if (_boxes[i].hit(n)) { hit = i; break; } } setState(() => _selected = hit); } void _deleteSelected() { if (_selected == null) return; setState(() { _boxes.removeAt(_selected!); _selected = null; }); } void _undo() { if (_boxes.isEmpty) return; setState(() { _boxes.removeLast(); _selected = null; }); } Future _submit({required bool noTarget}) async { if (_submitting) return; if (!noTarget && _boxes.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('请先画框,或使用「画面无目标」提交'))); return; } setState(() => _submitting = true); try { final payload = noTarget ? >[] : _boxes .map((b) => { 'class': 0, 'cx': b.cx, 'cy': b.cy, 'w': b.w, 'h': b.h, 'confidence': 1, }) .toList(); final res = await widget.api.submit(_current.imageId, payload); if (res.granted) _earnedThisBatch = true; if (!mounted) return; if (res.granted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( '恭喜!累计提交满档,${res.minutes} 分钟时长已到账(今日已得 ${res.todayEarnedMinutes} 分钟)'), backgroundColor: Colors.green, )); } _next(); } on SessionExpiredException { if (mounted) Navigator.of(context).pop(false); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('$e'.replaceFirst('Exception: ', '')))); } } finally { if (mounted) setState(() => _submitting = false); } } void _next() { if (_index + 1 >= widget.images.length) { // 本批完成:回任务列表刷新进度 ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(_earnedThisBatch ? '本批完成,奖励已到账' : '本批完成,继续加油!'))); Navigator.of(context).pop(true); return; } setState(() { _index++; _boxes.clear(); _selected = null; _dragStart = null; _dragCur = null; }); } @override Widget build(BuildContext context) { final img = _current; final aspect = img.width > 0 && img.height > 0 ? img.width / img.height : 4 / 3; return Scaffold( appBar: AppBar( title: Text('${widget.task.name}(${_index + 1}/${widget.images.length})'), ), body: SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.all(12), child: FutureBuilder>( future: widget.api.imageHeaders(), builder: (context, snap) { final headers = snap.data ?? const {}; return LayoutBuilder(builder: (context, constraints) { final maxW = constraints.maxWidth; final maxH = constraints.maxHeight.isInfinite ? MediaQuery.of(context).size.height * 0.55 : constraints.maxHeight; var w = maxW; var h = w / aspect; if (h > maxH) { h = maxH; w = h * aspect; } _canvasSize = Size(w, h); return Center( child: GestureDetector( onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, onTapUp: _onTapUp, child: Stack( children: [ SizedBox( width: w, height: h, child: Image.network( widget.api.imageUrl(img.url).toString(), headers: headers, fit: BoxFit.fill, loadingBuilder: (c, child, progress) => progress == null ? child : const Center( child: CircularProgressIndicator()), errorBuilder: (c, e, st) => const Center( child: Text('图片加载失败', style: TextStyle(color: Colors.red))), ), ), Positioned.fill( child: CustomPaint( painter: _BoxesPainter( boxes: _boxes, selected: _selected, dragStart: _dragStart == null ? null : Offset(_dragStart!.dx / w, _dragStart!.dy / h), dragCur: _dragCur == null ? null : Offset( _dragCur!.dx / w, _dragCur!.dy / h), ), ), ), ], ), ), ); }); }, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ TextButton.icon( onPressed: _boxes.isEmpty ? null : _undo, icon: const Icon(Icons.undo), label: const Text('撤销'), ), TextButton.icon( onPressed: _selected == null ? null : _deleteSelected, icon: const Icon(Icons.delete_outline), label: const Text('删除选中'), ), Text( '${_boxes.length} 框', style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), const Spacer(), Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: OutlinedButton( onPressed: _submitting ? null : () => _submit(noTarget: true), child: const Text('画面无目标'), ), ), const SizedBox(width: 12), Expanded( flex: 2, child: FilledButton.icon( onPressed: _submitting ? null : () => _submit(noTarget: false), icon: _submitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.check), label: Text(_submitting ? '提交中…' : '提交${_boxes.isEmpty ? '' : '(${_boxes.length} 框)'}'), ), ), ], ), ), ], ), ), ); } } class _BoxesPainter extends CustomPainter { final List<_NormBox> boxes; final int? selected; final Offset? dragStart; // 归一化 final Offset? dragCur; _BoxesPainter({ required this.boxes, required this.selected, required this.dragStart, required this.dragCur, }); @override void paint(Canvas canvas, Size size) { final border = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2; for (var i = 0; i < boxes.length; i++) { final b = boxes[i]; border.color = i == selected ? Colors.orange : Colors.green; canvas.drawRect( Rect.fromLTWH((b.cx - b.w / 2) * size.width, (b.cy - b.h / 2) * size.height, b.w * size.width, b.h * size.height), border, ); } final s = dragStart; final e = dragCur; if (s != null && e != null) { border.color = Colors.blue; canvas.drawRect( Rect.fromPoints(Offset(s.dx * size.width, s.dy * size.height), Offset(e.dx * size.width, e.dy * size.height)), border, ); } } @override bool shouldRepaint(covariant _BoxesPainter old) => old.boxes != boxes || old.selected != selected || old.dragCur != dragCur; }