feat: add Flutter web build support

This commit is contained in:
2026-08-02 01:30:07 +08:00
parent 98bf7ed6a9
commit f0b8af0bc8
24 changed files with 708 additions and 329 deletions

View File

@@ -1,146 +1,2 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
class EncryptedDraftStore {
EncryptedDraftStore({
FlutterSecureStorage? storage,
AesGcm? algorithm,
}) : _storage = storage ?? const FlutterSecureStorage(),
_algorithm = algorithm ?? AesGcm.with256bits();
static const _keyPrefix = 'service_draft_key_';
final FlutterSecureStorage _storage;
final AesGcm _algorithm;
Future<void> saveDraft({
required String accountIdentity,
required String taskIdentity,
required Map<String, Object?> value,
}) async {
final directory = await _accountDirectory(accountIdentity);
final key = await _key(accountIdentity);
final nonce = List<int>.generate(12, (_) => Random.secure().nextInt(256));
final box = await _algorithm.encrypt(
utf8.encode(jsonEncode(value)),
secretKey: key,
nonce: nonce,
);
final payload = jsonEncode({
'nonce': base64Encode(box.nonce),
'cipherText': base64Encode(box.cipherText),
'mac': base64Encode(box.mac.bytes),
});
await File('${directory.path}/$taskIdentity.draft').writeAsString(payload, flush: true);
}
Future<String> sealAttachment({
required String accountIdentity,
required String taskIdentity,
required String stage,
required String sourcePath,
}) async {
final source = File(sourcePath);
final bytes = await source.readAsBytes();
final box = await _encrypt(accountIdentity, bytes);
final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence';
final file = File('${(await _accountDirectory(accountIdentity)).path}/$name');
await file.writeAsString(_boxJson(box), flush: true);
if (source.existsSync()) await source.delete();
return name;
}
Future<String> materializeAttachment({
required String accountIdentity,
required String sealedName,
}) async {
final source = File('${(await _accountDirectory(accountIdentity)).path}/$sealedName');
final clear = await _decrypt(accountIdentity, await source.readAsString());
final temporary = await getTemporaryDirectory();
final file = File('${temporary.path}/${sealedName.replaceAll('.evidence', '.jpg')}');
await file.writeAsBytes(clear, flush: true);
return file.path;
}
Future<Map<String, Object?>?> readDraft(String accountIdentity, String taskIdentity) async {
final file = File('${(await _accountDirectory(accountIdentity)).path}/$taskIdentity.draft');
if (!file.existsSync()) return null;
final payload = jsonDecode(await file.readAsString());
if (payload is! Map) return null;
final clear = await _decrypt(accountIdentity, await file.readAsString());
final decoded = jsonDecode(utf8.decode(clear));
if (decoded is! Map) return null;
return decoded.map<String, Object?>((key, value) => MapEntry(key.toString(), value));
}
Future<int> count(String accountIdentity) async {
final directory = await _accountDirectory(accountIdentity);
return directory
.listSync()
.whereType<File>()
.where((file) => file.path.endsWith('.draft'))
.length;
}
Future<void> deleteDraft(String accountIdentity, String taskIdentity) async {
final directory = await _accountDirectory(accountIdentity);
final file = File('${directory.path}/$taskIdentity.draft');
if (file.existsSync()) await file.delete();
for (final evidence in directory.listSync().whereType<File>().where(
(item) => item.uri.pathSegments.last.startsWith('$taskIdentity-'),
)) {
await evidence.delete();
}
}
Future<void> discardAccount(String accountIdentity) async {
final directory = await _accountDirectory(accountIdentity);
if (directory.existsSync()) await directory.delete(recursive: true);
await _storage.delete(key: '$_keyPrefix$accountIdentity');
}
Future<Directory> _accountDirectory(String accountIdentity) async {
final root = await getApplicationSupportDirectory();
final directory = Directory('${root.path}/drafts/$accountIdentity');
if (!directory.existsSync()) await directory.create(recursive: true);
return directory;
}
Future<SecretKey> _key(String accountIdentity) async {
final storageKey = '$_keyPrefix$accountIdentity';
var encoded = await _storage.read(key: storageKey);
if (encoded == null) {
encoded = base64Encode(await (await _algorithm.newSecretKey()).extractBytes());
await _storage.write(key: storageKey, value: encoded);
}
return SecretKey(base64Decode(encoded));
}
Future<SecretBox> _encrypt(String accountIdentity, List<int> clear) async {
final nonce = List<int>.generate(12, (_) => Random.secure().nextInt(256));
return _algorithm.encrypt(clear, secretKey: await _key(accountIdentity), nonce: nonce);
}
Future<List<int>> _decrypt(String accountIdentity, String encodedPayload) async {
final payload = jsonDecode(encodedPayload);
if (payload is! Map) throw const FormatException('Invalid encrypted draft');
return _algorithm.decrypt(
SecretBox(
base64Decode(payload['cipherText'] as String),
nonce: base64Decode(payload['nonce'] as String),
mac: Mac(base64Decode(payload['mac'] as String)),
),
secretKey: await _key(accountIdentity),
);
}
String _boxJson(SecretBox box) => jsonEncode({
'nonce': base64Encode(box.nonce),
'cipherText': base64Encode(box.cipherText),
'mac': base64Encode(box.mac.bytes),
});
}
export 'encrypted_draft_store_io.dart'
if (dart.library.html) 'encrypted_draft_store_web.dart';

