feat: add Flutter mobile clients and staff delivery API

This commit is contained in:
david
2026-07-30 21:47:41 +08:00
parent 550efb3812
commit 36a5ced1c0
228 changed files with 17159 additions and 22 deletions

View File

@@ -0,0 +1,225 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:uuid/uuid.dart';
import '../../../app/dependencies.dart';
import '../../../data/offline/encrypted_draft_store.dart';
import '../../../data/repositories/service_repository.dart';
class EvidencePage extends StatefulWidget {
const EvidencePage({
required this.session,
required this.repository,
required this.drafts,
required this.taskIdentity,
super.key,
});
final StaffSession session;
final ServiceRepository repository;
final EncryptedDraftStore drafts;
final String taskIdentity;
@override
State<EvidencePage> createState() => _EvidencePageState();
}
class _EvidencePageState extends State<EvidencePage> {
final _picker = ImagePicker();
final _result = TextEditingController();
final Map<String, Map<String, Object?>> _evidence = {};
bool _busy = false;
String _conclusion = 'qualified';
List<String> get _requiredStages => widget.session.roleCode == 'operations'
? const ['inspection', 'signature']
: const ['before', 'during', 'after', 'signature'];
@override
void initState() {
super.initState();
_restore();
}
Future<void> _restore() async {
final draft = await widget.drafts.readDraft(widget.session.identity, widget.taskIdentity);
if (draft == null || !mounted) return;
final values = draft['evidence'];
if (values is List) {
for (final value in values.whereType<Map<Object?, Object?>>()) {
final mapped = value.map<String, Object?>((key, item) => MapEntry(key.toString(), item));
final stage = mapped['stage'] as String? ?? '';
if (stage.isNotEmpty) _evidence[stage] = mapped;
}
}
_result.text = draft['result'] as String? ?? '';
_conclusion = draft['conclusion'] as String? ?? 'qualified';
setState(() {});
}
Future<void> _capture(String stage) async {
final image = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 82,
maxWidth: 1800,
);
if (image == null) return;
final sealedName = await widget.drafts.sealAttachment(
accountIdentity: widget.session.identity,
taskIdentity: widget.taskIdentity,
stage: stage,
sourcePath: image.path,
);
_evidence[stage] = {
'stage': stage,
'sealed_name': sealedName,
'captured_at': DateTime.now().toUtc().toIso8601String(),
'request_no': const Uuid().v7(),
'media_type': stage == 'signature' ? 'signature' : 'image',
};
await _save();
if (mounted) setState(() {});
}
Future<void> _save() => widget.drafts.saveDraft(
accountIdentity: widget.session.identity,
taskIdentity: widget.taskIdentity,
value: {
'task_identity': widget.taskIdentity,
'result': _result.text,
'conclusion': _conclusion,
'updated_at': DateTime.now().toUtc().toIso8601String(),
'evidence': _evidence.values.toList(),
},
);
Future<void> _submit() async {
if (!_requiredStages.every(_evidence.containsKey) || _result.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项')));
return;
}
setState(() => _busy = true);
final temporaryFiles = <String>[];
try {
await _save();
final inputs = <EvidenceInput>[];
for (final item in _evidence.values) {
final path = await widget.drafts.materializeAttachment(
accountIdentity: widget.session.identity,
sealedName: item['sealed_name'] as String,
);
temporaryFiles.add(path);
inputs.add(
EvidenceInput(
evidenceType: item['stage'] as String,
mediaType: item['media_type'] as String,
filePath: path,
capturedAt: DateTime.parse(item['captured_at'] as String),
requestNo: item['request_no'] as String,
),
);
}
await widget.repository.submitTicketResult(
identity: widget.taskIdentity,
result: _result.text.trim(),
conclusion: _conclusion,
evidence: inputs,
);
await widget.drafts.deleteDraft(widget.session.identity, widget.taskIdentity);
if (mounted) Navigator.pop(context, true);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败,草稿仍安全保留:$error')));
}
} finally {
for (final path in temporaryFiles) {
final file = File(path);
if (file.existsSync()) await file.delete();
}
if (mounted) setState(() => _busy = false);
}
}
@override
void dispose() {
_result.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('现场取证')),
body: ListView(
padding: const EdgeInsets.all(18),
children: [
const Card(
child: Padding(
padding: EdgeInsets.all(18),
child: Row(
children: [
Icon(Icons.lock_outline),
SizedBox(width: 12),
Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')),
],
),
),
),
const SizedBox(height: 10),
..._requiredStages.map(
(stage) => Card(
child: ListTile(
leading: Icon(
_evidence.containsKey(stage) ? Icons.check_circle : Icons.camera_alt_outlined,
color: _evidence.containsKey(stage) ? Colors.green : null,
),
title: Text(_stageName(stage)),
subtitle: Text(_evidence.containsKey(stage) ? '已加密暂存' : '尚未采集'),
trailing: TextButton(
onPressed: _busy ? null : () => _capture(stage),
child: Text(_evidence.containsKey(stage) ? '重拍' : '拍摄'),
),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _result,
maxLines: 4,
onChanged: (_) => _save(),
decoration: const InputDecoration(labelText: '现场结果说明'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _conclusion,
decoration: const InputDecoration(labelText: '结论'),
items: const [
DropdownMenuItem(value: 'qualified', child: Text('合格')),
DropdownMenuItem(value: 'noncompliant', child: Text('不合格')),
DropdownMenuItem(value: 'high_risk', child: Text('高风险')),
],
onChanged: (value) {
if (value == null) return;
setState(() => _conclusion = value);
_save();
},
),
const SizedBox(height: 22),
ElevatedButton(
onPressed: _busy ? null : _submit,
child: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'),
),
],
),
);
String _stageName(String stage) => switch (stage) {
'before' => '作业前照片',
'during' => '作业中照片',
'after' => '作业后照片',
'inspection' => '检查现场照片',
'signature' => '用户签名图片',
_ => stage,
};
}