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,146 @@
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),
});
}