View File

@@ -0,0 +1,154 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
/// Android/iOS 端按账号加密保存现场草稿和附件。
class EncryptedDraftStore {
EncryptedDraftStore({FlutterSecureStorage? storage, AesGcm? algorithm})
: _storage = storage ?? const FlutterSecureStorage(),
_algorithm = algorithm ?? AesGcm.with256bits();
static const _keyPrefix = 'service_draft_key_';
final FlutterSecureStorage _storage;
final AesGcm _algorithm;
Future<void> saveDraft({
required String accountIdentity,
required String taskIdentity,
required Map<String, Object?> value,
}) async {
final directory = await _accountDirectory(accountIdentity);
final box = await _encrypt(accountIdentity, utf8.encode(jsonEncode(value)));
await File(
'${directory.path}/$taskIdentity.draft',
).writeAsString(_boxJson(box), flush: true);
}
Future<String> sealAttachment({
required String accountIdentity,
required String taskIdentity,
required String stage,
required XFile source,
}) async {
final box = await _encrypt(accountIdentity, await source.readAsBytes());
final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence';
final file = File(
'${(await _accountDirectory(accountIdentity)).path}/$name',
);
await file.writeAsString(_boxJson(box), flush: true);
final sourceFile = File(source.path);
if (sourceFile.existsSync()) await sourceFile.delete();
return name;
}
Future<XFile> materializeAttachment({
required String accountIdentity,
required String sealedName,
}) async {
final source = File(
'${(await _accountDirectory(accountIdentity)).path}/$sealedName',
);
final clear = await _decrypt(accountIdentity, await source.readAsString());
final temporary = await getTemporaryDirectory();
final file = File(
'${temporary.path}/${sealedName.replaceAll('.evidence', '.jpg')}',
);
await file.writeAsBytes(clear, flush: true);
return XFile(file.path);
}
Future<void> discardMaterializedAttachment(XFile file) async {
final temporary = File(file.path);
if (temporary.existsSync()) await temporary.delete();
}
Future<Map<String, Object?>?> readDraft(
String accountIdentity,
String taskIdentity,
) async {
final file = File(
'${(await _accountDirectory(accountIdentity)).path}/$taskIdentity.draft',
);
if (!file.existsSync()) return null;
final decoded = jsonDecode(
utf8.decode(await _decrypt(accountIdentity, await file.readAsString())),
);
if (decoded is! Map) return null;
return decoded.map<String, Object?>(
(key, value) => MapEntry(key.toString(), value),
);
}
Future<int> count(String accountIdentity) async => (await _accountDirectory(
accountIdentity,
)).listSync().whereType<File>().where((file) => file.path.endsWith('.draft')).length;
Future<void> deleteDraft(String accountIdentity, String taskIdentity) async {
final directory = await _accountDirectory(accountIdentity);
final file = File('${directory.path}/$taskIdentity.draft');
if (file.existsSync()) await file.delete();
for (final evidence in directory.listSync().whereType<File>().where(
(item) => item.uri.pathSegments.last.startsWith('$taskIdentity-'),
)) {
await evidence.delete();
}
}
Future<void> discardAccount(String accountIdentity) async {
final directory = await _accountDirectory(accountIdentity);
if (directory.existsSync()) await directory.delete(recursive: true);
await _storage.delete(key: '$_keyPrefix$accountIdentity');
}
Future<Directory> _accountDirectory(String accountIdentity) async {
final root = await getApplicationSupportDirectory();
final directory = Directory('${root.path}/drafts/$accountIdentity');
if (!directory.existsSync()) await directory.create(recursive: true);
return directory;
}
Future<SecretKey> _key(String accountIdentity) async {
final storageKey = '$_keyPrefix$accountIdentity';
var encoded = await _storage.read(key: storageKey);
if (encoded == null) {
encoded = base64Encode(
await (await _algorithm.newSecretKey()).extractBytes(),
);
await _storage.write(key: storageKey, value: encoded);
}
return SecretKey(base64Decode(encoded));
}
Future<SecretBox> _encrypt(String accountIdentity, List<int> clear) async => _algorithm.encrypt(
clear,
secretKey: await _key(accountIdentity),
nonce: List<int>.generate(12, (_) => Random.secure().nextInt(256)),
);
Future<List<int>> _decrypt(
String accountIdentity,
String encodedPayload,
) async {
final payload = jsonDecode(encodedPayload);
if (payload is! Map) throw const FormatException('Invalid encrypted draft');
return _algorithm.decrypt(
SecretBox(
base64Decode(payload['cipherText'] as String),
nonce: base64Decode(payload['nonce'] as String),
mac: Mac(base64Decode(payload['mac'] as String)),
),
secretKey: await _key(accountIdentity),
);
}
String _boxJson(SecretBox box) => jsonEncode({
'nonce': base64Encode(box.nonce),
'cipherText': base64Encode(box.cipherText),
'mac': base64Encode(box.mac.bytes),
});
}

