feat: add Flutter mobile clients and staff delivery API
This commit is contained in:
146
apps/service_app/lib/data/offline/encrypted_draft_store.dart
Normal file
146
apps/service_app/lib/data/offline/encrypted_draft_store.dart
Normal 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),
|
||||
});
|
||||
}
|
||||
166
apps/service_app/lib/data/repositories/service_repository.dart
Normal file
166
apps/service_app/lib/data/repositories/service_repository.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../domain/models/service_models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/location_service.dart';
|
||||
|
||||
class ServiceRepository {
|
||||
ServiceRepository(this._api, this._location);
|
||||
|
||||
static const root = '/heqi/client/v1/staff';
|
||||
final ApiClient _api;
|
||||
final LocationService _location;
|
||||
|
||||
Future<StaffProfile> profile() async =>
|
||||
StaffProfile.fromJson(jsonMap(await _api.get('$root/auth/profile')));
|
||||
|
||||
Future<PreflightResult> preflight() async =>
|
||||
PreflightResult.fromJson(jsonMap(await _api.get('$root/preflight')));
|
||||
|
||||
Future<Map<String, Object?>> wallet() async => jsonMap(await _api.get('$root/wallet'));
|
||||
|
||||
Future<void> attendance(String action, String deviceIdentity) async {
|
||||
final location = await _location.current();
|
||||
await _api.post(
|
||||
'$root/attendance',
|
||||
body: {
|
||||
'action': action,
|
||||
'occurred_at': location.occurredAt.toUtc().toIso8601String(),
|
||||
'longitude': location.longitude,
|
||||
'latitude': location.latitude,
|
||||
'device_identity': deviceIdentity,
|
||||
'request_no': const Uuid().v7(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<WorkItem>> tasks(String roleCode) async {
|
||||
if (roleCode == 'delivery') {
|
||||
return jsonList(
|
||||
await _api.get('$root/delivery/orders'),
|
||||
).map(WorkItem.delivery).toList(growable: false);
|
||||
}
|
||||
return jsonList(
|
||||
await _api.get('$root/tickets'),
|
||||
).map(WorkItem.ticket).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<WorkItem> deliveryDetail(String identity) async {
|
||||
final details = jsonMap(await _api.get('$root/delivery/orders/$identity'));
|
||||
return WorkItem.delivery(jsonMap(details['order']));
|
||||
}
|
||||
|
||||
Future<WorkItem> ticketDetail(String identity) async =>
|
||||
WorkItem.ticket(jsonMap(await _api.get('$root/tickets/$identity')));
|
||||
|
||||
Future<void> start(WorkItem item, String roleCode) async {
|
||||
final path = roleCode == 'delivery'
|
||||
? '$root/delivery/orders/${item.identity}/start'
|
||||
: '$root/tickets/${item.identity}/start';
|
||||
await _api.post(path, body: {'reason': '工作人员开始执行'});
|
||||
}
|
||||
|
||||
Future<void> exception(WorkItem item, String roleCode, String reason) async {
|
||||
final path = roleCode == 'delivery'
|
||||
? '$root/delivery/orders/${item.identity}/exception'
|
||||
: '$root/tickets/${item.identity}/exception';
|
||||
await _api.post(path, body: {'reason': reason});
|
||||
}
|
||||
|
||||
Future<void> recover(WorkItem item, String roleCode, String reason) async {
|
||||
final path = roleCode == 'delivery'
|
||||
? '$root/delivery/orders/${item.identity}/recover'
|
||||
: '$root/tickets/${item.identity}/recover';
|
||||
await _api.post(path, body: {'reason': reason});
|
||||
}
|
||||
|
||||
Future<void> appendCurrentTrack(String identity) async {
|
||||
final point = await _location.current();
|
||||
await _api.post(
|
||||
'$root/delivery/orders/$identity/tracks',
|
||||
body: {
|
||||
'points': [_pointJson(point)],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> arrive(String identity) async {
|
||||
final point = await _location.current();
|
||||
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,
|
||||
}) async {
|
||||
final proofUri = await _api.upload(proofFile);
|
||||
await _api.post(
|
||||
'$root/delivery/orders/$identity/submit-receipt',
|
||||
body: {
|
||||
'request_no': const Uuid().v7(),
|
||||
'confirm_type': 'signature',
|
||||
'recipient_name': recipientName,
|
||||
'recipient_phone': recipientPhone,
|
||||
'proof_uri': proofUri,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> submitTicketResult({
|
||||
required String identity,
|
||||
required String result,
|
||||
required String conclusion,
|
||||
required List<EvidenceInput> evidence,
|
||||
}) async {
|
||||
final location = await _location.current();
|
||||
final uploaded = <Map<String, Object?>>[];
|
||||
for (final item in evidence) {
|
||||
final uri = await _api.upload(
|
||||
item.filePath,
|
||||
contentType: item.mediaType == 'video' ? 'video/mp4' : 'image/jpeg',
|
||||
);
|
||||
uploaded.add({
|
||||
'evidence_type': item.evidenceType,
|
||||
'media_type': item.mediaType,
|
||||
'file_uri': uri,
|
||||
'captured_at': item.capturedAt.toUtc().toIso8601String(),
|
||||
'longitude': location.longitude,
|
||||
'latitude': location.latitude,
|
||||
'request_no': item.requestNo,
|
||||
});
|
||||
}
|
||||
await _api.post(
|
||||
'$root/tickets/$identity/submit-result',
|
||||
body: {'result': result, 'conclusion': conclusion, 'evidences': uploaded},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _pointJson(LocationPoint point) => {
|
||||
'request_no': const Uuid().v7(),
|
||||
'longitude': point.longitude,
|
||||
'latitude': point.latitude,
|
||||
'occurred_at': point.occurredAt.toUtc().toIso8601String(),
|
||||
'source': 'gps',
|
||||
'accuracy': point.accuracy,
|
||||
'speed': '',
|
||||
'direction': '',
|
||||
};
|
||||
}
|
||||
|
||||
class EvidenceInput {
|
||||
const EvidenceInput({
|
||||
required this.evidenceType,
|
||||
required this.mediaType,
|
||||
required this.filePath,
|
||||
required this.capturedAt,
|
||||
required this.requestNo,
|
||||
});
|
||||
|
||||
final String evidenceType;
|
||||
final String mediaType;
|
||||
final String filePath;
|
||||
final DateTime capturedAt;
|
||||
final String requestNo;
|
||||
}
|
||||
93
apps/service_app/lib/data/services/api_client.dart
Normal file
93
apps/service_app/lib/data/services/api_client.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
ApiClient(this._tokenProvider, {http.Client? client, String? baseUrl})
|
||||
: _client = client ?? http.Client(),
|
||||
baseUrl =
|
||||
baseUrl ??
|
||||
const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://10.0.2.2:12426',
|
||||
);
|
||||
|
||||
final String baseUrl;
|
||||
final String Function() _tokenProvider;
|
||||
final http.Client _client;
|
||||
|
||||
Future<Object?> get(String path, {bool authenticated = true}) =>
|
||||
_send('GET', path, authenticated: authenticated);
|
||||
|
||||
Future<Object?> post(
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
bool authenticated = true,
|
||||
}) => _send('POST', path, body: body, authenticated: authenticated);
|
||||
|
||||
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();
|
||||
request.fields['declared_content_type'] = contentType;
|
||||
request.files.add(await http.MultipartFile.fromPath('file', filePath));
|
||||
final response = await http.Response.fromStream(await request.send());
|
||||
final details = _decode(response);
|
||||
return jsonMap(details)['uri'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<Object?> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
bool authenticated = true,
|
||||
}) async {
|
||||
final request = http.Request(method, Uri.parse('$baseUrl$path'));
|
||||
request.headers[HttpHeaders.acceptHeader] = 'application/json';
|
||||
if (authenticated && _tokenProvider().isNotEmpty) {
|
||||
request.headers[HttpHeaders.authorizationHeader] = _tokenProvider();
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8';
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
final response = await http.Response.fromStream(await _client.send(request));
|
||||
return _decode(response);
|
||||
}
|
||||
|
||||
Object? _decode(http.Response response) {
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(response.statusCode, '网络请求失败(${response.statusCode})');
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const ApiException(500, '服务端响应格式错误');
|
||||
}
|
||||
final code = (decoded['code'] as num?)?.toInt() ?? 500;
|
||||
if (code != 0) throw ApiException(code, decoded['message'] as String? ?? '操作失败');
|
||||
return decoded['details'];
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> jsonMap(Object? value) {
|
||||
if (value is Map<String, Object?>) return value;
|
||||
if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item));
|
||||
throw const ApiException(500, '服务端数据格式错误');
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> jsonList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.map<Map<String, Object?>>(jsonMap).toList(growable: false);
|
||||
}
|
||||
42
apps/service_app/lib/data/services/location_service.dart
Normal file
42
apps/service_app/lib/data/services/location_service.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class LocationPoint {
|
||||
const LocationPoint({
|
||||
required this.longitude,
|
||||
required this.latitude,
|
||||
required this.accuracy,
|
||||
required this.occurredAt,
|
||||
});
|
||||
|
||||
final String longitude;
|
||||
final String latitude;
|
||||
final String accuracy;
|
||||
final DateTime occurredAt;
|
||||
}
|
||||
|
||||
abstract interface class LocationService {
|
||||
Future<LocationPoint> current();
|
||||
}
|
||||
|
||||
class GeolocatorLocationService implements LocationService {
|
||||
@override
|
||||
Future<LocationPoint> current() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
throw StateError('请先开启系统定位服务');
|
||||
}
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
||||
throw StateError('定位权限未授权,无法完成该在线动作');
|
||||
}
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
return LocationPoint(
|
||||
longitude: position.longitude.toStringAsFixed(7),
|
||||
latitude: position.latitude.toStringAsFixed(7),
|
||||
accuracy: position.accuracy.toStringAsFixed(1),
|
||||
occurredAt: position.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
12
apps/service_app/lib/data/services/secure_session_store.dart
Normal file
12
apps/service_app/lib/data/services/secure_session_store.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class SecureSessionStore {
|
||||
SecureSessionStore({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Future<String?> read(String key) => _storage.read(key: key);
|
||||
Future<void> write(String key, String value) => _storage.write(key: key, value: value);
|
||||
Future<void> delete(String key) => _storage.delete(key: key);
|
||||
}
|
||||
Reference in New Issue
Block a user