feat: add Flutter mobile clients and staff delivery API
This commit is contained in:
225
apps/service_app/lib/ui/features/evidence/evidence_page.dart
Normal file
225
apps/service_app/lib/ui/features/evidence/evidence_page.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user