View File

@@ -0,0 +1,150 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:image_picker/image_picker.dart';
import 'package:web/web.dart' as web;
/// Web 端仅保存 AES-GCM 密文;密钥继续由浏览器安全存储插件管理。
class EncryptedDraftStore {
EncryptedDraftStore({FlutterSecureStorage? storage, AesGcm? algorithm})
: _storage = storage ?? const FlutterSecureStorage(),
_algorithm = algorithm ?? AesGcm.with256bits();
static const _keyPrefix = 'service_draft_key_';
static const _entryPrefix = 'service_draft_entry_';
final FlutterSecureStorage _storage;
final AesGcm _algorithm;
Future<void> saveDraft({
required String accountIdentity,
required String taskIdentity,
required Map<String, Object?> value,
}) async {
final box = await _encrypt(accountIdentity, utf8.encode(jsonEncode(value)));
_entries.setItem(_draftKey(accountIdentity, taskIdentity), _boxJson(box));
}
Future<String> sealAttachment({
required String accountIdentity,
required String taskIdentity,
required String stage,
required XFile source,
}) async {
final box = await _encrypt(accountIdentity, await source.readAsBytes());
final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence';
_entries.setItem(
_attachmentKey(accountIdentity, name),
jsonEncode({'box': _boxJson(box), 'file_name': source.name}),
);
return name;
}
Future<XFile> materializeAttachment({
required String accountIdentity,
required String sealedName,
}) async {
final encoded = _entries.getItem(_attachmentKey(accountIdentity, sealedName));
if (encoded == null) {
throw StateError('未找到加密附件');
}
final payload = jsonDecode(encoded);
if (payload is! Map) {
throw const FormatException('Invalid encrypted attachment');
}
final clear = await _decrypt(accountIdentity, payload['box'] as String);
return XFile.fromData(
Uint8List.fromList(clear),
name: payload['file_name'] as String? ?? '$sealedName.jpg',
);
}
Future<void> discardMaterializedAttachment(XFile file) async {}
Future<Map<String, Object?>?> readDraft(String accountIdentity, String taskIdentity) async {
final encoded = _entries.getItem(_draftKey(accountIdentity, taskIdentity));
if (encoded == null) {
return null;
}
final decoded = jsonDecode(utf8.decode(await _decrypt(accountIdentity, encoded)));
if (decoded is! Map) {
return null;
}
return decoded.map<String, Object?>((key, value) => MapEntry(key.toString(), value));
}
Future<int> count(String accountIdentity) async =>
_entryKeys.where((key) => key.startsWith('$_entryPrefix${accountIdentity}_draft_')).length;
Future<void> deleteDraft(String accountIdentity, String taskIdentity) async {
final prefix = '$_entryPrefix${accountIdentity}_';
_entries.removeItem(
'$prefix'
'draft_$taskIdentity',
);
final attachmentPrefix =
'$prefix'
'attachment_$taskIdentity-';
for (final key in _entryKeys.where((key) => key.startsWith(attachmentPrefix)).toList()) {
_entries.removeItem(key);
}
}
Future<void> discardAccount(String accountIdentity) async {
final prefix = '$_entryPrefix${accountIdentity}_';
for (final key in _entryKeys.where((key) => key.startsWith(prefix)).toList()) {
_entries.removeItem(key);
}
await _storage.delete(key: '$_keyPrefix$accountIdentity');
}
web.Storage get _entries => web.window.localStorage;
Iterable<String> get _entryKeys =>
List<String?>.generate(_entries.length, (index) => _entries.key(index)).whereType<String>();
String _draftKey(String accountIdentity, String taskIdentity) =>
'$_entryPrefix${accountIdentity}_draft_$taskIdentity';
String _attachmentKey(String accountIdentity, String sealedName) =>
'$_entryPrefix${accountIdentity}_attachment_$sealedName';
Future<SecretKey> _key(String accountIdentity) async {
final storageKey = '$_keyPrefix$accountIdentity';
var encoded = await _storage.read(key: storageKey);
if (encoded == null) {
encoded = base64Encode(await (await _algorithm.newSecretKey()).extractBytes());
await _storage.write(key: storageKey, value: encoded);
}
return SecretKey(base64Decode(encoded));
}
Future<SecretBox> _encrypt(String accountIdentity, List<int> clear) async => _algorithm.encrypt(
clear,
secretKey: await _key(accountIdentity),
nonce: List<int>.generate(12, (_) => Random.secure().nextInt(256)),
);
Future<List<int>> _decrypt(String accountIdentity, String encodedPayload) async {
final payload = jsonDecode(encodedPayload);
if (payload is! Map) {
throw const FormatException('Invalid encrypted draft');
}
return _algorithm.decrypt(
SecretBox(
base64Decode(payload['cipherText'] as String),
nonce: base64Decode(payload['nonce'] as String),
mac: Mac(base64Decode(payload['mac'] as String)),
),
secretKey: await _key(accountIdentity),
);
}
String _boxJson(SecretBox box) => jsonEncode({
'nonce': base64Encode(box.nonce),
'cipherText': base64Encode(box.cipherText),
'mac': base64Encode(box.mac.bytes),
});
}

