app scaffold
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/// 扫码能力的对外 API。来源:conti-docs/07-native-integration.md。
|
||||
///
|
||||
/// **调用方只允许 import 这个文件**,不允许直接 import `src/generated/` 里的
|
||||
/// 生成代码(07 §使用规则)。调用方包括 `feature_scan` 和 `core_webview` 的
|
||||
/// JSBridge——后者正是 01 里「`core_*` 允许依赖 `native_*`」这条例外存在的
|
||||
/// 原因。
|
||||
///
|
||||
/// 本包按 01 的硬约束**不依赖仓库内任何其他包**(连 `core_foundation` 也不),
|
||||
/// 所以这里抛的是包内自定义的 [NativeScanException];转成统一错误体系里的
|
||||
/// `NativeException` 由调用方完成。
|
||||
library;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'src/generated/scan_api.g.dart';
|
||||
|
||||
export 'src/generated/scan_api.g.dart' show ScanMode, ScanOptions, ScanResult;
|
||||
|
||||
/// 扫码失败的错误码。
|
||||
///
|
||||
/// 取值与 12 的 `NativeException.code` 对齐,调用方可以直接透传。
|
||||
abstract final class NativeScanErrorCode {
|
||||
/// 用户主动取消。
|
||||
///
|
||||
/// 这不是异常流程,调用方通常应当静默返回,**不弹错误提示、不上报**。
|
||||
static const String cancelled = 'CANCELLED';
|
||||
|
||||
/// 相机权限被拒绝。
|
||||
static const String permissionDenied = 'PERMISSION_DENIED';
|
||||
|
||||
/// 能力暂时不可用(相机被占用、初始化失败等)。
|
||||
static const String unavailable = 'UNAVAILABLE';
|
||||
|
||||
/// 当前平台没有实现。
|
||||
///
|
||||
/// 见 07 §「OHOS 后续演进」:**不允许静默返回空值或占位假数据**——
|
||||
/// 静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。
|
||||
static const String unsupportedPlatform = 'UNSUPPORTED_PLATFORM';
|
||||
|
||||
/// 超时。
|
||||
static const String timeout = 'TIMEOUT';
|
||||
}
|
||||
|
||||
/// 扫码相关的异常。
|
||||
class NativeScanException implements Exception {
|
||||
/// [code] 取自 [NativeScanErrorCode]。
|
||||
const NativeScanException(this.code, this.message);
|
||||
|
||||
/// 稳定错误码。
|
||||
final String code;
|
||||
|
||||
/// 面向开发者的描述。**不要直接展示给用户**——文案由调用方按 12 的
|
||||
/// `ErrorPresenter` 决定。
|
||||
final String message;
|
||||
|
||||
/// 是否是用户主动取消。
|
||||
bool get isCancelled => code == NativeScanErrorCode.cancelled;
|
||||
|
||||
@override
|
||||
String toString() => 'NativeScanException($code): $message';
|
||||
}
|
||||
|
||||
/// 扫码。
|
||||
class NativeScan {
|
||||
/// [api] 仅供测试注入;生产走默认实例。
|
||||
NativeScan({ScanHostApi? api}) : _api = api ?? ScanHostApi();
|
||||
|
||||
final ScanHostApi _api;
|
||||
|
||||
/// 打开扫码页并等待一次结果。
|
||||
///
|
||||
/// 用户取消时抛 [NativeScanException](`code == CANCELLED`)而不是返回 null——
|
||||
/// 「取消」和「扫到了空字符串」必须能区分开。
|
||||
Future<ScanResult> startScan(ScanOptions options) async {
|
||||
try {
|
||||
return await _api.startScan(options);
|
||||
} on PlatformException catch (e) {
|
||||
// 原生异常类型不外泄(07 §使用规则)。
|
||||
throw NativeScanException(e.code, e.message ?? '扫码失败');
|
||||
} on MissingPluginException {
|
||||
throw const NativeScanException(NativeScanErrorCode.unsupportedPlatform, '当前平台未实现扫码能力');
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前平台是否支持指定识别类型。
|
||||
///
|
||||
/// 调用方应当先查这个再决定要不要显示入口,而不是等 [startScan] 抛错——
|
||||
/// 车牌识别的技术路径尚未确定(07 待确认项),首版可能只有部分平台支持。
|
||||
Future<bool> isModeSupported(ScanMode mode) async {
|
||||
try {
|
||||
return await _api.isModeSupported(mode);
|
||||
} on PlatformException {
|
||||
return false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Autogenerated from Pigeon (v27.3.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(
|
||||
List<Object?>? replyList,
|
||||
String channelName, {
|
||||
required bool isNullValid,
|
||||
}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: replyList[0]! as String,
|
||||
message: replyList[1] as String?,
|
||||
details: replyList[2],
|
||||
);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a is double && b is double) {
|
||||
if (a.isNaN && b.isNaN) {
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (final MapEntry<Object?, Object?> entryA in a.entries) {
|
||||
bool found = false;
|
||||
for (final MapEntry<Object?, Object?> entryB in b.entries) {
|
||||
if (_deepEquals(entryA.key, entryB.key)) {
|
||||
if (_deepEquals(entryA.value, entryB.value)) {
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
int _deepHash(Object? value) {
|
||||
if (value is List) {
|
||||
return Object.hashAll(value.map(_deepHash));
|
||||
}
|
||||
if (value is Map) {
|
||||
int result = 0;
|
||||
for (final MapEntry<Object?, Object?> entry in value.entries) {
|
||||
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is double && value.isNaN) {
|
||||
// Normalize NaN to a consistent hash.
|
||||
return 0x7FF8000000000000.hashCode;
|
||||
}
|
||||
if (value is double && value == 0.0) {
|
||||
// Normalize -0.0 to 0.0 so they have the same hash code.
|
||||
return 0.0.hashCode;
|
||||
}
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
/// 识别类型。
|
||||
///
|
||||
/// **即使首版只做条码,这个参数也必须先留出来**(07 §「待确认:VIN 码与车牌
|
||||
/// 识别的技术路径」):车牌走的是专用 OCR、VIN 印刷字符走通用 OCR + 校验位
|
||||
/// 过滤,技术路径还没定。参数先在 schema 里占好位,后面加识别类型就不用改
|
||||
/// 接口签名——改签名意味着三端生成物和所有调用点一起动。
|
||||
enum ScanMode {
|
||||
/// 二维码 / 条形码(商品、库位)。
|
||||
barcode,
|
||||
|
||||
/// VIN 码。可能是 Code 39 条码,也可能只有印刷字符。
|
||||
vin,
|
||||
|
||||
/// 车牌。
|
||||
plate,
|
||||
}
|
||||
|
||||
/// 扫码入参。
|
||||
class ScanOptions {
|
||||
ScanOptions({required this.mode, this.timeoutMs, this.torchEnabled, this.title});
|
||||
|
||||
/// 识别类型。
|
||||
ScanMode mode;
|
||||
|
||||
/// 超时毫秒数。null 表示不超时,由用户手动取消。
|
||||
int? timeoutMs;
|
||||
|
||||
/// 是否默认打开闪光灯。
|
||||
bool? torchEnabled;
|
||||
|
||||
/// 扫码页标题。由调用方传,`native_scan` 不依赖任何 i18n 资源。
|
||||
String? title;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[mode, timeoutMs, torchEnabled, title];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static ScanOptions decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return ScanOptions(
|
||||
mode: result[0]! as ScanMode,
|
||||
timeoutMs: result[1] as int?,
|
||||
torchEnabled: result[2] as bool?,
|
||||
title: result[3] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ScanOptions || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(mode, other.mode) &&
|
||||
_deepEquals(timeoutMs, other.timeoutMs) &&
|
||||
_deepEquals(torchEnabled, other.torchEnabled) &&
|
||||
_deepEquals(title, other.title);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ScanOptions(mode: $mode, timeoutMs: $timeoutMs, torchEnabled: $torchEnabled, title: $title)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫码结果。
|
||||
class ScanResult {
|
||||
ScanResult({required this.mode, required this.value, required this.durationMs, this.rawFormat});
|
||||
|
||||
/// 实际生效的识别类型。
|
||||
ScanMode mode;
|
||||
|
||||
/// 识别到的文本。
|
||||
String value;
|
||||
|
||||
/// 从打开扫码页到出结果的耗时,供埋点用(见 13 的 `scan_succeeded`)。
|
||||
int durationMs;
|
||||
|
||||
/// 原始码制(如 `CODE_39` / `QR_CODE`)。OCR 路径下为 null。
|
||||
String? rawFormat;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[mode, value, durationMs, rawFormat];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static ScanResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return ScanResult(
|
||||
mode: result[0]! as ScanMode,
|
||||
value: result[1]! as String,
|
||||
durationMs: result[2]! as int,
|
||||
rawFormat: result[3] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ScanResult || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(mode, other.mode) &&
|
||||
_deepEquals(value, other.value) &&
|
||||
_deepEquals(durationMs, other.durationMs) &&
|
||||
_deepEquals(rawFormat, other.rawFormat);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ScanResult(mode: $mode, value: $value, durationMs: $durationMs, rawFormat: $rawFormat)';
|
||||
}
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is ScanMode) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is ScanOptions) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is ScanResult) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : ScanMode.values[value];
|
||||
case 130:
|
||||
return ScanOptions.decode(readValue(buffer)!);
|
||||
case 131:
|
||||
return ScanResult.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dart → 原生。
|
||||
///
|
||||
/// 用户取消、权限拒绝、平台未实现这三类都通过 `FlutterError` 抛出,
|
||||
/// 由 Dart 侧的公共 API 转成 `NativeScanException`——**原生异常类型
|
||||
/// (`PlatformException`)不允许直接抛到业务代码里**(07 §使用规则)。
|
||||
class ScanHostApi {
|
||||
/// Constructor for [ScanHostApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
ScanHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
/// 打开扫码页并等待一次结果。
|
||||
///
|
||||
/// 用户取消时抛 code 为 `CANCELLED` 的错误,而不是返回 null——
|
||||
/// 「取消」和「扫到了空字符串」必须能区分开。
|
||||
Future<ScanResult> startScan(ScanOptions options) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.native_scan.ScanHostApi.startScan$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[options]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as ScanResult;
|
||||
}
|
||||
|
||||
/// 当前平台是否支持指定识别类型。
|
||||
///
|
||||
/// 车牌识别的技术路径未定(见上),首版可能只有部分平台支持;
|
||||
/// 调用方应当先查这个再决定要不要显示入口,而不是等 `startScan` 抛错。
|
||||
Future<bool> isModeSupported(ScanMode mode) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.native_scan.ScanHostApi.isModeSupported$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mode]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user