View File

@@ -1,4 +1,5 @@
import 'package:uuid/uuid.dart';
import 'package:image_picker/image_picker.dart';
import '../../domain/models/service_models.dart';
import '../services/api_client.dart';
@@ -86,14 +87,17 @@ class ServiceRepository {
Future<void> arrive(String identity) async {
final point = await _location.current();
await _api.post('$root/delivery/orders/$identity/arrive', body: _pointJson(point));
await _api.post(
'$root/delivery/orders/$identity/arrive',
body: _pointJson(point),
);
}
Future<void> submitDeliveryReceipt({
required String identity,
required String recipientName,
required String recipientPhone,
required String proofFile,
required XFile proofFile,
}) async {
final proofUri = await _api.upload(proofFile);
await _api.post(
@@ -118,7 +122,7 @@ class ServiceRepository {
final uploaded = <Map<String, Object?>>[];
for (final item in evidence) {
final uri = await _api.upload(
item.filePath,
item.file,
contentType: item.mediaType == 'video' ? 'video/mp4' : 'image/jpeg',
);
uploaded.add({
@@ -153,14 +157,14 @@ class EvidenceInput {
const EvidenceInput({
required this.evidenceType,
required this.mediaType,
required this.filePath,
required this.file,
required this.capturedAt,
required this.requestNo,
});
final String evidenceType;
final String mediaType;
final String filePath;
final XFile file;
final DateTime capturedAt;
final String requestNo;
}

View File

@@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
class ApiException implements Exception {
const ApiException(this.code, this.message);
@@ -38,11 +38,21 @@ class ApiClient {
Future<Object?> put(String path, {Map<String, Object?>? body}) => _send('PUT', path, body: body);
Future<String> upload(String filePath, {String contentType = 'image/jpeg'}) async {
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/upload/file'));
request.headers[HttpHeaders.authorizationHeader] = _tokenProvider();
/// 上传跨平台文件对象;浏览器端不能依赖本地文件路径。
Future<String> upload(XFile file, {String contentType = 'image/jpeg'}) async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl/upload/file'),
);
request.headers['authorization'] = _tokenProvider();
request.fields['declared_content_type'] = contentType;
request.files.add(await http.MultipartFile.fromPath('file', filePath));
request.files.add(
http.MultipartFile.fromBytes(
'file',
await file.readAsBytes(),
filename: file.name,
),
);
final response = await http.Response.fromStream(await request.send());
final details = _decode(response);
return jsonMap(details)['uri'] as String? ?? '';
@@ -55,15 +65,17 @@ class ApiClient {
bool authenticated = true,
}) async {
final request = http.Request(method, Uri.parse('$baseUrl$path'));
request.headers[HttpHeaders.acceptHeader] = 'application/json';
request.headers['accept'] = 'application/json';
if (authenticated && _tokenProvider().isNotEmpty) {
request.headers[HttpHeaders.authorizationHeader] = _tokenProvider();
request.headers['authorization'] = _tokenProvider();
}
if (body != null) {
request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8';
request.headers['content-type'] = 'application/json; charset=UTF-8';
request.body = jsonEncode(body);
}
final response = await http.Response.fromStream(await _client.send(request));
final response = await http.Response.fromStream(
await _client.send(request),
);
return _decode(response);
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:uuid/uuid.dart';
@@ -44,12 +42,17 @@ class _EvidencePageState extends State<EvidencePage> {
}
Future<void> _restore() async {
final draft = await widget.drafts.readDraft(widget.session.identity, widget.taskIdentity);
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 mapped = value.map<String, Object?>(
(key, item) => MapEntry(key.toString(), item),
);
final stage = mapped['stage'] as String? ?? '';
if (stage.isNotEmpty) _evidence[stage] = mapped;
}
@@ -70,7 +73,7 @@ class _EvidencePageState extends State<EvidencePage> {
accountIdentity: widget.session.identity,
taskIdentity: widget.taskIdentity,
stage: stage,
sourcePath: image.path,
source: image,
);
_evidence[stage] = {
'stage': stage,
@@ -97,25 +100,27 @@ class _EvidencePageState extends State<EvidencePage> {
Future<void> _submit() async {
if (!_requiredStages.every(_evidence.containsKey) || _result.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项')));
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项')));
return;
}
setState(() => _busy = true);
final temporaryFiles = <String>[];
final temporaryFiles = <XFile>[];
try {
await _save();
final inputs = <EvidenceInput>[];
for (final item in _evidence.values) {
final path = await widget.drafts.materializeAttachment(
final file = await widget.drafts.materializeAttachment(
accountIdentity: widget.session.identity,
sealedName: item['sealed_name'] as String,
);
temporaryFiles.add(path);
temporaryFiles.add(file);
inputs.add(
EvidenceInput(
evidenceType: item['stage'] as String,
mediaType: item['media_type'] as String,
filePath: path,
file: file,
capturedAt: DateTime.parse(item['captured_at'] as String),
requestNo: item['request_no'] as String,
),
@@ -127,16 +132,20 @@ class _EvidencePageState extends State<EvidencePage> {
conclusion: _conclusion,
evidence: inputs,
);
await widget.drafts.deleteDraft(widget.session.identity, widget.taskIdentity);
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')));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('提交失败,草稿仍安全保留:$error')));
}
} finally {
for (final path in temporaryFiles) {
final file = File(path);
if (file.existsSync()) await file.delete();
for (final file in temporaryFiles) {
await widget.drafts.discardMaterializedAttachment(file);
}
if (mounted) setState(() => _busy = false);
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
@@ -42,14 +40,19 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
? widget.repository.deliveryDetail(widget.identity)
: widget.repository.ticketDetail(widget.identity);
Future<void> _run(Future<void> Function(WorkItem) action, WorkItem item) async {
Future<void> _run(
Future<void> Function(WorkItem) action,
WorkItem item,
) async {
setState(() => _busy = true);
try {
await action(item);
setState(() => _future = _load());
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
@@ -68,7 +71,10 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
decoration: const InputDecoration(labelText: '原因'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('确认'),
@@ -81,7 +87,10 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
}
Future<void> _receipt(WorkItem item) async {
final existing = await widget.drafts.readDraft(widget.session.identity, item.identity);
final existing = await widget.drafts.readDraft(
widget.session.identity,
item.identity,
);
if (!mounted) return;
String? sealedName;
if (existing?['kind'] == 'delivery_receipt' && existing?['sealed_name'] is String) {
@@ -91,25 +100,34 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
title: const Text('发现未提交签收草稿'),
content: const Text('是否继续提交上次加密保存的签收凭证?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('重新拍摄')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('继续提交')),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('重新拍摄'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('继续提交'),
),
],
),
);
if (reuse == true) sealedName = existing!['sealed_name'] as String;
}
if (sealedName == null) {
final image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 82);
final image = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 82,
);
if (image == null) return;
sealedName = await widget.drafts.sealAttachment(
accountIdentity: widget.session.identity,
taskIdentity: item.identity,
stage: 'receipt',
sourcePath: image.path,
source: image,
);
}
setState(() => _busy = true);
String? temporaryPath;
XFile? temporaryFile;
try {
await widget.drafts.saveDraft(
accountIdentity: widget.session.identity,
@@ -122,7 +140,7 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
'captured_at': DateTime.now().toUtc().toIso8601String(),
},
);
temporaryPath = await widget.drafts.materializeAttachment(
temporaryFile = await widget.drafts.materializeAttachment(
accountIdentity: widget.session.identity,
sealedName: sealedName,
);
@@ -130,7 +148,7 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
identity: item.identity,
recipientName: item.raw['contact_name'] as String? ?? '收货人',
recipientPhone: item.raw['contact_phone'] as String? ?? '',
proofFile: temporaryPath,
proofFile: temporaryFile,
);
await widget.drafts.deleteDraft(widget.session.identity, item.identity);
if (mounted) setState(() => _future = _load());
@@ -141,9 +159,8 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
).showSnackBar(SnackBar(content: Text('签收提交失败,加密草稿已保留:$error')));
}
} finally {
if (temporaryPath != null) {
final file = File(temporaryPath);
if (file.existsSync()) await file.delete();
if (temporaryFile != null) {
await widget.drafts.discardMaterializedAttachment(temporaryFile);
}
if (mounted) setState(() => _busy = false);
}
@@ -170,7 +187,10 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.number, style: const TextStyle(color: Colors.white70)),
Text(
item.number,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 8),
Text(
item.title,
@@ -206,7 +226,10 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.start(value, widget.session.roleCode),
(value) => widget.repository.start(
value,
widget.session.roleCode,
),
item,
),
child: const Text('开始处理'),
@@ -216,7 +239,9 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.appendCurrentTrack(value.identity),
(value) => widget.repository.appendCurrentTrack(
value.identity,
),
item,
),
child: const Text('上报当前位置'),
@@ -225,7 +250,10 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
ElevatedButton(
onPressed: _busy
? null
: () => _run((value) => widget.repository.arrive(value.identity), item),
: () => _run(
(value) => widget.repository.arrive(value.identity),
item,
),
child: const Text('到达并校验围栏'),
),
if (item.allowedActions.contains('submit_receipt'))
@@ -253,8 +281,11 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) =>
widget.repository.exception(value, widget.session.roleCode, reason),
(value) => widget.repository.exception(
value,
widget.session.roleCode,
reason,
),
item,
);
}
@@ -269,8 +300,11 @@ class _WorkDetailPageState extends State<WorkDetailPage> {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) =>
widget.repository.recover(value, widget.session.roleCode, reason),
(value) => widget.repository.recover(
value,
widget.session.roleCode,
reason,
),
item,
);
}